Threema 4 днів тому
батько
коміт
ef1138b645
100 змінених файлів з 3057 додано та 4652 видалено
  1. 1 1
      .gitignore
  2. 1 1
      README.md
  3. 3 3
      app/assets/license.html
  4. 50 41
      app/build.gradle.kts
  5. 2 2
      app/src/androidTest/java/ch/threema/KoinTestRule.kt
  6. 0 237
      app/src/androidTest/java/ch/threema/app/TestCoreServiceManager.kt
  7. 68 0
      app/src/androidTest/java/ch/threema/app/TestMultiDeviceManager.kt
  8. 26 0
      app/src/androidTest/java/ch/threema/app/TestMultiDevicePropertyProvider.kt
  9. 27 0
      app/src/androidTest/java/ch/threema/app/TestNonceStore.kt
  10. 56 0
      app/src/androidTest/java/ch/threema/app/TestTaskManager.kt
  11. 246 269
      app/src/androidTest/java/ch/threema/app/contacts/AddOrUpdateContactBackgroundTaskTest.kt
  12. 24 35
      app/src/androidTest/java/ch/threema/app/contacts/MarkContactAsDeletedBackgroundTaskTest.kt
  13. 44 44
      app/src/androidTest/java/ch/threema/app/contacts/ReflectedContactSyncTaskTest.kt
  14. 32 14
      app/src/androidTest/java/ch/threema/app/edithistory/EditHistoryTest.kt
  15. 17 15
      app/src/androidTest/java/ch/threema/app/groupmanagement/CreateGroupFlowTest.kt
  16. 13 10
      app/src/androidTest/java/ch/threema/app/groupmanagement/DisbandGroupFlowTest.kt
  17. 14 13
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupFlowTest.kt
  18. 14 11
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupResyncFlowTest.kt
  19. 3 113
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt
  20. 0 141
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt
  21. 10 260
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt
  22. 14 11
      app/src/androidTest/java/ch/threema/app/groupmanagement/LeaveGroupFlowTest.kt
  23. 14 11
      app/src/androidTest/java/ch/threema/app/groupmanagement/RemoveGroupFlowTest.kt
  24. 30 36
      app/src/androidTest/java/ch/threema/app/groupmanagement/UpdateGroupFlowTest.kt
  25. 29 29
      app/src/androidTest/java/ch/threema/app/processors/IncomingMessageProcessorTest.kt
  26. 42 83
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt
  27. 9 17
      app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt
  28. 0 119
      app/src/androidTest/java/ch/threema/app/services/BlockedIdentitiesServiceTest.kt
  29. 0 72
      app/src/androidTest/java/ch/threema/app/stores/EncryptedPreferenceStoreImplTest.kt
  30. 147 70
      app/src/androidTest/java/ch/threema/app/stores/PreferencesStoreImplTest.kt
  31. 15 12
      app/src/androidTest/java/ch/threema/app/tasks/GroupCreateTaskTest.kt
  32. 142 35
      app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt
  33. 1 3
      app/src/androidTest/java/ch/threema/app/testutils/AndroidTestUtils.kt
  34. 0 16
      app/src/androidTest/java/ch/threema/app/testutils/PreferenceStoreMock.kt
  35. 0 72
      app/src/androidTest/java/ch/threema/app/testutils/RecyclerViewMatcher.java
  36. 7 7
      app/src/androidTest/java/ch/threema/app/testutils/TestHelpers.java
  37. 0 165
      app/src/androidTest/java/ch/threema/app/utils/BackgroundErrorNotificationTest.java
  38. 7 7
      app/src/androidTest/java/ch/threema/app/utils/BundledMessagesSendStepsTest.kt
  39. 24 17
      app/src/androidTest/java/ch/threema/app/utils/FileUtilTest.kt
  40. 2 1
      app/src/androidTest/java/ch/threema/app/utils/LinkifyUtilTest.kt
  41. 0 224
      app/src/androidTest/java/ch/threema/app/voip/VoipStatusMessageTest.java
  42. 13 14
      app/src/androidTest/java/ch/threema/app/webclient/activities/SessionsActivityTest.java
  43. 23 4
      app/src/androidTest/java/ch/threema/app/webclient/converter/MessageTest.java
  44. 130 126
      app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt
  45. 29 26
      app/src/androidTest/java/ch/threema/data/repositories/EditHistoryRepositoryTest.kt
  46. 106 56
      app/src/androidTest/java/ch/threema/data/repositories/EmojiReactionsRepositoryTest.kt
  47. 39 28
      app/src/androidTest/java/ch/threema/data/repositories/GroupModelRepositoryTest.kt
  48. 129 0
      app/src/androidTest/java/ch/threema/data/storage/SqliteDatabaseBackendTest.kt
  49. 78 0
      app/src/androidTest/java/ch/threema/test/TestData.kt
  50. 6 1
      app/src/androidTest/java/ch/threema/test/TestDatabaseProvider.kt
  51. 21 0
      app/src/androidTest/java/ch/threema/test/TestIdentityProvider.kt
  52. 16 16
      app/src/blue/res/drawable/logo_main.xml
  53. 0 35
      app/src/blue/res/drawable/logo_main_white.xml
  54. 107 165
      app/src/blue/res/layout/activity_enter_serial.xml
  55. 4 7
      app/src/blue/res/xml/file_paths.xml
  56. 40 0
      app/src/debug/java/ch/threema/app/appstartup/ForceDowngradeButton.kt
  57. 48 0
      app/src/debug/java/ch/threema/storage/DatabaseDowngradeHelperImpl.kt
  58. 3 3
      app/src/foss_based/assets/license.html
  59. 6 7
      app/src/google_services_based/java/ch/threema/app/push/PushService.java
  60. 5 5
      app/src/google_services_based/java/ch/threema/app/services/VoiceActionService.java
  61. 6 5
      app/src/hms_services_based/java/ch/threema/app/push/PushService.java
  62. 4 0
      app/src/libre/play/release-notes/de/default.txt
  63. 5 1
      app/src/libre/play/release-notes/en-US/default.txt
  64. 38 44
      app/src/main/AndroidManifest.xml
  65. 2 9
      app/src/main/java/ch/threema/app/AppConstants.kt
  66. 7 6
      app/src/main/java/ch/threema/app/GlobalAppState.kt
  67. 10 6
      app/src/main/java/ch/threema/app/GlobalBroadcastReceivers.kt
  68. 13 796
      app/src/main/java/ch/threema/app/GlobalListeners.java
  69. 43 192
      app/src/main/java/ch/threema/app/ThreemaApplication.kt
  70. 3 10
      app/src/main/java/ch/threema/app/actions/LocationMessageSendAction.java
  71. 1 5
      app/src/main/java/ch/threema/app/actions/SendAction.java
  72. 7 9
      app/src/main/java/ch/threema/app/actions/TextMessageSendAction.java
  73. 12 15
      app/src/main/java/ch/threema/app/activities/AddContactActivity.java
  74. 25 24
      app/src/main/java/ch/threema/app/activities/AppLinksActivity.java
  75. 15 9
      app/src/main/java/ch/threema/app/activities/BackupAdminActivity.java
  76. 4 6
      app/src/main/java/ch/threema/app/activities/BackupRestoreProgressActivity.kt
  77. 2 2
      app/src/main/java/ch/threema/app/activities/BlockedIdentitiesActivity.kt
  78. 143 57
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.kt
  79. 4 4
      app/src/main/java/ch/threema/app/activities/CropImageActivity.kt
  80. 0 10
      app/src/main/java/ch/threema/app/activities/DisableBatteryOptimizationsActivity.java
  81. 8 5
      app/src/main/java/ch/threema/app/activities/DistributionListAddActivity.kt
  82. 30 25
      app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java
  83. 2 2
      app/src/main/java/ch/threema/app/activities/ExcludedSyncIdentitiesActivity.kt
  84. 5 1
      app/src/main/java/ch/threema/app/activities/ExportIDActivity.kt
  85. 4 2
      app/src/main/java/ch/threema/app/activities/ExportIDResultActivity.java
  86. 7 3
      app/src/main/java/ch/threema/app/activities/GroupAdd2Activity.java
  87. 1 1
      app/src/main/java/ch/threema/app/activities/GroupAddActivity.java
  88. 100 111
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  89. 5 11
      app/src/main/java/ch/threema/app/activities/IdentityListActivity.java
  90. 21 20
      app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java
  91. 1 1
      app/src/main/java/ch/threema/app/activities/ImagePaintKeyboardActivity.java
  92. 90 45
      app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java
  93. 4 3
      app/src/main/java/ch/threema/app/activities/MemberChooseActivity.java
  94. 17 10
      app/src/main/java/ch/threema/app/activities/PermissionRequestActivity.kt
  95. 367 329
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  96. 32 24
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  97. 0 123
      app/src/main/java/ch/threema/app/activities/ServerMessageActivity.java
  98. 14 15
      app/src/main/java/ch/threema/app/activities/ThreemaActivity.java
  99. 1 1
      app/src/main/java/ch/threema/app/activities/ThreemaAppCompatActivity.java
  100. 67 0
      app/src/main/java/ch/threema/app/activities/ThreemaComposeActivity.kt

+ 1 - 1
.gitignore

@@ -16,7 +16,7 @@ fastlane/descriptions/
 fastlane/jenkins-screenshots/
 export/
 threema-android-*
-release/
+/release/
 reproduce/
 .cxx/
 **/.DS_Store

+ 1 - 1
README.md

@@ -272,7 +272,7 @@ language, please contact us at `support at threema dot ch`.
 
 Threema for Android is licensed under the GNU Affero General Public License v3.
 
-    Copyright (c) Threema GmbH
+    Copyright (c) Threema AG
 
     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,

+ 3 - 3
app/assets/license.html

@@ -51,7 +51,7 @@
 <body>
 
 <p class="maincopyright">
-    Copyright © Threema GmbH.<br/>
+    Copyright © Threema AG.<br/>
     All rights reserved.
 </p>
 
@@ -262,12 +262,12 @@
 
 
 <h2>saltyrtc-client-java</h2>
-<p>Copyright © Threema GmbH</p>
+<p>Copyright © Threema AG</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>saltyrtc-task-webrtc-java</h2>
-<p>Copyright © Threema GmbH</p>
+<p>Copyright © Threema AG</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 

+ 50 - 41
app/build.gradle.kts

@@ -1,5 +1,6 @@
 import com.android.build.gradle.internal.api.ApkVariantOutputImpl
 import com.android.build.gradle.internal.tasks.factory.dependsOn
+import config.BuildFeatureFlags
 import config.PublicKeys
 import config.SentryConfig
 import config.setProductNames
@@ -15,6 +16,7 @@ plugins {
     alias(libs.plugins.rust.android)
     id("com.android.application")
     id("kotlin-android")
+    id("kotlin-parcelize")
     alias(libs.plugins.ksp)
     alias(libs.plugins.compose.compiler)
     alias(libs.plugins.stem)
@@ -31,7 +33,7 @@ if (gradle.startParameter.taskRequests.toString().contains("Hms")) {
 /**
  * Only use the scheme "<major>.<minor>.<patch>" for the appVersion
  */
-val appVersion = "6.4.3"
+val appVersion = "6.5.0"
 
 /**
  * betaSuffix with leading dash (e.g. `-beta1`).
@@ -40,7 +42,7 @@ val appVersion = "6.4.3"
  */
 val betaSuffix = ""
 
-val defaultVersionCode = 1148
+val defaultVersionCode = 1208
 
 /**
  * Map with keystore paths (if found).
@@ -54,9 +56,9 @@ val keystores: Map<String, KeystoreConfig?> = mapOf(
 )
 
 android {
-    // NOTE: When adjusting compileSdkVersion, buildToolsVersion or ndkVersion,
-    //       make sure to adjust them in `scripts/Dockerfile` as well!
-    compileSdk = 35
+    // NOTE: When adjusting compileSdk, buildToolsVersion or ndkVersion, make sure to also adjust
+    //   `scripts/Dockerfile`, `commonAndroid/build.gradle.kts`, and the image in `.gitlab-ci.yml`
+    compileSdk = 36
     buildToolsVersion = "35.0.0"
     ndkVersion = "28.2.13676358"
 
@@ -67,7 +69,7 @@ android {
             put("disableAnalytics", "true")
         }
         minSdk = 24
-        targetSdk = 35
+        targetSdk = 36
         vectorDrawables.useSupportLibrary = true
         applicationId = "ch.threema.app"
         testApplicationId = "$applicationId.test"
@@ -130,7 +132,8 @@ android {
         stringArrayBuildConfigField("ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", emptyArray())
         booleanBuildConfigField("MD_SYNC_DISTRIBUTION_LISTS", false)
         booleanBuildConfigField("AVAILABILITY_STATUS_ENABLED", false)
-        booleanBuildConfigField("ERROR_REPORTING_SUPPORTED", false)
+        booleanBuildConfigField("CROSS_PLATFORM_BACKUPS_ENABLED", BuildFeatureFlags["cross_platform_backups"] ?: false)
+        booleanBuildConfigField("NEW_CONVERSATION_SCREEN_ENABLED", BuildFeatureFlags["new_conversation_screen"] ?: false)
 
         // config fields for action URLs / deep links
         stringBuildConfigField("uriScheme", "threema")
@@ -140,11 +143,6 @@ android {
         // The OPPF url must be null in the default config. Do not change this.
         stringBuildConfigField("PRESET_OPPF_URL", null)
 
-        setSentryConfig(
-            projectId = 0,
-            publicApikey = "",
-        )
-
         with(manifestPlaceholders) {
             put("uriScheme", "threema")
             put("contactActionUrl", "threema.id")
@@ -216,10 +214,16 @@ android {
     namespace = "ch.threema.app"
     flavorDimensions.add("default")
     productFlavors {
-        create("none")
-        create("store_google")
+        create("none") {
+            setSentryConfig(null)
+        }
+        create("store_google") {
+            setSentryConfig(SentryConfig.PRODUCTION)
+        }
         create("store_threema") {
+            versionName = "${appVersion}s$betaSuffix"
             stringResValue("shop_download_filename", "Threema-update.apk")
+            setSentryConfig(SentryConfig.PRODUCTION)
         }
         create("store_google_work") {
             versionName = "${appVersion}k$betaSuffix"
@@ -252,15 +256,13 @@ android {
                 put("actionUrl", "work.threema.ch")
                 put("callMimeType", "vnd.android.cursor.item/vnd.$applicationId.call")
             }
+
+            setSentryConfig(SentryConfig.PRODUCTION)
         }
         create("green") {
             applicationId = "ch.threema.app.green"
             testApplicationId = "$applicationId.test"
             setProductNames(appName = "Threema Green")
-            setSentryConfig(
-                projectId = SentryConfig.SANDBOX_PROJECT_ID,
-                publicApikey = SentryConfig.SANDBOX_PUBLIC_API_KEY,
-            )
             stringResValue("package_name", applicationId!!)
             stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.$applicationId.profile")
             stringResValue("call_mime_type", "vnd.android.cursor.item/vnd.$applicationId.call")
@@ -282,7 +284,8 @@ android {
             stringBuildConfigField("MAP_POI_AROUND_URL", "https://poi.test.threema.ch/around/{latitude}/{longitude}/{radius}/")
             stringBuildConfigField("MAP_POI_NAMES_URL", "https://poi.test.threema.ch/names/{latitude}/{longitude}/{query}/")
             stringBuildConfigField("BLOB_MIRROR_SERVER_URL", "https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
-            booleanBuildConfigField("ERROR_REPORTING_SUPPORTED", true)
+
+            setSentryConfig(SentryConfig.SANDBOX)
         }
         create("sandbox_work") {
             versionName = "${appVersion}k$betaSuffix"
@@ -292,10 +295,6 @@ android {
                 appName = "Threema Sandbox Work",
                 appNameDesktop = "Threema Blue",
             )
-            setSentryConfig(
-                projectId = SentryConfig.SANDBOX_PROJECT_ID,
-                publicApikey = SentryConfig.SANDBOX_PUBLIC_API_KEY,
-            )
             stringResValue("package_name", applicationId!!)
             stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.$applicationId.profile")
             stringResValue("call_mime_type", "vnd.android.cursor.item/vnd.$applicationId.call")
@@ -330,7 +329,6 @@ android {
             stringBuildConfigField("actionUrl", "work.test.threema.ch")
 
             booleanBuildConfigField("AVAILABILITY_STATUS_ENABLED", true)
-            booleanBuildConfigField("ERROR_REPORTING_SUPPORTED", true)
 
             stringBuildConfigField("MD_CLIENT_DOWNLOAD_URL", "https://three.ma/mdw")
 
@@ -338,6 +336,8 @@ android {
                 put("uriScheme", "threemawork")
                 put("actionUrl", "work.test.threema.ch")
             }
+
+            setSentryConfig(SentryConfig.SANDBOX)
         }
         create("onprem") {
             versionName = "${appVersion}o$betaSuffix"
@@ -388,6 +388,8 @@ android {
                 put("actionUrl", actionUrl)
                 put("callMimeType", "vnd.android.cursor.item/vnd.$applicationId.call")
             }
+
+            setSentryConfig(null)
         }
         create("blue") {
             // Essentially like sandbox work, but with a different icon and application id, used for internal testing
@@ -396,10 +398,6 @@ android {
             applicationId = "ch.threema.app.red"
             testApplicationId = "ch.threema.app.blue.test"
             setProductNames(appName = "Threema Blue")
-            setSentryConfig(
-                projectId = SentryConfig.SANDBOX_PROJECT_ID,
-                publicApikey = SentryConfig.SANDBOX_PUBLIC_API_KEY,
-            )
             stringResValue("package_name", applicationId!!)
             stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.blue.profile")
             stringResValue("call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.blue.call")
@@ -430,7 +428,6 @@ android {
             stringBuildConfigField("BLOB_MIRROR_SERVER_URL", "https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
 
             booleanBuildConfigField("AVAILABILITY_STATUS_ENABLED", true)
-            booleanBuildConfigField("ERROR_REPORTING_SUPPORTED", true)
 
             // config fields for action URLs / deep links
             stringBuildConfigField("uriScheme", "threemablue")
@@ -441,9 +438,12 @@ android {
                 put("actionUrl", "blue.threema.ch")
                 put("callMimeType", "vnd.android.cursor.item/vnd.ch.threema.app.blue.call")
             }
+
+            setSentryConfig(SentryConfig.SANDBOX)
         }
         create("hms") {
             applicationId = "ch.threema.app.hms"
+            setSentryConfig(SentryConfig.PRODUCTION)
         }
         create("hms_work") {
             versionName = "${appVersion}k$betaSuffix"
@@ -473,6 +473,8 @@ android {
                 put("actionUrl", "work.threema.ch")
                 put("callMimeType", "vnd.android.cursor.item/vnd.ch.threema.app.work.call")
             }
+
+            setSentryConfig(SentryConfig.PRODUCTION)
         }
         create("libre") {
             versionName = "${appVersion}l$betaSuffix"
@@ -484,6 +486,8 @@ android {
                 appNameDesktop = "Threema",
             )
             stringBuildConfigField("MEDIA_PATH", "ThreemaLibre")
+
+            setSentryConfig(null)
         }
     }
 
@@ -827,18 +831,9 @@ dependencies {
     testImplementation(libs.koin.test)
     testImplementation(libs.koin.test.junit4)
 
+    // Database
     implementation(libs.sqlcipher.android)
 
-    implementation(libs.subsamplingScaleImageView)
-    implementation(libs.opencsv)
-    implementation(libs.zip4j)
-    implementation(libs.taptargetview)
-    implementation(libs.slf4j.api)
-    implementation(libs.androidImageCropper)
-    implementation(libs.fastscroll)
-    implementation(libs.ezVcard)
-    implementation(libs.gestureViews)
-
     // AndroidX / Jetpack support libraries
     implementation(libs.androidx.preference)
     implementation(libs.androidx.recyclerview)
@@ -881,16 +876,30 @@ dependencies {
     implementation(libs.androidx.material3)
     implementation(libs.androidx.ui.tooling.preview)
     implementation(libs.androidx.activity.compose)
+    implementation(libs.androidx.fragment.compose)
     implementation(libs.androidx.lifecycle.viewmodel.compose)
     implementation(libs.androidx.lifecycle.runtime.compose)
     debugImplementation(libs.androidx.ui.tooling)
     debugImplementation(libs.androidx.ui.test.manifest)
     androidTestImplementation(platform(libs.compose.bom))
 
-    implementation(libs.bcprov.jdk15to18)
-
+    // UI / Views
+    implementation(libs.subsamplingScaleImageView)
+    implementation(libs.taptargetview)
+    implementation(libs.fastscroll)
+    implementation(libs.gestureViews)
     implementation(libs.material)
+    implementation(libs.androidImageCropper)
     implementation(libs.zxing)
+
+    // Cryptography
+    implementation(libs.bcprov.jdk15to18)
+
+    // Files / data processing
+    implementation(libs.opencsv)
+    implementation(libs.zip4j)
+    implementation(libs.slf4j.api)
+    implementation(libs.ezVcard)
     implementation(libs.libphonenumber)
 
     // webclient dependencies

+ 2 - 2
app/src/androidTest/java/ch/threema/KoinTestRule.kt

@@ -1,6 +1,6 @@
 package ch.threema
 
-import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.core.app.ApplicationProvider
 import org.junit.rules.TestWatcher
 import org.junit.runner.Description
 import org.koin.android.ext.koin.androidContext
@@ -16,7 +16,7 @@ class KoinTestRule(
     override fun starting(description: Description) {
         if (getKoinApplicationOrNull() == null) {
             startKoin {
-                androidContext(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext)
+                androidContext(ApplicationProvider.getApplicationContext())
                 modules(modules)
             }
         } else {

+ 0 - 237
app/src/androidTest/java/ch/threema/app/TestCoreServiceManager.kt

@@ -1,237 +0,0 @@
-package ch.threema.app
-
-import androidx.annotation.WorkerThread
-import ch.threema.app.managers.CoreServiceManager
-import ch.threema.app.managers.ServiceManager
-import ch.threema.app.multidevice.MultiDeviceManager
-import ch.threema.app.multidevice.PersistedMultiDeviceProperties
-import ch.threema.app.multidevice.linking.DeviceLinkingStatus
-import ch.threema.app.services.ContactService
-import ch.threema.app.services.UserService
-import ch.threema.app.stores.EncryptedPreferenceStore
-import ch.threema.app.stores.IdentityProvider
-import ch.threema.app.stores.PreferenceStore
-import ch.threema.app.tasks.TaskCreator
-import ch.threema.app.utils.AppVersionProvider
-import ch.threema.base.crypto.HashedNonce
-import ch.threema.base.crypto.Nonce
-import ch.threema.base.crypto.NonceFactory
-import ch.threema.base.crypto.NonceScope
-import ch.threema.base.crypto.NonceStore
-import ch.threema.domain.helpers.TransactionAckTaskCodec
-import ch.threema.domain.models.AppVersion
-import ch.threema.domain.protocol.D2mProtocolDefines
-import ch.threema.domain.protocol.connection.csp.DeviceCookieManager
-import ch.threema.domain.protocol.connection.d2m.MultiDevicePropertyProvider
-import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
-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.connection.data.InboundD2mMessage.DevicesInfo
-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.stores.IdentityStore
-import ch.threema.domain.taskmanager.ActiveTaskCodec
-import ch.threema.domain.taskmanager.QueueSendCompleteListener
-import ch.threema.domain.taskmanager.Task
-import ch.threema.domain.taskmanager.TaskArchiver
-import ch.threema.domain.taskmanager.TaskCodec
-import ch.threema.domain.taskmanager.TaskManager
-import ch.threema.storage.DatabaseProvider
-import ch.threema.storage.DatabaseService
-import ch.threema.testhelpers.MUST_NOT_BE_CALLED
-import io.mockk.mockk
-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.launch
-import kotlinx.coroutines.runBlocking
-import org.koin.mp.KoinPlatform
-
-class TestCoreServiceManager
-@JvmOverloads
-constructor(
-    override val version: AppVersion = AppVersionProvider.appVersion,
-    override val databaseProvider: DatabaseProvider,
-    identityProvider: IdentityProvider = mockk(),
-    override val databaseService: DatabaseService = DatabaseService(databaseProvider, identityProvider),
-    override val preferenceStore: PreferenceStore = KoinPlatform.getKoin().get(),
-    override val encryptedPreferenceStore: EncryptedPreferenceStore = KoinPlatform.getKoin().get(),
-    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 = mockk(),
-    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(
-    private 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 { },
-) : MultiDeviceManager {
-    @WorkerThread
-    override fun removeMultiDeviceLocally(serviceManager: ServiceManager) {
-        MUST_NOT_BE_CALLED()
-    }
-
-    override suspend fun setDeviceLabel(deviceLabel: String) {
-        MUST_NOT_BE_CALLED()
-    }
-
-    override suspend fun linkDevice(
-        serviceManager: ServiceManager,
-        deviceJoinOfferUri: String,
-        taskCreator: TaskCreator,
-    ): Flow<DeviceLinkingStatus> {
-        MUST_NOT_BE_CALLED()
-    }
-
-    override suspend fun loadLinkedDevices(taskCreator: TaskCreator): Result<Map<DeviceId, DevicesInfo.AugmentedDeviceInfo>> {
-        MUST_NOT_BE_CALLED()
-    }
-
-    override suspend fun setProperties(persistedProperties: PersistedMultiDeviceProperties?) {
-        MUST_NOT_BE_CALLED()
-    }
-
-    override fun reconnect() {
-        MUST_NOT_BE_CALLED()
-    }
-
-    override suspend fun disableForwardSecurity(
-        handle: ActiveTaskCodec,
-        contactService: ContactService,
-        userService: UserService,
-        fsMessageProcessor: ForwardSecurityMessageProcessor,
-        taskCreator: TaskCreator,
-    ) {
-        MUST_NOT_BE_CALLED()
-    }
-
-    override fun enableForwardSecurity(serviceManager: ServiceManager) {
-        MUST_NOT_BE_CALLED()
-    }
-}
-
-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,
-        hashedNonces: 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),
-        ) { }
-}

+ 68 - 0
app/src/androidTest/java/ch/threema/app/TestMultiDeviceManager.kt

@@ -0,0 +1,68 @@
+package ch.threema.app
+
+import androidx.annotation.WorkerThread
+import ch.threema.app.managers.ServiceManager
+import ch.threema.app.multidevice.MultiDeviceManager
+import ch.threema.app.multidevice.PersistedMultiDeviceProperties
+import ch.threema.app.multidevice.linking.DeviceLinkingStatus
+import ch.threema.app.services.ContactService
+import ch.threema.app.services.UserService
+import ch.threema.app.tasks.TaskCreator
+import ch.threema.domain.protocol.connection.d2m.MultiDevicePropertyProvider
+import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
+import ch.threema.domain.protocol.connection.data.DeviceId
+import ch.threema.domain.protocol.connection.data.InboundD2mMessage.DevicesInfo
+import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
+import ch.threema.domain.taskmanager.ActiveTaskCodec
+import ch.threema.testhelpers.MUST_NOT_BE_CALLED
+import kotlinx.coroutines.flow.Flow
+
+class TestMultiDeviceManager(
+    override val isMdDisabledOrSupportsFs: Boolean = true,
+    override val isMultiDeviceActive: Boolean = false,
+    override val propertiesProvider: MultiDevicePropertyProvider = TestMultiDevicePropertyProvider,
+    override val socketCloseListener: D2mSocketCloseListener = D2mSocketCloseListener { },
+) : MultiDeviceManager {
+    @WorkerThread
+    override fun removeMultiDeviceLocally(serviceManager: ServiceManager) {
+        MUST_NOT_BE_CALLED()
+    }
+
+    override suspend fun setDeviceLabel(deviceLabel: String) {
+        MUST_NOT_BE_CALLED()
+    }
+
+    override suspend fun linkDevice(
+        serviceManager: ServiceManager,
+        deviceJoinOfferUri: String,
+        taskCreator: TaskCreator,
+    ): Flow<DeviceLinkingStatus> {
+        MUST_NOT_BE_CALLED()
+    }
+
+    override suspend fun loadLinkedDevices(taskCreator: TaskCreator): Result<Map<DeviceId, DevicesInfo.AugmentedDeviceInfo>> {
+        MUST_NOT_BE_CALLED()
+    }
+
+    override suspend fun setProperties(persistedProperties: PersistedMultiDeviceProperties?) {
+        MUST_NOT_BE_CALLED()
+    }
+
+    override fun reconnect() {
+        MUST_NOT_BE_CALLED()
+    }
+
+    override suspend fun disableForwardSecurity(
+        handle: ActiveTaskCodec,
+        contactService: ContactService,
+        userService: UserService,
+        fsMessageProcessor: ForwardSecurityMessageProcessor,
+        taskCreator: TaskCreator,
+    ) {
+        MUST_NOT_BE_CALLED()
+    }
+
+    override fun enableForwardSecurity(serviceManager: ServiceManager) {
+        MUST_NOT_BE_CALLED()
+    }
+}

+ 26 - 0
app/src/androidTest/java/ch/threema/app/TestMultiDevicePropertyProvider.kt

@@ -0,0 +1,26 @@
+package ch.threema.app
+
+import ch.threema.domain.protocol.D2mProtocolDefines
+import ch.threema.domain.protocol.connection.d2m.MultiDevicePropertyProvider
+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.multidevice.MultiDeviceKeys
+import ch.threema.domain.protocol.multidevice.MultiDeviceProperties
+
+object TestMultiDevicePropertyProvider : MultiDevicePropertyProvider {
+    override fun get() = MultiDeviceProperties(
+        registrationTime = 0u,
+        mediatorDeviceId = DeviceId(0u),
+        cspDeviceId = DeviceId(0u),
+        keys = MultiDeviceKeys(ByteArray(D2mProtocolDefines.DGK_LENGTH_BYTES)),
+        deviceInfo = D2dMessage.DeviceInfo(
+            platform = D2dMessage.DeviceInfo.Platform.ANDROID,
+            platformDetails = "",
+            appVersion = "",
+            label = "",
+        ),
+        protocolVersion = D2mProtocolVersion(UInt.MIN_VALUE, UInt.MAX_VALUE),
+        serverInfoListener = {},
+    )
+}

+ 27 - 0
app/src/androidTest/java/ch/threema/app/TestNonceStore.kt

@@ -0,0 +1,27 @@
+package ch.threema.app
+
+import ch.threema.base.crypto.HashedNonce
+import ch.threema.base.crypto.Nonce
+import ch.threema.base.crypto.NonceScope
+import ch.threema.base.crypto.NonceStore
+
+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,
+        hashedNonces: MutableList<HashedNonce>,
+    ) {
+        // Nothing to do
+    }
+
+    override fun insertHashedNonces(scope: NonceScope, nonces: List<HashedNonce>) = true
+}

+ 56 - 0
app/src/androidTest/java/ch/threema/app/TestTaskManager.kt

@@ -0,0 +1,56 @@
+package ch.threema.app
+
+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 kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+
+class TestTaskManager(
+    private 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
+    }
+}

+ 246 - 269
app/src/androidTest/java/ch/threema/app/contacts/AddOrUpdateContactBackgroundTaskTest.kt

@@ -1,7 +1,9 @@
 package ch.threema.app.contacts
 
 import android.os.Looper
-import ch.threema.app.TestCoreServiceManager
+import ch.threema.app.TestMultiDeviceManager
+import ch.threema.app.TestNonceStore
+import ch.threema.app.TestTaskManager
 import ch.threema.app.asynctasks.AddContactRestrictionPolicy
 import ch.threema.app.asynctasks.AddOrUpdateContactBackgroundTask
 import ch.threema.app.asynctasks.AlreadyVerified
@@ -13,95 +15,97 @@ 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.protocolsteps.Contact
+import ch.threema.app.protocolsteps.ContactOrInit
+import ch.threema.app.protocolsteps.Init
+import ch.threema.app.protocolsteps.Invalid
+import ch.threema.app.protocolsteps.UserContact
+import ch.threema.app.protocolsteps.ValidContactsLookupSteps
 import ch.threema.app.restrictions.AppRestrictions
-import ch.threema.app.stores.IdentityProvider
 import ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.base.crypto.NaCl
-import ch.threema.common.Http
+import ch.threema.base.crypto.NonceFactory
+import ch.threema.data.models.ContactModel
 import ch.threema.data.repositories.ContactModelRepository
 import ch.threema.data.repositories.ModelRepositories
+import ch.threema.domain.helpers.TransactionAckTaskCodec
+import ch.threema.domain.models.AcquaintanceLevel
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.VerificationLevel
-import ch.threema.domain.protocol.Version
-import ch.threema.domain.protocol.api.APIConnector
-import ch.threema.domain.protocol.api.APIConnector.FetchIdentityResult
-import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
-import ch.threema.domain.stores.IdentityStore
 import ch.threema.domain.types.Identity
 import ch.threema.domain.types.IdentityString
-import ch.threema.storage.TestDatabaseProvider
-import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import ch.threema.test.TestData
+import ch.threema.test.TestData.PUBLIC_KEY
+import ch.threema.test.TestDatabaseProvider
+import ch.threema.test.TestIdentityProvider
+import ch.threema.testhelpers.TestDispatcherProvider
+import io.mockk.Runs
 import io.mockk.every
+import io.mockk.just
 import io.mockk.mockk
+import io.mockk.verify
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertFalse
+import kotlin.test.assertIs
 import kotlin.test.assertNotEquals
 import kotlin.test.assertTrue
 import kotlin.test.fail
-import kotlinx.coroutines.runBlocking
-import okhttp3.OkHttpClient
-import org.koin.core.component.KoinComponent
-import org.koin.core.component.inject
+import kotlinx.coroutines.test.runTest
 
-class AddOrUpdateContactBackgroundTaskTest : KoinComponent {
-    private val appRestrictions: AppRestrictions by inject()
+class AddOrUpdateContactBackgroundTaskTest {
+    private val appRestrictions: AppRestrictions = mockk {
+        every { isAddContactDisabled() } returns false
+    }
 
     private val backgroundExecutor = BackgroundExecutor()
     private lateinit var databaseProvider: TestDatabaseProvider
-    private lateinit var coreServiceManager: CoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
 
-    private val myIdentity = "00000000"
-
     @BeforeTest
     fun before() {
         databaseProvider = TestDatabaseProvider()
-        val identityProviderMock = mockk<IdentityProvider> {
-            every { getIdentity() } returns Identity(myIdentity)
-            every { getIdentityString() } returns myIdentity
-        }
-        val identityStoreMock = mockk<IdentityStore> {
-            every { getIdentity() } returns Identity(myIdentity)
-            every { getIdentityString() } returns myIdentity
-        }
-        coreServiceManager = TestCoreServiceManager(
-            databaseProvider = databaseProvider,
-            identityProvider = identityProviderMock,
-            identityStore = identityStoreMock,
-        )
+        val identityProviderMock = TestIdentityProvider(Identity(MY_IDENTITY))
         contactModelRepository = ModelRepositories(
-            coreServiceManager = coreServiceManager,
+            databaseProvider = databaseProvider,
             identityProvider = identityProviderMock,
+            multiDeviceManager = TestMultiDeviceManager(),
+            taskManager = TestTaskManager(TransactionAckTaskCodec()),
+            nonceFactory = NonceFactory(TestNonceStore()),
+            globalEventBuses = mockk(relaxed = true),
+            globalEventFlows = mockk(relaxed = true),
+            dispatcherProvider = TestDispatcherProvider(),
         ).contacts
     }
 
     @Test
-    fun testAddSuccessful() {
+    fun testAddSuccessful() = runTest {
         val newIdentity = "01234567"
 
         testAddingContact(
             { identity ->
-                FetchIdentityResult().also {
-                    it.identity = identity
-                    it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
-                    it.featureMask = 12
-                    it.type = 0
-                    it.state = IdentityState.ACTIVE.value
-                }
+                Init(
+                    TestData.createContactModelData(
+                        identity = Identity(identity),
+                        featureMask = 12u,
+                        identityType = IdentityType.REGULAR,
+                        activityState = IdentityState.ACTIVE,
+                        verificationLevel = VerificationLevel.UNVERIFIED,
+                        acquaintanceLevel = AcquaintanceLevel.DIRECT,
+                    ),
+                )
             },
             {
-                assertTrue(it is ContactCreated)
+                assertIs<ContactCreated>(it)
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data!!
                 assertEquals(newIdentity, data.identity)
-                assertContentEquals(ByteArray(NaCl.PUBLIC_KEY_BYTES), data.publicKey)
+                assertContentEquals(PUBLIC_KEY, data.publicKey)
                 assertEquals(12u, data.featureMask)
-                assertEquals(IdentityType.NORMAL, data.identityType)
+                assertEquals(IdentityType.REGULAR, data.identityType)
                 assertEquals(IdentityState.ACTIVE, data.activityState)
                 assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
                 assertEquals(AcquaintanceLevel.DIRECT, data.acquaintanceLevel)
@@ -110,340 +114,322 @@ class AddOrUpdateContactBackgroundTaskTest : KoinComponent {
     }
 
     @Test
-    fun testAddGroupContactSuccessful() {
+    fun testAddGroupContactSuccessful() = runTest {
         val newIdentity = "01234567"
 
         testAddingContact(
-            fetchIdentity = { identity ->
-                FetchIdentityResult().also {
-                    it.identity = identity
-                    it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
-                    it.featureMask = 12
-                    it.type = 0
-                    it.state = IdentityState.ACTIVE.value
-                }
+            { identity ->
+                Init(
+                    TestData.createContactModelData(
+                        identity = Identity(identity),
+                        featureMask = 12u,
+                        identityType = IdentityType.REGULAR,
+                        activityState = IdentityState.ACTIVE,
+                        verificationLevel = VerificationLevel.UNVERIFIED,
+                        acquaintanceLevel = AcquaintanceLevel.DIRECT,
+                    ),
+                )
             },
-            acquaintanceLevel = AcquaintanceLevel.GROUP,
+            acquaintanceLevel = AcquaintanceLevel.GROUP_OR_DELETED,
             runOnFinished = {
-                assertTrue(it is ContactCreated)
+                assertIs<ContactCreated>(it)
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data!!
                 assertEquals(newIdentity, data.identity)
-                assertContentEquals(ByteArray(NaCl.PUBLIC_KEY_BYTES), data.publicKey)
+                assertContentEquals(PUBLIC_KEY, data.publicKey)
                 assertEquals(12u, data.featureMask)
-                assertEquals(IdentityType.NORMAL, data.identityType)
+                assertEquals(IdentityType.REGULAR, data.identityType)
                 assertEquals(IdentityState.ACTIVE, data.activityState)
                 assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
-                assertEquals(AcquaintanceLevel.GROUP, data.acquaintanceLevel)
+                assertEquals(AcquaintanceLevel.GROUP_OR_DELETED, data.acquaintanceLevel)
             },
         )
     }
 
     @Test
-    fun testAddSuccessfulVerified() {
+    fun testAddSuccessfulVerified() = runTest {
         val newIdentity = "01234567"
 
         testAddingContact(
             { identity ->
-                FetchIdentityResult().also {
-                    it.identity = identity
-                    it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
-                    it.featureMask = 127
-                    it.type = 1
-                    it.state = IdentityState.INACTIVE.value
-                }
+                Init(
+                    TestData.createContactModelData(
+                        identity = Identity(identity),
+                        featureMask = 127u,
+                        identityType = IdentityType.WORK,
+                        activityState = IdentityState.INACTIVE,
+                        verificationLevel = VerificationLevel.FULLY_VERIFIED,
+                        acquaintanceLevel = AcquaintanceLevel.DIRECT,
+                    ),
+                )
             },
             {
-                assertTrue(it is ContactCreated)
+                assertIs<ContactCreated>(it)
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data!!
                 assertEquals(newIdentity, data.identity)
-                assertContentEquals(ByteArray(NaCl.PUBLIC_KEY_BYTES), data.publicKey)
+                assertContentEquals(PUBLIC_KEY, data.publicKey)
                 assertEquals(127u, data.featureMask)
                 assertEquals(IdentityType.WORK, data.identityType)
                 assertEquals(IdentityState.INACTIVE, data.activityState)
                 assertEquals(VerificationLevel.FULLY_VERIFIED, data.verificationLevel)
                 assertEquals(AcquaintanceLevel.DIRECT, data.acquaintanceLevel)
             },
-            publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES),
+            publicKey = PUBLIC_KEY,
         )
     }
 
     @Test
-    fun testAddMyIdentity() {
+    fun testAddMyIdentity() = runTest {
         testAddingContact(
             { identity ->
-                FetchIdentityResult().also {
-                    it.identity = identity
-                    it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
-                    it.featureMask = 127
-                    it.type = 1
-                    it.state = IdentityState.INACTIVE.value
-                }
+                Init(
+                    TestData.createContactModelData(
+                        identity = Identity(identity),
+                        featureMask = 12u,
+                        identityType = IdentityType.REGULAR,
+                        activityState = IdentityState.ACTIVE,
+                        verificationLevel = VerificationLevel.UNVERIFIED,
+                        acquaintanceLevel = AcquaintanceLevel.DIRECT,
+                    ),
+                )
             },
             {
-                assertTrue(it is UserIdentity)
+                assertIs<UserIdentity>(it)
             },
-            newIdentity = myIdentity,
-            myIdentity = myIdentity,
+            newIdentity = MY_IDENTITY,
+            myIdentity = MY_IDENTITY,
         )
     }
 
     @Test
-    fun testAddPublicKeyMismatch() {
+    fun testAddPublicKeyMismatch() = runTest {
         testAddingContact(
             { identity ->
-                FetchIdentityResult().also {
-                    it.identity = identity
-                    it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
-                    it.featureMask = 12
-                    it.type = 0
-                    it.state = IdentityState.ACTIVE.value
-                }
+                Init(
+                    TestData.createContactModelData(
+                        identity = Identity(identity),
+                        featureMask = 12u,
+                        identityType = IdentityType.REGULAR,
+                        activityState = IdentityState.ACTIVE,
+                        verificationLevel = VerificationLevel.UNVERIFIED,
+                        acquaintanceLevel = AcquaintanceLevel.DIRECT,
+                    ),
+                )
             },
             {
-                assertTrue(it is RemotePublicKeyMismatch)
+                assertIs<RemotePublicKeyMismatch>(it)
             },
-            publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES).also { it.fill(1) },
+            publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES) { 1 },
         )
     }
 
     @Test
-    fun testAddInvalidId() {
+    fun testAddInvalidId() = runTest {
         testAddingContact(
-            {
-                throw HttpConnectionException(Http.StatusCode.NOT_FOUND, Exception())
+            { identity ->
+                Invalid(identity = identity)
             },
             {
-                assertTrue(it is InvalidThreemaId)
+                assertIs<InvalidThreemaId>(it)
             },
         )
     }
 
     @Test
-    fun testAddExistingContact() {
-        val apiConnectorResult: (identity: IdentityString) -> FetchIdentityResult = { identity ->
-            FetchIdentityResult().also {
-                it.identity = identity
-                it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
-                it.featureMask = 12
-                it.type = 0
-                it.state = IdentityState.ACTIVE.value
-            }
+    fun testAddExistingContact() = runTest {
+        val validContactsLookupStepsResult: (identity: IdentityString) -> ContactOrInit = { identity ->
+            Init(
+                TestData.createContactModelData(
+                    identity = Identity(identity),
+                    featureMask = 12u,
+                    identityType = IdentityType.REGULAR,
+                    activityState = IdentityState.ACTIVE,
+                    verificationLevel = VerificationLevel.UNVERIFIED,
+                    acquaintanceLevel = AcquaintanceLevel.DIRECT,
+                ),
+            )
         }
 
         // The first time adding the contact should succeed
         testAddingContact(
-            apiConnectorResult,
+            validContactsLookupStepsResult,
             {
-                assertTrue(it is ContactCreated)
+                assertIs<ContactCreated>(it)
             },
         )
 
         // The second time adding the contact should fail
         testAddingContact(
-            apiConnectorResult,
+            validContactsLookupStepsResult,
             {
-                assertTrue(it is ContactExists)
+                assertIs<ContactExists>(it)
             },
         )
     }
 
     @Test
-    fun testVerifyTwice() {
-        val publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES).apply { fill(2) }
-
-        val apiConnectorResult: (identity: IdentityString) -> FetchIdentityResult = { identity ->
-            FetchIdentityResult().also {
-                it.identity = identity
-                it.publicKey = publicKey
-                it.featureMask = 12
-                it.type = 0
-                it.state = IdentityState.ACTIVE.value
-            }
+    fun testVerifyingAlreadyVerifiedContact() = runTest {
+        val publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES) { 2 }
+        val verifiedContactIdentity = "01234567"
+        val contactModelMock = mockk<ContactModel> {
+            every { identity } returns verifiedContactIdentity
+            every { data } returns TestData.createContactModelData(
+                identity = Identity(verifiedContactIdentity),
+                publicKey = publicKey,
+                featureMask = 12u,
+                identityType = IdentityType.REGULAR,
+                activityState = IdentityState.ACTIVE,
+                verificationLevel = VerificationLevel.FULLY_VERIFIED,
+                acquaintanceLevel = AcquaintanceLevel.DIRECT,
+            )
         }
 
-        // The first time adding the contact should succeed
-        testAddingContact(
-            apiConnectorResult,
-            {
-                assertTrue(it is ContactCreated)
-            },
-            publicKey = publicKey,
-        )
+        val validContactsLookupStepsResult: (identity: IdentityString) -> ContactOrInit = { identity ->
+            if (identity == verifiedContactIdentity) {
+                Contact(contactModelMock)
+            } else {
+                fail("Unexpected identity to run valid contacts lookup steps ($identity)")
+            }
+        }
 
-        // The second time adding the contact should fail
+        // The result should be already verified, as the contact is already verified
         testAddingContact(
-            apiConnectorResult,
+            validContactsLookupStepsResult,
             {
-                assertTrue(it is AlreadyVerified)
+                assertIs<AlreadyVerified>(it)
             },
             publicKey = publicKey,
         )
     }
 
     @Test
-    fun testUpgradeGroupContact() {
+    fun testUpgradeGroupContact() = runTest {
         val newIdentity = "01234567"
 
-        val apiConnectorResult: (identity: IdentityString) -> FetchIdentityResult = { identity ->
-            FetchIdentityResult().also {
-                it.identity = identity
-                it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
-                it.featureMask = 12
-                it.type = 0
-                it.state = IdentityState.ACTIVE.value
-            }
+        val contactModelMock = mockk<ContactModel> {
+            every { identity } returns newIdentity
+            every { data } returns TestData.createContactModelData(
+                identity = Identity(identity),
+                featureMask = 12u,
+                identityType = IdentityType.REGULAR,
+                activityState = IdentityState.ACTIVE,
+                verificationLevel = VerificationLevel.UNVERIFIED,
+                acquaintanceLevel = AcquaintanceLevel.GROUP_OR_DELETED,
+            )
+            every { setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT) } just Runs
         }
 
-        // The first time adding the contact should succeed
-        testAddingContact(
-            apiConnectorResult,
-            {
-                assertTrue(it is ContactCreated)
-            },
-            newIdentity = newIdentity,
-        )
-
-        val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
-
-        // Downgrade the contact to a group contact
-        contactModel.setAcquaintanceLevelFromLocal(AcquaintanceLevel.GROUP)
-
-        // Assert that the acquaintance level change worked
-        assertEquals(AcquaintanceLevel.GROUP, contactModel.data!!.acquaintanceLevel)
+        val validContactLookupStepsResult: (identity: IdentityString) -> ContactOrInit = { identity ->
+            if (identity == newIdentity) {
+                Contact(contactModelMock)
+            } else {
+                fail("Unexpected identity to run valid contacts lookup steps ($identity)")
+            }
+        }
 
         // When adding the contact again, it should be converted back to a direct contact
         testAddingContact(
-            apiConnectorResult,
+            validContactLookupStepsResult,
             {
-                assertTrue(it is ContactModified)
+                assertIs<ContactModified>(it)
                 assertTrue(it.acquaintanceLevelChanged)
                 assertFalse(it.verificationLevelChanged)
-                assertEquals(AcquaintanceLevel.DIRECT, contactModel.data!!.acquaintanceLevel)
+                verify(exactly = 1) { contactModelMock.setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT) }
             },
             newIdentity = newIdentity,
         )
     }
 
     @Test
-    fun testVerificationLevelUpgrade() {
+    fun testVerificationLevelUpgrade() = runTest {
         val newIdentity = "01234567"
 
-        val apiConnectorResult: (identity: IdentityString) -> FetchIdentityResult = { identity ->
-            FetchIdentityResult().also {
-                it.identity = identity
-                it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
-                it.featureMask = 12
-                it.type = 0
-                it.state = IdentityState.ACTIVE.value
-            }
+        val contactModelMock = mockk<ContactModel> {
+            every { identity } returns newIdentity
+            every { data } returns TestData.createContactModelData(
+                identity = Identity(identity),
+                featureMask = 12u,
+                identityType = IdentityType.REGULAR,
+                activityState = IdentityState.ACTIVE,
+                verificationLevel = VerificationLevel.UNVERIFIED,
+                acquaintanceLevel = AcquaintanceLevel.DIRECT,
+            )
+            every { setVerificationLevelFromLocal(VerificationLevel.FULLY_VERIFIED) } just Runs
         }
 
-        // The first time adding the contact should succeed
-        testAddingContact(
-            apiConnectorResult,
-            {
-                assertTrue(it is ContactCreated)
-            },
-            newIdentity = newIdentity,
-        )
-
-        val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
-
-        // Assert that the verification level is unverified
-        assertEquals(VerificationLevel.UNVERIFIED, contactModel.data!!.verificationLevel)
+        val validContactsLookupStepsResult: (identity: IdentityString) -> ContactOrInit = { identity ->
+            if (identity == newIdentity) {
+                Contact(contactModelMock)
+            } else {
+                fail("Unexpected identity to run valid contacts lookup steps ($identity)")
+            }
+        }
 
-        // When adding the contact again, it should be fully verified
+        // When "adding" the contact again, it should be upgraded to "fully verified"
         testAddingContact(
-            apiConnectorResult,
-            {
-                assertTrue(it is ContactModified)
-                assertTrue(it.verificationLevelChanged)
-                assertFalse(it.acquaintanceLevelChanged)
-                assertEquals(
-                    VerificationLevel.FULLY_VERIFIED,
-                    contactModel.data!!.verificationLevel,
-                )
+            validContactsLookupStepsResult,
+            { contactResult ->
+                assertIs<ContactModified>(contactResult)
+                assertTrue(contactResult.verificationLevelChanged)
+                assertFalse(contactResult.acquaintanceLevelChanged)
+                verify(exactly = 1) { contactModelMock.setVerificationLevelFromLocal(VerificationLevel.FULLY_VERIFIED) }
             },
             newIdentity = newIdentity,
-            publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES),
+            publicKey = PUBLIC_KEY,
         )
     }
 
     @Test
-    fun testAddAndVerifyGroupContact() {
+    fun testAddAndVerifyGroupContact() = runTest {
         val newIdentity = "01234567"
 
-        val apiConnectorResult: (identity: IdentityString) -> FetchIdentityResult = { identity ->
-            FetchIdentityResult().also {
-                it.identity = identity
-                it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
-                it.featureMask = 12
-                it.type = 0
-                it.state = IdentityState.ACTIVE.value
-            }
+        val contactModelMock = mockk<ContactModel> {
+            every { identity } returns newIdentity
+            every { data } returns TestData.createContactModelData(
+                identity = Identity(newIdentity),
+                featureMask = 12u,
+                identityType = IdentityType.REGULAR,
+                activityState = IdentityState.ACTIVE,
+                verificationLevel = VerificationLevel.UNVERIFIED,
+                acquaintanceLevel = AcquaintanceLevel.GROUP_OR_DELETED,
+            )
+            every { setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT) } just Runs
+            every { setVerificationLevelFromLocal(VerificationLevel.FULLY_VERIFIED) } just Runs
         }
 
-        // The first time adding the contact should succeed
-        testAddingContact(
-            apiConnectorResult,
-            {
-                assertTrue(it is ContactCreated)
-            },
-            newIdentity = newIdentity,
-        )
-
-        val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
-
-        // Assert that the verification level is unverified
-        assertEquals(VerificationLevel.UNVERIFIED, contactModel.data!!.verificationLevel)
-
-        // Downgrade the contact to acquaintance level group
-        contactModel.setAcquaintanceLevelFromLocal(AcquaintanceLevel.GROUP)
-        assertEquals(AcquaintanceLevel.GROUP, contactModel.data!!.acquaintanceLevel)
+        val validContactsLookupStepsResult: (identity: IdentityString) -> ContactOrInit = { identity ->
+            if (identity == newIdentity) {
+                Contact(contactModelMock)
+            } else {
+                fail("Unexpected identity to run valid contacts lookup steps ($identity)")
+            }
+        }
 
-        // When adding the contact again, it should be converted back to a direct contact
+        // When adding the contact that exists as an unverified group contact, it should be converted to a direct contact (that is verified)
         testAddingContact(
-            apiConnectorResult,
+            validContactsLookupStepsResult,
             {
-                assertTrue(it is ContactModified)
+                assertIs<ContactModified>(it)
                 assertTrue(it.acquaintanceLevelChanged)
                 assertTrue(it.verificationLevelChanged)
-                assertEquals(AcquaintanceLevel.DIRECT, contactModel.data!!.acquaintanceLevel)
-                assertEquals(
-                    VerificationLevel.FULLY_VERIFIED,
-                    contactModel.data!!.verificationLevel,
-                )
+                verify(exactly = 1) { contactModelMock.setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT) }
+                verify(exactly = 1) { contactModelMock.setVerificationLevelFromLocal(VerificationLevel.FULLY_VERIFIED) }
             },
             newIdentity = newIdentity,
-            publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES),
+            publicKey = PUBLIC_KEY,
         )
     }
 
     @Test
-    fun testThreadUsage() {
+    fun testThreadUsage() = runTest {
         val identity = "01234567"
-        val myIdentity = "00000000"
-
-        testAddingContact(
-            {
-                FetchIdentityResult().also {
-                    it.identity = identity
-                    it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
-                    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 validContactsLookupStepsResult = mockk<ValidContactsLookupSteps> {
+            every { run(identity) } returns Init(
+                TestData.createContactModelData(
+                    identity = Identity(identity),
+                ),
+            )
         }
 
         val testThreadId = Thread.currentThread().id
@@ -451,8 +437,7 @@ class AddOrUpdateContactBackgroundTaskTest : KoinComponent {
         val addTask = object : AddOrUpdateContactBackgroundTask<Boolean>(
             identity = identity,
             acquaintanceLevel = AcquaintanceLevel.DIRECT,
-            myIdentity = myIdentity,
-            apiConnector = unusedAPIConnector,
+            validContactsLookupSteps = validContactsLookupStepsResult,
             contactModelRepository = contactModelRepository,
             addContactRestrictionPolicy = AddContactRestrictionPolicy.CHECK,
             appRestrictions = appRestrictions,
@@ -463,7 +448,7 @@ class AddOrUpdateContactBackgroundTaskTest : KoinComponent {
             }
 
             override fun onContactResult(result: ContactResult): Boolean {
-                assertTrue(result is ContactExists)
+                assertIs<ContactCreated>(result)
                 assertNotEquals(testThreadId, Thread.currentThread().id)
                 assertNotEquals(Looper.getMainLooper(), Looper.myLooper())
                 return true
@@ -475,25 +460,22 @@ class AddOrUpdateContactBackgroundTaskTest : KoinComponent {
             }
         }
 
-        runBlocking {
-            assertTrue(backgroundExecutor.executeDeferred(addTask).await())
-        }
+        assertTrue(backgroundExecutor.executeDeferred(addTask).await())
     }
 
-    private fun testAddingContact(
-        fetchIdentity: (identity: IdentityString) -> FetchIdentityResult,
+    private suspend fun testAddingContact(
+        validContactsLookupStepsResult: (identity: IdentityString) -> ContactOrInit,
         runOnFinished: (result: ContactResult) -> Unit,
         newIdentity: IdentityString = "01234567",
         acquaintanceLevel: AcquaintanceLevel = AcquaintanceLevel.DIRECT,
         myIdentity: IdentityString = "00000000",
         publicKey: ByteArray? = null,
     ) {
-        val apiConnector = getTestApiConnector {
-            if (it != newIdentity) {
-                fail("Wrong identity is fetched: $it")
+        val validContactsLookupStepsMock: ValidContactsLookupSteps = mockk {
+            every { run(any<String>()) } answers {
+                validContactsLookupStepsResult(firstArg())
             }
-
-            fetchIdentity(it)
+            every { run(myIdentity) } returns UserContact(myIdentity)
         }
 
         val contactAdded =
@@ -501,8 +483,7 @@ class AddOrUpdateContactBackgroundTaskTest : KoinComponent {
                 object : BasicAddOrUpdateContactBackgroundTask(
                     identity = newIdentity,
                     acquaintanceLevel = acquaintanceLevel,
-                    myIdentity = myIdentity,
-                    apiConnector = apiConnector,
+                    validContactsLookupSteps = validContactsLookupStepsMock,
                     contactModelRepository = contactModelRepository,
                     addContactRestrictionPolicy = AddContactRestrictionPolicy.CHECK,
                     appRestrictions = appRestrictions,
@@ -515,14 +496,10 @@ class AddOrUpdateContactBackgroundTaskTest : KoinComponent {
             )
 
         // Assert that the test is not stopped before running the background task completely
-        runBlocking {
-            contactAdded.await()
-        }
+        contactAdded.await()
     }
 
-    private fun getTestApiConnector(onIdentityFetchCalled: (identity: IdentityString) -> FetchIdentityResult): APIConnector {
-        return object : APIConnector(false, null, false, OkHttpClient(), Version(), null, null) {
-            override fun fetchIdentity(identity: IdentityString) = onIdentityFetchCalled(identity)
-        }
+    companion object {
+        private const val MY_IDENTITY = "00000000"
     }
 }

+ 24 - 35
app/src/androidTest/java/ch/threema/app/contacts/MarkContactAsDeletedBackgroundTaskTest.kt

@@ -2,30 +2,30 @@ package ch.threema.app.contacts
 
 import androidx.annotation.WorkerThread
 import ch.threema.app.DangerousTest
-import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestMultiDevicePropertyProvider
-import ch.threema.app.ThreemaApplication
+import ch.threema.app.TestNonceStore
 import ch.threema.app.asynctasks.AndroidContactLinkPolicy
 import ch.threema.app.asynctasks.ContactSyncPolicy
 import ch.threema.app.asynctasks.DeleteContactServices
 import ch.threema.app.asynctasks.MarkContactAsDeletedBackgroundTask
-import ch.threema.app.managers.CoreServiceManager
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.multidevice.MultiDeviceManager
 import ch.threema.app.multidevice.PersistedMultiDeviceProperties
 import ch.threema.app.multidevice.linking.DeviceLinkingStatus
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.UserService
-import ch.threema.app.stores.IdentityProvider
 import ch.threema.app.tasks.ReflectContactSyncUpdateTask
 import ch.threema.app.tasks.TaskCreator
 import ch.threema.app.utils.executor.BackgroundExecutor
-import ch.threema.base.crypto.NaCl
+import ch.threema.base.crypto.NonceFactory
+import ch.threema.common.DispatcherProvider
 import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ConversationVisibility
 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.AcquaintanceLevel
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
@@ -37,20 +37,19 @@ import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
 import ch.threema.domain.protocol.connection.data.DeviceId
 import ch.threema.domain.protocol.connection.data.InboundD2mMessage.DevicesInfo
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
-import ch.threema.domain.stores.IdentityStore
 import ch.threema.domain.taskmanager.ActiveTaskCodec
 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.domain.types.Identity
-import ch.threema.storage.TestDatabaseProvider
 import ch.threema.storage.factories.ContactModelFactory
-import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import ch.threema.test.TestData.PUBLIC_KEY
+import ch.threema.test.TestDatabaseProvider
+import ch.threema.test.TestIdentityProvider
 import ch.threema.testhelpers.MUST_NOT_BE_CALLED
-import io.mockk.every
 import io.mockk.mockk
-import java.util.Date
+import java.time.Instant
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -141,26 +140,26 @@ class MarkContactAsDeletedBackgroundTaskTest {
         }
     }
 
-    private lateinit var coreServiceManager: CoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
     private lateinit var deleteContactServices: DeleteContactServices
     private val testContactModelData = ContactModelData(
         identity = "12345678",
-        publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES),
-        createdAt = Date(),
+        publicKey = PUBLIC_KEY,
+        createdAt = Instant.now(),
+        lastUpdateAt = null,
         firstName = "1234",
         lastName = "5678",
         nickname = null,
         verificationLevel = VerificationLevel.FULLY_VERIFIED,
         workVerificationLevel = WorkVerificationLevel.NONE,
-        identityType = IdentityType.NORMAL,
+        identityType = IdentityType.REGULAR,
         acquaintanceLevel = AcquaintanceLevel.DIRECT,
         activityState = IdentityState.ACTIVE,
         syncState = ContactSyncState.INITIAL,
         featureMask = 0u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-        isArchived = false,
+        conversationVisibility = ConversationVisibility.NORMAL,
         androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
@@ -175,26 +174,10 @@ class MarkContactAsDeletedBackgroundTaskTest {
     @BeforeTest
     fun before() {
         databaseProvider = TestDatabaseProvider()
-        val serviceManager = ThreemaApplication.requireServiceManager()
+        val serviceManager = ServiceManager.require()
         testTaskCodec = TransactionAckTaskCodec()
         val myIdentity = "00000000"
-        val identityProviderMock = mockk<IdentityProvider> {
-            every { getIdentity() } returns Identity(myIdentity)
-            every { getIdentityString() } returns myIdentity
-        }
-        val identityStoreMock = mockk<IdentityStore> {
-            every { getIdentity() } returns Identity(myIdentity)
-            every { getIdentityString() } returns myIdentity
-        }
-        coreServiceManager = TestCoreServiceManager(
-            databaseProvider = databaseProvider,
-            identityProvider = identityProviderMock,
-            preferenceStore = serviceManager.preferenceStore,
-            encryptedPreferenceStore = serviceManager.encryptedPreferenceStore,
-            multiDeviceManager = multiDeviceManager,
-            taskManager = testTaskManager,
-            identityStore = identityStoreMock,
-        )
+        val identityProviderMock = TestIdentityProvider(Identity(myIdentity))
         deleteContactServices = DeleteContactServices(
             serviceManager.userService,
             serviceManager.contactService,
@@ -210,8 +193,14 @@ class MarkContactAsDeletedBackgroundTaskTest {
             ContactModelFactory(databaseProvider, identityProviderMock),
         )
         contactModelRepository = ModelRepositories(
-            coreServiceManager = coreServiceManager,
+            databaseProvider = databaseProvider,
             identityProvider = identityProviderMock,
+            multiDeviceManager = multiDeviceManager,
+            taskManager = testTaskManager,
+            nonceFactory = NonceFactory(TestNonceStore()),
+            globalEventBuses = mockk(relaxed = true),
+            globalEventFlows = mockk(relaxed = true),
+            dispatcherProvider = DispatcherProvider.default,
         ).contacts
 
         // Add a contact "from sync". This has no side effects and does not reflect the contact.
@@ -237,7 +226,7 @@ class MarkContactAsDeletedBackgroundTaskTest {
         ).await()
 
         // Assert that the contact's acquaintance level is "group" now
-        assertEquals(AcquaintanceLevel.GROUP, contactModel.data?.acquaintanceLevel)
+        assertEquals(AcquaintanceLevel.GROUP_OR_DELETED, contactModel.data?.acquaintanceLevel)
     }
 
     @Test

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

@@ -2,21 +2,22 @@ package ch.threema.app.contacts
 
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import ch.threema.KoinTestRule
-import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestMultiDeviceManager
+import ch.threema.app.TestNonceStore
 import ch.threema.app.TestTaskManager
-import ch.threema.app.ThreemaApplication
+import ch.threema.app.managers.ServiceManager
 import ch.threema.app.multidevice.MultiDeviceManager
 import ch.threema.app.processors.reflectedd2dsync.ReflectedContactSyncTask
-import ch.threema.app.stores.IdentityProvider
-import ch.threema.base.crypto.NaCl
+import ch.threema.base.crypto.NonceFactory
 import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ConversationVisibility
 import ch.threema.data.datatypes.IdColor
 import ch.threema.data.models.ContactModel
 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.AcquaintanceLevel
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
@@ -24,7 +25,6 @@ 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.IdentityStore
 import ch.threema.domain.types.Identity
 import ch.threema.protobuf.common.unit
 import ch.threema.protobuf.d2d.ContactSyncKt.create
@@ -35,14 +35,15 @@ import ch.threema.protobuf.d2d.sync.ContactKt.deprecatedNotificationSoundPolicyO
 import ch.threema.protobuf.d2d.sync.ContactKt.readReceiptPolicyOverride
 import ch.threema.protobuf.d2d.sync.ContactKt.typingIndicatorPolicyOverride
 import ch.threema.protobuf.d2d.sync.ConversationCategory
-import ch.threema.protobuf.d2d.sync.ConversationVisibility
+import ch.threema.protobuf.d2d.sync.ConversationVisibility as ProtocolsConversationVisibility
 import ch.threema.protobuf.d2d.sync.contact
-import ch.threema.storage.TestDatabaseProvider
-import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import ch.threema.test.TestData.PUBLIC_KEY
+import ch.threema.test.TestDatabaseProvider
+import ch.threema.test.TestIdentityProvider
+import ch.threema.testhelpers.TestDispatcherProvider
 import com.google.protobuf.kotlin.toByteString
-import io.mockk.every
 import io.mockk.mockk
-import java.util.Date
+import java.time.Instant
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
@@ -58,29 +59,32 @@ import org.koin.dsl.module
 
 @RunWith(AndroidJUnit4::class)
 class ReflectedContactSyncTaskTest {
+
     private lateinit var databaseProvider: TestDatabaseProvider
     private lateinit var taskCodec: TransactionAckTaskCodec
-    private lateinit var coreServiceManager: TestCoreServiceManager
+    private lateinit var multiDeviceManager: TestMultiDeviceManager
+    private lateinit var taskManager: TestTaskManager
     private lateinit var contactModelRepository: ContactModelRepository
 
     private val initialContactModelData = ContactModelData(
         identity = "01234567",
-        publicKey = ByteArray(32),
-        createdAt = Date(),
+        publicKey = PUBLIC_KEY,
+        createdAt = Instant.now(),
+        lastUpdateAt = null,
         firstName = "",
         lastName = "",
         nickname = "Nick",
         idColor = IdColor.ofIdentity("01234567"),
         verificationLevel = VerificationLevel.UNVERIFIED,
         workVerificationLevel = WorkVerificationLevel.NONE,
-        identityType = IdentityType.NORMAL,
+        identityType = IdentityType.REGULAR,
         acquaintanceLevel = AcquaintanceLevel.DIRECT,
         activityState = IdentityState.ACTIVE,
         syncState = ContactSyncState.INITIAL,
         featureMask = 511u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-        isArchived = false,
+        conversationVisibility = ConversationVisibility.NORMAL,
         androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
@@ -93,7 +97,7 @@ class ReflectedContactSyncTaskTest {
     )
 
     private val instrumentedTestModule = module {
-        factory<MultiDeviceManager> { coreServiceManager.multiDeviceManager }
+        factory<MultiDeviceManager> { multiDeviceManager }
     }
 
     @get:Rule
@@ -106,27 +110,21 @@ class ReflectedContactSyncTaskTest {
         databaseProvider = TestDatabaseProvider()
         taskCodec = TransactionAckTaskCodec()
         val myIdentity = "00000000"
-        val identityProviderMock = mockk<IdentityProvider> {
-            every { getIdentity() } returns Identity(myIdentity)
-            every { getIdentityString() } returns myIdentity
-        }
-        val identityStoreMock = mockk<IdentityStore> {
-            every { getIdentity() } returns Identity(myIdentity)
-            every { getIdentityString() } returns myIdentity
-        }
-        coreServiceManager = TestCoreServiceManager(
-            databaseProvider = databaseProvider,
-            identityProvider = identityProviderMock,
-            multiDeviceManager = TestMultiDeviceManager(
-                isMultiDeviceActive = true,
-                isMdDisabledOrSupportsFs = false,
-            ),
-            taskManager = TestTaskManager(taskCodec),
-            identityStore = identityStoreMock,
+        val identityProviderMock = TestIdentityProvider(Identity(myIdentity))
+        multiDeviceManager = TestMultiDeviceManager(
+            isMultiDeviceActive = true,
+            isMdDisabledOrSupportsFs = false,
         )
+        taskManager = TestTaskManager(taskCodec)
         contactModelRepository = ModelRepositories(
-            coreServiceManager = coreServiceManager,
+            databaseProvider = databaseProvider,
             identityProvider = identityProviderMock,
+            multiDeviceManager = multiDeviceManager,
+            taskManager = taskManager,
+            nonceFactory = NonceFactory(TestNonceStore()),
+            globalEventBuses = mockk(relaxed = true),
+            globalEventFlows = mockk(relaxed = true),
+            dispatcherProvider = TestDispatcherProvider(),
         ).contacts
     }
 
@@ -134,8 +132,8 @@ class ReflectedContactSyncTaskTest {
     fun testNewReflectedContact() = runTest {
         val contact = contact {
             identity = "01234567"
-            publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES) { it.toByte() }.toByteString()
-            createdAt = Date().time
+            publicKey = PUBLIC_KEY.toByteString()
+            createdAt = Instant.now().toEpochMilli()
             firstName = "0123"
             // No last name provided
             nickname = "nick"
@@ -157,14 +155,14 @@ class ReflectedContactSyncTaskTest {
                 default = unit {}
             }
             conversationCategory = ConversationCategory.DEFAULT
-            conversationVisibility = ConversationVisibility.NORMAL
+            conversationVisibility = ProtocolsConversationVisibility.NORMAL
         }
 
         testReflectedContactCreate(contact) { contactModel ->
             val data = contactModel.data!!
             assertEquals(contact.identity, data.identity)
-            assertContentEquals(ByteArray(NaCl.PUBLIC_KEY_BYTES) { it.toByte() }, data.publicKey)
-            assertEquals(contact.createdAt, data.createdAt.time)
+            assertContentEquals(PUBLIC_KEY, data.publicKey)
+            assertEquals(contact.createdAt, data.createdAt.toEpochMilli())
             assertEquals(contact.firstName, data.firstName)
             assertEquals("", data.lastName)
             assertEquals(contact.nickname, data.nickname)
@@ -205,7 +203,8 @@ class ReflectedContactSyncTaskTest {
         ReflectedContactSyncTask(
             contactSync = contact.toContactSyncCreate(),
             contactModelRepository = contactModelRepository,
-            serviceManager = ThreemaApplication.requireServiceManager(),
+            serviceManager = ServiceManager.require(),
+            globalEventBuses = mockk(relaxed = true),
         ).run()
 
         // Assert that no transaction have been executed
@@ -228,7 +227,8 @@ class ReflectedContactSyncTaskTest {
         ReflectedContactSyncTask(
             contactSync = contact.toContactSyncUpdate(),
             contactModelRepository = contactModelRepository,
-            serviceManager = ThreemaApplication.requireServiceManager(),
+            serviceManager = ServiceManager.require(),
+            globalEventBuses = mockk(relaxed = true),
         ).run()
 
         // Assert that no transaction have been executed
@@ -266,7 +266,7 @@ class ReflectedContactSyncTaskTest {
     }
 
     private fun assertNoMessagesSent() {
-        assertTrue { taskCodec.outboundMessages.isEmpty() }
+        assertTrue(taskCodec.outboundMessages.isEmpty())
     }
 
     private fun Contact.toContactSyncCreate() = contactSync {
@@ -296,7 +296,7 @@ class ReflectedContactSyncTaskTest {
         }
 
     private fun Contact.IdentityType.convert(): IdentityType = when (this) {
-        Contact.IdentityType.REGULAR -> IdentityType.NORMAL
+        Contact.IdentityType.REGULAR -> IdentityType.REGULAR
         Contact.IdentityType.WORK -> IdentityType.WORK
         Contact.IdentityType.UNRECOGNIZED -> fail("Identity type is unrecognized")
     }
@@ -304,7 +304,7 @@ class ReflectedContactSyncTaskTest {
     private fun Contact.AcquaintanceLevel.convert(): AcquaintanceLevel =
         when (this) {
             Contact.AcquaintanceLevel.DIRECT -> AcquaintanceLevel.DIRECT
-            Contact.AcquaintanceLevel.GROUP_OR_DELETED -> AcquaintanceLevel.GROUP
+            Contact.AcquaintanceLevel.GROUP_OR_DELETED -> AcquaintanceLevel.GROUP_OR_DELETED
             Contact.AcquaintanceLevel.UNRECOGNIZED -> fail("Acquaintance level is unrecognized")
         }
 

+ 32 - 14
app/src/androidTest/java/ch/threema/app/edithistory/EditHistoryTest.kt

@@ -19,8 +19,9 @@ import ch.threema.app.services.DistributionListService
 import ch.threema.app.services.GroupFlowDispatcher
 import ch.threema.app.services.GroupService
 import ch.threema.app.services.MessageService
+import ch.threema.app.usecases.conversations.EmptyOrDeleteConversationsUseCase
 import ch.threema.app.utils.executor.BackgroundExecutor
-import ch.threema.data.models.GroupIdentity
+import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.data.repositories.ContactModelRepository
 import ch.threema.data.repositories.GroupModelRepository
 import ch.threema.data.storage.EditHistoryDao
@@ -39,16 +40,19 @@ import ch.threema.storage.factories.MessageModelFactory
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.group.GroupMessageModel
-import java.util.Date
+import java.time.Instant
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertNotNull
 import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
 import kotlinx.coroutines.test.runTest
 import org.junit.runner.RunWith
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.get
 
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @DangerousTest
@@ -158,10 +162,12 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
         messageModel.assertHistorySize(0)
 
         messageModel.sendEdit()
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(1)
 
         messageModel.sendDelete()
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(0)
     }
@@ -175,10 +181,12 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
         messageModel.assertHistorySize(0)
 
         messageModel.sendEdit()
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(1)
 
         messageModel.sendDelete()
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(0)
     }
@@ -192,10 +200,12 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
         messageModel.assertHistorySize(0)
 
         messageModel.receiveEdit()
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(1)
 
-        messageModel.emptyOrDeleteChat(EmptyOrDeleteConversationsAsyncTask.Mode.EMPTY)
+        messageModel.emptyOrDeleteChat(EmptyOrDeleteConversationsUseCase.Mode.EMPTY)
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(0)
     }
@@ -209,10 +219,12 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
         messageModel.assertHistorySize(0)
 
         messageModel.receiveEdit()
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(1)
 
-        messageModel.emptyOrDeleteChat(EmptyOrDeleteConversationsAsyncTask.Mode.EMPTY)
+        messageModel.emptyOrDeleteChat(EmptyOrDeleteConversationsUseCase.Mode.EMPTY)
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(0)
     }
@@ -226,10 +238,12 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
         messageModel.assertHistorySize(0)
 
         messageModel.receiveEdit()
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(1)
 
-        messageModel.emptyOrDeleteChat(EmptyOrDeleteConversationsAsyncTask.Mode.DELETE)
+        messageModel.emptyOrDeleteChat(EmptyOrDeleteConversationsUseCase.Mode.DELETE)
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(0)
     }
@@ -243,10 +257,12 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
         messageModel.assertHistorySize(0)
 
         messageModel.receiveEdit()
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(1)
 
-        messageModel.emptyOrDeleteChat(EmptyOrDeleteConversationsAsyncTask.Mode.DELETE)
+        messageModel.emptyOrDeleteChat(EmptyOrDeleteConversationsUseCase.Mode.DELETE)
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(0)
     }
@@ -260,6 +276,7 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
         messageModel.assertHistorySize(0)
 
         messageModel.receiveEdit()
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(1)
 
@@ -285,6 +302,7 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
         messageModel.assertHistorySize(0)
 
         messageModel.receiveEdit()
+        advanceUntilIdle()
 
         messageModel.assertHistorySize(1)
 
@@ -303,7 +321,7 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
         messageModel.assertHistorySize(0)
     }
 
-    private suspend fun AbstractMessageModel.emptyOrDeleteChat(mode: EmptyOrDeleteConversationsAsyncTask.Mode) {
+    private suspend fun AbstractMessageModel.emptyOrDeleteChat(mode: EmptyOrDeleteConversationsUseCase.Mode) {
         val receiver = messageService.getMessageReceiver(this)
         val deferred = CompletableDeferred<Unit>()
         @Suppress("DEPRECATION")
@@ -340,8 +358,8 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
     private suspend fun MessageModel.receiveEdit() {
         val editMessage = EditMessage(
             EditMessageData(
-                MessageId.fromString(apiMessageId).messageIdLong,
-                "$body Edited",
+                messageId = messageId!!,
+                text = "$body Edited",
             ),
         ).apply {
             fromIdentity = identity
@@ -352,7 +370,7 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
 
     private suspend fun MessageModel.receiveDelete() {
         val deleteMessage = DeleteMessage(
-            DeleteMessageData(MessageId.fromString(apiMessageId).messageIdLong),
+            DeleteMessageData(messageId!!),
         ).apply {
             fromIdentity = identity
             toIdentity = myContact.identity
@@ -381,8 +399,8 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
     private suspend fun GroupMessageModel.receiveEdit() {
         val editMessage = GroupEditMessage(
             EditMessageData(
-                MessageId.fromString(apiMessageId).messageIdLong,
-                "$body Edited",
+                messageId = messageId!!,
+                text = "$body Edited",
             ),
         ).apply {
             apiGroupId = groupA.apiGroupId
@@ -395,7 +413,7 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
 
     private suspend fun GroupMessageModel.receiveDelete() {
         val deleteMessage = GroupDeleteMessage(
-            DeleteMessageData(MessageId.fromString(apiMessageId).messageIdLong),
+            DeleteMessageData(messageId!!),
         ).apply {
             apiGroupId = groupA.apiGroupId
             groupCreator = groupA.groupCreator.identity
@@ -413,7 +431,7 @@ class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
 
     private fun AbstractMessageModel.sendEdit() {
         val receiver = messageService.getMessageReceiver(this)
-        messageService.sendEditedMessageText(this, "$body Edited", Date(), receiver)
+        messageService.sendEditedMessageText(this, "$body Edited", Instant.now(), receiver)
     }
 
     private fun AbstractMessageModel.sendDelete() {

+ 17 - 15
app/src/androidTest/java/ch/threema/app/groupmanagement/CreateGroupFlowTest.kt

@@ -1,6 +1,5 @@
 package ch.threema.app.groupmanagement
 
-import android.text.format.DateUtils
 import ch.threema.app.DangerousTest
 import ch.threema.app.groupflows.GroupCreateProperties
 import ch.threema.app.groupflows.GroupFlowResult
@@ -11,11 +10,14 @@ import ch.threema.app.tasks.ReflectGroupSyncCreateTask
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.clearDatabaseAndCaches
+import ch.threema.common.minus
 import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ConversationVisibility
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.GroupModel
 import ch.threema.data.models.GroupModelData
 import ch.threema.domain.helpers.ControlledTaskManager
+import ch.threema.domain.models.AcquaintanceLevel
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
@@ -27,16 +29,16 @@ import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
-import ch.threema.storage.models.ContactModel
-import java.util.Date
+import ch.threema.test.TestData.PUBLIC_KEY
+import java.time.Instant
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
-import kotlin.test.assertFalse
 import kotlin.test.assertIs
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
 import kotlinx.coroutines.test.runTest
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.inject
@@ -55,21 +57,22 @@ class CreateGroupFlowTest : GroupFlowTest(), KoinComponent {
 
     private val initialContactModelData = ContactModelData(
         identity = "12345678",
-        publicKey = ByteArray(32),
-        createdAt = Date(),
+        publicKey = PUBLIC_KEY,
+        createdAt = Instant.now(),
+        lastUpdateAt = null,
         firstName = "",
         lastName = "",
         verificationLevel = VerificationLevel.SERVER_VERIFIED,
         workVerificationLevel = WorkVerificationLevel.NONE,
         nickname = null,
-        identityType = IdentityType.NORMAL,
-        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        identityType = IdentityType.REGULAR,
+        acquaintanceLevel = AcquaintanceLevel.DIRECT,
         activityState = IdentityState.ACTIVE,
         syncState = ContactSyncState.INITIAL,
         featureMask = 255u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-        isArchived = false,
+        conversationVisibility = ConversationVisibility.NORMAL,
         profilePictureBlobId = null,
         androidContactLookupInfo = null,
         localAvatarExpires = null,
@@ -197,7 +200,7 @@ class CreateGroupFlowTest : GroupFlowTest(), KoinComponent {
         ).await()
 
         // assert
-        assertTrue(groupFlowResult is GroupFlowResult.Failure.Network)
+        assertIs<GroupFlowResult.Failure.Network>(groupFlowResult)
     }
 
     private suspend fun testAndAssertSuccessfulGroupCreation(
@@ -205,7 +208,7 @@ class CreateGroupFlowTest : GroupFlowTest(), KoinComponent {
         reflectionExpectation: ReflectionExpectation,
     ): GroupModel {
         val createGroupFlowResult = testGroupCreation(groupCreateProperties, reflectionExpectation)
-        assertTrue(createGroupFlowResult is GroupFlowResult.Success)
+        assertIs<GroupFlowResult.Success>(createGroupFlowResult)
         val groupModel = createGroupFlowResult.groupModel
         groupModel.assertCreatedFrom(groupCreateProperties)
         groupModel.assertNewGroup()
@@ -283,11 +286,10 @@ class CreateGroupFlowTest : GroupFlowTest(), KoinComponent {
         assertEquals(UserState.MEMBER, userState)
         assertEquals(lastUpdate, createdAt)
         assertTrue {
-            val current = Date().time
-            val aWhileAgo = current - DateUtils.SECOND_IN_MILLIS * 30
-            createdAt.time in aWhileAgo..<current
+            val now = Instant.now()
+            createdAt in (now - 30.seconds)..<now
         }
-        assertFalse(isArchived)
+        assertEquals(ConversationVisibility.NORMAL, conversationVisibility)
         assertNull(groupDescription)
         assertNull(groupDescriptionChangedAt)
     }

+ 13 - 10
app/src/androidTest/java/ch/threema/app/groupmanagement/DisbandGroupFlowTest.kt

@@ -9,11 +9,13 @@ import ch.threema.app.tasks.ReflectLocalGroupLeaveOrDisband
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.clearDatabaseAndCaches
 import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ConversationVisibility
+import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.data.models.ContactModelData
-import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModel
 import ch.threema.data.models.GroupModelData
 import ch.threema.domain.helpers.ControlledTaskManager
+import ch.threema.domain.models.AcquaintanceLevel
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
@@ -24,8 +26,8 @@ import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
-import ch.threema.storage.models.ContactModel.AcquaintanceLevel
-import java.util.Date
+import ch.threema.test.TestData.PUBLIC_KEY
+import java.time.Instant
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -42,21 +44,22 @@ class DisbandGroupFlowTest : GroupFlowTest() {
 
     private val initialContactData = ContactModelData(
         identity = "12345678",
-        publicKey = ByteArray(32),
-        createdAt = Date(),
+        publicKey = PUBLIC_KEY,
+        createdAt = Instant.now(),
+        lastUpdateAt = null,
         firstName = "",
         lastName = "",
         verificationLevel = VerificationLevel.SERVER_VERIFIED,
         workVerificationLevel = WorkVerificationLevel.NONE,
         nickname = null,
-        identityType = IdentityType.NORMAL,
+        identityType = IdentityType.REGULAR,
         acquaintanceLevel = AcquaintanceLevel.DIRECT,
         activityState = IdentityState.ACTIVE,
         syncState = ContactSyncState.INITIAL,
         featureMask = 255u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-        isArchived = false,
+        conversationVisibility = ConversationVisibility.NORMAL,
         profilePictureBlobId = null,
         androidContactLookupInfo = null,
         localAvatarExpires = null,
@@ -71,10 +74,10 @@ class DisbandGroupFlowTest : GroupFlowTest() {
     private val myInitialGroupModelData = GroupModelData(
         groupIdentity = GroupIdentity(myContact.identity, 42),
         name = "MyExistingGroup",
-        createdAt = Date(),
+        createdAt = Instant.now(),
         synchronizedAt = null,
-        lastUpdate = Date(),
-        isArchived = false,
+        lastUpdate = Instant.now(),
+        conversationVisibility = ConversationVisibility.NORMAL,
         groupDescription = null,
         groupDescriptionChangedAt = null,
         otherMembers = setOf(initialContactData.identity),

+ 14 - 13
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupFlowTest.kt

@@ -2,14 +2,17 @@ package ch.threema.app.groupmanagement
 
 import ch.threema.KoinTestRule
 import ch.threema.app.TestMultiDeviceManager
-import ch.threema.app.ThreemaApplication
 import ch.threema.app.di.modules.sessionScopedModule
+import ch.threema.app.eventbus.GlobalEventBuses
+import ch.threema.app.managers.ServiceManager
 import ch.threema.app.services.GroupFlowDispatcher
+import ch.threema.common.stateFlowOf
 import ch.threema.domain.protocol.connection.ConnectionState
-import ch.threema.domain.protocol.connection.ConnectionStateListener
 import ch.threema.domain.protocol.connection.ServerConnection
 import ch.threema.domain.taskmanager.TaskManager
 import org.junit.Rule
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
 import org.koin.core.module.Module
 
 enum class SetupConfig {
@@ -35,11 +38,12 @@ enum class ReflectionExpectation(val setupConfig: SetupConfig) {
     REFLECTION_SKIPPED(SetupConfig.MULTI_DEVICE_DISABLED),
 }
 
-abstract class GroupFlowTest {
-    protected val serviceManager by lazy { ThreemaApplication.requireServiceManager() }
+abstract class GroupFlowTest : KoinComponent {
+    protected val serviceManager by lazy { ServiceManager.require() }
 
     protected val contactModelRepository by lazy { serviceManager.modelRepositories.contacts }
     protected val groupModelRepository by lazy { serviceManager.modelRepositories.groups }
+    private val globalEventBuses: GlobalEventBuses by inject()
 
     protected val testMultiDeviceManagerEnabled by lazy {
         TestMultiDeviceManager(
@@ -83,6 +87,7 @@ abstract class GroupFlowTest {
         taskManager,
         connection,
         serviceManager.identityBlockedSteps,
+        globalEventBuses,
     )
 
     /**
@@ -101,6 +106,8 @@ abstract class GroupFlowTest {
 
         override val connectionState: ConnectionState = ConnectionState.DISCONNECTED
 
+        override fun watchConnectionState() = stateFlowOf(ConnectionState.DISCONNECTED)
+
         override val isNewConnectionSession: Boolean = false
 
         override fun disableReconnect() {}
@@ -108,17 +115,15 @@ abstract class GroupFlowTest {
         override fun start() {}
 
         override fun stop() {}
-
-        override fun addConnectionStateListener(listener: ConnectionStateListener) {}
-
-        override fun removeConnectionStateListener(listener: ConnectionStateListener) {}
     }
 
     data object ConnectionLoggedIn : ServerConnection {
 
         override val isRunning: Boolean = true
 
-        override val connectionState: ConnectionState = ConnectionState.LOGGEDIN
+        override val connectionState: ConnectionState = ConnectionState.LOGGED_IN
+
+        override fun watchConnectionState() = stateFlowOf(ConnectionState.DISCONNECTED)
 
         override val isNewConnectionSession: Boolean = true
 
@@ -127,9 +132,5 @@ abstract class GroupFlowTest {
         override fun start() {}
 
         override fun stop() {}
-
-        override fun addConnectionStateListener(listener: ConnectionStateListener) {}
-
-        override fun removeConnectionStateListener(listener: ConnectionStateListener) {}
     }
 }

+ 14 - 11
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupResyncFlowTest.kt

@@ -6,11 +6,13 @@ import ch.threema.app.tasks.ActiveGroupStateResyncTask
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.clearDatabaseAndCaches
 import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ConversationVisibility
+import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.data.models.ContactModelData
-import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModel
 import ch.threema.data.models.GroupModelData
 import ch.threema.domain.helpers.ControlledTaskManager
+import ch.threema.domain.models.AcquaintanceLevel
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
@@ -21,8 +23,8 @@ import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
-import ch.threema.storage.models.ContactModel
-import java.util.Date
+import ch.threema.test.TestData.PUBLIC_KEY
+import java.time.Instant
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertIs
@@ -36,21 +38,22 @@ class GroupResyncFlowTest : GroupFlowTest() {
 
     private val initialContactData = ContactModelData(
         identity = "12345678",
-        publicKey = ByteArray(32),
-        createdAt = Date(),
+        publicKey = PUBLIC_KEY,
+        createdAt = Instant.now(),
+        lastUpdateAt = null,
         firstName = "",
         lastName = "",
         verificationLevel = VerificationLevel.SERVER_VERIFIED,
         workVerificationLevel = WorkVerificationLevel.NONE,
         nickname = null,
-        identityType = IdentityType.NORMAL,
-        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        identityType = IdentityType.REGULAR,
+        acquaintanceLevel = AcquaintanceLevel.DIRECT,
         activityState = IdentityState.ACTIVE,
         syncState = ContactSyncState.INITIAL,
         featureMask = 255u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-        isArchived = false,
+        conversationVisibility = ConversationVisibility.NORMAL,
         profilePictureBlobId = null,
         androidContactLookupInfo = null,
         localAvatarExpires = null,
@@ -65,10 +68,10 @@ class GroupResyncFlowTest : GroupFlowTest() {
     private val myInitialGroupModelData = GroupModelData(
         groupIdentity = GroupIdentity(myContact.identity, 42),
         name = "MyExistingGroup",
-        createdAt = Date(),
+        createdAt = Instant.now(),
         synchronizedAt = null,
-        lastUpdate = Date(),
-        isArchived = false,
+        lastUpdate = Instant.now(),
+        conversationVisibility = ConversationVisibility.NORMAL,
         groupDescription = null,
         groupDescriptionChangedAt = null,
         otherMembers = emptySet(),

+ 3 - 113
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt

@@ -3,19 +3,12 @@ package ch.threema.app.groupmanagement
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import ch.threema.app.DangerousTest
-import ch.threema.app.listeners.GroupListener
-import ch.threema.app.managers.ListenerManager
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
-import ch.threema.data.models.GroupIdentity
+import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
 import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
-import ch.threema.domain.types.IdentityString
 import junit.framework.TestCase.assertEquals
-import junit.framework.TestCase.assertFalse
-import junit.framework.TestCase.assertTrue
-import junit.framework.TestCase.fail
-import kotlin.test.AfterTest
 import kotlin.test.Test
 import kotlinx.coroutines.test.runTest
 import org.junit.runner.RunWith
@@ -23,16 +16,16 @@ import org.junit.runner.RunWith
 /**
  * Tests that incoming group leave messages are handled correctly.
  */
-@RunWith(AndroidJUnit4::class)
 @LargeTest
 @DangerousTest
+@RunWith(AndroidJUnit4::class)
 class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
     /**
      * Test that contact A leaving my group works as expected.
      */
     @Test
     fun testValidLeaveInMyGroup() = runTest {
-        assertSuccessfulLeave(myGroup, contactA, true)
+        assertSuccessfulLeave(myGroup, contactA)
     }
 
     /**
@@ -91,11 +84,6 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
         )
     }
 
-    @AfterTest
-    fun removeAllGroupListeners() {
-        GroupLeaveTracker.stopAllListeners()
-    }
-
     override fun createMessageForGroup() = GroupLeaveMessage()
 
     override fun testCommonGroupReceiveStepUnknownGroupUserCreator() {
@@ -125,7 +113,6 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
     private suspend fun assertSuccessfulLeave(
         group: TestGroup,
         contact: TestContact,
-        expectStateChange: Boolean = false,
     ) {
         serviceManager.groupService.resetCache(group.groupModel.id)
         val groupIdentity = GroupIdentity(group.groupCreator.identity, group.apiGroupId.toLong())
@@ -136,16 +123,9 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             serviceManager.modelRepositories.groups.getByGroupIdentity(groupIdentity)?.data?.otherMembers?.toList(),
         )
 
-        val leaveTracker = GroupLeaveTracker(group, contact.identity, expectStateChange)
-            .apply { start() }
-
         // Process the group rename message
         processMessage(createEncryptedGroupLeaveMessage(group, contact), contact.identityStore)
 
-        leaveTracker.assertMemberLeft()
-
-        leaveTracker.stop()
-
         serviceManager.groupService.resetCache(group.groupModel.id)
 
         assertEquals(
@@ -174,15 +154,9 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
         assertGroupIdentities(expectedMemberList, group)
         assertMemberCount(expectedMemberList.size, group)
 
-        val leaveTracker = GroupLeaveTracker(group, contact.identity).apply { start() }
-
         // Process the group rename message
         processMessage(createEncryptedGroupLeaveMessage(group, contact), contact.identityStore)
 
-        leaveTracker.assertNoMemberLeft()
-
-        leaveTracker.stop()
-
         serviceManager.groupService.resetCache(group.groupModel.id)
 
         assertGroupIdentities(expectedMemberList, group)
@@ -241,88 +215,4 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             )
         }
     }
-
-    private class GroupLeaveTracker(
-        private val group: TestGroup?,
-        private val leavingIdentity: IdentityString?,
-        private val expectStateChange: Boolean = false,
-    ) {
-        private var memberHasLeft = false
-
-        private val groupListener = object : GroupListener {
-            override fun onCreate(groupIdentity: GroupIdentity) = fail()
-
-            override fun onRename(groupIdentity: GroupIdentity) = fail()
-
-            override fun onUpdatePhoto(groupIdentity: GroupIdentity) = fail()
-
-            override fun onRemove(groupDbId: Long) = fail()
-
-            override fun onNewMember(
-                groupIdentity: GroupIdentity,
-                identityNew: String,
-            ) = fail()
-
-            override fun onMemberLeave(
-                groupIdentity: GroupIdentity,
-                identityLeft: String,
-            ) {
-                assertFalse(memberHasLeft)
-                group?.let {
-                    assertEquals(it.apiGroupId.toLong(), groupIdentity.groupId)
-                    assertEquals(it.groupCreator.identity, groupIdentity.creatorIdentity)
-                    assertEquals(leavingIdentity, identityLeft)
-                }
-                memberHasLeft = true
-            }
-
-            override fun onMemberKicked(
-                groupIdentity: GroupIdentity,
-                identityKicked: String,
-            ) = fail()
-
-            override fun onUpdate(groupIdentity: GroupIdentity) = fail()
-
-            override fun onLeave(groupIdentity: GroupIdentity) = fail()
-
-            override fun onGroupStateChanged(
-                groupIdentity: GroupIdentity,
-                oldState: Int,
-                newState: Int,
-            ) {
-                if (!expectStateChange) {
-                    fail()
-                }
-            }
-        }
-
-        companion object {
-            private val groupListeners: MutableList<GroupListener> = mutableListOf()
-
-            fun stopAllListeners() {
-                for (groupListener in groupListeners) {
-                    ListenerManager.groupListeners.remove(groupListener)
-                }
-                groupListeners.clear()
-            }
-        }
-
-        fun start() {
-            ListenerManager.groupListeners.add(groupListener)
-            groupListeners.add(groupListener)
-        }
-
-        fun assertMemberLeft() {
-            assertTrue(memberHasLeft)
-        }
-
-        fun assertNoMemberLeft() {
-            assertFalse(memberHasLeft)
-        }
-
-        fun stop() {
-            ListenerManager.groupListeners.remove(groupListener)
-            groupListeners.remove(groupListener)
-        }
-    }
 }

+ 0 - 141
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt

@@ -3,19 +3,11 @@ package ch.threema.app.groupmanagement
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import ch.threema.app.DangerousTest
-import ch.threema.app.listeners.GroupListener
-import ch.threema.app.managers.ListenerManager
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
-import ch.threema.data.models.GroupIdentity
 import ch.threema.domain.models.GroupId
 import ch.threema.domain.protocol.csp.messages.GroupNameMessage
 import ch.threema.domain.types.IdentityString
-import junit.framework.TestCase.assertEquals
-import junit.framework.TestCase.assertFalse
-import junit.framework.TestCase.assertTrue
-import junit.framework.TestCase.fail
-import kotlin.test.AfterTest
 import kotlin.test.Test
 import kotlinx.coroutines.test.runTest
 import org.junit.runner.RunWith
@@ -52,8 +44,6 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
                 myContact.identity,
             )
 
-        val renameTracker = GroupRenameTracker(groupARenamed).apply { start() }
-
         val message = createEncryptedRenameMessage(
             newGroupName = groupARenamed.groupName,
             groupCreatorIdentity = groupARenamed.groupCreator.identity,
@@ -64,10 +54,6 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
         // Process the group rename message
         processMessage(message, groupARenamed.groupCreator.identityStore)
 
-        // Assert that the listeners were triggered
-        renameTracker.assertRename()
-        renameTracker.stop()
-
         // Assert that the group name change has been processed
         assertGroupConversations(
             expectedGroups = initialGroups.replace(groupA, groupARenamed),
@@ -95,8 +81,6 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
                 myContact.identity,
             )
 
-        val renameTracker = GroupRenameTracker(null).apply { start() }
-
         val message = createEncryptedRenameMessage(
             newGroupName = groupARenamed.groupName,
             // Note that this will be ignored anyway
@@ -109,9 +93,6 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
         // Process the group rename message
         processMessage(message, contactB.identityStore)
 
-        renameTracker.assertNoRename()
-        renameTracker.stop()
-
         assertGroupConversations(
             expectedGroups = initialGroups,
         )
@@ -122,24 +103,12 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
         // be this user in this test
     }
 
-    override fun testCommonGroupReceiveStepUnknownGroupUserNotCreator() {
-        runWithoutGroupRename {
-            super.testCommonGroupReceiveStepUnknownGroupUserNotCreator()
-        }
-    }
-
     override fun testCommonGroupReceiveStepLeftGroupUserCreator() {
         // Don't test this step. The group rename message is always sent as creator of the group
         // and if the sender of the message is the creator of a group owned by this user, then the
         // message comes from this user itself - which is impossible.
     }
 
-    override fun testCommonGroupReceiveStepLeftGroupUserNotCreator() {
-        runWithoutGroupRename {
-            super.testCommonGroupReceiveStepLeftGroupUserNotCreator()
-        }
-    }
-
     override fun testCommonGroupReceiveStepSenderNotMemberUserCreator() {
         // Don't test this step. The group rename message is always sent as creator of the group
         // and therefore the sender of the message is never missing in the group. However, the group
@@ -154,11 +123,6 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
         // receive steps.
     }
 
-    @AfterTest
-    fun removeAllGroupListeners() {
-        GroupRenameTracker.stopAllListeners()
-    }
-
     private fun createEncryptedRenameMessage(
         newGroupName: String,
         groupCreatorIdentity: IdentityString,
@@ -171,109 +135,4 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
         setApiGroupId(apiGroupId)
         toIdentity = myContact.identity
     }
-
-    /**
-     * Run [processMessage] and assert that no group rename happens.
-     */
-    private fun runWithoutGroupRename(processMessage: () -> Unit) {
-        val groupRenameTracker = GroupRenameTracker(null).apply { start() }
-
-        processMessage()
-
-        groupRenameTracker.assertNoRename()
-        groupRenameTracker.stop()
-    }
-
-    private class GroupRenameTracker(private val group: TestGroup?) {
-        private var hasBeenRenamed = false
-
-        private val groupListener = object : GroupListener {
-            override fun onCreate(groupIdentity: GroupIdentity) {
-                fail()
-            }
-
-            override fun onRename(groupIdentity: GroupIdentity) {
-                assertFalse(hasBeenRenamed)
-                group?.let {
-                    assertEquals(it.apiGroupId.toLong(), groupIdentity.groupId)
-                    assertEquals(it.groupCreator.identity, groupIdentity.creatorIdentity)
-                }
-                hasBeenRenamed = true
-            }
-
-            override fun onUpdatePhoto(groupIdentity: GroupIdentity) {
-                fail()
-            }
-
-            override fun onRemove(groupDbId: Long) {
-                fail()
-            }
-
-            override fun onNewMember(
-                groupIdentity: GroupIdentity,
-                identityNew: String?,
-            ) {
-                fail()
-            }
-
-            override fun onMemberLeave(
-                groupIdentity: GroupIdentity,
-                identityLeft: String,
-            ) {
-                fail()
-            }
-
-            override fun onMemberKicked(
-                groupIdentity: GroupIdentity,
-                identityKicked: String?,
-            ) {
-                fail()
-            }
-
-            override fun onUpdate(groupIdentity: GroupIdentity) {
-                fail()
-            }
-
-            override fun onLeave(groupIdentity: GroupIdentity) {
-                fail()
-            }
-
-            override fun onGroupStateChanged(
-                groupIdentity: GroupIdentity,
-                oldState: Int,
-                newState: Int,
-            ) {
-                fail()
-            }
-        }
-
-        companion object {
-            private val groupListeners: MutableList<GroupListener> = mutableListOf()
-
-            fun stopAllListeners() {
-                for (groupListener in groupListeners) {
-                    ListenerManager.groupListeners.remove(groupListener)
-                }
-                groupListeners.clear()
-            }
-        }
-
-        fun start() {
-            ListenerManager.groupListeners.add(groupListener)
-            groupListeners.add(groupListener)
-        }
-
-        fun assertRename() {
-            assertTrue(hasBeenRenamed)
-        }
-
-        fun assertNoRename() {
-            assertFalse(hasBeenRenamed)
-        }
-
-        fun stop() {
-            ListenerManager.groupListeners.remove(groupListener)
-            groupListeners.remove(groupListener)
-        }
-    }
 }

+ 10 - 260
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt

@@ -3,14 +3,13 @@ package ch.threema.app.groupmanagement
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import ch.threema.app.DangerousTest
-import ch.threema.app.listeners.GroupListener
-import ch.threema.app.managers.ListenerManager
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
-import ch.threema.base.crypto.NaCl
 import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ConversationVisibility
+import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.data.models.ContactModelData
-import ch.threema.data.models.GroupIdentity
+import ch.threema.domain.models.AcquaintanceLevel
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
@@ -20,18 +19,14 @@ import ch.threema.domain.models.UserState
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.csp.messages.GroupSetupMessage
-import ch.threema.domain.types.IdentityString
-import ch.threema.storage.models.ContactModel.AcquaintanceLevel
-import java.util.Date
-import junit.framework.TestCase
-import kotlin.test.AfterTest
+import ch.threema.test.TestData.PUBLIC_KEY
+import java.time.Instant
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertFalse
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
-import kotlin.test.fail
 import kotlinx.coroutines.test.runTest
 import org.junit.runner.RunWith
 
@@ -58,16 +53,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             expectedGroups = initialGroups,
         )
 
-        val setupTracker = GroupSetupTracker(
-            groupAUnknown,
-            myContact.identity,
-            expectCreate = false,
-            expectKick = false,
-            emptyList(),
-            emptyList(),
-        )
-        setupTracker.start()
-
         // Create the group setup message
         val message = createGroupSetupMessage(groupAUnknown)
         // Remove this user from the members
@@ -82,12 +67,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
-
-        // Assert that no action has been triggered
-        setupTracker.assertAllNewMembersAdded()
-        setupTracker.assertAllKickedMembersRemoved()
-        setupTracker.assertCreateLeave()
-        setupTracker.stop()
     }
 
     /**
@@ -100,16 +79,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             expectedGroups = initialGroups,
         )
 
-        val setupTracker = GroupSetupTracker(
-            groupAUnknown,
-            myContact.identity,
-            expectCreate = false,
-            expectKick = false,
-            emptyList(),
-            emptyList(),
-        )
-        setupTracker.start()
-
         // Create the group setup message
         val message = createGroupSetupMessage(groupAUnknown)
         // Group is empty
@@ -124,12 +93,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
-
-        // Assert that no action has been triggered
-        setupTracker.assertAllNewMembersAdded()
-        setupTracker.assertAllKickedMembersRemoved()
-        setupTracker.assertCreateLeave()
-        setupTracker.stop()
     }
 
     /**
@@ -173,16 +136,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         assertEquals(UserState.MEMBER, beforeKicked.userState)
         assertTrue(groupService.isGroupMember(beforeKicked))
 
-        val setupTracker = GroupSetupTracker(
-            groupAB,
-            myContact.identity,
-            expectCreate = false,
-            expectKick = true,
-            emptyList(),
-            listOf(myContact.identity),
-        )
-        setupTracker.start()
-
         // Create the group setup message
         val message = createGroupSetupMessage(groupAB)
         // Only contact B is a member of this group, so this user has been kicked
@@ -204,12 +157,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
-
-        // Assert that the user has been kicked and the members are updated
-        setupTracker.assertAllNewMembersAdded()
-        setupTracker.assertAllKickedMembersRemoved()
-        setupTracker.assertCreateLeave()
-        setupTracker.stop()
     }
 
     /**
@@ -222,16 +169,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             expectedGroups = initialGroups,
         )
 
-        val setupTracker = GroupSetupTracker(
-            groupAB,
-            myContact.identity,
-            expectCreate = false,
-            expectKick = false,
-            listOf(contactC.identity),
-            listOf(contactB.identity),
-        )
-        setupTracker.start()
-
         // Create the group setup message
         val message = createGroupSetupMessage(groupAB).apply {
             // Remove contact B from group and add contact C to group
@@ -247,12 +184,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
-
-        // Assert that the members have changed
-        setupTracker.assertAllNewMembersAdded()
-        setupTracker.assertAllKickedMembersRemoved()
-        setupTracker.assertCreateLeave()
-        setupTracker.stop()
     }
 
     /**
@@ -283,16 +214,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             expectedGroups = initialGroups,
         )
 
-        val setupTracker = GroupSetupTracker(
-            groupAB,
-            myContact.identity,
-            expectCreate = false,
-            expectKick = true,
-            listOf(myContact.identity),
-            listOf(myContact.identity),
-        )
-        setupTracker.start()
-
         // Create the group setup message
         val removeMessage = createGroupSetupMessage(groupAB)
         // Only contact B is a member of this group, so this user has been kicked
@@ -312,12 +233,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
-
-        // Assert that the user has been kicked and added again
-        setupTracker.assertAllNewMembersAdded()
-        setupTracker.assertAllKickedMembersRemoved()
-        setupTracker.assertCreateLeave()
-        setupTracker.stop()
     }
 
     @Test
@@ -340,17 +255,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             myContact.identity,
         )
 
-        val setupTracker = GroupSetupTracker(
-            group = newGroup,
-            myIdentity = myContact.identity,
-            expectCreate = true,
-            expectKick = false,
-            newMembers = newGroup.members.filter { it.identity != invalidMemberId }
-                .map { it.identity } + newGroup.groupCreator.identity,
-            kickedMembers = emptyList(),
-        )
-        setupTracker.start()
-
         // Create the group setup message
         val message = createGroupSetupMessage(newGroup)
         // Create message box from contact A (group creator)
@@ -363,12 +267,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
-
-        // Assert that the group has been created and the new members are set correctly
-        setupTracker.assertAllNewMembersAdded()
-        setupTracker.assertAllKickedMembersRemoved()
-        setupTracker.assertCreateLeave()
-        setupTracker.stop()
     }
 
     @Test
@@ -394,17 +292,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             myContact.identity,
         )
 
-        val setupTracker = GroupSetupTracker(
-            newGroup,
-            myContact.identity,
-            expectCreate = true,
-            expectKick = false,
-            newGroup.members.filter { it.identity != revokedContactModelData.identity }
-                .map { it.identity } + newGroup.groupCreator.identity,
-            emptyList(),
-        )
-        setupTracker.start()
-
         // Create the group setup message
         val message = createGroupSetupMessage(newGroup)
 
@@ -422,12 +309,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
 
-        // Assert that the group has been created and the new members are set correctly
-        setupTracker.assertAllNewMembersAdded()
-        setupTracker.assertAllKickedMembersRemoved()
-        setupTracker.assertCreateLeave()
-        setupTracker.stop()
-
         // Get the group model of the group and check that it exists and the revoked identity is not listed as a member
         val newGroupModel = groupModelRepository.getByCreatorIdentityAndId(newGroup.groupCreator.identity, newGroup.apiGroupId)
         assertNotNull(newGroupModel)
@@ -449,16 +330,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             expectedGroups = initialGroups,
         )
 
-        val setupTracker = GroupSetupTracker(
-            newGroup,
-            myContact.identity,
-            expectCreate = true,
-            expectKick = false,
-            newGroup.members.map { it.identity } + newGroup.groupCreator.identity,
-            emptyList(),
-        )
-        setupTracker.start()
-
         // Create the group setup message
         val message = createGroupSetupMessage(newGroup)
         // Create message box from contact A (group creator)
@@ -474,12 +345,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
 
-        // Assert that the group has been created and the new members are set correctly
-        setupTracker.assertAllNewMembersAdded()
-        setupTracker.assertAllKickedMembersRemoved()
-        setupTracker.assertCreateLeave()
-        setupTracker.stop()
-
         // Assert that the group has the correct members
         val group = groupService.getByApiGroupIdAndCreator(
             newGroup.apiGroupId,
@@ -518,139 +383,24 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
                     .toTypedArray()
         }
 
-    private class GroupSetupTracker(
-        private val group: TestGroup?,
-        private val myIdentity: IdentityString,
-        private val expectCreate: Boolean,
-        private val expectKick: Boolean,
-        private val newMembers: List<String>,
-        private val kickedMembers: List<String>,
-    ) {
-        private var hasBeenCreated = false
-        private var hasBeenKicked = false
-        private var newMembersAdded = mutableListOf<String>()
-        private var kickedMembersRemoved = mutableListOf<String>()
-
-        private val groupListener = object : GroupListener {
-            override fun onCreate(groupIdentity: GroupIdentity) {
-                assertTrue(expectCreate)
-                assertFalse(hasBeenCreated)
-                group?.let {
-                    assertEquals(
-                        it.apiGroupId.toLong(),
-                        groupIdentity.groupId,
-                    )
-                    TestCase.assertEquals(it.groupCreator.identity, groupIdentity.creatorIdentity)
-                }
-                hasBeenCreated = true
-            }
-
-            override fun onRename(groupIdentity: GroupIdentity) = fail()
-
-            override fun onUpdatePhoto(groupIdentity: GroupIdentity) = fail()
-
-            override fun onRemove(groupDbId: Long) = fail()
-
-            override fun onNewMember(
-                groupIdentity: GroupIdentity,
-                identityNew: String?,
-            ) {
-                assertTrue(newMembers.contains(identityNew), "Did not expect member $identityNew")
-                newMembersAdded.add(identityNew!!)
-            }
-
-            override fun onMemberLeave(
-                groupIdentity: GroupIdentity,
-                identityLeft: String,
-            ) = fail()
-
-            override fun onMemberKicked(
-                groupIdentity: GroupIdentity,
-                identityKicked: String,
-            ) {
-                assertTrue(kickedMembers.contains(identityKicked))
-                kickedMembersRemoved.add(identityKicked)
-
-                if (identityKicked == myIdentity) {
-                    assertTrue(expectKick)
-                    assertFalse(hasBeenKicked)
-                    hasBeenKicked = true
-                }
-            }
-
-            override fun onUpdate(groupIdentity: GroupIdentity) {
-                // This should only be called if the receiver has been changed (a member has been
-                // added or kicked)
-                assertTrue(newMembers.isNotEmpty() || kickedMembers.isNotEmpty())
-            }
-
-            override fun onLeave(groupIdentity: GroupIdentity) = fail()
-
-            override fun onGroupStateChanged(
-                groupIdentity: GroupIdentity,
-                oldState: Int,
-                newState: Int,
-            ) {
-            }
-        }
-
-        companion object {
-            private val groupListeners: MutableList<GroupListener> = mutableListOf()
-
-            fun stopAllListeners() {
-                for (groupListener in groupListeners) {
-                    ListenerManager.groupListeners.remove(groupListener)
-                }
-                groupListeners.clear()
-            }
-        }
-
-        fun start() {
-            ListenerManager.groupListeners.add(groupListener)
-            groupListeners.add(groupListener)
-        }
-
-        fun assertAllNewMembersAdded() {
-            assertEquals(newMembers.toSet(), newMembersAdded.toSet())
-        }
-
-        fun assertAllKickedMembersRemoved() {
-            assertEquals(kickedMembers.toSet(), kickedMembersRemoved.toSet())
-        }
-
-        fun assertCreateLeave() {
-            assertEquals(expectCreate, hasBeenCreated)
-            assertEquals(expectKick, hasBeenKicked)
-        }
-
-        fun stop() {
-            ListenerManager.groupListeners.remove(groupListener)
-            groupListeners.remove(groupListener)
-        }
-    }
-
-    @AfterTest
-    fun removeAllGroupListeners() {
-        GroupSetupTracker.stopAllListeners()
-    }
-
     private val revokedContactModelData = ContactModelData(
         identity = "01238765",
-        publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES),
-        createdAt = Date(),
+        publicKey = PUBLIC_KEY,
+        createdAt = Instant.now(),
+        lastUpdateAt = null,
         firstName = "1234",
         lastName = "8765",
         nickname = null,
         verificationLevel = VerificationLevel.FULLY_VERIFIED,
         workVerificationLevel = WorkVerificationLevel.NONE,
-        identityType = IdentityType.NORMAL,
+        identityType = IdentityType.REGULAR,
         acquaintanceLevel = AcquaintanceLevel.DIRECT,
         activityState = IdentityState.INVALID,
         syncState = ContactSyncState.INITIAL,
         featureMask = 0u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-        isArchived = false,
+        conversationVisibility = ConversationVisibility.NORMAL,
         androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,

+ 14 - 11
app/src/androidTest/java/ch/threema/app/groupmanagement/LeaveGroupFlowTest.kt

@@ -9,11 +9,13 @@ import ch.threema.app.tasks.ReflectLocalGroupLeaveOrDisband
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.clearDatabaseAndCaches
 import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ConversationVisibility
+import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.data.models.ContactModelData
-import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModel
 import ch.threema.data.models.GroupModelData
 import ch.threema.domain.helpers.ControlledTaskManager
+import ch.threema.domain.models.AcquaintanceLevel
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
@@ -24,8 +26,8 @@ import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
-import ch.threema.storage.models.ContactModel
-import java.util.Date
+import ch.threema.test.TestData.PUBLIC_KEY
+import java.time.Instant
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -41,21 +43,22 @@ class LeaveGroupFlowTest : GroupFlowTest() {
 
     private val initialContactData = ContactModelData(
         identity = "12345678",
-        publicKey = ByteArray(32),
-        createdAt = Date(),
+        publicKey = PUBLIC_KEY,
+        createdAt = Instant.now(),
+        lastUpdateAt = null,
         firstName = "",
         lastName = "",
         verificationLevel = VerificationLevel.SERVER_VERIFIED,
         workVerificationLevel = WorkVerificationLevel.NONE,
         nickname = null,
-        identityType = IdentityType.NORMAL,
-        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        identityType = IdentityType.REGULAR,
+        acquaintanceLevel = AcquaintanceLevel.DIRECT,
         activityState = IdentityState.ACTIVE,
         syncState = ContactSyncState.INITIAL,
         featureMask = 255u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-        isArchived = false,
+        conversationVisibility = ConversationVisibility.NORMAL,
         profilePictureBlobId = null,
         androidContactLookupInfo = null,
         localAvatarExpires = null,
@@ -70,10 +73,10 @@ class LeaveGroupFlowTest : GroupFlowTest() {
     private val myInitialGroupModelData = GroupModelData(
         groupIdentity = GroupIdentity(myContact.identity, 42),
         name = "MyExistingGroup",
-        createdAt = Date(),
+        createdAt = Instant.now(),
         synchronizedAt = null,
-        lastUpdate = Date(),
-        isArchived = false,
+        lastUpdate = Instant.now(),
+        conversationVisibility = ConversationVisibility.NORMAL,
         groupDescription = null,
         groupDescriptionChangedAt = null,
         otherMembers = emptySet(),

+ 14 - 11
app/src/androidTest/java/ch/threema/app/groupmanagement/RemoveGroupFlowTest.kt

@@ -7,11 +7,13 @@ import ch.threema.app.tasks.ReflectGroupSyncDeleteTask
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.clearDatabaseAndCaches
 import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ConversationVisibility
+import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.data.models.ContactModelData
-import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModel
 import ch.threema.data.models.GroupModelData
 import ch.threema.domain.helpers.ControlledTaskManager
+import ch.threema.domain.models.AcquaintanceLevel
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
@@ -22,8 +24,8 @@ import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
-import ch.threema.storage.models.ContactModel
-import java.util.Date
+import ch.threema.test.TestData.PUBLIC_KEY
+import java.time.Instant
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertIs
@@ -38,21 +40,22 @@ class RemoveGroupFlowTest : GroupFlowTest() {
 
     private val initialContactData = ContactModelData(
         identity = PreviewData.IDENTITY_OTHER_1.value,
-        publicKey = ByteArray(32),
-        createdAt = Date(),
+        publicKey = PUBLIC_KEY,
+        createdAt = Instant.now(),
+        lastUpdateAt = null,
         firstName = "",
         lastName = "",
         verificationLevel = VerificationLevel.SERVER_VERIFIED,
         workVerificationLevel = WorkVerificationLevel.NONE,
         nickname = null,
-        identityType = IdentityType.NORMAL,
-        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        identityType = IdentityType.REGULAR,
+        acquaintanceLevel = AcquaintanceLevel.DIRECT,
         activityState = IdentityState.ACTIVE,
         syncState = ContactSyncState.INITIAL,
         featureMask = 255u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-        isArchived = false,
+        conversationVisibility = ConversationVisibility.NORMAL,
         profilePictureBlobId = null,
         androidContactLookupInfo = null,
         localAvatarExpires = null,
@@ -67,10 +70,10 @@ class RemoveGroupFlowTest : GroupFlowTest() {
     private val myInitialGroupModelData = GroupModelData(
         groupIdentity = GroupIdentity(myContact.identity, 42),
         name = "MyExistingGroup",
-        createdAt = Date(),
+        createdAt = Instant.now(),
         synchronizedAt = null,
-        lastUpdate = Date(),
-        isArchived = false,
+        lastUpdate = Instant.now(),
+        conversationVisibility = ConversationVisibility.NORMAL,
         groupDescription = null,
         groupDescriptionChangedAt = null,
         otherMembers = emptySet(),

+ 30 - 36
app/src/androidTest/java/ch/threema/app/groupmanagement/UpdateGroupFlowTest.kt

@@ -10,11 +10,13 @@ import ch.threema.app.tasks.ReflectLocalGroupUpdate
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.clearDatabaseAndCaches
 import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ConversationVisibility
+import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.data.models.ContactModelData
-import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModel
 import ch.threema.data.models.GroupModelData
 import ch.threema.domain.helpers.ControlledTaskManager
+import ch.threema.domain.models.AcquaintanceLevel
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
@@ -26,8 +28,8 @@ import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
-import ch.threema.storage.models.ContactModel
-import java.util.Date
+import ch.threema.test.TestData.PUBLIC_KEY
+import java.time.Instant
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertContains
@@ -50,21 +52,22 @@ class UpdateGroupFlowTest : GroupFlowTest() {
      */
     private val initialContactData = ContactModelData(
         identity = "12345678",
-        publicKey = ByteArray(32),
-        createdAt = Date(),
+        publicKey = PUBLIC_KEY,
+        createdAt = Instant.now(),
+        lastUpdateAt = null,
         firstName = "",
         lastName = "",
         verificationLevel = VerificationLevel.SERVER_VERIFIED,
         workVerificationLevel = WorkVerificationLevel.NONE,
         nickname = null,
-        identityType = IdentityType.NORMAL,
-        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        identityType = IdentityType.REGULAR,
+        acquaintanceLevel = AcquaintanceLevel.DIRECT,
         activityState = IdentityState.ACTIVE,
         syncState = ContactSyncState.INITIAL,
         featureMask = 255u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-        isArchived = false,
+        conversationVisibility = ConversationVisibility.NORMAL,
         profilePictureBlobId = null,
         androidContactLookupInfo = null,
         localAvatarExpires = null,
@@ -82,21 +85,22 @@ class UpdateGroupFlowTest : GroupFlowTest() {
      */
     private val initialGroupMemberData = ContactModelData(
         identity = "TESTTEST",
-        publicKey = ByteArray(32),
-        createdAt = Date(),
+        publicKey = ByteArray(32) { 2.toByte() },
+        createdAt = Instant.now(),
+        lastUpdateAt = null,
         firstName = "",
         lastName = "",
         verificationLevel = VerificationLevel.SERVER_VERIFIED,
         workVerificationLevel = WorkVerificationLevel.NONE,
         nickname = null,
-        identityType = IdentityType.NORMAL,
-        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        identityType = IdentityType.REGULAR,
+        acquaintanceLevel = AcquaintanceLevel.DIRECT,
         activityState = IdentityState.ACTIVE,
         syncState = ContactSyncState.INITIAL,
         featureMask = 255u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-        isArchived = false,
+        conversationVisibility = ConversationVisibility.NORMAL,
         profilePictureBlobId = null,
         androidContactLookupInfo = null,
         localAvatarExpires = null,
@@ -111,10 +115,10 @@ class UpdateGroupFlowTest : GroupFlowTest() {
     private val myInitialGroupModelData = GroupModelData(
         groupIdentity = GroupIdentity(myContact.identity, 42),
         name = "MyExistingGroup",
-        createdAt = Date(),
+        createdAt = Instant.now(),
         synchronizedAt = null,
-        lastUpdate = Date(),
-        isArchived = false,
+        lastUpdate = Instant.now(),
+        conversationVisibility = ConversationVisibility.NORMAL,
         groupDescription = null,
         groupDescriptionChangedAt = null,
         otherMembers = setOf(initialGroupMemberData.identity),
@@ -125,10 +129,10 @@ class UpdateGroupFlowTest : GroupFlowTest() {
     private val initialGroupModelData = GroupModelData(
         groupIdentity = GroupIdentity(initialContactData.identity, 42),
         name = "ExistingGroup",
-        createdAt = Date(),
+        createdAt = Instant.now(),
         synchronizedAt = null,
-        lastUpdate = Date(),
-        isArchived = false,
+        lastUpdate = Instant.now(),
+        conversationVisibility = ConversationVisibility.NORMAL,
         groupDescription = null,
         groupDescriptionChangedAt = null,
         // User is the only member besides from the creator
@@ -204,9 +208,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         assertNotNull(groupModel)
 
         // Assert that the new member is not yet a member of the group
-        assertTrue {
-            groupModel.data?.otherMembers?.contains(initialContactData.identity) == false
-        }
+        assertEquals(false, groupModel.data?.otherMembers?.contains(initialContactData.identity))
 
         val groupChanges = GroupChanges(
             name = "NewGroupName",
@@ -221,9 +223,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
             ReflectionExpectation.REFLECTION_SUCCESS,
         )
 
-        assertTrue {
-            groupModel.data?.otherMembers?.contains(initialContactData.identity) == true
-        }
+        assertEquals(true, groupModel.data?.otherMembers?.contains(initialContactData.identity))
     }
 
     @Test
@@ -243,9 +243,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
             ReflectionExpectation.REFLECTION_SKIPPED,
         )
 
-        assertTrue {
-            groupModel.data?.otherMembers?.contains(initialContactData.identity) == true
-        }
+        assertEquals(true, groupModel.data?.otherMembers?.contains(initialContactData.identity))
     }
 
     @Test
@@ -265,7 +263,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
             ReflectionExpectation.REFLECTION_SUCCESS,
         )
 
-        assertTrue { groupModel.data?.otherMembers?.isEmpty() == true }
+        assertEquals(true, groupModel.data?.otherMembers?.isEmpty())
     }
 
     @Test
@@ -285,7 +283,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
             ReflectionExpectation.REFLECTION_SKIPPED,
         )
 
-        assertTrue { groupModel.data?.otherMembers?.isEmpty() == true }
+        assertEquals(true, groupModel.data?.otherMembers?.isEmpty())
     }
 
     @Test
@@ -353,9 +351,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
             ReflectionExpectation.REFLECTION_SKIPPED,
         )
 
-        assertTrue {
-            groupModel.data?.otherMembers?.contains(initialGroupMemberData.identity) == false
-        }
+        assertEquals(false, groupModel.data?.otherMembers?.contains(initialGroupMemberData.identity))
     }
 
     @Test
@@ -375,9 +371,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
             ReflectionExpectation.REFLECTION_SKIPPED,
         )
 
-        assertTrue {
-            groupModel.data?.otherMembers?.contains(initialGroupMemberData.identity) == false
-        }
+        assertEquals(false, groupModel.data?.otherMembers?.contains(initialGroupMemberData.identity))
     }
 
     @Test

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

@@ -12,16 +12,16 @@ import ch.threema.domain.protocol.csp.messages.AbstractMessage
 import ch.threema.domain.protocol.csp.messages.DeliveryReceiptMessage
 import ch.threema.domain.protocol.csp.messages.TextMessage
 import ch.threema.domain.protocol.csp.messages.TypingIndicatorMessage
-import ch.threema.domain.protocol.csp.messages.ballot.BallotData
-import ch.threema.domain.protocol.csp.messages.ballot.BallotDataChoice
-import ch.threema.domain.protocol.csp.messages.ballot.BallotDataChoiceBuilder
-import ch.threema.domain.protocol.csp.messages.ballot.BallotId
-import ch.threema.domain.protocol.csp.messages.ballot.BallotVote
-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.location.LocationMessage
 import ch.threema.domain.protocol.csp.messages.location.LocationMessageData
-import java.util.Date
+import ch.threema.domain.protocol.csp.messages.poll.PollData
+import ch.threema.domain.protocol.csp.messages.poll.PollDataChoice
+import ch.threema.domain.protocol.csp.messages.poll.PollDataChoiceBuilder
+import ch.threema.domain.protocol.csp.messages.poll.PollId
+import ch.threema.domain.protocol.csp.messages.poll.PollSetupMessage
+import ch.threema.domain.protocol.csp.messages.poll.PollVote
+import ch.threema.domain.protocol.csp.messages.poll.PollVoteMessage
+import java.time.Instant
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertTrue
 import kotlin.test.Test
@@ -55,39 +55,39 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
 
     @Test
     fun testIncomingPoll() = runTest {
-        val ballotId = BallotId()
-        val ballotCreator = contactA.identity
-
-        val ballotData = BallotData().also { data ->
-            data.description = "This describes the ballot!"
-            data.assessmentType = BallotData.AssessmentType.SINGLE
-            data.type = BallotData.Type.INTERMEDIATE
-            List<BallotDataChoice>(10) { index ->
-                BallotDataChoiceBuilder()
+        val pollId = PollId()
+        val pollCreator = contactA.identity
+
+        val pollData = PollData().also { data ->
+            data.description = "This describes the poll!"
+            data.assessmentType = PollData.AssessmentType.SINGLE
+            data.type = PollData.Type.INTERMEDIATE
+            List<PollDataChoice>(10) { index ->
+                PollDataChoiceBuilder()
                     .setId(index)
                     .setDescription("This is choice $index!")
                     .setSortKey(index)
                     .build()
             }.forEach { data.addChoice(it) }
-            data.displayType = BallotData.DisplayType.LIST_MODE
-            data.state = BallotData.State.OPEN
+            data.displayType = PollData.DisplayType.LIST_MODE
+            data.state = PollData.State.OPEN
         }
 
         val pollSetupMessage = PollSetupMessage().also {
-            it.ballotCreatorIdentity = ballotCreator
-            it.ballotId = ballotId
-            it.ballotData = ballotData
+            it.pollCreatorIdentity = pollCreator
+            it.pollId = pollId
+            it.pollData = pollData
         }.enrich()
 
-        // Test a valid ballot setup message that opens a poll
+        // Test a valid poll setup message that opens a poll
         assertSuccessfulMessageProcessing(pollSetupMessage, contactA)
 
         val pollVoteMessage = PollVoteMessage().also { voteMessage ->
-            voteMessage.ballotId = ballotId
-            voteMessage.ballotCreatorIdentity = ballotCreator
+            voteMessage.pollId = pollId
+            voteMessage.pollCreatorIdentity = pollCreator
             voteMessage.votes.addAll(
                 List(5) { index ->
-                    BallotVote(index, 0)
+                    PollVote(index, 0)
                 },
             )
         }.enrich()
@@ -175,7 +175,7 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
             it.fromIdentity = contactA.identity
             it.toIdentity = myContact.identity
             it.messageId = MessageId.random()
-            it.date = Date()
+            it.timestamp = Instant.now()
             it.text = "" // Bad message; cannot be decoded due to invalid length
         }
 
@@ -194,7 +194,7 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
             it.fromIdentity = contactA.identity
             it.toIdentity = contactB.identity
             it.messageId = MessageId.random()
-            it.date = Date()
+            it.timestamp = Instant.now()
             it.text = "This message is for contact B!"
         }
 
@@ -245,7 +245,7 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
 
     private fun AbstractMessage.enrich(): AbstractMessage {
         toIdentity = myContact.identity
-        date = Date()
+        timestamp = Instant.now()
         messageId = MessageId.random()
         return this
     }

+ 42 - 83
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt

@@ -1,39 +1,37 @@
 package ch.threema.app.processors
 
 import android.Manifest
-import android.content.Intent
 import android.os.Build
 import androidx.annotation.CallSuper
 import androidx.test.rule.GrantPermissionRule
 import ch.threema.KoinTestRule
-import ch.threema.app.TestCoreServiceManager
-import ch.threema.app.ThreemaApplication
+import ch.threema.app.di.SessionScopeContainer
+import ch.threema.app.di.SessionScopeContainerHolder
 import ch.threema.app.di.modules.sessionScopedModule
-import ch.threema.app.managers.ListenerManager
+import ch.threema.app.eventbus.GlobalEventBuses
+import ch.threema.app.eventbus.events.ContactEvent
+import ch.threema.app.eventbus.events.GroupEvent
+import ch.threema.app.files.ProfilePictureFileHandleProvider
 import ch.threema.app.managers.ServiceManager
-import ch.threema.app.multidevice.MultiDeviceManagerImpl
 import ch.threema.app.services.FileService
 import ch.threema.app.services.LifetimeService
-import ch.threema.app.tasks.archive.TaskArchiverImpl
-import ch.threema.app.tasks.archive.recovery.TaskRecoveryManagerImpl
-import ch.threema.app.tasks.getDebugString
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.app.testutils.clearDatabaseAndCaches
-import ch.threema.app.utils.AppVersionProvider
 import ch.threema.app.utils.ForwardSecurityStatusSender
 import ch.threema.base.crypto.HashedNonce
 import ch.threema.base.crypto.Nonce
 import ch.threema.base.crypto.NonceFactory
 import ch.threema.base.crypto.NonceScope
 import ch.threema.base.crypto.NonceStore
-import ch.threema.data.models.GroupIdentity
+import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.domain.fs.DHSession
 import ch.threema.domain.helpers.DecryptTaskCodec
 import ch.threema.domain.helpers.InMemoryContactStore
 import ch.threema.domain.helpers.InMemoryDHSessionStore
 import ch.threema.domain.helpers.InMemoryNonceStore
+import ch.threema.domain.models.AcquaintanceLevel
 import ch.threema.domain.models.BasicContact
 import ch.threema.domain.models.Contact
 import ch.threema.domain.models.GroupId
@@ -62,8 +60,9 @@ import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.domain.taskmanager.toCspMessage
 import ch.threema.storage.DatabaseService
 import ch.threema.storage.factories.TaskArchiveFactory
-import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.group.GroupMemberModel
+import io.mockk.every
+import io.mockk.mockk
 import java.io.ByteArrayInputStream
 import java.util.Queue
 import java.util.concurrent.ConcurrentLinkedQueue
@@ -78,9 +77,17 @@ import org.junit.Rule
 import org.junit.rules.Timeout
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.get
-import org.koin.dsl.module
+import org.koin.core.component.inject
 
 open class MessageProcessorProvider : KoinComponent {
+    private val globalEventBuses: GlobalEventBuses by inject()
+    private val profilePictureFileHandleProvider: ProfilePictureFileHandleProvider by inject()
+    private val sessionScopeContainer: SessionScopeContainer by lazy {
+        get<SessionScopeContainerHolder>().sessionScopeContainer!!
+    }
+    protected val serviceManager: ServiceManager
+        get() = sessionScopeContainer.serviceManager
+
     protected val myContact: TestContact = TestHelpers.TEST_CONTACT
     protected val contactA = TestContact("12345678")
     protected val contactB = TestContact("ABCDEFGH")
@@ -157,9 +164,6 @@ open class MessageProcessorProvider : KoinComponent {
             myContact.identity,
         )
 
-    protected val serviceManager: ServiceManager by lazy {
-        ThreemaApplication.requireServiceManager()
-    }
     private val contactStore: ContactStore = InMemoryContactStore().apply {
         addContact(myContact.contact)
         addContact(contactA.contact)
@@ -179,8 +183,6 @@ open class MessageProcessorProvider : KoinComponent {
             serviceManager.contactService,
             serviceManager.messageService,
             APIConnector(
-                /* ipv6 = */
-                false,
                 /* serverAddressProvider = */
                 null,
                 /* isWork = */
@@ -240,8 +242,6 @@ open class MessageProcessorProvider : KoinComponent {
      */
     private lateinit var originalTaskManager: TaskManager
 
-    private lateinit var taskManager: TaskManager
-
     /**
      * The local task codec is used for running tasks directly in the tests. We can use this to
      * check that messages are being sent inside the directly run task. Note that the test task
@@ -297,13 +297,9 @@ open class MessageProcessorProvider : KoinComponent {
             GrantPermissionRule.grant()
         }
 
-    private val instrumentedTestModule = module {
-        factory<TaskManager> { taskManager }
-    }
-
     @get:Rule
     val koinTestRule = KoinTestRule(
-        modules = listOf(sessionScopedModule, instrumentedTestModule),
+        modules = listOf(sessionScopedModule),
     )
 
     /**
@@ -312,17 +308,13 @@ open class MessageProcessorProvider : KoinComponent {
     @BeforeTest
     @CallSuper
     open fun setup() {
-        TestHelpers.setIdentity(
-            ThreemaApplication.requireServiceManager(),
-            TestHelpers.TEST_CONTACT,
-        )
+        TestHelpers.setIdentity(serviceManager, TestHelpers.TEST_CONTACT)
 
         // Delete persisted tasks as they are not needed for tests
         get<TaskArchiveFactory>().deleteAll()
 
         // Replace original task manager (save a copy of it)
-        originalTaskManager = serviceManager.taskManager
-
+        originalTaskManager = sessionScopeContainer.taskManager
         val mockTaskManager = object : TaskManager {
             override fun <R> schedule(task: Task<R, TaskCodec>): Deferred<R> {
                 val deferred = CompletableDeferred<R>()
@@ -340,9 +332,6 @@ open class MessageProcessorProvider : KoinComponent {
                 // Nothing to do
             }
         }
-
-        taskManager = mockTaskManager
-
         setTaskManager(mockTaskManager)
 
         disableLifetimeService()
@@ -420,7 +409,7 @@ open class MessageProcessorProvider : KoinComponent {
         }
 
         // Remove files
-        serviceManager.fileService.removeAllAvatars()
+        profilePictureFileHandleProvider.deleteAll()
 
         // Unblock contacts
         val blockedIdentitiesService = serviceManager.blockedIdentitiesService
@@ -430,45 +419,13 @@ open class MessageProcessorProvider : KoinComponent {
     }
 
     private fun setTaskManager(taskManager: TaskManager) {
-        val serviceManager = ThreemaApplication.requireServiceManager()
-        val coreServiceManager = TestCoreServiceManager(
-            version = AppVersionProvider.appVersion,
-            databaseProvider = get(),
-            databaseService = serviceManager.databaseService,
-            preferenceStore = serviceManager.preferenceStore,
-            encryptedPreferenceStore = serviceManager.encryptedPreferenceStore,
-            taskArchiver = TaskArchiverImpl(get(), TaskRecoveryManagerImpl(), getDebugString),
-            deviceCookieManager = serviceManager.deviceCookieManager,
-            taskManager = taskManager,
-            multiDeviceManager = serviceManager.multiDeviceManager as MultiDeviceManagerImpl,
-            identityStore = serviceManager.identityStore,
-            nonceFactory = serviceManager.nonceFactory,
-        )
-
-        val field = ServiceManager::class.java.getDeclaredField("coreServiceManager")
-        field.isAccessible = true
-        field.set(ThreemaApplication.getServiceManager(), coreServiceManager)
+        sessionScopeContainer.taskManagerOverride = taskManager
     }
 
     private fun disableLifetimeService() {
-        val field = ServiceManager::class.java.getDeclaredField("lifetimeService")
-        field.isAccessible = true
-        field.set(
-            ThreemaApplication.getServiceManager(),
-            object : LifetimeService {
-                override fun acquireConnection(sourceTag: String, unpauseable: Boolean) = Unit
-                override fun acquireConnection(source: String) = Unit
-                override fun acquireUnpauseableConnection(source: String) = Unit
-                override fun releaseConnection(sourceTag: String) = Unit
-                override fun releaseConnectionLinger(sourceTag: String, timeoutMs: Long) = Unit
-                override fun ensureConnection() = Unit
-                override fun alarm(intent: Intent?) = Unit
-                override fun isActive(): Boolean = true
-                override fun pause() = Unit
-                override fun unpause() = Unit
-                override fun addListener(listener: LifetimeService.LifetimeServiceListener?) = Unit
-            },
-        )
+        sessionScopeContainer.lifetimeServiceOverride = mockk<LifetimeService>(relaxed = true) {
+            every { isActive } returns true
+        }
     }
 
     /**
@@ -485,7 +442,7 @@ open class MessageProcessorProvider : KoinComponent {
                 it,
                 databaseService,
                 contactStore,
-                AcquaintanceLevel.GROUP,
+                AcquaintanceLevel.GROUP_OR_DELETED,
             )
         }
 
@@ -498,15 +455,15 @@ open class MessageProcessorProvider : KoinComponent {
         contactStore: ContactStore,
         acquaintanceLevel: AcquaintanceLevel = AcquaintanceLevel.DIRECT,
     ) {
-        databaseService.contactModelFactory.createOrUpdate(
+        databaseService.contactModelFactory.create(
             testContact.contactModel.setAcquaintanceLevel(acquaintanceLevel)
                 .setFeatureMask(ThreemaFeature.FORWARD_SECURITY),
         )
 
         contactStore.addCachedContact(testContact.toBasicContact())
 
-        // We trigger the listeners to invalidate the cache of the new contact model.
-        ListenerManager.contactListeners.handle { it.onModified(testContact.identity) }
+        // We trigger the event bus to invalidate the cache of the new contact model.
+        globalEventBuses.contacts.emit(ContactEvent.ContactUpdated.javaCreate(testContact.identity))
     }
 
     private fun addGroupToDatabase(
@@ -534,12 +491,9 @@ open class MessageProcessorProvider : KoinComponent {
             )
         }
 
-        // We trigger the listeners to invalidate the cache of the new group model.
-        ListenerManager.groupListeners.handle {
-            it.onUpdate(
-                GroupIdentity(testGroup.groupCreator.identity, testGroup.apiGroupId.toLong()),
-            )
-        }
+        // We notify the event bus to invalidate the cache of the new group model.
+        val groupIdentity = GroupIdentity(testGroup.groupCreator.identity, testGroup.apiGroupId.toLong())
+        globalEventBuses.groups.emit(GroupEvent.GroupUpdated(groupIdentity))
     }
 
     /**
@@ -556,7 +510,12 @@ open class MessageProcessorProvider : KoinComponent {
         )
 
         // Process the group message
-        val messageProcessor = IncomingMessageProcessorImpl(serviceManager)
+        val messageProcessor = IncomingMessageProcessorImpl(
+            serviceManager,
+            serverMessageModelRepository = mockk(relaxed = true),
+            globalEventBuses,
+            typingIndicatorManager = mockk(),
+        )
 
         messageProcessor.processIncomingCspMessage(messageBox, localTaskCodec)
 
@@ -624,7 +583,7 @@ open class MessageProcessorProvider : KoinComponent {
         featureMask = ThreemaFeature.Builder()
             .audio(true)
             .group(true)
-            .ballot(true)
+            .poll(true)
             .file(true)
             .voip(true)
             .videocalls(true)
@@ -634,7 +593,7 @@ open class MessageProcessorProvider : KoinComponent {
             .deleteMessages(true)
             .build().toULong(),
         identityState = IdentityState.ACTIVE,
-        identityType = IdentityType.NORMAL,
+        identityType = IdentityType.REGULAR,
         verificationLevel = VerificationLevel.UNVERIFIED,
         workVerificationLevel = WorkVerificationLevel.NONE,
         jobTitle = null,

+ 9 - 17
app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt

@@ -8,15 +8,15 @@ import ch.threema.app.protocolsteps.IdentityBlockedSteps
 import ch.threema.app.services.BlockedIdentitiesService
 import ch.threema.app.services.GroupService
 import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.data.datatypes.ConversationVisibility
 import ch.threema.data.models.ContactModel
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.repositories.ContactModelRepository
-import ch.threema.domain.stores.ContactStore
-import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import ch.threema.domain.models.AcquaintanceLevel
 import ch.threema.storage.models.group.GroupModelOld
 import io.mockk.every
 import io.mockk.mockk
-import java.util.Date
+import java.time.Instant
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import org.junit.Assume.assumeTrue
@@ -33,7 +33,6 @@ class IdentityBlockedStepsTest {
     private val inLeftGroup = TestContact("--------")
 
     private lateinit var contactModelRepositoryMock: ContactModelRepository
-    private lateinit var contactStoreMock: ContactStore
     private lateinit var groupServiceMock: GroupService
     private lateinit var blockedIdentitiesServiceMock: BlockedIdentitiesService
     private lateinit var noBlockUnknownSynchronizedSettingsServiceMock: SynchronizedSettingsService
@@ -47,7 +46,6 @@ class IdentityBlockedStepsTest {
         )
 
         contactModelRepositoryMock = getContactModelRepositoryMock()
-        contactStoreMock = getContactStoreMock()
         groupServiceMock = getGroupServiceMock()
         blockedIdentitiesServiceMock = mockk {
             every { isBlocked(any()) } answers {
@@ -153,7 +151,6 @@ class IdentityBlockedStepsTest {
     private fun runIdentityBlockedSteps(identity: String, synchronizedSettingsService: SynchronizedSettingsService) =
         IdentityBlockedSteps(
             contactModelRepository = contactModelRepositoryMock,
-            contactStore = contactStoreMock,
             groupService = groupServiceMock,
             blockedIdentitiesService = blockedIdentitiesServiceMock,
             synchronizedSettingsService = synchronizedSettingsService,
@@ -164,17 +161,11 @@ class IdentityBlockedStepsTest {
 
         every { getByIdentity(unknownContact.identity) } returns null
 
-        every { getByIdentity(inGroup.identity) } returns getContactModelMock(inGroup, AcquaintanceLevel.GROUP)
+        every { getByIdentity(inGroup.identity) } returns getContactModelMock(inGroup, AcquaintanceLevel.GROUP_OR_DELETED)
 
-        every { getByIdentity(inNoGroup.identity) } returns getContactModelMock(inNoGroup, AcquaintanceLevel.GROUP)
+        every { getByIdentity(inNoGroup.identity) } returns getContactModelMock(inNoGroup, AcquaintanceLevel.GROUP_OR_DELETED)
 
-        every { getByIdentity(inLeftGroup.identity) } returns getContactModelMock(inLeftGroup, AcquaintanceLevel.GROUP)
-    }
-
-    private fun getContactStoreMock(): ContactStore = mockk {
-        every { isSpecialContact(any()) } answers {
-            firstArg<String>() == specialContact.identity
-        }
+        every { getByIdentity(inLeftGroup.identity) } returns getContactModelMock(inLeftGroup, AcquaintanceLevel.GROUP_OR_DELETED)
     }
 
     private fun getGroupServiceMock(): GroupService = mockk {
@@ -197,7 +188,8 @@ class IdentityBlockedStepsTest {
         val contactModelData = ContactModelData(
             identity = contact.identity,
             publicKey = contact.publicKey,
-            createdAt = Date(),
+            createdAt = Instant.now(),
+            lastUpdateAt = null,
             firstName = "",
             lastName = "",
             nickname = null,
@@ -211,7 +203,7 @@ class IdentityBlockedStepsTest {
             featureMask = 0u,
             readReceiptPolicy = mockk(),
             typingIndicatorPolicy = mockk(),
-            isArchived = false,
+            conversationVisibility = ConversationVisibility.NORMAL,
             androidContactLookupInfo = null,
             localAvatarExpires = null,
             isRestored = false,

+ 0 - 119
app/src/androidTest/java/ch/threema/app/services/BlockedIdentitiesServiceTest.kt

@@ -1,119 +0,0 @@
-package ch.threema.app.services
-
-import ch.threema.app.TestMultiDeviceManager
-import ch.threema.app.listeners.ContactListener
-import ch.threema.app.managers.ListenerManager
-import ch.threema.app.preference.service.PreferenceServiceImpl
-import ch.threema.domain.types.IdentityString
-import io.mockk.mockk
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
-import org.koin.core.component.KoinComponent
-import org.koin.core.component.get
-
-class BlockedIdentitiesServiceTest : KoinComponent {
-    private val multiDeviceManager = TestMultiDeviceManager(
-        isMultiDeviceActive = false,
-    )
-
-    private val preferenceService by lazy {
-        PreferenceServiceImpl(
-            /* appContext = */
-            get(),
-            /* preferenceStore = */
-            get(),
-            /* encryptedPreferenceStore = */
-            get(),
-        )
-    }
-
-    private val blockedIdentitiesService: BlockedIdentitiesService by lazy {
-        BlockedIdentitiesServiceImpl(
-            preferenceService = preferenceService,
-            multiDeviceManager = multiDeviceManager,
-            taskCreator = mockk(),
-        )
-    }
-
-    private val onModified = ArrayDeque<String>()
-
-    @BeforeTest
-    fun initListener() {
-        // Remove all listeners to prevent side effects
-        ListenerManager.contactListeners.clear()
-
-        // Add listener to track which contact has been modified
-        ListenerManager.contactListeners.add(object : ContactListener {
-            override fun onModified(identity: IdentityString) {
-                onModified.addLast(identity)
-            }
-        })
-
-        blockedIdentitiesService.persistBlockedIdentities(emptySet())
-        onModified.clear()
-        // Assert that initially no identities are blocked
-        assertTrue { blockedIdentitiesService.getAllBlockedIdentities().isEmpty() }
-    }
-
-    @Test
-    fun testBlockIdentity() {
-        blockedIdentitiesService.blockIdentity("ABCDEFGH")
-        blockedIdentitiesService.blockIdentity("TESTTEST")
-
-        assertTrue { blockedIdentitiesService.isBlocked("ABCDEFGH") }
-        assertTrue { blockedIdentitiesService.isBlocked("TESTTEST") }
-
-        assertTrue { onModified.removeFirst() == "ABCDEFGH" }
-        assertTrue { onModified.removeFirst() == "TESTTEST" }
-    }
-
-    @Test
-    fun testUnblockIdentity() {
-        blockedIdentitiesService.blockIdentity("ABCDEFGH")
-        blockedIdentitiesService.blockIdentity("TESTTEST")
-        blockedIdentitiesService.unblockIdentity("ABCDEFGH")
-
-        assertTrue { !blockedIdentitiesService.isBlocked("ABCDEFGH") }
-        assertTrue { blockedIdentitiesService.isBlocked("TESTTEST") }
-
-        assertTrue { onModified.removeFirst() == "ABCDEFGH" }
-        assertTrue { onModified.removeFirst() == "TESTTEST" }
-        assertTrue { onModified.removeFirst() == "ABCDEFGH" }
-    }
-
-    @Test
-    fun testPersistIdentities() {
-        blockedIdentitiesService.persistBlockedIdentities(setOf("ABCDEFGH", "12345678"))
-
-        assertEquals(
-            setOf("ABCDEFGH", "12345678"),
-            setOf(onModified.removeFirst(), onModified.removeFirst()),
-        )
-
-        blockedIdentitiesService.persistBlockedIdentities(setOf("ABCDEFGH", "TESTTEST"))
-
-        assertEquals(
-            setOf("12345678", "TESTTEST"),
-            setOf(onModified.removeFirst(), onModified.removeFirst()),
-        )
-        assertTrue { onModified.isEmpty() }
-    }
-
-    @Test
-    fun testToggle() {
-        blockedIdentitiesService.toggleBlocked("12345678")
-
-        assertTrue { blockedIdentitiesService.isBlocked("12345678") }
-        assertEquals("12345678", onModified.removeFirst())
-
-        blockedIdentitiesService.toggleBlocked("12345678")
-
-        assertFalse { blockedIdentitiesService.isBlocked("12345678") }
-        assertEquals("12345678", onModified.removeFirst())
-
-        assertTrue { onModified.isEmpty() }
-    }
-}

+ 0 - 72
app/src/androidTest/java/ch/threema/app/stores/EncryptedPreferenceStoreImplTest.kt

@@ -14,16 +14,12 @@ import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
 import kotlin.test.assertFalse
-import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
 import kotlinx.coroutines.flow.MutableStateFlow
-import org.json.JSONArray
-import org.json.JSONObject
 
 class EncryptedPreferenceStoreImplTest {
 
-    private var onChangedCalled = false
     private lateinit var directory: File
     private lateinit var store: EncryptedPreferenceStore
 
@@ -35,10 +31,8 @@ class EncryptedPreferenceStoreImplTest {
             masterKeyProvider = MasterKeyProvider(
                 masterKeyFlow = stateFlowOf(MasterKeyImpl(MASTER_KEY_DATA)),
             ),
-            onChanged = { _, _ -> onChangedCalled = true },
         )
         store.clear()
-        onChangedCalled = false
     }
 
     @AfterTest
@@ -79,18 +73,15 @@ class EncryptedPreferenceStoreImplTest {
         store.save("foo", "Hello Wörld")
 
         assertEquals("Hello Wörld", store.getString("foo"))
-        assertTrue(onChangedCalled)
     }
 
     @Test
     fun saveAndGetNullString() {
         store.save("foo", "Hello Wörld")
-        onChangedCalled = false
 
         store.save("foo", null as String?)
 
         assertNull(store.getString("foo"))
-        assertTrue(onChangedCalled)
     }
 
     @Test
@@ -100,51 +91,6 @@ class EncryptedPreferenceStoreImplTest {
         store.save("foo", bytes)
 
         assertContentEquals(bytes, store.getBytes("foo"))
-        assertTrue(onChangedCalled)
-    }
-
-    @Test
-    fun saveAndGetJsonArray() {
-        val jsonArray = JSONArray(arrayOf<Any>(1, true, "Hello"))
-
-        store.save("foo", jsonArray)
-
-        val readJsonArray = store.getJSONArray("foo")
-
-        assertNotNull(readJsonArray)
-        assertEquals(3, readJsonArray.length())
-        assertEquals(1, readJsonArray.getInt(0))
-        assertEquals(true, readJsonArray.getBoolean(1))
-        assertEquals("Hello", readJsonArray.getString(2))
-        assertTrue(onChangedCalled)
-    }
-
-    @Test
-    fun saveAndGetInvalidJsonArray() {
-        store.save("foo", "not a valid json array")
-        assertNull(store.getJSONArray("foo"))
-    }
-
-    @Test
-    fun saveAndGetJsonObject() {
-        val jsonObject = JSONObject(mapOf("a" to "Hello", "b" to 123))
-
-        store.save("foo", jsonObject)
-
-        val readJsonObject = store.getJSONObject("foo")
-
-        assertNotNull(readJsonObject)
-        assertEquals(setOf("a", "b"), readJsonObject.keys().asSequence().toSet())
-        assertEquals("Hello", readJsonObject.getString("a"))
-        assertEquals(123, readJsonObject.getInt("b"))
-        assertTrue(onChangedCalled)
-    }
-
-    @Test
-    fun saveAndGetInvalidJsonObject() {
-        store.save("foo", "not a valid json object")
-
-        assertNull(store.getJSONObject("foo"))
     }
 
     @Test
@@ -154,7 +100,6 @@ class EncryptedPreferenceStoreImplTest {
         store.save("foo", strings)
 
         assertContentEquals(strings, store.getStringArray("foo"))
-        assertTrue(onChangedCalled)
     }
 
     @Test
@@ -164,17 +109,6 @@ class EncryptedPreferenceStoreImplTest {
         store.save("foo", strings)
 
         assertContentEquals(strings, store.getStringArray("foo"))
-        assertTrue(onChangedCalled)
-    }
-
-    @Test
-    fun saveAndGetStringQuietlyArray() {
-        val strings = arrayOf("Hello", "World")
-
-        store.saveQuietly("foo", strings)
-
-        assertContentEquals(strings, store.getStringArray("foo"))
-        assertFalse(onChangedCalled)
     }
 
     @Test
@@ -184,7 +118,6 @@ class EncryptedPreferenceStoreImplTest {
         store.save("foo", map)
 
         assertEquals(map, store.getMap("foo"))
-        assertTrue(onChangedCalled)
     }
 
     @Test
@@ -200,8 +133,6 @@ class EncryptedPreferenceStoreImplTest {
         assertNull(store.getStringArray("foo"))
         assertNull(store.getMap("foo"))
         assertNull(store.getBytes("foo"))
-        assertNull(store.getJSONArray("foo"))
-        assertNull(store.getJSONObject("foo"))
     }
 
     @Test
@@ -241,7 +172,6 @@ class EncryptedPreferenceStoreImplTest {
             masterKeyProvider = MasterKeyProvider(
                 masterKeyFlow = masterKeyFlow,
             ),
-            onChanged = { _, _ -> onChangedCalled = true },
         )
         store.clear()
         store.save("foo", "Test")
@@ -260,14 +190,12 @@ class EncryptedPreferenceStoreImplTest {
             masterKeyProvider = MasterKeyProvider(
                 masterKeyFlow = stateFlowOf(null),
             ),
-            onChanged = { _, _ -> onChangedCalled = true },
         )
         store.clear()
 
         assertFailsWith<MasterKeyLockedException> {
             store.save("foo", "Hello")
         }
-        assertFalse(onChangedCalled)
     }
 
     companion object {

+ 147 - 70
app/src/androidTest/java/ch/threema/app/stores/PreferencesStoreImplTest.kt

@@ -1,10 +1,9 @@
 package ch.threema.app.stores
 
+import android.content.Context
 import androidx.core.content.edit
-import androidx.preference.PreferenceManager
+import androidx.test.core.app.ApplicationProvider
 import app.cash.turbine.test
-import ch.threema.app.ThreemaApplication
-import ch.threema.common.emptyByteArray
 import ch.threema.testhelpers.expectItem
 import java.time.Instant
 import kotlin.test.BeforeTest
@@ -13,29 +12,22 @@ import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
 import kotlin.test.assertFalse
-import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
 import kotlinx.coroutines.test.runTest
-import org.json.JSONArray
-import org.json.JSONObject
-import org.koin.core.component.KoinComponent
-import org.koin.core.component.get
 
-class PreferencesStoreImplTest : KoinComponent {
+class PreferencesStoreImplTest {
 
-    private var onChangedCalled = false
+    private val sharedPreferences = ApplicationProvider.getApplicationContext<Context>().getSharedPreferences("preferences-store-test", 0)
     private lateinit var store: PreferenceStore
 
     @BeforeTest
     fun setUp() {
         store = PreferenceStoreImpl(
-            sharedPreferences = get(),
-            onChanged = { _, _ -> onChangedCalled = true },
+            sharedPreferences = sharedPreferences,
             commit = true,
         )
         store.clear()
-        onChangedCalled = false
     }
 
     @Test
@@ -71,7 +63,6 @@ class PreferencesStoreImplTest : KoinComponent {
         store.save("foo", "Hello Wörld")
 
         assertEquals("Hello Wörld", store.getString("foo"))
-        assertTrue(onChangedCalled)
     }
 
     @Test
@@ -81,7 +72,6 @@ class PreferencesStoreImplTest : KoinComponent {
         store.save("foo", 123)
 
         assertEquals(123, store.getInt("foo"))
-        assertTrue(onChangedCalled)
     }
 
     @Test
@@ -92,7 +82,6 @@ class PreferencesStoreImplTest : KoinComponent {
         store.save("foo", Instant.ofEpochMilli(1_766_407_316_000L))
 
         assertEquals(Instant.ofEpochMilli(1_766_407_316_000L), store.getInstant("foo"))
-        assertTrue(onChangedCalled)
 
         store.save("foo", null as Instant?)
 
@@ -107,7 +96,6 @@ class PreferencesStoreImplTest : KoinComponent {
         store.save("foo", true)
 
         assertEquals(true, store.getBoolean("foo"))
-        assertTrue(onChangedCalled)
     }
 
     @Test
@@ -117,7 +105,6 @@ class PreferencesStoreImplTest : KoinComponent {
         store.save("foo", 123_000_000_000_000L)
 
         assertEquals(123_000_000_000_000L, store.getLong("foo"))
-        assertTrue(onChangedCalled)
     }
 
     @Test
@@ -127,7 +114,6 @@ class PreferencesStoreImplTest : KoinComponent {
         store.save("foo", 123.456f)
 
         assertEquals(123.456f, store.getFloat("foo", -1f))
-        assertTrue(onChangedCalled)
     }
 
     @Test
@@ -137,37 +123,6 @@ class PreferencesStoreImplTest : KoinComponent {
         store.save("foo", bytes)
 
         assertContentEquals(bytes, store.getBytes("foo"))
-        assertTrue(onChangedCalled)
-    }
-
-    @Test
-    fun saveAndRestoreJsonArray() {
-        val jsonArray = JSONArray(arrayOf<Any>(1, true, "Hello"))
-
-        store.save("foo", jsonArray)
-
-        val readJsonArray = store.getJSONArray("foo")
-
-        assertEquals(3, readJsonArray.length())
-        assertEquals(1, readJsonArray.getInt(0))
-        assertEquals(true, readJsonArray.getBoolean(1))
-        assertEquals("Hello", readJsonArray.getString(2))
-        assertTrue(onChangedCalled)
-    }
-
-    @Test
-    fun saveAndRestoreJsonObject() {
-        val jsonObject = JSONObject(mapOf("a" to "Hello", "b" to 123))
-
-        store.save("foo", jsonObject)
-
-        val readJsonObject = store.getJSONObject("foo")
-
-        assertNotNull(readJsonObject)
-        assertEquals(setOf("a", "b"), readJsonObject.keys().asSequence().toSet())
-        assertEquals("Hello", readJsonObject.getString("a"))
-        assertEquals(123, readJsonObject.getInt("b"))
-        assertTrue(onChangedCalled)
     }
 
     @Test
@@ -177,17 +132,6 @@ class PreferencesStoreImplTest : KoinComponent {
         store.save("foo", strings)
 
         assertContentEquals(strings, store.getStringArray("foo"))
-        assertTrue(onChangedCalled)
-    }
-
-    @Test
-    fun saveAndRestoreStringQuietlyArray() {
-        val strings = arrayOf("Hello", "World")
-
-        store.saveQuietly("foo", strings)
-
-        assertContentEquals(strings, store.getStringArray("foo"))
-        assertFalse(onChangedCalled)
     }
 
     @Test
@@ -197,7 +141,6 @@ class PreferencesStoreImplTest : KoinComponent {
         store.save("foo", map)
 
         assertEquals(map, store.getMap("foo"))
-        assertTrue(onChangedCalled)
     }
 
     @Test
@@ -209,19 +152,24 @@ class PreferencesStoreImplTest : KoinComponent {
         assertEquals(false, store.getBoolean("foo"))
         assertNull(store.getStringArray("foo"))
         assertEquals(emptyMap(), store.getMap("foo"))
-        assertContentEquals(emptyByteArray(), store.getBytes("foo"))
-        assertEquals(JSONArray(), store.getJSONArray("foo"))
-        assertNull(store.getJSONObject("foo"))
+        assertNull(store.getBytes("foo"))
     }
 
     @Test
     fun restorePreviouslyStoredValue() {
-        PreferenceManager.getDefaultSharedPreferences(ThreemaApplication.getAppContext())
-            .edit {
-                putString("test", "Hello")
-            }
+        sharedPreferences.edit {
+            putInt("int", 123)
+            putString("string", "Hello")
+            putString("map", """[["A","a"],["B","b"],["C"],["D",null]]""")
+            putString("string-array", "A;B;C")
+            putStringSet("string-set", setOf("A", "B", "C"))
+        }
 
-        assertEquals("Hello", store.getString("test"))
+        assertEquals(123, store.getInt("int"))
+        assertEquals("Hello", store.getString("string"))
+        assertEquals(mapOf("A" to "a", "B" to "b", "C" to null, "D" to null), store.getMap("map"))
+        assertContentEquals(arrayOf("A", "B", "C"), store.getStringArray("string-array"))
+        assertEquals(setOf("A", "B", "C"), store.getStringSet("string-set"))
     }
 
     @Test
@@ -273,4 +221,133 @@ class PreferencesStoreImplTest : KoinComponent {
             cancelAndIgnoreRemainingEvents()
         }
     }
+
+    @Test
+    fun watchLongShouldEmitCorrectValueChangesToKey() = runTest {
+        val key = "number_of_things"
+        store.watchLong(key, -1L).test {
+            // Expect the defined default value (as the key does not exist on disk right now)
+            expectItem(-1L)
+
+            // Change the value
+            store.save(key, 1L)
+            expectItem(1L)
+
+            // Should emit the defined default value when removing the preference
+            store.remove(key)
+            expectItem(-1L)
+
+            // Add the key again
+            store.save(key, 2L)
+            expectItem(2L)
+
+            // Expect no distinct change
+            store.save(key, 2L)
+            expectNoEvents()
+
+            // Change the value (to the default value)
+            store.save(key, -1L)
+            expectItem(-1L)
+
+            // Expecting no distinct change, as the last saved value was already the default value
+            store.remove(key)
+            expectNoEvents()
+
+            // Adding the key again (with its default value)
+            store.save(key, -1L)
+            expectNoEvents()
+
+            // Changing the value
+            store.save(key, 3L)
+            expectItem(3L)
+
+            cancelAndIgnoreRemainingEvents()
+        }
+    }
+
+    @Test
+    fun watchIntShouldEmitCorrectValueChangesToKey() = runTest {
+        val key = "number_of_things"
+        store.watchInt(key, -1).test {
+            // Expect the defined default value (as the key does not exist on disk right now)
+            expectItem(-1)
+
+            // Change the value
+            store.save(key, 1)
+            expectItem(1)
+
+            // Should emit the defined default value when removing the preference
+            store.remove(key)
+            expectItem(-1)
+
+            // Add the key again
+            store.save(key, 2)
+            expectItem(2)
+
+            // Expect no distinct change
+            store.save(key, 2)
+            expectNoEvents()
+
+            // Change the value (to the default value)
+            store.save(key, -1)
+            expectItem(-1)
+
+            // Expecting no distinct change, as the last saved value was already the default value
+            store.remove(key)
+            expectNoEvents()
+
+            // Adding the key again (with its default value)
+            store.save(key, -1)
+            expectNoEvents()
+
+            // Changing the value
+            store.save(key, 3)
+            expectItem(3)
+
+            cancelAndIgnoreRemainingEvents()
+        }
+    }
+
+    @Test
+    fun watchInstantShouldEmitCorrectValueChangesToKey() = runTest {
+        val key = "some_time"
+        store.watchInstant(key).test {
+            // Expect null initially (as the key does not exist on disk right now)
+            expectItem(null)
+
+            // Change the value
+            store.save(key, Instant.ofEpochMilli(123))
+            expectItem(Instant.ofEpochMilli(123))
+
+            // Should emit the defined default value when removing the preference
+            store.remove(key)
+            expectItem(null)
+
+            // Add the key again
+            store.save(key, Instant.ofEpochMilli(456))
+            expectItem(Instant.ofEpochMilli(456))
+
+            // Expect no distinct change
+            store.save(key, Instant.ofEpochMilli(456))
+            expectNoEvents()
+
+            // Change the value (to the default value)
+            store.save(key, null as Instant?)
+            expectItem(null)
+
+            // Expecting no distinct change, as the last saved value was already null
+            store.remove(key)
+            expectNoEvents()
+
+            // Adding the key again (with null as the value)
+            store.save(key, null as Instant?)
+            expectNoEvents()
+
+            // Changing the value
+            store.save(key, Instant.ofEpochMilli(789))
+            expectItem(Instant.ofEpochMilli(789))
+
+            cancelAndIgnoreRemainingEvents()
+        }
+    }
 }

+ 15 - 12
app/src/androidTest/java/ch/threema/app/tasks/GroupCreateTaskTest.kt

@@ -3,8 +3,8 @@ package ch.threema.app.tasks
 import ch.threema.KoinTestRule
 import ch.threema.app.DangerousTest
 import ch.threema.app.TestMultiDeviceManager
-import ch.threema.app.ThreemaApplication
 import ch.threema.app.di.modules.sessionScopedModule
+import ch.threema.app.managers.ServiceManager
 import ch.threema.app.multidevice.MultiDeviceManager
 import ch.threema.app.protocolsteps.ExpectedProfilePictureChange
 import ch.threema.app.protocolsteps.PredefinedMessageIds
@@ -12,11 +12,13 @@ import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.clearDatabaseAndCaches
 import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ConversationVisibility
+import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.data.models.ContactModelData
-import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModelData
 import ch.threema.data.repositories.GroupCreateException
 import ch.threema.domain.helpers.TransactionAckTaskCodec
+import ch.threema.domain.models.AcquaintanceLevel
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
@@ -27,8 +29,8 @@ import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.connection.data.CspMessage
 import ch.threema.domain.protocol.connection.data.OutboundD2mMessage
-import ch.threema.storage.models.ContactModel
-import java.util.Date
+import ch.threema.test.TestData.PUBLIC_KEY
+import java.time.Instant
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -43,21 +45,22 @@ class GroupCreateTaskTest {
 
     private val initialContactModelData = ContactModelData(
         identity = "12345678",
-        publicKey = ByteArray(32),
-        createdAt = Date(),
+        publicKey = PUBLIC_KEY,
+        createdAt = Instant.now(),
+        lastUpdateAt = null,
         firstName = "",
         lastName = "",
         verificationLevel = VerificationLevel.SERVER_VERIFIED,
         workVerificationLevel = WorkVerificationLevel.NONE,
         nickname = null,
-        identityType = IdentityType.NORMAL,
-        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        identityType = IdentityType.REGULAR,
+        acquaintanceLevel = AcquaintanceLevel.DIRECT,
         activityState = IdentityState.ACTIVE,
         syncState = ContactSyncState.INITIAL,
         featureMask = 255u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-        isArchived = false,
+        conversationVisibility = ConversationVisibility.NORMAL,
         profilePictureBlobId = null,
         androidContactLookupInfo = null,
         localAvatarExpires = null,
@@ -69,7 +72,7 @@ class GroupCreateTaskTest {
         workLastFullSyncAt = null,
     )
 
-    private val serviceManager by lazy { ThreemaApplication.requireServiceManager() }
+    private val serviceManager by lazy { ServiceManager.require() }
 
     private var isMultiDeviceEnabled = false
 
@@ -113,7 +116,7 @@ class GroupCreateTaskTest {
 
         // Note that we use from sync to prevent any reflection. This is only acceptable in tests.
         try {
-            val now = Date()
+            val now = Instant.now()
             serviceManager.modelRepositories.groups.createFromSync(
                 GroupModelData(
                     groupIdentity = GroupIdentity(myContact.identity, 42),
@@ -121,7 +124,7 @@ class GroupCreateTaskTest {
                     createdAt = now,
                     synchronizedAt = null,
                     lastUpdate = now,
-                    isArchived = false,
+                    conversationVisibility = ConversationVisibility.NORMAL,
                     userState = UserState.MEMBER,
                     otherMembers = setOf(initialContactModelData.identity),
                     groupDescription = null,

+ 142 - 35
app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt

@@ -470,24 +470,28 @@ class PersistableTasksTest {
     }
 
     @Test
-    fun testContactAvailabilityStatusUpdate() {
+    fun testBatchUpdateContactAvailabilityStatusTask() {
         assertValidEncoding(
-            ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate::class,
-            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate.""" +
-                """ReflectAvailabilityStatusUpdateData","availabilityStatus":{"type":"ch.threema.data.datatypes.AvailabilityStatus.None"},""" +
-                """"identity":"01234567"}""",
+            BatchUpdateContactAvailabilityStatusTask::class,
+            """{"type":"ch.threema.app.tasks.BatchUpdateContactAvailabilityStatusTask.BatchUpdateContactAvailabilityStatusTaskData",""" +
+                """"availabilityStatusesTaskData":[]}""",
         )
         assertValidEncoding(
-            ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate::class,
-            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate.""" +
-                """ReflectAvailabilityStatusUpdateData","availabilityStatus":{"type":"ch.threema.data.datatypes.AvailabilityStatus.Unavailable",""" +
-                """"description":"On vacation"},"identity":"01234567"}""",
+            BatchUpdateContactAvailabilityStatusTask::class,
+            """{"type":"ch.threema.app.tasks.BatchUpdateContactAvailabilityStatusTask.BatchUpdateContactAvailabilityStatusTaskData",""" +
+                """"availabilityStatusesTaskData":[""" +
+                """{"identity":"01234567","category":0,"description":""}""" +
+                """]}""",
         )
         assertValidEncoding(
-            ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate::class,
-            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate.""" +
-                """ReflectAvailabilityStatusUpdateData","availabilityStatus":{"type":"ch.threema.data.datatypes.AvailabilityStatus.Busy",""" +
-                """"description":"In a meeting"},"identity":"01234567"}""",
+            BatchUpdateContactAvailabilityStatusTask::class,
+            """{"type":"ch.threema.app.tasks.BatchUpdateContactAvailabilityStatusTask.BatchUpdateContactAvailabilityStatusTaskData",""" +
+                """"availabilityStatusesTaskData":[""" +
+                """{"identity":"01234567","category":0,"description":""},""" +
+                """{"identity":"01234567","category":1,"description":"Vacation"},""" +
+                """{"identity":"01234567","category":2,"description":"In a meeting"},""" +
+                """{"identity":"01234567","category":3,"description":"not-valid"}""" +
+                """]}""",
         )
     }
 
@@ -500,22 +504,27 @@ class PersistableTasksTest {
     }
 
     @Test
-    fun testReflectWorkLastFullSyncAtUpdate() {
+    fun testBatchUpdateContactWorkLastFullSyncAtTask() {
         assertValidEncoding(
-            ReflectContactSyncUpdateTask.ReflectWorkLastFullSyncAtUpdate::class,
-            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectWorkLastFullSyncAtUpdate.""" +
-                """ReflectWorkLastFullSyncAtUpdateData","workLastFullSyncAt":1775144426988, "identity":"01234567"}""",
+            BatchUpdateContactWorkLastFullSyncAtTask::class,
+            """{"type":"ch.threema.app.tasks.BatchUpdateContactWorkLastFullSyncAtTask.""" +
+                """BatchUpdateContactWorkLastFullSyncAtUpdateTaskData",""" +
+                """"workLastFullSyncAtTimestamps":{"01234567":"2026-04-01T12:00:26.988Z"}}""",
+        )
+        assertValidEncoding(
+            BatchUpdateContactWorkLastFullSyncAtTask::class,
+            """{"type":"ch.threema.app.tasks.BatchUpdateContactWorkLastFullSyncAtTask.""" +
+                """BatchUpdateContactWorkLastFullSyncAtUpdateTaskData",""" +
+                """"workLastFullSyncAtTimestamps":{""" +
+                """"01234567":"2026-04-01T12:00:26.988Z",""" +
+                """"01234568":"2026-04-02T08:15:00Z"""" +
+                """}}""",
         )
-    }
-
-    @Test
-    fun testReflectWorkLastFullSyncAtWithAvailabilityStatusUpdate() {
         assertValidEncoding(
-            ReflectContactSyncUpdateTask.ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdate::class,
-            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdate.""" +
-                """ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdateData","workLastFullSyncAt":1775144426988, """ +
-                """"availabilityStatus":{"type":"ch.threema.data.datatypes.AvailabilityStatus.Busy", "description":"In a meeting"}, """ +
-                """"identity":"01234567"}""",
+            BatchUpdateContactWorkLastFullSyncAtTask::class,
+            """{"type":"ch.threema.app.tasks.BatchUpdateContactWorkLastFullSyncAtTask.""" +
+                """BatchUpdateContactWorkLastFullSyncAtUpdateTaskData",""" +
+                """"workLastFullSyncAtTimestamps":{}}""",
         )
     }
 
@@ -789,13 +798,34 @@ class PersistableTasksTest {
     }
 
     @Test
-    fun testGroupNotificationTriggerPolicyOverrideUpdate() {
+    fun testGroupNotificationTriggerPolicyOverrideUpdateMutedIndefinite() {
         assertValidEncoding(
             ReflectGroupSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate::class,
-            """{"type":"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate.""" +
-                """ReflectNotificationTriggerPolicyOverrideUpdateData","newNotificationTriggerPolicyOverride":""" +
-                """{"type":"ch.threema.data.datatypes.NotificationTriggerPolicyOverride.MutedUntil","dbValue":1740396953761,""" +
-                """"utcMillis":1740396953761},"groupIdentity":{"creatorIdentity":"01234567","groupId":6361180283070237492}}""",
+            "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate.ReflectNotificationTriggerPolicyOverrideUpdateData\",\"newNotificationTriggerPolicyOverride\":-1,\"groupIdentity\":{\"creatorIdentity\":\"01234567\",\"groupId\":-5934730444204722858}}",
+        )
+    }
+
+    @Test
+    fun testGroupNotificationTriggerPolicyOverrideUpdateNotMuted() {
+        assertValidEncoding(
+            ReflectGroupSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate::class,
+            "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate.ReflectNotificationTriggerPolicyOverrideUpdateData\",\"newNotificationTriggerPolicyOverride\":null,\"groupIdentity\":{\"creatorIdentity\":\"01234567\",\"groupId\":-5934730444204722858}}",
+        )
+    }
+
+    @Test
+    fun testGroupNotificationTriggerPolicyOverrideUpdateMutedIndefiniteExceptMentions() {
+        assertValidEncoding(
+            ReflectGroupSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate::class,
+            "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate.ReflectNotificationTriggerPolicyOverrideUpdateData\",\"newNotificationTriggerPolicyOverride\":-2,\"groupIdentity\":{\"creatorIdentity\":\"01234567\",\"groupId\":-5934730444204722858}}",
+        )
+    }
+
+    @Test
+    fun testGroupNotificationTriggerPolicyOverrideUpdateMutedUntil() {
+        assertValidEncoding(
+            ReflectGroupSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate::class,
+            "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate.ReflectNotificationTriggerPolicyOverrideUpdateData\",\"newNotificationTriggerPolicyOverride\":1774242424242,\"groupIdentity\":{\"creatorIdentity\":\"01234567\",\"groupId\":-5934730444204722858}}",
         )
     }
 
@@ -818,38 +848,107 @@ class PersistableTasksTest {
         )
     }
 
+    @Test
+    fun testReflectContactConversationVisibilityUpdateNormal() {
+        assertValidEncoding(
+            expectedTaskClass = ReflectContactSyncUpdateTask.ReflectConversationVisibilityUpdate::class,
+            encodedTask = "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectConversationVisibilityUpdate" +
+                ".ReflectConversationVisibilityUpdateData\",\"conversationVisibilitySerialized\":0,\"contactIdentity\":\"08A929AT\"}",
+        )
+    }
+
+    @Test
+    fun testReflectContactConversationVisibilityUpdateArchived() {
+        assertValidEncoding(
+            expectedTaskClass = ReflectContactSyncUpdateTask.ReflectConversationVisibilityUpdate::class,
+            encodedTask = "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectConversationVisibilityUpdate" +
+                ".ReflectConversationVisibilityUpdateData\",\"conversationVisibilitySerialized\":1,\"contactIdentity\":\"01234567\"}",
+        )
+    }
+
+    @Test
+    fun testReflectContactConversationVisibilityUpdatePinned() {
+        assertValidEncoding(
+            expectedTaskClass = ReflectContactSyncUpdateTask.ReflectConversationVisibilityUpdate::class,
+            encodedTask = "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectConversationVisibilityUpdate" +
+                ".ReflectConversationVisibilityUpdateData\",\"conversationVisibilitySerialized\":2,\"contactIdentity\":\"01234567\"}",
+        )
+    }
+
+    @Test
+    fun testReflectGroupConversationVisibilityUpdateNormal() {
+        assertValidEncoding(
+            expectedTaskClass = ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityUpdate::class,
+            encodedTask = "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityUpdate" +
+                ".ReflectGroupConversationVisibilityUpdateData\",\"conversationVisibilitySerialized\":0,\"groupIdentity\":" +
+                "{\"creatorIdentity\":\"01234567\",\"groupId\":2389353804288243297}}",
+        )
+    }
+
+    @Test
+    fun testReflectGroupConversationVisibilityUpdateArchived() {
+        assertValidEncoding(
+            expectedTaskClass = ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityUpdate::class,
+            encodedTask = "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityUpdate" +
+                ".ReflectGroupConversationVisibilityUpdateData\",\"conversationVisibilitySerialized\":1,\"groupIdentity\":" +
+                "{\"creatorIdentity\":\"01234567\",\"groupId\":2389353804288243297}}",
+        )
+    }
+
+    @Test
+    fun testReflectGroupConversationVisibilityUpdatePinned() {
+        assertValidEncoding(
+            expectedTaskClass = ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityUpdate::class,
+            encodedTask = "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityUpdate" +
+                ".ReflectGroupConversationVisibilityUpdateData\",\"conversationVisibilitySerialized\":2,\"groupIdentity\":" +
+                "{\"creatorIdentity\":\"01234567\",\"groupId\":2389353804288243297}}",
+        )
+    }
+
+    /**
+     * TODO(ANDR-4974): Remove this.
+     */
     @Test
     fun testReflectContactConversationVisibilityArchiveUpdate() {
         assertValidEncoding(
-            ReflectContactSyncUpdateTask.ReflectConversationVisibilityArchiveUpdate::class,
+            ReflectContactSyncUpdateTask.ReflectConversationVisibilityUpdate::class,
             "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectConversationVisibilityArchiveUpdate" +
                 ".ReflectConversationVisibilityArchiveUpdateData\",\"isArchived\":true,\"contactIdentity\":\"01234567\"}",
         )
     }
 
+    /**
+     * TODO(ANDR-4974): Remove this.
+     */
     @Test
     fun testReflectGroupConversationVisibilityArchiveUpdate() {
         assertValidEncoding(
-            ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityArchiveUpdate::class,
+            ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityUpdate::class,
             "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityArchiveUpdate" +
                 ".ReflectGroupConversationVisibilityArchiveUpdateData\",\"isArchived\":true," +
                 "\"groupIdentity\":{\"creatorIdentity\":\"01234567\",\"groupId\":6361180283070237492}}",
         )
     }
 
+    /**
+     * TODO(ANDR-4974): Remove this.
+     */
     @Test
     fun testReflectContactConversationVisibilityPinnedUpdate() {
         assertValidEncoding(
-            ReflectContactSyncUpdateTask.ReflectConversationVisibilityPinnedUpdate::class,
+            ReflectContactSyncUpdateTask.ReflectConversationVisibilityUpdate::class,
             "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectConversationVisibilityPinnedUpdate" +
                 ".ReflectConversationVisibilityPinnedUpdateData\",\"isPinned\":true,\"contactIdentity\":\"01234567\"}",
         )
     }
 
+    /**
+     * TODO(ANDR-4974): Remove this.
+     */
     @Test
     fun testReflectGroupConversationVisibilityPinnedUpdate() {
         assertValidEncoding(
-            ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityPinnedUpdate::class,
+            ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityUpdate::class,
             "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityPinnedUpdate" +
                 ".ReflectGroupConversationVisibilityPinnedUpdateData\",\"isPinned\":true," +
                 "\"groupIdentity\":{\"creatorIdentity\":\"01234567\",\"groupId\":6361180283070237492}}",
@@ -889,6 +988,14 @@ class PersistableTasksTest {
         )
     }
 
+    @Test
+    fun testSyncPredefinedContactsTask() {
+        assertValidEncoding(
+            expectedTaskClass = SyncPredefinedContactsTask::class,
+            encodedTask = "{\"type\":\"ch.threema.app.tasks.SyncPredefinedContactsTask.SyncPredefinedContactsTaskData\"}",
+        )
+    }
+
     private fun assertValidEncoding(expectedTaskClass: KClass<*>, encodedTask: String) {
         val decodedTask = Json.decodeFromString<SerializableTaskData>(encodedTask).createTask()
         assertNotNull(decodedTask)

+ 1 - 3
app/src/androidTest/java/ch/threema/app/testutils/AndroidTestUtils.kt

@@ -1,9 +1,8 @@
 package ch.threema.app.testutils
 
-import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ServiceManager
+import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.data.models.ContactModel
-import ch.threema.data.models.GroupIdentity
 import ch.threema.storage.DatabaseProvider
 import ch.threema.storage.runTransaction
 import org.koin.mp.KoinPlatform
@@ -42,7 +41,6 @@ fun clearDatabaseAndCaches(serviceManager: ServiceManager) {
     val myIdentity = serviceManager.identityStore.getIdentityString()
     contactIdentities.forEach { identity ->
         contactService.invalidateCache(identity)
-        ListenerManager.contactListeners.handle { it.onRemoved(identity) }
         serviceManager.dhSessionStore.deleteAllDHSessions(myIdentity, identity)
     }
     val groupService = serviceManager.groupService

+ 0 - 16
app/src/androidTest/java/ch/threema/app/testutils/PreferenceStoreMock.kt

@@ -1,16 +0,0 @@
-package ch.threema.app.testutils
-
-import ch.threema.app.stores.EncryptedPreferenceStore
-import ch.threema.app.stores.PreferenceStore
-import io.mockk.every
-
-fun PreferenceStore.mockUser(contact: TestHelpers.TestContact) {
-    every { getString(PreferenceStore.PREFS_IDENTITY) } returns contact.identity
-    every { getBytes(PreferenceStore.PREFS_PUBLIC_KEY) } returns contact.publicKey
-    every { getString(PreferenceStore.PREFS_PUBLIC_NICKNAME) } returns "nickname of ${contact.identity}"
-    every { getString(PreferenceStore.PREFS_SERVER_GROUP) } returns "serverGroup"
-}
-
-fun EncryptedPreferenceStore.mockUser(contact: TestHelpers.TestContact) {
-    every { getBytes(EncryptedPreferenceStore.PREFS_PRIVATE_KEY) } returns contact.privateKey
-}

+ 0 - 72
app/src/androidTest/java/ch/threema/app/testutils/RecyclerViewMatcher.java

@@ -1,72 +0,0 @@
-package ch.threema.app.testutils;
-
-import android.content.res.Resources;
-
-import androidx.recyclerview.widget.RecyclerView;
-
-import android.view.View;
-
-import org.hamcrest.Description;
-import org.hamcrest.Matcher;
-import org.hamcrest.TypeSafeMatcher;
-
-/**
- * https://github.com/dannyroa/espresso-samples/blob/master/RecyclerView/app/src/androidTest/java/com/dannyroa/espresso_samples/recyclerview/RecyclerViewMatcher.java
- */
-public class RecyclerViewMatcher {
-    private final int recyclerViewId;
-
-    public RecyclerViewMatcher(int recyclerViewId) {
-        this.recyclerViewId = recyclerViewId;
-    }
-
-    public Matcher<View> atPosition(final int position) {
-        return atPositionOnView(position, -1);
-    }
-
-    public Matcher<View> atPositionOnView(final int position, final int targetViewId) {
-
-        return new TypeSafeMatcher<View>() {
-            Resources resources = null;
-            View childView;
-
-            public void describeTo(Description description) {
-                String idDescription = Integer.toString(recyclerViewId);
-                if (this.resources != null) {
-                    try {
-                        idDescription = this.resources.getResourceName(recyclerViewId);
-                    } catch (Resources.NotFoundException var4) {
-                        idDescription = String.format("%s (resource name not found)",
-                            new Object[]{Integer.valueOf
-                                (recyclerViewId)});
-                    }
-                }
-
-                description.appendText("with id: " + idDescription);
-            }
-
-            public boolean matchesSafely(View view) {
-
-                this.resources = view.getResources();
-
-                if (childView == null) {
-                    RecyclerView recyclerView =
-                        (RecyclerView) view.getRootView().findViewById(recyclerViewId);
-                    if (recyclerView != null && recyclerView.getId() == recyclerViewId) {
-                        childView = recyclerView.findViewHolderForAdapterPosition(position).itemView;
-                    } else {
-                        return false;
-                    }
-                }
-
-                if (targetViewId == -1) {
-                    return view == childView;
-                } else {
-                    View targetView = childView.findViewById(targetViewId);
-                    return view == targetView;
-                }
-
-            }
-        };
-    }
-}

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

@@ -4,8 +4,8 @@ import android.util.Log;
 
 import ch.threema.base.crypto.NaCl;
 
+import java.time.Instant;
 import java.util.Collection;
-import java.util.Date;
 import java.util.List;
 import java.util.stream.Collectors;
 
@@ -17,7 +17,6 @@ import androidx.test.uiautomator.UiDevice;
 import androidx.test.uiautomator.Until;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.UserService;
-import ch.threema.base.utils.Utils;
 import ch.threema.domain.helpers.InMemoryIdentityStore;
 import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.BasicContact;
@@ -33,14 +32,15 @@ import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.group.GroupModelOld;
 
 import static org.junit.Assert.assertNotNull;
+import static ch.threema.common.JavaCompat.hexToByteArray;
 
 public class TestHelpers {
     private static final String TAG = "TestHelpers";
 
     public static final TestContact TEST_CONTACT = new TestContact(
         "XERCUKNS",
-        Utils.hexStringToByteArray("2bbc16092ff45ffcd0045c00f2f5e1e9597621f89360bbca23a2a2956b3c3b36"),
-        Utils.hexStringToByteArray("977aba4ab367041f6137afef69ab9676d445011ca7aca0455a5c64805b80b77a")
+        hexToByteArray("2bbc16092ff45ffcd0045c00f2f5e1e9597621f89360bbca23a2a2956b3c3b36"),
+        hexToByteArray("977aba4ab367041f6137afef69ab9676d445011ca7aca0455a5c64805b80b77a")
     );
 
     public static final class TestContact {
@@ -93,7 +93,7 @@ public class TestHelpers {
                 new ThreemaFeature.Builder()
                     .audio(true)
                     .group(true)
-                    .ballot(true)
+                    .poll(true)
                     .file(true)
                     .voip(true)
                     .videocalls(true)
@@ -103,7 +103,7 @@ public class TestHelpers {
                     .deleteMessages(true)
                     .build(),
                 IdentityState.ACTIVE,
-                IdentityType.NORMAL,
+                IdentityType.REGULAR,
                 VerificationLevel.UNVERIFIED,
                 WorkVerificationLevel.NONE,
                 null,
@@ -178,7 +178,7 @@ public class TestHelpers {
         private GroupModelOld getGroupModel(@NonNull UserState userState) {
             return new GroupModelOld()
                 .setApiGroupId(apiGroupId)
-                .setCreatedAt(new Date())
+                .setCreatedAt(Instant.now())
                 .setName(this.groupName)
                 .setCreatorIdentity(this.groupCreator.identity)
                 .setId(localGroupId)

+ 0 - 165
app/src/androidTest/java/ch/threema/app/utils/BackgroundErrorNotificationTest.java

@@ -1,165 +0,0 @@
-package ch.threema.app.utils;
-
-import android.content.Context;
-
-import org.junit.Before;
-import org.junit.Ignore;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.RuleChain;
-import org.junit.runner.RunWith;
-
-import java.io.BufferedOutputStream;
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.regex.Pattern;
-
-import androidx.annotation.NonNull;
-import androidx.test.core.app.ApplicationProvider;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
-import androidx.test.filters.LargeTest;
-import androidx.test.platform.app.InstrumentationRegistry;
-import androidx.test.uiautomator.By;
-import androidx.test.uiautomator.BySelector;
-import androidx.test.uiautomator.UiDevice;
-import androidx.test.uiautomator.UiObject2;
-import androidx.test.uiautomator.Until;
-import ch.threema.app.R;
-import ch.threema.app.ScreenshotTakingRule;
-import ch.threema.app.notifications.BackgroundErrorNotification;
-import ch.threema.app.testutils.TestHelpers;
-
-import static ch.threema.app.PermissionRuleUtilsKt.getNotificationPermissionRule;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-
-@LargeTest
-@RunWith(AndroidJUnit4.class)
-public class BackgroundErrorNotificationTest {
-    private UiDevice mDevice;
-
-    @Rule
-    public final RuleChain activityRule = ScreenshotTakingRule.getRuleChain().around(
-        getNotificationPermissionRule()
-    );
-
-    @Before
-    public void getDevice() {
-        // Get device instance
-        mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
-    }
-
-    /**
-     * Dump the UI state (screenshot + UI XML) to the /sdcard/ directory.
-     */
-    @SuppressWarnings("unused") // Used for manual debugging
-    private static void dumpState(@NonNull UiDevice device) throws IOException {
-        device.takeScreenshot(new File("/sdcard/screenshot.png"));
-        try (OutputStream stream = new BufferedOutputStream(new FileOutputStream("/sdcard/screenshot.uix"))) {
-            // Note: Explicitly opening and closing stream since the UiAutomator dumpWindowHierarchy(File)
-            // method leaks a file descriptor.
-            device.dumpWindowHierarchy(stream);
-        }
-    }
-
-    /**
-     * Ensure that a notification is shown, without a "send to support" action.
-     */
-    @Test
-    public void testNotificationWithoutAction() {
-        // Go to home screen
-        mDevice.pressHome();
-
-        // Show notification
-        final Context context = ApplicationProvider.getApplicationContext();
-        BackgroundErrorNotification.showNotification(
-            context,
-            "T1tl3",
-            "The body of the notification",
-            "BackgroundErrorNotificationTest",
-            false,
-            null
-        );
-
-        // Get notification area object
-        TestHelpers.openNotificationArea(mDevice);
-
-        // Verify notification contents
-        final BySelector titleSelector = By.res("android:id/title").text(context.getString(R.string.error) + ": T1tl3");
-        final BySelector bodySelector = By.text("The body of the notification");
-        assertNotNull("Notification title not found", mDevice.wait(Until.findObject(titleSelector), 1000));
-        assertNotNull("Notification text not found", mDevice.wait(Until.findObject(bodySelector), 1000));
-
-        // Ensure that no notifications are visible
-        assertNull(
-            "Actions found, but they shouldn't be there",
-            mDevice.findObject(
-                By
-                    .pkg("com.android.systemui")
-                    .res("com.android.systemui:id/notification_stack_scroller")
-                    .hasDescendant(By.text(context.getString(R.string.send_to_support)))
-            )
-        );
-    }
-
-    /**
-     * Ensure that a notification with "send to support" action works.
-     */
-    @Ignore("Disabled until we have an empty test database")
-    @Test
-    public void testNotificationWithAction() {
-        // Go to home screen
-        mDevice.pressHome();
-
-        // Show notification
-        final Context context = ApplicationProvider.getApplicationContext();
-        final String scope = "BackgroundErrorNotificationTest";
-        final String notificationBody = "The body of the notification";
-        BackgroundErrorNotification.showNotification(
-            context,
-            "T1tl3",
-            notificationBody,
-            scope,
-            true,
-            null
-        );
-
-        // Find notification
-        TestHelpers.openNotificationArea(mDevice);
-
-        // Find action
-        final BySelector actionSelector = By
-            .res("android:id/action0")
-            .text(Pattern.compile(context.getString(R.string.send_to_support), Pattern.CASE_INSENSITIVE));
-        final UiObject2 action = mDevice.findObject(actionSelector);
-        assertNotNull("Action not found", action);
-
-        // Click action
-        action.click();
-
-        // Wait for app to appear
-        final BySelector chatPartnerSelector = By
-            .pkg("ch.threema.app")
-            .res("ch.threema.app:id/title");
-        mDevice.wait(Until.findObject(chatPartnerSelector), 3000);
-        final UiObject2 chatPartner = mDevice.findObject(chatPartnerSelector);
-
-        // Ensure that we're talking to the support user
-        assertEquals("*SUPPORT", chatPartner.getText());
-
-        // Validate message
-        final BySelector messageSelector = By
-            .pkg("ch.threema.app")
-            .res("ch.threema.app:id/embedded_text_editor");
-        final String message = mDevice.findObject(
-            messageSelector
-        ).getText();
-        assertTrue(message.contains("An error occurred in " + scope));
-        assertTrue(message.contains(notificationBody));
-        assertTrue(message.contains("My phone model"));
-    }
-}

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

@@ -8,7 +8,7 @@ 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 java.util.Date
+import java.time.Instant
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -38,7 +38,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
     fun testContactMessage() {
         runInsideOfATask { handle ->
             val messageId = MessageId.random()
-            val createdAt = Date()
+            val createdAt = Instant.now()
             var hasBeenMarkedAsSent = false
             var forwardSecurityModes: Map<String, ForwardSecurityMode>? = null
             val outgoingCspMessageHandle = OutgoingCspMessageHandle(
@@ -76,7 +76,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
     fun testGroupMessage() {
         runInsideOfATask { handle ->
             val messageId = MessageId.random()
-            val createdAt = Date()
+            val createdAt = Instant.now()
             val group = groupAB
             var hasBeenMarkedAsSent = false
             var forwardSecurityModes: Map<String, ForwardSecurityMode>? = null
@@ -120,7 +120,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
                 contactA.toBasicContact(),
                 OutgoingCspContactMessageCreator(
                     MessageId.random(),
-                    Date(),
+                    Instant.now(),
                     contactA.identity,
                 ) {
                     TextMessage().apply { text = "Test" }
@@ -131,7 +131,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
                 contactB.toBasicContact(),
                 OutgoingCspContactMessageCreator(
                     MessageId.random(),
-                    Date(),
+                    Instant.now(),
                     contactA.identity,
                 ) {
                     TextMessage().apply { text = "Test" }
@@ -142,7 +142,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
                 groupAB.members.map { it.toBasicContact() }.toSet(),
                 OutgoingCspGroupMessageCreator(
                     MessageId.random(),
-                    Date(),
+                    Instant.now(),
                     groupAB.groupModel,
                 ) {
                     GroupTextMessage().apply { text = "Test" }
@@ -197,7 +197,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
                     messageHandle.messageCreator.messageId.messageIdLong,
                     it.messageId.messageIdLong,
                 )
-                assertEquals(messageHandle.messageCreator.createdAt.time, it.date.time)
+                assertEquals(messageHandle.messageCreator.createdAt, it.timestamp)
             }
             .map { it.toIdentity }
             .toList()

+ 24 - 17
app/src/androidTest/java/ch/threema/app/utils/FileUtilTest.kt

@@ -1,11 +1,18 @@
 package ch.threema.app.utils
 
-import ch.threema.app.ThreemaApplication
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
 import kotlin.test.Test
 import kotlin.test.assertFalse
 import kotlin.test.assertTrue
 
 class FileUtilTest {
+
+    private val appContext: Context
+        get() = ApplicationProvider.getApplicationContext()
+    private val packageName
+        get() = appContext.packageName
+
     @Test
     fun testValidPaths() {
         // Arrange
@@ -16,10 +23,10 @@ class FileUtilTest {
 
         paths.forEach { path ->
             // Act
-            val isSanePath = FileUtil.isSanePath(ThreemaApplication.getAppContext(), path)
+            val isSanePath = FileUtil.isSanePath(appContext, path)
 
             // Assert
-            assertTrue { isSanePath }
+            assertTrue(isSanePath)
         }
     }
 
@@ -34,10 +41,10 @@ class FileUtilTest {
 
         paths.forEach { path ->
             // Act
-            val isSanePath = FileUtil.isSanePath(ThreemaApplication.getAppContext(), path)
+            val isSanePath = FileUtil.isSanePath(appContext, path)
 
             // Assert
-            assertTrue { isSanePath }
+            assertTrue(isSanePath)
         }
     }
 
@@ -45,17 +52,17 @@ class FileUtilTest {
     fun testInvalidInternalPaths() {
         // Arrange
         val paths = listOf(
-            "/data/data/ch.threema.app/databases/db.db",
-            "/data/data/ch.threema.app/files/file.txt",
-            "/data/data/ch.threema.app/file.txt",
+            "/data/data/$packageName/databases/db.db",
+            "/data/data/$packageName/files/file.txt",
+            "/data/data/$packageName/file.txt",
         )
 
         paths.forEach { path ->
             // Act
-            val isSanePath = FileUtil.isSanePath(ThreemaApplication.getAppContext(), path)
+            val isSanePath = FileUtil.isSanePath(appContext, path)
 
             // Assert
-            assertFalse { isSanePath }
+            assertFalse(isSanePath)
         }
     }
 
@@ -63,19 +70,19 @@ class FileUtilTest {
     fun testInvalidInternalPathsWithPathTraversals() {
         // Arrange
         val paths = listOf(
-            "../.././data/data/ch.threema.app/databases/db.db",
-            "/data/data/../data/ch.threema.app/files/file.txt",
-            "/data/data/ch.threema.app/../ch.threema.app/../ch.threema.app/file.txt",
-            "/data/data/.///./ch.threema.app/file.txt",
-            "/data/../../../data/data/ch.threema.app/file.txt",
+            "../.././data/data/$packageName/databases/db.db",
+            "/data/data/../data/$packageName/files/file.txt",
+            "/data/data/$packageName/../$packageName/../$packageName/file.txt",
+            "/data/data/.///./$packageName/file.txt",
+            "/data/../../../data/data/$packageName/file.txt",
         )
 
         paths.forEach { path ->
             // Act
-            val isSanePath = FileUtil.isSanePath(ThreemaApplication.getAppContext(), path)
+            val isSanePath = FileUtil.isSanePath(appContext, path)
 
             // Assert
-            assertFalse { isSanePath }
+            assertFalse(isSanePath)
         }
     }
 }

+ 2 - 1
app/src/androidTest/java/ch/threema/app/utils/LinkifyUtilTest.kt

@@ -3,6 +3,7 @@ package ch.threema.app.utils
 import android.text.Spanned
 import android.text.style.URLSpan
 import android.widget.TextView
+import androidx.test.core.app.ApplicationProvider
 import androidx.test.platform.app.InstrumentationRegistry
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -16,7 +17,7 @@ class LinkifyUtilTest {
         text: String,
         includePhoneNumbers: Boolean = true,
     ): Pair<Spanned?, List<URLSpan>> {
-        val textView = TextView(InstrumentationRegistry.getInstrumentation().context)
+        val textView = TextView(ApplicationProvider.getApplicationContext())
         textView.text = text
         InstrumentationRegistry.getInstrumentation().runOnMainSync {
             LinkifyUtil.getInstance().linkifyText(textView, includePhoneNumbers)

+ 0 - 224
app/src/androidTest/java/ch/threema/app/voip/VoipStatusMessageTest.java

@@ -1,224 +0,0 @@
-package ch.threema.app.voip;
-
-import android.content.Context;
-import android.content.res.Configuration;
-
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import java.util.Locale;
-
-import androidx.test.ext.junit.runners.AndroidJUnit4;
-import androidx.test.platform.app.InstrumentationRegistry;
-import ch.threema.app.R;
-import ch.threema.app.ui.models.MessageViewElement;
-import ch.threema.app.utils.MessageUtil;
-import ch.threema.data.datatypes.ContactNameFormat;
-import ch.threema.domain.protocol.csp.messages.voip.VoipCallAnswerData;
-import ch.threema.storage.models.AbstractMessageModel;
-import ch.threema.storage.models.MessageModel;
-import ch.threema.storage.models.data.status.VoipStatusDataModel;
-
-import static ch.threema.storage.models.data.status.VoipStatusDataModel.NO_CALL_ID;
-import static org.junit.Assert.assertEquals;
-
-/**
- * Test proper rendering of VoIP status messags.
- */
-@RunWith(AndroidJUnit4.class)
-public class VoipStatusMessageTest {
-    private static int ICON_OUTGOING = R.drawable.ic_call_missed_outgoing_black_24dp;
-    private static int ICON_INCOMING = R.drawable.ic_call_missed_black_24dp;
-    private static int COLOR_RED = R.color.material_red;
-    private static int COLOR_ORANGE = R.color.material_orange;
-
-    /**
-     * Return a context where the locale has been overriden to "en".
-     */
-    private Context getContext() {
-        final Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
-        final Configuration config = new Configuration();
-        config.setLocale(new Locale("en"));
-        return context.createConfigurationContext(config);
-    }
-
-    class TestCase {
-        private Context context;
-        private AbstractMessageModel messageModel;
-        private int expectedIcon;
-        private int expectedColor;
-        private String expectedPlaceholder;
-        private String expectedText;
-
-        public TestCase(
-            Context context,
-            boolean outgoing,
-            VoipStatusDataModel dataModel,
-            int expectedIcon,
-            int expectedColor,
-            String expectedPlaceholder,
-            String expectedText
-        ) {
-            this.context = context;
-            final MessageModel messageModel = new MessageModel(true);
-            messageModel.setOutbox(outgoing);
-            messageModel.setVoipStatusData(dataModel);
-            this.messageModel = messageModel;
-            this.expectedIcon = expectedIcon;
-            this.expectedColor = expectedColor;
-            this.expectedPlaceholder = expectedPlaceholder;
-            this.expectedText = expectedText;
-        }
-
-        public void test() {
-            final MessageViewElement element = MessageUtil.getViewElement(this.context, this.messageModel, ContactNameFormat.DEFAULT);
-            assertEquals((Integer) this.expectedIcon, element.icon);
-            assertEquals((Integer) this.expectedColor, element.color);
-            assertEquals(this.expectedPlaceholder, element.placeholder);
-            assertEquals(this.expectedText, element.text);
-        }
-    }
-
-    @Test
-    public void testIncomingMissed() {
-        new TestCase(
-            this.getContext(),
-            false,
-            VoipStatusDataModel.createMissed(NO_CALL_ID, null),
-            ICON_INCOMING,
-            COLOR_RED,
-            "Missed call",
-            "Missed call"
-        ).test();
-    }
-
-    @Test
-    public void testIncomingRejectedUnknown() {
-        new TestCase(
-            this.getContext(),
-            false,
-            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.UNKNOWN),
-            ICON_INCOMING,
-            COLOR_RED,
-            "Missed call",
-            "Missed call"
-        ).test();
-    }
-
-    @Test
-    public void testIncomingRejectedBusy() {
-        new TestCase(
-            this.getContext(),
-            false,
-            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.BUSY),
-            ICON_INCOMING,
-            COLOR_RED,
-            "Missed call (Busy)",
-            "Missed call (Busy)"
-        ).test();
-    }
-
-    @Test
-    public void testIncomingRejectedTimeout() {
-        new TestCase(
-            this.getContext(),
-            false,
-            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.TIMEOUT),
-            ICON_INCOMING,
-            COLOR_RED,
-            "Missed call",
-            "Missed call"
-        ).test();
-    }
-
-    @Test
-    public void testIncomingRejectedRejected() {
-        new TestCase(
-            this.getContext(),
-            false,
-            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.REJECTED),
-            ICON_INCOMING,
-            COLOR_ORANGE,
-            "Call declined",
-            "Call declined"
-        ).test();
-    }
-
-    @Test
-    public void testIncomingRejectedDisabled() {
-        new TestCase(
-            this.getContext(),
-            false,
-            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.DISABLED),
-            ICON_INCOMING,
-            COLOR_ORANGE,
-            "Call declined",
-            "Call declined"
-        ).test();
-    }
-
-    @Test
-    public void testOutgoingRejectedUnknown() {
-        new TestCase(
-            this.getContext(),
-            true,
-            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.UNKNOWN),
-            ICON_OUTGOING,
-            COLOR_RED,
-            "Call declined",
-            "Call declined"
-        ).test();
-    }
-
-    @Test
-    public void testOutgoingRejectedBusy() {
-        new TestCase(
-            this.getContext(),
-            true,
-            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.BUSY),
-            ICON_OUTGOING,
-            COLOR_RED,
-            "Call recipient is busy",
-            "Call recipient is busy"
-        ).test();
-    }
-
-    @Test
-    public void testOutgoingRejectedTimeout() {
-        new TestCase(
-            this.getContext(),
-            true,
-            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.TIMEOUT),
-            ICON_OUTGOING,
-            COLOR_RED,
-            "Call recipient is unavailable",
-            "Call recipient is unavailable"
-        ).test();
-    }
-
-    @Test
-    public void testOutgoingRejectedRejected() {
-        new TestCase(
-            this.getContext(),
-            true,
-            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.REJECTED),
-            ICON_OUTGOING,
-            COLOR_RED,
-            "Call declined",
-            "Call declined"
-        ).test();
-    }
-
-    @Test
-    public void testOutgoingRejectedDisabled() {
-        new TestCase(
-            this.getContext(),
-            true,
-            VoipStatusDataModel.createRejected(NO_CALL_ID, VoipCallAnswerData.RejectReason.DISABLED),
-            ICON_OUTGOING,
-            COLOR_RED,
-            "Threema calls disabled by recipient",
-            "Threema calls disabled by recipient"
-        ).test();
-    }
-}

+ 13 - 14
app/src/androidTest/java/ch/threema/app/webclient/activities/SessionsActivityTest.java

@@ -14,12 +14,11 @@ import org.junit.rules.RuleChain;
 import org.junit.runner.RunWith;
 import org.koin.java.KoinJavaComponent;
 
-import java.util.Collections;
-import java.util.Date;
+import java.time.Instant;import java.util.Collections;
 
 import androidx.annotation.NonNull;
 import androidx.preference.PreferenceManager;
-import androidx.test.InstrumentationRegistry;
+import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.LargeTest;
 import androidx.test.rule.ActivityTestRule;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -30,7 +29,7 @@ import ch.threema.app.ScreenshotTakingRule;
 import ch.threema.storage.factories.WebClientSessionModelFactory;
 import ch.threema.storage.models.WebClientSessionModel;
 
-import static androidx.test.InstrumentationRegistry.getInstrumentation;
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
 import static androidx.test.espresso.Espresso.onView;
 import static androidx.test.espresso.action.ViewActions.click;
 import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
@@ -99,8 +98,8 @@ public class SessionsActivityTest {
         String label,
         WebClientSessionModel.State state,
         boolean persistent,
-        @NonNull Date created,
-        @NonNull Date lastConnection,
+        @NonNull Instant created,
+        @NonNull Instant lastConnection,
         @NonNull Browser browser
     ) {
         final WebClientSessionModel model = new WebClientSessionModel();
@@ -136,7 +135,7 @@ public class SessionsActivityTest {
 
     @Before
     public void setUp() {
-        final Context context = InstrumentationRegistry.getTargetContext();
+        final Context context = ApplicationProvider.getApplicationContext();
 
         // By default, don't show welcome screen
         showWelcomeScreen(context, false);
@@ -154,7 +153,7 @@ public class SessionsActivityTest {
      */
     @Test
     public void testWelcomeScreen() {
-        showWelcomeScreen(InstrumentationRegistry.getTargetContext(), true);
+        showWelcomeScreen(ApplicationProvider.getApplicationContext(), true);
 
         final Instrumentation.ActivityMonitor monitor = getInstrumentation()
             .addMonitor(SessionsIntroActivity.class.getName(), null, false);
@@ -172,10 +171,10 @@ public class SessionsActivityTest {
     @Test
     public void testSessionList() {
         // Create two sessions
-        createSession("Feuerfuchs", WebClientSessionModel.State.AUTHORIZED, true, new Date(), new Date(), Browser.FIREFOX);
+        createSession("Feuerfuchs", WebClientSessionModel.State.AUTHORIZED, true, Instant.now(), Instant.now(), Browser.FIREFOX);
         createSession("Googlebrowser", WebClientSessionModel.State.ERROR,
-            true, new Date(System.currentTimeMillis() - 3600),
-            new Date(System.currentTimeMillis() - 3500), Browser.CHROME
+            true, Instant.ofEpochMilli(System.currentTimeMillis() - 3600),
+            Instant.ofEpochMilli(System.currentTimeMillis() - 3500), Browser.CHROME
         );
 
         // Start activty
@@ -208,9 +207,9 @@ public class SessionsActivityTest {
     public void testCleanOnStart() throws Exception {
         final long hours = 3600000;
 
-        final Date now = new Date();
-        final Date hours23ago = new Date(System.currentTimeMillis() - hours * 23);
-        final Date hours25ago = new Date(System.currentTimeMillis() - hours * 25);
+        final Instant now = Instant.now();
+        final Instant hours23ago = Instant.ofEpochMilli(now.toEpochMilli() - hours * 23);
+        final Instant hours25ago = Instant.ofEpochMilli(now.toEpochMilli() - hours * 25);
 
         createSession("Persistent now", WebClientSessionModel.State.AUTHORIZED, true, now, now, Browser.FIREFOX);
         createSession("Persistent old", WebClientSessionModel.State.AUTHORIZED, true, hours25ago, hours25ago, Browser.CHROME);

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

@@ -8,8 +8,13 @@ import org.msgpack.value.Value;
 
 import java.io.IOException;
 import java.nio.ByteBuffer;
-import java.util.Date;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
 import java.util.HashMap;
+import java.util.Locale;
 import java.util.Map;
 
 import androidx.annotation.NonNull;
@@ -42,7 +47,7 @@ public class MessageTest {
 
     private static String testMaybePutFileImpl(
         @NonNull String inputMimeType,
-        @Nullable Date createdAt,
+        @Nullable Instant createdAt,
         @Nullable String messageId
     ) throws IOException {
         // The Threema protocol does not require a file name in a file message,
@@ -83,7 +88,21 @@ public class MessageTest {
 
     @Test
     public void testMaybePutFile() throws IOException {
-        assertEquals("threema-20201212-000000-null.png", testMaybePutFileImpl("image/png", new Date(2020 - 1900, 12 - 1, 12), null));
-        assertEquals("threema-20100130-131400-msgidasdf.txt", testMaybePutFileImpl("text/plain", new Date(2010 - 1900, 1 - 1, 30, 13, 14), "msgidasdf"));
+        assertEquals(
+            "threema-20201212-000000-null.png",
+            testMaybePutFileImpl(
+                "image/png",
+                LocalDateTime.of(2020, 12, 12, 0, 0).atZone(ZoneId.systemDefault()).toInstant(),
+                null
+            )
+        );
+        assertEquals(
+            "threema-20100130-131400-msgidasdf.txt",
+            testMaybePutFileImpl(
+                "text/plain",
+                LocalDateTime.of(2010, 1, 30, 13, 14).atZone(ZoneId.systemDefault()).toInstant(),
+                "msgidasdf"
+            )
+        );
     }
 }

+ 130 - 126
app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt

@@ -1,22 +1,24 @@
 package ch.threema.data.repositories
 
 import ch.threema.KoinTestRule
-import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestMultiDeviceManager
+import ch.threema.app.TestNonceStore
 import ch.threema.app.TestTaskManager
+import ch.threema.app.eventbus.GlobalEventBuses
+import ch.threema.app.eventbus.GlobalEventFlows
 import ch.threema.app.multidevice.MultiDeviceManager
-import ch.threema.app.stores.EncryptedPreferenceStore
-import ch.threema.app.stores.IdentityProvider
-import ch.threema.app.stores.PreferenceStore
 import ch.threema.app.testutils.TestHelpers
-import ch.threema.app.testutils.mockUser
-import ch.threema.base.crypto.NaCl
+import ch.threema.base.crypto.NonceFactory
+import ch.threema.common.DispatcherProvider
+import ch.threema.data.IdentityProvider
 import ch.threema.data.datatypes.AndroidContactLookupInfo
 import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ConversationVisibility
 import ch.threema.data.datatypes.IdColor
 import ch.threema.data.models.ContactModelData
 import ch.threema.domain.helpers.TransactionAckTaskCodec
 import ch.threema.domain.helpers.UnusedTaskCodec
+import ch.threema.domain.models.AcquaintanceLevel
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
@@ -24,19 +26,19 @@ 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.IdentityStore
 import ch.threema.domain.types.Identity
 import ch.threema.domain.types.IdentityString
-import ch.threema.storage.DatabaseService
-import ch.threema.storage.TestDatabaseProvider
+import ch.threema.storage.factories.ContactModelFactory
 import ch.threema.storage.models.ContactModel
-import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import ch.threema.test.TestData.PUBLIC_KEY
+import ch.threema.test.TestDatabaseProvider
 import ch.threema.testhelpers.nonSecureRandomArray
 import ch.threema.testhelpers.randomIdentity
 import io.mockk.every
 import io.mockk.mockk
-import java.util.Date
+import java.time.Instant
 import junit.framework.TestCase.assertNotNull
+import kotlin.getValue
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
@@ -49,21 +51,31 @@ import kotlinx.coroutines.runBlocking
 import org.junit.Rule
 import org.junit.runner.RunWith
 import org.junit.runners.Parameterized
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
 import org.koin.dsl.module
 
 @RunWith(value = Parameterized::class)
-class ContactModelRepositoryTest(private val contactModelData: ContactModelData) {
+class ContactModelRepositoryTest(private val contactModelData: ContactModelData) : KoinComponent {
+    private val globalEventFlows: GlobalEventFlows by inject()
+    private val globalEventBuses: GlobalEventBuses by inject()
+    private val dispatcherProvider: DispatcherProvider by inject()
+
     // Services where MD is disabled
     private lateinit var databaseProvider: TestDatabaseProvider
-    private lateinit var databaseService: DatabaseService
-    private lateinit var coreServiceManager: TestCoreServiceManager
+    private lateinit var contactModelFactory: ContactModelFactory
+    private lateinit var multiDeviceManager: TestMultiDeviceManager
+    private lateinit var taskManager: TestTaskManager
+    private lateinit var nonceFactory: NonceFactory
     private lateinit var contactModelRepository: ContactModelRepository
 
     // Services where MD is enabled
     private lateinit var databaseProviderMd: TestDatabaseProvider
-    private lateinit var databaseServiceMd: DatabaseService
+    private lateinit var contactModelFactoryMd: ContactModelFactory
     private lateinit var taskCodecMd: TransactionAckTaskCodec
-    private lateinit var coreServiceManagerMd: TestCoreServiceManager
+    private lateinit var multiDeviceManagerMd: TestMultiDeviceManager
+    private lateinit var taskManagerMd: TestTaskManager
+    private lateinit var nonceFactoryMd: NonceFactory
     private lateinit var contactModelRepositoryMd: ContactModelRepository
 
     private enum class TestTriggerSource {
@@ -76,9 +88,9 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
     private val instrumentedTestModule = module {
         factory<MultiDeviceManager> {
             if (isMultiDeviceEnabled) {
-                coreServiceManagerMd.multiDeviceManager
+                multiDeviceManagerMd
             } else {
-                coreServiceManager.multiDeviceManager
+                multiDeviceManager
             }
         }
     }
@@ -88,123 +100,49 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         modules = listOf(instrumentedTestModule),
     )
 
-    companion object {
-        @JvmStatic
-        @Parameterized.Parameters
-        fun initialValuesSet() = setOf(
-            getInitialContactModelData(),
-            getInitialContactModelData(publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES) { 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: IdentityString = "ABCDEFGH",
-            publicKey: ByteArray = ByteArray(NaCl.PUBLIC_KEY_BYTES),
-            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,
-            androidContactLookupInfo: AndroidContactLookupInfo? = 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,
-            idColor = IdColor.ofIdentity(identity),
-            verificationLevel = verificationLevel,
-            workVerificationLevel = workVerificationLevel,
-            identityType = identityType,
-            acquaintanceLevel = acquaintanceLevel,
-            activityState = activityState,
-            syncState = syncState,
-            featureMask = featureMask,
-            readReceiptPolicy = readReceiptPolicy,
-            typingIndicatorPolicy = typingIndicatorPolicy,
-            isArchived = false,
-            androidContactLookupInfo = androidContactLookupInfo,
-            localAvatarExpires = localAvatarExpires,
-            isRestored = isRestored,
-            profilePictureBlobId = profilePictureBlobId,
-            jobTitle = jobTitle,
-            department = department,
-            notificationTriggerPolicyOverride = null,
-            availabilityStatus = AvailabilityStatus.None,
-            workLastFullSyncAt = null,
-        )
-    }
-
     @BeforeTest
     fun before() {
-        val preferenceStore: PreferenceStore = mockk {
-            mockUser(TestHelpers.TEST_CONTACT)
-        }
-        val encryptedPreferenceStore: EncryptedPreferenceStore = mockk {
-            mockUser(TestHelpers.TEST_CONTACT)
-        }
         val identityProviderMock: IdentityProvider = mockk {
             every { getIdentity() } returns Identity(TestHelpers.TEST_CONTACT.identity)
             every { getIdentityString() } returns TestHelpers.TEST_CONTACT.identity
         }
-        val identityStoreMock = mockk<IdentityStore> {
-            every { getIdentity() } returns Identity(TestHelpers.TEST_CONTACT.identity)
-            every { getIdentityString() } returns TestHelpers.TEST_CONTACT.identity
-        }
 
         // Instantiate services where MD is disabled
         this.databaseProvider = TestDatabaseProvider()
-        this.coreServiceManager = TestCoreServiceManager(
-            databaseProvider = databaseProvider,
-            identityProvider = identityProviderMock,
-            preferenceStore = preferenceStore,
-            encryptedPreferenceStore = encryptedPreferenceStore,
-            taskManager = TestTaskManager(UnusedTaskCodec()),
-            identityStore = identityStoreMock,
-        )
-        this.databaseService = coreServiceManager.databaseService
+        multiDeviceManager = TestMultiDeviceManager()
+        taskManager = TestTaskManager(UnusedTaskCodec())
+        nonceFactory = NonceFactory(TestNonceStore())
+        this.contactModelFactory = ContactModelFactory(databaseProvider, identityProviderMock)
         this.contactModelRepository = ModelRepositories(
-            coreServiceManager = coreServiceManager,
+            databaseProvider = databaseProvider,
             identityProvider = identityProviderMock,
+            multiDeviceManager = multiDeviceManager,
+            taskManager = taskManager,
+            nonceFactory = nonceFactory,
+            globalEventBuses = globalEventBuses,
+            globalEventFlows = globalEventFlows,
+            dispatcherProvider = dispatcherProvider,
         ).contacts
 
         // Instantiate services where MD is enabled
         this.databaseProviderMd = TestDatabaseProvider()
         this.taskCodecMd = TransactionAckTaskCodec()
-        this.coreServiceManagerMd = TestCoreServiceManager(
-            databaseProvider = databaseProviderMd,
-            identityProvider = identityProviderMock,
-            preferenceStore = preferenceStore,
-            encryptedPreferenceStore = encryptedPreferenceStore,
-            multiDeviceManager = TestMultiDeviceManager(
-                isMultiDeviceActive = true,
-                isMdDisabledOrSupportsFs = false,
-            ),
-            taskManager = TestTaskManager(taskCodecMd),
-            identityStore = identityStoreMock,
+        multiDeviceManagerMd = TestMultiDeviceManager(
+            isMultiDeviceActive = true,
+            isMdDisabledOrSupportsFs = false,
         )
-        this.databaseServiceMd = coreServiceManagerMd.databaseService
+        taskManagerMd = TestTaskManager(taskCodecMd)
+        nonceFactoryMd = NonceFactory(TestNonceStore())
+        this.contactModelFactoryMd = ContactModelFactory(databaseProviderMd, identityProviderMock)
         this.contactModelRepositoryMd = ModelRepositories(
-            coreServiceManager = coreServiceManagerMd,
+            databaseProvider = databaseProviderMd,
             identityProvider = identityProviderMock,
+            multiDeviceManager = multiDeviceManagerMd,
+            taskManager = taskManagerMd,
+            nonceFactory = nonceFactoryMd,
+            globalEventBuses = globalEventBuses,
+            globalEventFlows = globalEventFlows,
+            dispatcherProvider = dispatcherProvider,
         ).contacts
     }
 
@@ -273,16 +211,16 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         val publicKey = nonSecureRandomArray(32)
 
         // Create contact using "old model"
-        databaseService.contactModelFactory.createOrUpdate(ContactModel.create(identity, publicKey))
-        databaseServiceMd.contactModelFactory.createOrUpdate(ContactModel.create(identity, publicKey))
+        contactModelFactory.create(ContactModel.create(identity, publicKey))
+        contactModelFactoryMd.create(ContactModel.create(identity, publicKey))
 
         // Fetch contact using "new model"
         val model = contactModelRepository.getByIdentity(identity)!!
         val modelMd = contactModelRepositoryMd.getByIdentity(identity)!!
         assertEquals(model.identity, modelMd.identity)
         assertContentEquals(model.data, modelMd.data)
-        assertTrue { model.identity == identity }
-        assertTrue { model.data?.identity == identity }
+        assertEquals(identity, model.identity)
+        assertEquals(identity, model.data?.identity)
         assertContentEquals(publicKey, model.data?.publicKey)
     }
 
@@ -369,11 +307,10 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
                     declareNonMdDependencies()
                     contactModelRepository.createFromLocal(contactModelData)
                 }
-                val runCreationMd =
-                    suspend {
-                        declareMdDependencies()
-                        contactModelRepositoryMd.createFromLocal(contactModelData)
-                    }
+                val runCreationMd = suspend {
+                    declareMdDependencies()
+                    contactModelRepositoryMd.createFromLocal(contactModelData)
+                }
 
                 runCreation to runCreationMd
             }
@@ -485,7 +422,7 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
     fun updateNickname() {
         // Create contact using "old model"
         val identity = randomIdentity()
-        databaseService.contactModelFactory.createOrUpdate(
+        contactModelFactory.create(
             ContactModel.create(
                 identity,
                 nonSecureRandomArray(32),
@@ -549,4 +486,71 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         // Just in case there are new fields added that are not explicitly compared here
         assertEquals(expected, actual)
     }
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters
+        fun initialValuesSet() = setOf(
+            getInitialContactModelData(),
+            getInitialContactModelData(publicKey = PUBLIC_KEY),
+            getInitialContactModelData(createdAt = Instant.ofEpochMilli(42)),
+            getInitialContactModelData(identityType = IdentityType.WORK),
+            getInitialContactModelData(acquaintanceLevel = AcquaintanceLevel.GROUP_OR_DELETED),
+            getInitialContactModelData(activityState = IdentityState.INACTIVE),
+            getInitialContactModelData(featureMask = 64.toULong()),
+        )
+
+        private fun getInitialContactModelData(
+            identity: IdentityString = "ABCDEFGH",
+            publicKey: ByteArray = PUBLIC_KEY,
+            createdAt: Instant = Instant.now(),
+            lastUpdateAt: Instant? = null,
+            firstName: String = "",
+            lastName: String = "",
+            nickname: String? = null,
+            verificationLevel: VerificationLevel = VerificationLevel.UNVERIFIED,
+            workVerificationLevel: WorkVerificationLevel = WorkVerificationLevel.NONE,
+            identityType: IdentityType = IdentityType.REGULAR,
+            acquaintanceLevel: AcquaintanceLevel = AcquaintanceLevel.DIRECT,
+            activityState: IdentityState = IdentityState.ACTIVE,
+            syncState: ContactSyncState = ContactSyncState.INITIAL,
+            featureMask: ULong = 0u,
+            readReceiptPolicy: ReadReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+            typingIndicatorPolicy: TypingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+            androidContactLookupInfo: AndroidContactLookupInfo? = null,
+            localAvatarExpires: Instant? = null,
+            isRestored: Boolean = false,
+            profilePictureBlobId: ByteArray? = null,
+            jobTitle: String? = null,
+            department: String? = null,
+        ) = ContactModelData(
+            identity = identity,
+            publicKey = publicKey,
+            createdAt = createdAt,
+            lastUpdateAt = lastUpdateAt,
+            firstName = firstName,
+            lastName = lastName,
+            nickname = nickname,
+            idColor = IdColor.ofIdentity(identity),
+            verificationLevel = verificationLevel,
+            workVerificationLevel = workVerificationLevel,
+            identityType = identityType,
+            acquaintanceLevel = acquaintanceLevel,
+            activityState = activityState,
+            syncState = syncState,
+            featureMask = featureMask,
+            readReceiptPolicy = readReceiptPolicy,
+            typingIndicatorPolicy = typingIndicatorPolicy,
+            conversationVisibility = ConversationVisibility.NORMAL,
+            androidContactLookupInfo = androidContactLookupInfo,
+            localAvatarExpires = localAvatarExpires,
+            isRestored = isRestored,
+            profilePictureBlobId = profilePictureBlobId,
+            jobTitle = jobTitle,
+            department = department,
+            notificationTriggerPolicyOverride = null,
+            availabilityStatus = AvailabilityStatus.None,
+            workLastFullSyncAt = null,
+        )
+    }
 }

+ 29 - 26
app/src/androidTest/java/ch/threema/data/repositories/EditHistoryRepositoryTest.kt

@@ -1,18 +1,19 @@
 package ch.threema.data.repositories
 
-import ch.threema.app.TestCoreServiceManager
+import ch.threema.app.TestMultiDeviceManager
 import ch.threema.app.TestTaskManager
-import ch.threema.app.testutils.TestHelpers
-import ch.threema.app.testutils.mockUser
+import ch.threema.data.ModelCache
 import ch.threema.data.storage.EditHistoryDao
 import ch.threema.data.storage.EditHistoryDaoImpl
 import ch.threema.domain.helpers.UnusedTaskCodec
-import ch.threema.storage.TestDatabaseProvider
+import ch.threema.domain.models.MessageId
+import ch.threema.storage.factories.GroupMessageModelFactory
+import ch.threema.storage.factories.MessageModelFactory
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageType
 import ch.threema.storage.models.group.GroupMessageModel
-import io.mockk.mockk
+import ch.threema.test.TestDatabaseProvider
 import java.util.UUID
 import kotlin.test.BeforeTest
 import kotlin.test.Test
@@ -21,28 +22,29 @@ import kotlin.test.assertFailsWith
 
 class EditHistoryRepositoryTest {
     private lateinit var databaseProvider: TestDatabaseProvider
-    private lateinit var coreServiceManager: TestCoreServiceManager
+    private lateinit var messageModelFactory: MessageModelFactory
+    private lateinit var groupMessageModelFactory: GroupMessageModelFactory
+    private lateinit var multiDeviceManager: TestMultiDeviceManager
+    private lateinit var taskManager: TestTaskManager
     private lateinit var editHistoryRepository: EditHistoryRepository
     private lateinit var editHistoryDao: EditHistoryDao
 
     @BeforeTest
     fun before() {
         databaseProvider = TestDatabaseProvider()
-        coreServiceManager = TestCoreServiceManager(
-            databaseProvider = databaseProvider,
-            preferenceStore = mockk {
-                mockUser(TestHelpers.TEST_CONTACT)
-            },
-            encryptedPreferenceStore = mockk {
-                mockUser(TestHelpers.TEST_CONTACT)
-            },
-            taskManager = TestTaskManager(UnusedTaskCodec()),
-        )
-        editHistoryRepository = ModelRepositories(
-            coreServiceManager = coreServiceManager,
-            identityProvider = mockk(),
-        ).editHistory
+        messageModelFactory = MessageModelFactory(databaseProvider)
+        groupMessageModelFactory = GroupMessageModelFactory(databaseProvider)
+        multiDeviceManager = TestMultiDeviceManager()
+        taskManager = TestTaskManager(UnusedTaskCodec())
+
+        val cache = ModelCache()
         editHistoryDao = EditHistoryDaoImpl(databaseProvider)
+        editHistoryRepository = EditHistoryRepository(
+            cache = cache.editHistory,
+            editHistoryDao = editHistoryDao,
+            multiDeviceManager = multiDeviceManager,
+            taskManager = taskManager,
+        )
     }
 
     @Test
@@ -53,18 +55,18 @@ class EditHistoryRepositoryTest {
             editHistoryRepository.createEntry(contactMessage)
         }
 
-        coreServiceManager.databaseService.messageModelFactory.create(contactMessage)
+        messageModelFactory.create(contactMessage)
 
         contactMessage.assertEditHistorySize(0)
 
         contactMessage.body = "Edited"
 
         editHistoryRepository.createEntry(contactMessage)
-        coreServiceManager.databaseService.messageModelFactory.update(contactMessage)
+        messageModelFactory.update(contactMessage)
 
         contactMessage.assertEditHistorySize(1)
 
-        coreServiceManager.databaseService.messageModelFactory.delete(contactMessage)
+        messageModelFactory.delete(contactMessage)
 
         contactMessage.assertEditHistorySize(0)
     }
@@ -77,18 +79,18 @@ class EditHistoryRepositoryTest {
             editHistoryRepository.createEntry(groupMessage)
         }
 
-        coreServiceManager.databaseService.groupMessageModelFactory.create(groupMessage)
+        groupMessageModelFactory.create(groupMessage)
 
         groupMessage.assertEditHistorySize(0)
 
         groupMessage.body = "Edited"
 
         editHistoryRepository.createEntry(groupMessage)
-        coreServiceManager.databaseService.groupMessageModelFactory.update(groupMessage)
+        groupMessageModelFactory.update(groupMessage)
 
         groupMessage.assertEditHistorySize(1)
 
-        coreServiceManager.databaseService.groupMessageModelFactory.delete(groupMessage)
+        groupMessageModelFactory.delete(groupMessage)
 
         groupMessage.assertEditHistorySize(0)
     }
@@ -107,6 +109,7 @@ class EditHistoryRepositoryTest {
     }
 
     private fun <T : AbstractMessageModel> T.enrich(text: String = "Text"): T = apply {
+        messageId = MessageId.random()
         type = MessageType.TEXT
         uid = UUID.randomUUID().toString()
         body = text

+ 106 - 56
app/src/androidTest/java/ch/threema/data/repositories/EmojiReactionsRepositoryTest.kt

@@ -1,10 +1,9 @@
 package ch.threema.data.repositories
 
-import ch.threema.app.TestCoreServiceManager
+import ch.threema.app.TestMultiDeviceManager
 import ch.threema.app.TestTaskManager
-import ch.threema.app.stores.IdentityProvider
 import ch.threema.app.testutils.TestHelpers
-import ch.threema.app.testutils.mockUser
+import ch.threema.data.ModelCache
 import ch.threema.data.ModelTypeCache
 import ch.threema.data.models.EmojiReactionData
 import ch.threema.data.models.EmojiReactionsModel
@@ -12,14 +11,16 @@ import ch.threema.data.repositories.EmojiReactionsRepository.ReactionMessageIden
 import ch.threema.data.storage.EmojiReactionsDao
 import ch.threema.data.storage.EmojiReactionsDaoImpl
 import ch.threema.domain.helpers.UnusedTaskCodec
-import ch.threema.domain.stores.IdentityStore
+import ch.threema.domain.models.MessageId
 import ch.threema.domain.types.Identity
-import ch.threema.storage.TestDatabaseProvider
+import ch.threema.storage.factories.GroupMessageModelFactory
+import ch.threema.storage.factories.MessageModelFactory
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.DistributionListMessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageType
 import ch.threema.storage.models.group.GroupMessageModel
+import ch.threema.test.TestDatabaseProvider
 import io.mockk.every
 import io.mockk.mockk
 import java.time.Instant
@@ -34,40 +35,36 @@ import kotlin.test.assertNull
 import kotlin.test.fail
 
 class EmojiReactionsRepositoryTest {
-    private lateinit var testCoreServiceManager: TestCoreServiceManager
+    private lateinit var multiDeviceManager: TestMultiDeviceManager
+    private lateinit var taskManager: TestTaskManager
+    private lateinit var messageModelFactory: MessageModelFactory
+    private lateinit var groupMessageModelFactory: GroupMessageModelFactory
     private lateinit var databaseProvider: TestDatabaseProvider
     private lateinit var emojiReactionsRepository: EmojiReactionsRepository
     private lateinit var emojiReactionDao: EmojiReactionsDao
 
+    private val reactedAt = Instant.ofEpochMilli(123456789)
+
     @BeforeTest
     fun before() {
         databaseProvider = TestDatabaseProvider()
-        val identityProviderMock: IdentityProvider = mockk {
-            every { getIdentity() } returns Identity(TestHelpers.TEST_CONTACT.identity)
-            every { getIdentityString() } returns TestHelpers.TEST_CONTACT.identity
-        }
-        val identityStoreMock = mockk<IdentityStore> {
-            every { getIdentity() } returns Identity(TestHelpers.TEST_CONTACT.identity)
-            every { getIdentityString() } returns TestHelpers.TEST_CONTACT.identity
-        }
-        testCoreServiceManager = TestCoreServiceManager(
-            databaseProvider = databaseProvider,
-            identityProvider = identityProviderMock,
-            preferenceStore = mockk {
-                mockUser(TestHelpers.TEST_CONTACT)
-            },
-            encryptedPreferenceStore = mockk {
-                mockUser(TestHelpers.TEST_CONTACT)
-            },
-            taskManager = TestTaskManager(UnusedTaskCodec()),
-            identityStore = identityStoreMock,
-        )
+        messageModelFactory = MessageModelFactory(databaseProvider)
+        groupMessageModelFactory = GroupMessageModelFactory(databaseProvider)
+        multiDeviceManager = TestMultiDeviceManager()
+        taskManager = TestTaskManager(UnusedTaskCodec())
 
-        emojiReactionsRepository = ModelRepositories(
-            coreServiceManager = testCoreServiceManager,
-            identityProvider = mockk(),
-        ).emojiReaction
+        val cache = ModelCache()
         emojiReactionDao = EmojiReactionsDaoImpl(databaseProvider)
+        emojiReactionsRepository = EmojiReactionsRepository(
+            cache = cache.emojiReaction,
+            emojiReactionDao = emojiReactionDao,
+            identityProvider = mockk {
+                every { getIdentity() } returns Identity(TestHelpers.TEST_CONTACT.identity)
+                every { getIdentityString() } returns TestHelpers.TEST_CONTACT.identity
+            },
+            multiDeviceManager = multiDeviceManager,
+            taskManager = taskManager,
+        )
     }
 
     @Test
@@ -75,21 +72,31 @@ class EmojiReactionsRepositoryTest {
         val contactMessage = MessageModel().enrich()
 
         assertFailsWith<EmojiReactionEntryCreateException> {
-            emojiReactionsRepository.createEntry(contactMessage, "ABCDEFGH", "\uD83C\uDFC8")
+            emojiReactionsRepository.createEntry(
+                targetMessage = contactMessage,
+                senderIdentity = "ABCDEFGH",
+                emojiSequence = "\uD83C\uDFC8",
+                reactedAt = reactedAt,
+            )
         }
 
-        testCoreServiceManager.databaseService.messageModelFactory.create(contactMessage)
+        messageModelFactory.create(contactMessage)
 
         contactMessage.assertEmojiReactionSize(0)
 
         contactMessage.body = "reacted"
 
-        emojiReactionsRepository.createEntry(contactMessage, "ABCDEFGH", "⚽")
-        testCoreServiceManager.databaseService.messageModelFactory.update(contactMessage)
+        emojiReactionsRepository.createEntry(
+            targetMessage = contactMessage,
+            senderIdentity = "ABCDEFGH",
+            emojiSequence = "⚽",
+            reactedAt = reactedAt,
+        )
+        messageModelFactory.update(contactMessage)
 
         contactMessage.assertEmojiReactionSize(1)
 
-        testCoreServiceManager.databaseService.messageModelFactory.delete(contactMessage)
+        messageModelFactory.delete(contactMessage)
 
         contactMessage.assertEmojiReactionSize(0)
     }
@@ -99,21 +106,31 @@ class EmojiReactionsRepositoryTest {
         val groupMessage = GroupMessageModel().enrich()
 
         assertFailsWith<EmojiReactionEntryCreateException> {
-            emojiReactionsRepository.createEntry(groupMessage, "ABCDEFGH", "⚾")
+            emojiReactionsRepository.createEntry(
+                targetMessage = groupMessage,
+                senderIdentity = "ABCDEFGH",
+                emojiSequence = "⚾",
+                reactedAt = reactedAt,
+            )
         }
 
-        testCoreServiceManager.databaseService.groupMessageModelFactory.create(groupMessage)
+        groupMessageModelFactory.create(groupMessage)
 
         groupMessage.assertEmojiReactionSize(0)
 
         groupMessage.body = "Reacted"
 
-        emojiReactionsRepository.createEntry(groupMessage, "ABCDEFGH", "⚽")
-        testCoreServiceManager.databaseService.groupMessageModelFactory.update(groupMessage)
+        emojiReactionsRepository.createEntry(
+            targetMessage = groupMessage,
+            senderIdentity = "ABCDEFGH",
+            emojiSequence = "⚽",
+            reactedAt = reactedAt,
+        )
+        groupMessageModelFactory.update(groupMessage)
 
         groupMessage.assertEmojiReactionSize(1)
 
-        testCoreServiceManager.databaseService.groupMessageModelFactory.delete(groupMessage)
+        groupMessageModelFactory.delete(groupMessage)
 
         groupMessage.assertEmojiReactionSize(0)
     }
@@ -121,18 +138,28 @@ class EmojiReactionsRepositoryTest {
     @Test
     fun testEmojiReactionUniqueness() {
         val message = MessageModel().enrich()
-        testCoreServiceManager.databaseService.messageModelFactory.create(message)
+        messageModelFactory.create(message)
 
         message.assertEmojiReactionSize(0)
         message.body = "reacted"
 
-        emojiReactionsRepository.createEntry(message, "ABCDEFGH", "⚽")
-        testCoreServiceManager.databaseService.messageModelFactory.update(message)
+        emojiReactionsRepository.createEntry(
+            targetMessage = message,
+            senderIdentity = "ABCDEFGH",
+            emojiSequence = "⚽",
+            reactedAt = reactedAt,
+        )
+        messageModelFactory.update(message)
 
         message.assertEmojiReactionSize(1)
 
         assertFailsWith<EmojiReactionEntryCreateException> {
-            emojiReactionsRepository.createEntry(message, "ABCDEFGH", "⚽")
+            emojiReactionsRepository.createEntry(
+                targetMessage = message,
+                senderIdentity = "ABCDEFGH",
+                emojiSequence = "⚽",
+                reactedAt = reactedAt,
+            )
         }
 
         message.assertEmojiReactionSize(1)
@@ -143,7 +170,7 @@ class EmojiReactionsRepositoryTest {
         val reaction = reactions.data!![0]
         assertEquals("⚽", reaction.emojiSequence)
 
-        testCoreServiceManager.databaseService.messageModelFactory.delete(message)
+        messageModelFactory.delete(message)
     }
 
     @Test
@@ -151,8 +178,8 @@ class EmojiReactionsRepositoryTest {
         val contactMessage = MessageModel().enrich()
         val groupMessage = GroupMessageModel().enrich()
 
-        testCoreServiceManager.databaseService.messageModelFactory.create(contactMessage)
-        testCoreServiceManager.databaseService.groupMessageModelFactory.create(groupMessage)
+        messageModelFactory.create(contactMessage)
+        groupMessageModelFactory.create(groupMessage)
 
         assertEquals(1, contactMessage.id)
         assertEquals(1, groupMessage.id)
@@ -160,12 +187,22 @@ class EmojiReactionsRepositoryTest {
         contactMessage.assertEmojiReactionSize(0)
         groupMessage.assertEmojiReactionSize(0)
 
-        emojiReactionsRepository.createEntry(contactMessage, "ABCD1234", "⚾")
+        emojiReactionsRepository.createEntry(
+            targetMessage = contactMessage,
+            senderIdentity = "ABCD1234",
+            emojiSequence = "⚾",
+            reactedAt = reactedAt,
+        )
 
         contactMessage.assertEmojiReactionSize(1)
         groupMessage.assertEmojiReactionSize(0)
 
-        emojiReactionsRepository.createEntry(groupMessage, "ABCD1234", "⛵")
+        emojiReactionsRepository.createEntry(
+            targetMessage = groupMessage,
+            senderIdentity = "ABCD1234",
+            emojiSequence = "⛵",
+            reactedAt = reactedAt,
+        )
 
         contactMessage.assertEmojiReactionSize(1)
         groupMessage.assertEmojiReactionSize(1)
@@ -186,8 +223,8 @@ class EmojiReactionsRepositoryTest {
         val contactMessage = MessageModel().enrich()
         val groupMessage = GroupMessageModel().enrich()
 
-        testCoreServiceManager.databaseService.messageModelFactory.create(contactMessage)
-        testCoreServiceManager.databaseService.groupMessageModelFactory.create(groupMessage)
+        messageModelFactory.create(contactMessage)
+        groupMessageModelFactory.create(groupMessage)
 
         assertEquals(1, contactMessage.id)
         assertEquals(1, groupMessage.id)
@@ -195,12 +232,22 @@ class EmojiReactionsRepositoryTest {
         contactMessage.assertEmojiReactionSize(0)
         groupMessage.assertEmojiReactionSize(0)
 
-        emojiReactionsRepository.createEntry(contactMessage, "ABCD1234", reactionSequence)
+        emojiReactionsRepository.createEntry(
+            targetMessage = contactMessage,
+            senderIdentity = "ABCD1234",
+            emojiSequence = reactionSequence,
+            reactedAt = reactedAt,
+        )
 
         contactMessage.assertEmojiReactionSize(1)
         groupMessage.assertEmojiReactionSize(0)
 
-        emojiReactionsRepository.createEntry(groupMessage, "ABCD1234", reactionSequence)
+        emojiReactionsRepository.createEntry(
+            targetMessage = groupMessage,
+            senderIdentity = "ABCD1234",
+            emojiSequence = reactionSequence,
+            reactedAt = reactedAt,
+        )
 
         contactMessage.assertEmojiReactionSize(1)
         groupMessage.assertEmojiReactionSize(1)
@@ -221,7 +268,7 @@ class EmojiReactionsRepositoryTest {
         val testEmojiCache = ModelTypeCache<ReactionMessageIdentifier, EmojiReactionsModel>()
 
         val contactMessage = MessageModel().enrich()
-        testCoreServiceManager.databaseService.messageModelFactory.create(contactMessage)
+        messageModelFactory.create(contactMessage)
 
         // Test successful creation of reaction-message-identifier
         val reactionMessageIdentifier = ReactionMessageIdentifier.fromMessageModel(contactMessage)
@@ -246,7 +293,8 @@ class EmojiReactionsRepositoryTest {
         )
         val emojiReactionsModel = EmojiReactionsModel(
             data = listOf(emojiReactionData),
-            coreServiceManager = testCoreServiceManager,
+            multiDeviceManager = multiDeviceManager,
+            taskManager = taskManager,
         )
         cachedEntry = testEmojiCache.getOrCreate(reactionMessageIdentifier) { emojiReactionsModel }
         assertContentEquals(listOf(emojiReactionData), cachedEntry!!.data)
@@ -286,7 +334,8 @@ class EmojiReactionsRepositoryTest {
         )
         val emojiReactionsModelContact = EmojiReactionsModel(
             data = listOf(emojiReactionDataForContactMessage),
-            coreServiceManager = testCoreServiceManager,
+            multiDeviceManager = multiDeviceManager,
+            taskManager = taskManager,
         )
 
         val cachedEntryContact =
@@ -309,6 +358,7 @@ class EmojiReactionsRepositoryTest {
     }
 
     private fun <T : AbstractMessageModel> T.enrich(text: String = "Text"): T {
+        messageId = MessageId.random()
         type = MessageType.TEXT
         uid = UUID.randomUUID().toString()
         body = text

+ 39 - 28
app/src/androidTest/java/ch/threema/data/repositories/GroupModelRepositoryTest.kt

@@ -1,10 +1,11 @@
 package ch.threema.data.repositories
 
-import ch.threema.app.TestCoreServiceManager
+import ch.threema.app.TestMultiDeviceManager
+import ch.threema.app.TestNonceStore
 import ch.threema.app.TestTaskManager
-import ch.threema.app.testutils.TestHelpers
-import ch.threema.app.testutils.mockUser
-import ch.threema.data.models.GroupIdentity
+import ch.threema.base.crypto.NonceFactory
+import ch.threema.data.datatypes.ConversationVisibility
+import ch.threema.data.datatypes.GroupIdentity
 import ch.threema.data.models.GroupModelDataFactory
 import ch.threema.data.storage.DatabaseBackend
 import ch.threema.data.storage.DbGroup
@@ -12,20 +13,26 @@ import ch.threema.data.storage.SqliteDatabaseBackend
 import ch.threema.domain.helpers.UnusedTaskCodec
 import ch.threema.domain.models.GroupId
 import ch.threema.domain.models.UserState
-import ch.threema.storage.TestDatabaseProvider
+import ch.threema.storage.factories.GroupModelFactory
 import ch.threema.storage.models.group.GroupModelOld
+import ch.threema.test.TestDatabaseProvider
+import io.mockk.every
 import io.mockk.mockk
-import java.util.Date
+import java.time.Instant
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
 import kotlin.test.assertNull
+import kotlinx.coroutines.test.StandardTestDispatcher
 
 class GroupModelRepositoryTest {
     private lateinit var databaseProvider: TestDatabaseProvider
+    private lateinit var groupModelFactory: GroupModelFactory
     private lateinit var databaseBackend: DatabaseBackend
-    private lateinit var coreServiceManager: TestCoreServiceManager
+    private lateinit var multiDeviceManager: TestMultiDeviceManager
+    private lateinit var taskManager: TestTaskManager
+    private lateinit var nonceFactory: NonceFactory
     private lateinit var groupModelRepository: GroupModelRepository
 
     private fun createTestDbGroup(groupIdentity: GroupIdentity): DbGroup {
@@ -33,36 +40,40 @@ class GroupModelRepositoryTest {
             creatorIdentity = groupIdentity.creatorIdentity,
             groupId = groupIdentity.groupIdHexString,
             name = "Group",
-            createdAt = Date(),
-            synchronizedAt = Date(),
+            createdAt = Instant.now(),
+            synchronizedAt = Instant.now(),
             lastUpdate = null,
-            isArchived = false,
+            conversationVisibility = ConversationVisibility.NORMAL,
             colorIndex = 0,
             groupDescription = "Description",
-            groupDescriptionChangedAt = Date(),
+            groupDescriptionChangedAt = Instant.now(),
             members = setOf("AAAAAAAA", "BBBBBBBB"),
             userState = UserState.MEMBER,
-            notificationTriggerPolicyOverride = null,
+            notificationTriggerPolicyOverridePolicy = null,
+            notificationTriggerPolicyOverrideExpiresAt = null,
         )
     }
 
     @BeforeTest
     fun before() {
         this.databaseProvider = TestDatabaseProvider()
+        groupModelFactory = GroupModelFactory(databaseProvider)
         this.databaseBackend = SqliteDatabaseBackend(databaseProvider, mockk())
-        this.coreServiceManager = TestCoreServiceManager(
-            databaseProvider = databaseProvider,
-            preferenceStore = mockk {
-                mockUser(TestHelpers.TEST_CONTACT)
-            },
-            encryptedPreferenceStore = mockk {
-                mockUser(TestHelpers.TEST_CONTACT)
-            },
-            taskManager = TestTaskManager(UnusedTaskCodec()),
-        )
+        multiDeviceManager = TestMultiDeviceManager()
+        taskManager = TestTaskManager(UnusedTaskCodec())
+        nonceFactory = NonceFactory(TestNonceStore())
+        val testDispatcher = StandardTestDispatcher()
         this.groupModelRepository = ModelRepositories(
-            coreServiceManager = coreServiceManager,
+            databaseProvider = databaseProvider,
             identityProvider = mockk(),
+            multiDeviceManager = multiDeviceManager,
+            taskManager = taskManager,
+            nonceFactory = nonceFactory,
+            globalEventBuses = mockk(relaxed = true),
+            globalEventFlows = mockk(relaxed = true),
+            dispatcherProvider = mockk {
+                every { worker } returns testDispatcher
+            },
         ).groups
     }
 
@@ -84,11 +95,11 @@ class GroupModelRepositoryTest {
         val groupIdentity = GroupIdentity("TESTTEST", 42)
 
         // Create group using the "old" model
-        coreServiceManager.databaseService.groupModelFactory.create(
+        groupModelFactory.create(
             GroupModelOld()
                 .setCreatorIdentity(groupIdentity.creatorIdentity)
                 .setApiGroupId(GroupId(groupIdentity.groupId))
-                .setCreatedAt(Date()),
+                .setCreatedAt(Instant.now()),
         )
 
         // Fetch group using the "new" model
@@ -102,11 +113,11 @@ class GroupModelRepositoryTest {
         val groupId = GroupId(-42)
 
         // Create group using the "old" model
-        coreServiceManager.databaseService.groupModelFactory.create(
+        groupModelFactory.create(
             GroupModelOld()
                 .setCreatorIdentity(creatorIdentity)
                 .setApiGroupId(groupId)
-                .setCreatedAt(Date()),
+                .setCreatedAt(Instant.now()),
         )
 
         // Fetch group using the "new" model
@@ -123,7 +134,7 @@ class GroupModelRepositoryTest {
 
         // This should work because the database is initially empty and the local group id starts
         // with 1.
-        val fetchedGroup = groupModelRepository.getByLocalGroupDbId(1)
+        val fetchedGroup = groupModelRepository.getByGroupDatabaseId(1)
         assertEquals(GroupModelDataFactory.toDataType(testGroup), fetchedGroup?.data)
     }
 

+ 129 - 0
app/src/androidTest/java/ch/threema/data/storage/SqliteDatabaseBackendTest.kt

@@ -0,0 +1,129 @@
+package ch.threema.data.storage
+
+import ch.threema.data.datatypes.ConversationVisibility
+import ch.threema.domain.models.AcquaintanceLevel
+import ch.threema.domain.models.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.types.Identity
+import ch.threema.test.TestData.PUBLIC_KEY
+import ch.threema.test.TestDatabaseProvider
+import ch.threema.test.TestIdentityProvider
+import ch.threema.testhelpers.utcDate
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class SqliteDatabaseBackendTest {
+
+    private lateinit var databaseBackend: SqliteDatabaseBackend
+
+    @BeforeTest
+    fun setUp() {
+        databaseBackend = SqliteDatabaseBackend(
+            databaseProvider = TestDatabaseProvider(),
+            identityProvider = TestIdentityProvider(identity = MY_IDENTITY),
+        )
+    }
+
+    @Test
+    fun canCreateMultipleDifferentContacts() {
+        databaseBackend.createContact(CONTACT)
+        val otherContact = CONTACT.copy(
+            identity = "TEST9999",
+            publicKey = ByteArray(32) { 2 },
+        )
+        databaseBackend.createContact(otherContact)
+
+        val storedIdentities = databaseBackend.getAllContacts().map { it.identity }
+        assertEquals(listOf(CONTACT.identity, otherContact.identity), storedIdentities)
+    }
+
+    @Test
+    fun cannotCreateContactsWithInvalidIdentity() {
+        assertFailsWith<IllegalArgumentException> {
+            databaseBackend.createContact(CONTACT.copy(identity = "INVALID"))
+        }
+    }
+
+    @Test
+    fun cannotCreateContactsWithUserIdentity() {
+        assertFailsWith<IllegalArgumentException> {
+            databaseBackend.createContact(CONTACT.copy(identity = MY_IDENTITY.value))
+        }
+    }
+
+    @Test
+    fun cannotCreateContactsWithWrongLengthPublicKey() {
+        assertFailsWith<IllegalArgumentException> {
+            databaseBackend.createContact(CONTACT.copy(publicKey = ByteArray(33) { 1 }))
+        }
+        assertFailsWith<IllegalArgumentException> {
+            databaseBackend.createContact(CONTACT.copy(publicKey = ByteArray(31) { 1 }))
+        }
+    }
+
+    @Test
+    fun cannotCreateContactsWithAllZeroesPublicKey() {
+        assertFailsWith<IllegalArgumentException> {
+            databaseBackend.createContact(CONTACT.copy(publicKey = ByteArray(32)))
+        }
+    }
+
+    @Test
+    fun cannotCreateMultipleContactsWithSameIdentity() {
+        databaseBackend.createContact(CONTACT)
+        assertFailsWith<DatabaseException> {
+            databaseBackend.createContact(CONTACT)
+        }
+    }
+
+    @Test
+    fun cannotCreateMultipleContactsWithSamePublicKey() {
+        databaseBackend.createContact(CONTACT)
+        assertFailsWith<DatabaseException> {
+            val otherContact = CONTACT.copy(identity = "TEST9999")
+            databaseBackend.createContact(otherContact)
+        }
+    }
+
+    companion object {
+        private val MY_IDENTITY = Identity("00000000")
+
+        private val CONTACT = DbContact(
+            identity = "TESTTEST",
+            publicKey = PUBLIC_KEY,
+            createdAt = utcDate(2026, 4, 15),
+            lastUpdateAt = null,
+            firstName = "Testy",
+            lastName = "McTestface",
+            nickname = null,
+            colorIndex = 0,
+            verificationLevel = VerificationLevel.UNVERIFIED,
+            workVerificationLevel = WorkVerificationLevel.NONE,
+            identityType = IdentityType.REGULAR,
+            acquaintanceLevel = AcquaintanceLevel.DIRECT,
+            activityState = IdentityState.ACTIVE,
+            syncState = ContactSyncState.INITIAL,
+            featureMask = 0UL,
+            readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+            typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+            conversationVisibility = ConversationVisibility.NORMAL,
+            androidContactLookupKey = null,
+            localAvatarExpires = null,
+            isRestored = false,
+            profilePictureBlobId = null,
+            jobTitle = null,
+            department = null,
+            notificationTriggerPolicyOverridePolicy = null,
+            notificationTriggerPolicyOverrideExpiresAt = null,
+            availabilityStatusSet = null,
+            workLastFullSyncAt = null,
+        )
+    }
+}

+ 78 - 0
app/src/androidTest/java/ch/threema/test/TestData.kt

@@ -0,0 +1,78 @@
+package ch.threema.test
+
+import ch.threema.base.crypto.NaCl
+import ch.threema.data.datatypes.AndroidContactLookupInfo
+import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.ContactNotificationTriggerPolicyOverride
+import ch.threema.data.datatypes.ConversationVisibility
+import ch.threema.data.models.ContactModelData
+import ch.threema.domain.models.AcquaintanceLevel
+import ch.threema.domain.models.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.types.Identity
+import java.time.Instant
+
+object TestData {
+    val PUBLIC_KEY = ByteArray(NaCl.PUBLIC_KEY_BYTES) { it.toByte() }
+
+    fun createContactModelData(
+        identity: Identity,
+        publicKey: ByteArray = PUBLIC_KEY,
+        createdAt: Instant = Instant.now(),
+        lastUpdateAt: Instant? = null,
+        firstName: String = "First",
+        lastName: String = "Last",
+        nickname: String? = null,
+        verificationLevel: VerificationLevel = VerificationLevel.FULLY_VERIFIED,
+        workVerificationLevel: WorkVerificationLevel = WorkVerificationLevel.NONE,
+        identityType: IdentityType = IdentityType.REGULAR,
+        acquaintanceLevel: AcquaintanceLevel = AcquaintanceLevel.DIRECT,
+        activityState: IdentityState = IdentityState.ACTIVE,
+        syncState: ContactSyncState = ContactSyncState.INITIAL,
+        featureMask: ULong = 0u,
+        readReceiptPolicy: ReadReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+        typingIndicatorPolicy: TypingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        conversationVisibility: ConversationVisibility = ConversationVisibility.NORMAL,
+        androidContactLookupInfo: AndroidContactLookupInfo? = null,
+        localAvatarExpires: Instant? = null,
+        isRestored: Boolean = false,
+        profilePictureBlobId: ByteArray? = null,
+        jobTitle: String? = null,
+        department: String? = null,
+        notificationTriggerPolicyOverride: ContactNotificationTriggerPolicyOverride? = null,
+        availabilityStatus: AvailabilityStatus = AvailabilityStatus.None,
+        workLastFullSyncAt: Instant? = null,
+    ) = ContactModelData(
+        identity = identity.value,
+        publicKey = publicKey,
+        createdAt = createdAt,
+        lastUpdateAt = lastUpdateAt,
+        firstName = firstName,
+        lastName = lastName,
+        nickname = nickname,
+        verificationLevel = verificationLevel,
+        workVerificationLevel = workVerificationLevel,
+        identityType = identityType,
+        acquaintanceLevel = acquaintanceLevel,
+        activityState = activityState,
+        syncState = syncState,
+        featureMask = featureMask,
+        readReceiptPolicy = readReceiptPolicy,
+        typingIndicatorPolicy = typingIndicatorPolicy,
+        conversationVisibility = conversationVisibility,
+        androidContactLookupInfo = androidContactLookupInfo,
+        localAvatarExpires = localAvatarExpires,
+        isRestored = isRestored,
+        profilePictureBlobId = profilePictureBlobId,
+        jobTitle = jobTitle,
+        department = department,
+        notificationTriggerPolicyOverride = notificationTriggerPolicyOverride,
+        availabilityStatus = availabilityStatus,
+        workLastFullSyncAt = workLastFullSyncAt,
+    )
+}

+ 6 - 1
app/src/androidTest/java/ch/threema/storage/TestDatabaseProvider.kt → app/src/androidTest/java/ch/threema/test/TestDatabaseProvider.kt

@@ -1,7 +1,11 @@
-package ch.threema.storage
+package ch.threema.test
 
 import androidx.test.core.app.ApplicationProvider
 import ch.threema.common.stateFlowOf
+import ch.threema.storage.DatabaseOpenHelper
+import ch.threema.storage.DatabaseProvider
+import ch.threema.storage.DatabaseState
+import io.mockk.mockk
 import net.zetetic.database.sqlcipher.SQLiteDatabase
 
 class TestDatabaseProvider : DatabaseProvider {
@@ -9,6 +13,7 @@ class TestDatabaseProvider : DatabaseProvider {
         appContext = ApplicationProvider.getApplicationContext(),
         databaseName = null,
         password = "test-database-key".toByteArray(),
+        downgradeHelper = mockk(),
     )
 
     override val databaseState = stateFlowOf(DatabaseState.READY)

+ 21 - 0
app/src/androidTest/java/ch/threema/test/TestIdentityProvider.kt

@@ -0,0 +1,21 @@
+package ch.threema.test
+
+import ch.threema.data.IdentityProvider
+import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
+
+class TestIdentityProvider(
+    private val identity: Identity?,
+) : IdentityProvider {
+    override fun getIdentity(): Identity? =
+        identity
+
+    override fun getIdentityString(): IdentityString? =
+        identity?.value
+
+    companion object {
+        @JvmStatic
+        fun javaCreate(identity: IdentityString?) =
+            TestIdentityProvider(identity?.let(::Identity))
+    }
+}

+ 16 - 16
app/src/blue/res/drawable/logo_main.xml

@@ -5,50 +5,50 @@
     android:viewportHeight="45.75">
   <path
       android:pathData="M349.67,42.76m-2.99,0a2.99,2.99 0,1 1,5.98 0a2.99,2.99 0,1 1,-5.98 0"
-      android:fillColor="#002e63"/>
+      android:fillColor="?attr/colorPrimary"/>
   <path
       android:pathData="M359.44,42.76m-2.99,0a2.99,2.99 0,1 1,5.98 0a2.99,2.99 0,1 1,-5.98 0"
-      android:fillColor="#002e63"/>
+      android:fillColor="?attr/colorPrimary"/>
   <path
       android:pathData="M369.2,42.76m-2.99,0a2.99,2.99 0,1 1,5.98 0a2.99,2.99 0,1 1,-5.98 0"
-      android:fillColor="#002e63"/>
+      android:fillColor="?attr/colorPrimary"/>
   <path
-      android:fillColor="#FF000000"
+      android:fillColor="?attr/colorOnBackground"
       android:pathData="M14.35,8.84H0V0.38h39.09v8.46h-14.35v36.4h-10.38V8.84Z"/>
   <path
-      android:fillColor="#FF000000"
+      android:fillColor="?attr/colorOnBackground"
       android:pathData="M85.8,0.38v44.85h-10.38v-18.39h-20.38v18.39h-10.38V0.38h10.38v17.69h20.38V0.38h10.38Z"/>
   <path
-      android:fillColor="#FF000000"
+      android:fillColor="?attr/colorOnBackground"
       android:pathData="M124.01,45.24l-8.65,-12.5h-9.55v12.5h-10.38V0.38h19.42c3.97,0 7.42,0.66 10.35,1.99 2.93,1.32 5.18,3.2 6.76,5.64 1.58,2.44 2.37,5.32 2.37,8.65s-0.8,6.21 -2.4,8.62 -3.88,4.26 -6.82,5.54l10.06,14.42h-11.15ZM121.39,10.86c-1.62,-1.35 -3.99,-2.02 -7.11,-2.02h-8.46v15.63h8.46c3.12,0 5.49,-0.68 7.11,-2.05 1.62,-1.37 2.44,-3.29 2.44,-5.77s-0.81,-4.45 -2.44,-5.8Z"/>
   <path
-      android:fillColor="#FF000000"
+      android:fillColor="?attr/colorOnBackground"
       android:pathData="M177.26,36.91v8.33h-34.73V0.38h33.9v8.33h-23.58v9.74h20.83v8.07h-20.83v10.38h24.41Z"/>
   <path
-      android:fillColor="#FF000000"
+      android:fillColor="?attr/colorOnBackground"
       android:pathData="M220.26,36.91v8.33h-34.73V0.38h33.9v8.33h-23.58v9.74h20.83v8.07h-20.83v10.38h24.41Z"/>
   <path
-      android:fillColor="#FF000000"
+      android:fillColor="?attr/colorOnBackground"
       android:pathData="M269.34,45.24l-0.06,-26.91 -13.2,22.17h-4.68l-13.14,-21.59v26.34h-9.74V0.38h8.59l16.79,27.87L270.43,0.38h8.52l0.13,44.85h-9.74Z"/>
   <path
-      android:fillColor="#FF000000"
+      android:fillColor="?attr/colorOnBackground"
       android:pathData="M319.26,35.63h-20.83l-3.97,9.61h-10.64L303.81,0.38h10.25l20.06,44.85h-10.89l-3.97,-9.61ZM315.99,27.75l-7.11,-17.17 -7.11,17.17h14.22Z"/>
   <path
-      android:fillColor="#FF000000"
+      android:fillColor="?attr/colorOnBackground"
       android:pathData="M450.55,0.38l-15.12,44.85h-5l-13.14,-38.25 -13.2,38.25h-4.93L384.04,0.38h4.87l12.94,38.57L415.25,0.38h4.42l13.2,38.77L446,0.38h4.55Z"/>
   <path
-      android:fillColor="#FF000000"
+      android:fillColor="?attr/colorOnBackground"
       android:pathData="M464.42,42.64c-3.61,-1.99 -6.43,-4.71 -8.46,-8.17 -2.03,-3.46 -3.04,-7.35 -3.04,-11.66s1.01,-8.2 3.04,-11.66c2.03,-3.46 4.85,-6.18 8.46,-8.17 3.61,-1.99 7.63,-2.98 12.08,-2.98s8.45,0.98 12.01,2.95c3.57,1.97 6.38,4.69 8.43,8.17 2.05,3.48 3.08,7.38 3.08,11.69s-1.03,8.21 -3.08,11.69c-2.05,3.48 -4.86,6.21 -8.43,8.17 -3.57,1.97 -7.57,2.95 -12.01,2.95s-8.47,-0.99 -12.08,-2.98ZM486.12,38.99c2.86,-1.6 5.1,-3.82 6.73,-6.66 1.62,-2.84 2.44,-6.01 2.44,-9.52s-0.81,-6.67 -2.44,-9.52c-1.62,-2.84 -3.87,-5.06 -6.73,-6.66 -2.86,-1.6 -6.07,-2.4 -9.61,-2.4s-6.76,0.8 -9.64,2.4c-2.88,1.6 -5.15,3.82 -6.79,6.66 -1.64,2.84 -2.47,6.01 -2.47,9.52s0.82,6.68 2.47,9.52c1.64,2.84 3.91,5.06 6.79,6.66 2.88,1.6 6.1,2.4 9.64,2.4s6.75,-0.8 9.61,-2.4Z"/>
   <path
-      android:fillColor="#FF000000"
+      android:fillColor="?attr/colorOnBackground"
       android:pathData="M539.36,45.24l-10.25,-14.42c-1.15,0.13 -2.35,0.19 -3.59,0.19h-12.05v14.23h-4.74V0.38h16.79c5.72,0 10.21,1.37 13.46,4.1 3.25,2.73 4.87,6.49 4.87,11.28 0,3.5 -0.89,6.46 -2.66,8.87 -1.77,2.41 -4.3,4.16 -7.59,5.22l10.96,15.38h-5.19ZM535.58,24.03c2.35,-1.96 3.52,-4.72 3.52,-8.27s-1.18,-6.42 -3.52,-8.36c-2.35,-1.94 -5.75,-2.92 -10.19,-2.92h-11.92v22.49h11.92c4.44,0 7.84,-0.98 10.19,-2.95Z"/>
   <path
-      android:fillColor="#FF000000"
+      android:fillColor="?attr/colorOnBackground"
       android:pathData="M567.04,24.16l-9.16,9.29v11.79h-4.74V0.38h4.74v27.1L584.22,0.38h5.45l-19.42,20.31 20.76,24.54h-5.64l-18.33,-21.08Z"/>
   <path
       android:pathData="M359.04,10.5c-1.67,0 -3.02,1.36 -3.02,3.02v2.39h6.04v-2.39c0,-1.67 -1.36,-3.02 -3.02,-3.02Z"
-      android:fillColor="#002e63"/>
+      android:fillColor="?attr/colorPrimary"/>
   <path
       android:pathData="M359.04,0.03c-9.81,0 -17.79,7.98 -17.79,17.79 0,3.89 1.26,7.5 3.39,10.43l-2.22,6.61 6.84,-2.19c2.81,1.86 6.17,2.94 9.79,2.94 9.81,0 17.79,-7.98 17.79,-17.79S368.85,0.03 359.04,0.03ZM365.93,23.98c0,0.89 -0.72,1.61 -1.61,1.61h-10.55c-0.89,0 -1.61,-0.72 -1.61,-1.61v-6.46c0,-0.89 0.72,-1.61 1.61,-1.61h0.16v-2.39c0,-2.82 2.3,-5.12 5.12,-5.12s5.12,2.3 5.12,5.12v2.39h0.16c0.89,0 1.61,0.72 1.61,1.61v6.46Z"
-      android:fillColor="#002e63"/>
+      android:fillColor="?attr/colorPrimary"/>
 </vector>

+ 0 - 35
app/src/blue/res/drawable/logo_main_white.xml

@@ -1,35 +0,0 @@
-<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="15.481972dp" android:viewportHeight="45.75" android:viewportWidth="591.01" android:width="200dp">
-      
-    <path android:fillColor="#FFF" android:pathData="M349.67,42.76m-2.99,0a2.99,2.99 0,1 1,5.98 0a2.99,2.99 0,1 1,-5.98 0"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M359.44,42.76m-2.99,0a2.99,2.99 0,1 1,5.98 0a2.99,2.99 0,1 1,-5.98 0"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M369.2,42.76m-2.99,0a2.99,2.99 0,1 1,5.98 0a2.99,2.99 0,1 1,-5.98 0"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M14.35,8.84H0V0.38h39.09v8.46h-14.35v36.4h-10.38V8.84Z"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M85.8,0.38v44.85h-10.38v-18.39h-20.38v18.39h-10.38V0.38h10.38v17.69h20.38V0.38h10.38Z"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M124.01,45.24l-8.65,-12.5h-9.55v12.5h-10.38V0.38h19.42c3.97,0 7.42,0.66 10.35,1.99 2.93,1.32 5.18,3.2 6.76,5.64 1.58,2.44 2.37,5.32 2.37,8.65s-0.8,6.21 -2.4,8.62 -3.88,4.26 -6.82,5.54l10.06,14.42h-11.15ZM121.39,10.86c-1.62,-1.35 -3.99,-2.02 -7.11,-2.02h-8.46v15.63h8.46c3.12,0 5.49,-0.68 7.11,-2.05 1.62,-1.37 2.44,-3.29 2.44,-5.77s-0.81,-4.45 -2.44,-5.8Z"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M177.26,36.91v8.33h-34.73V0.38h33.9v8.33h-23.58v9.74h20.83v8.07h-20.83v10.38h24.41Z"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M220.26,36.91v8.33h-34.73V0.38h33.9v8.33h-23.58v9.74h20.83v8.07h-20.83v10.38h24.41Z"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M269.34,45.24l-0.06,-26.91 -13.2,22.17h-4.68l-13.14,-21.59v26.34h-9.74V0.38h8.59l16.79,27.87L270.43,0.38h8.52l0.13,44.85h-9.74Z"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M319.26,35.63h-20.83l-3.97,9.61h-10.64L303.81,0.38h10.25l20.06,44.85h-10.89l-3.97,-9.61ZM315.99,27.75l-7.11,-17.17 -7.11,17.17h14.22Z"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M450.55,0.38l-15.12,44.85h-5l-13.14,-38.25 -13.2,38.25h-4.93L384.04,0.38h4.87l12.94,38.57L415.25,0.38h4.42l13.2,38.77L446,0.38h4.55Z"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M464.42,42.64c-3.61,-1.99 -6.43,-4.71 -8.46,-8.17 -2.03,-3.46 -3.04,-7.35 -3.04,-11.66s1.01,-8.2 3.04,-11.66c2.03,-3.46 4.85,-6.18 8.46,-8.17 3.61,-1.99 7.63,-2.98 12.08,-2.98s8.45,0.98 12.01,2.95c3.57,1.97 6.38,4.69 8.43,8.17 2.05,3.48 3.08,7.38 3.08,11.69s-1.03,8.21 -3.08,11.69c-2.05,3.48 -4.86,6.21 -8.43,8.17 -3.57,1.97 -7.57,2.95 -12.01,2.95s-8.47,-0.99 -12.08,-2.98ZM486.12,38.99c2.86,-1.6 5.1,-3.82 6.73,-6.66 1.62,-2.84 2.44,-6.01 2.44,-9.52s-0.81,-6.67 -2.44,-9.52c-1.62,-2.84 -3.87,-5.06 -6.73,-6.66 -2.86,-1.6 -6.07,-2.4 -9.61,-2.4s-6.76,0.8 -9.64,2.4c-2.88,1.6 -5.15,3.82 -6.79,6.66 -1.64,2.84 -2.47,6.01 -2.47,9.52s0.82,6.68 2.47,9.52c1.64,2.84 3.91,5.06 6.79,6.66 2.88,1.6 6.1,2.4 9.64,2.4s6.75,-0.8 9.61,-2.4Z"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M539.36,45.24l-10.25,-14.42c-1.15,0.13 -2.35,0.19 -3.59,0.19h-12.05v14.23h-4.74V0.38h16.79c5.72,0 10.21,1.37 13.46,4.1 3.25,2.73 4.87,6.49 4.87,11.28 0,3.5 -0.89,6.46 -2.66,8.87 -1.77,2.41 -4.3,4.16 -7.59,5.22l10.96,15.38h-5.19ZM535.58,24.03c2.35,-1.96 3.52,-4.72 3.52,-8.27s-1.18,-6.42 -3.52,-8.36c-2.35,-1.94 -5.75,-2.92 -10.19,-2.92h-11.92v22.49h11.92c4.44,0 7.84,-0.98 10.19,-2.95Z"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M567.04,24.16l-9.16,9.29v11.79h-4.74V0.38h4.74v27.1L584.22,0.38h5.45l-19.42,20.31 20.76,24.54h-5.64l-18.33,-21.08Z"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M359.04,0.03c-9.81,0 -17.79,7.98 -17.79,17.79 0,3.89 1.26,7.5 3.39,10.43l-2.22,6.61 6.84,-2.19c2.81,1.86 6.17,2.94 9.79,2.94 9.81,0 17.79,-7.98 17.79,-17.79S368.85,0.03 359.04,0.03ZM365.93,23.98c0,0.89 -0.72,1.61 -1.61,1.61h-10.55c-0.89,0 -1.61,-0.72 -1.61,-1.61v-6.46c0,-0.89 0.72,-1.61 1.61,-1.61h0.16v-2.39c0,-2.82 2.3,-5.12 5.12,-5.12s5.12,2.3 5.12,5.12v2.39h0.16c0.89,0 1.61,0.72 1.61,1.61v6.46Z"/>
-      
-    <path android:fillColor="#FFF" android:pathData="M359.04,10.5c-1.67,0 -3.02,1.36 -3.02,3.02v2.39h6.04v-2.39c0,-1.67 -1.36,-3.02 -3.02,-3.02Z"/>
-    
-</vector>

+ 107 - 165
app/src/blue/res/layout/activity_enter_serial.xml

@@ -5,180 +5,122 @@
     android:id="@+id/top_view"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    android:background="@android:color/black"
     android:fillViewport="true">
 
-    <FrameLayout
+    <LinearLayout
+        android:id="@+id/layout_parent_top"
         android:layout_width="match_parent"
-        android:layout_height="wrap_content">
-
-        <ImageView
-            android:id="@+id/wizard_background"
-            android:layout_width="wrap_content"
-            android:layout_height="fill_parent"
-            android:adjustViewBounds="true"
-            android:clickable="false"
-            android:scaleType="centerCrop"
-            android:src="@drawable/background_pic" />
-
-        <LinearLayout
-            android:id="@+id/layout_parent_top"
-            android:layout_width="fill_parent"
+        android:layout_height="wrap_content"
+        android:gravity="center_horizontal"
+        android:orientation="vertical"
+        android:paddingHorizontal="@dimen/wizard_contents_padding_horizontal"
+        android:paddingVertical="@dimen/wizard_contents_padding">
+
+        <androidx.constraintlayout.widget.ConstraintLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="@dimen/grid_unit_x2">
+
+            <ImageView
+                android:id="@+id/enter_serial_welcome_title"
+                android:layout_width="0dp"
+                android:layout_height="58dp"
+                android:layout_marginHorizontal="@dimen/grid_unit_x5"
+                android:adjustViewBounds="true"
+                android:importantForAccessibility="no"
+                android:src="@drawable/logo_main"
+                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="parent"
+                app:layout_constraintWidth_percent="0.7" />
+
+        </androidx.constraintlayout.widget.ConstraintLayout>
+
+        <TextView
+            android:id="@+id/layout_top"
+            style="@style/WizardTextBodyMedium"
+            android:layout_width="match_parent"
             android:layout_height="wrap_content"
+            android:layout_marginTop="@dimen/grid_unit_x4"
+            android:autoLink="web"
             android:gravity="center_horizontal"
-            android:orientation="vertical"
-            android:paddingHorizontal="@dimen/wizard_contents_padding_horizontal"
-            android:paddingVertical="@dimen/wizard_contents_padding">
+            android:linksClickable="true"
+            android:text="@string/flavored__enter_serial_body" />
 
-            <androidx.constraintlayout.widget.ConstraintLayout
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="@dimen/grid_unit_x2">
-
-                <ImageView
-                    android:id="@+id/enter_serial_welcome_title"
-                    android:layout_width="0dp"
-                    android:layout_height="58dp"
-                    android:layout_marginHorizontal="@dimen/grid_unit_x5"
-                    android:adjustViewBounds="true"
-                    android:importantForAccessibility="no"
-                    android:src="@drawable/logo_main_white"
-                    app:layout_constraintBottom_toBottomOf="parent"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toTopOf="parent"
-                    app:layout_constraintWidth_percent="0.7" />
-
-            </androidx.constraintlayout.widget.ConstraintLayout>
-
-            <TextView
-                android:id="@+id/layout_top"
-                style="@style/WizardMediumText"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="@dimen/grid_unit_x4"
-                android:autoLink="web"
-                android:gravity="center_horizontal"
-                android:linksClickable="true"
-                android:text="@string/flavored__enter_serial_body" />
-
-            <androidx.constraintlayout.widget.ConstraintLayout
-                android:id="@+id/unlock_layout"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="@dimen/grid_unit_x5">
-
-                <ImageView
-                    android:id="@+id/unlock_logo"
-                    style="@style/WizardEditTextIcon"
-                    android:layout_width="0dp"
-                    android:layout_height="0dp"
-                    app:layout_constraintBottom_toBottomOf="@+id/license_key"
-                    app:layout_constraintDimensionRatio="1"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toTopOf="@+id/license_key"
-                    app:srcCompat="@drawable/ic_person_outline" />
-
-                <ch.threema.app.emojis.EmojiEditText
-                    android:id="@+id/license_key"
-                    style="@style/WizardEditText"
-                    android:layout_width="0dp"
-                    android:layout_height="wrap_content"
-                    android:hint="@string/username_hint"
-                    android:imeOptions="actionNext"
-                    android:inputType="textNoSuggestions"
-                    android:minHeight="48dp"
-                    android:nextFocusRight="@+id/password"
-                    android:singleLine="true"
-                    app:layout_constraintBottom_toBottomOf="parent"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toEndOf="@+id/unlock_logo"
-                    app:layout_constraintTop_toTopOf="parent">
-
-                    <requestFocus />
-
-                </ch.threema.app.emojis.EmojiEditText>
-
-            </androidx.constraintlayout.widget.ConstraintLayout>
-
-            <androidx.constraintlayout.widget.ConstraintLayout
-                android:id="@+id/password_layout"
+        <com.google.android.material.textfield.TextInputLayout
+            android:id="@+id/unlock_layout"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="@dimen/grid_unit_x5"
+            app:hintEnabled="false"
+            app:startIconDrawable="@drawable/ic_person_outline"
+            app:startIconTint="?attr/colorOnBackground">
+
+            <ch.threema.app.ui.ThreemaTextInputEditText
+                android:id="@+id/license_key"
                 android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="@dimen/grid_unit_x2">
-
-                <ImageView
-                    android:id="@+id/password_logo"
-                    style="@style/WizardEditTextIcon"
-                    android:layout_width="0dp"
-                    android:layout_height="0dp"
-                    app:layout_constraintBottom_toBottomOf="@id/password_container"
-                    app:layout_constraintDimensionRatio="1"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toTopOf="@+id/password_container"
-                    app:srcCompat="@drawable/ic_key_outline" />
-
-                <com.google.android.material.textfield.TextInputLayout
-                    android:id="@+id/password_container"
-                    android:layout_width="0dp"
-                    android:layout_height="wrap_content"
-                    android:minHeight="48dp"
-                    app:counterEnabled="false"
-                    app:errorEnabled="false"
-                    app:hintAnimationEnabled="false"
-                    app:hintEnabled="false"
-                    app:hintTextAppearance="@style/NoHintTextAppearance"
-                    app:layout_constraintBottom_toTopOf="@+id/unlock_state"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toEndOf="@+id/password_logo"
-                    app:layout_constraintTop_toTopOf="parent"
-                    app:passwordToggleEnabled="true">
-
-                    <ch.threema.app.ui.ThreemaTextInputEditText
-                        android:id="@+id/password"
-                        style="@style/WizardEditText"
-                        android:layout_width="match_parent"
-                        android:layout_height="wrap_content"
-                        android:hint="@string/password_hint"
-                        android:imeOptions="actionDone"
-                        android:inputType="textNoSuggestions|textPassword"
-                        android:minHeight="48dp"
-                        android:singleLine="true" />
-
-                </com.google.android.material.textfield.TextInputLayout>
-
-                <TextView
-                    android:id="@+id/unlock_state"
-                    android:layout_width="0dp"
-                    android:layout_height="wrap_content"
-                    android:layout_marginVertical="@dimen/grid_unit_x1"
-                    android:textColor="@color/material_red"
-                    android:textSize="@dimen/wizard_text_medium"
-                    app:layout_constraintBottom_toBottomOf="parent"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="@+id/password_container"
-                    tools:text="@string/invalid_input" />
-
-            </androidx.constraintlayout.widget.ConstraintLayout>
-
-            <ch.threema.app.activities.wizard.components.WizardButtonXml
-                android:id="@+id/unlock_button_work_compose"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_gravity="end"
-                app:wizardButton_text="@string/next" />
-
-            <TextView
-                android:id="@+id/work_lost_credential_help"
-                style="@style/WizardMediumText"
+                android:layout_height="match_parent"
+                android:hint="@string/username_hint"
+                android:imeOptions="actionNext"
+                android:inputType="textNoSuggestions"
+                android:nextFocusRight="@+id/password"
+                android:singleLine="true">
+
+                <requestFocus />
+
+            </ch.threema.app.ui.ThreemaTextInputEditText>
+
+        </com.google.android.material.textfield.TextInputLayout>
+
+        <com.google.android.material.textfield.TextInputLayout
+            android:id="@+id/password_container"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="@dimen/grid_unit_x2"
+            app:counterEnabled="false"
+            app:errorEnabled="false"
+            app:hintAnimationEnabled="false"
+            app:hintEnabled="false"
+            app:passwordToggleEnabled="true"
+            app:startIconDrawable="@drawable/ic_key_outline">
+
+            <ch.threema.app.ui.ThreemaTextInputEditText
+                android:id="@+id/password"
                 android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="@dimen/grid_unit_x4"
-                android:gravity="center_horizontal"
-                tools:text="@string/work_lost_credentials_help" />
+                android:layout_height="match_parent"
+                android:hint="@string/password_hint"
+                android:imeOptions="actionDone"
+                android:inputType="textNoSuggestions|textPassword"
+                android:singleLine="true" />
+
+        </com.google.android.material.textfield.TextInputLayout>
+
+        <TextView
+            android:id="@+id/unlock_state"
+            style="@style/WizardTextBodyMedium"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginVertical="@dimen/grid_unit_x1"
+            android:textColor="?attr/colorError"
+            tools:text="@string/invalid_input" />
 
-        </LinearLayout>
+        <ch.threema.app.ui.interop.ButtonPrimaryXml
+            android:id="@+id/unlock_button_work_compose"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="end"
+            app:buttonPrimary_text="@string/next" />
+
+        <TextView
+            android:id="@+id/work_lost_credential_help"
+            style="@style/WizardTextBodyMedium"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="@dimen/grid_unit_x4"
+            android:gravity="center_horizontal"
+            tools:text="@string/work_lost_credentials_help" />
 
-    </FrameLayout>
+    </LinearLayout>
 
 </ScrollView>

+ 4 - 7
app/src/blue/res/xml/file_paths.xml

@@ -1,12 +1,9 @@
 <?xml version="1.0" encoding="utf-8"?>
-<paths xmlns:android="http://schemas.android.com/apk/res/android">
-    <files-path
-        name="tmp_files"
-        path="tmp/" />
+<paths>
     <cache-path
-        name="cache_files"
-        path="/" />
+        name="share"
+        path="share/" />
     <external-path
         name="ext_files"
-        path="ThreemaRed/" />
+        path="ThreemaBlue/" />
 </paths>

+ 40 - 0
app/src/debug/java/ch/threema/app/appstartup/ForceDowngradeButton.kt

@@ -0,0 +1,40 @@
+package ch.threema.app.appstartup
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import ch.threema.app.compose.common.buttons.ButtonOutlined
+import ch.threema.app.compose.common.spacer.SpacerVertical
+import ch.threema.app.compose.common.text.ThemedText
+import ch.threema.app.compose.theme.AppTypography
+import ch.threema.app.compose.theme.dimens.GridUnit
+import ch.threema.storage.DatabaseDowngradeHelperImpl
+import ch.threema.storage.DatabaseUpdater
+import org.koin.mp.KoinPlatform
+
+@Composable
+fun ForceDowngradeButton() {
+    Column(
+        horizontalAlignment = Alignment.CenterHorizontally,
+    ) {
+        SpacerVertical(GridUnit.x2)
+        ButtonOutlined(
+            modifier = Modifier.fillMaxWidth(),
+            onClick = {
+                KoinPlatform.getKoin().get<DatabaseDowngradeHelperImpl>().forceEnableDowngrade()
+            },
+            text = "⚠\uFE0F Force-downgrade to version ${DatabaseUpdater.VERSION}",
+            maxLines = 2,
+        )
+
+        SpacerVertical(GridUnit.x0_5)
+        ThemedText(
+            text = "Data-loss may occur, use at your own risk!",
+            style = AppTypography.bodySmall,
+            textAlign = TextAlign.Center,
+        )
+    }
+}

+ 48 - 0
app/src/debug/java/ch/threema/storage/DatabaseDowngradeHelperImpl.kt

@@ -0,0 +1,48 @@
+package ch.threema.storage
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import ch.threema.android.showToast
+import kotlin.system.exitProcess
+import kotlin.time.Duration.Companion.seconds
+
+class DatabaseDowngradeHelperImpl(
+    private val appContext: Context,
+    private val sharedPreferences: SharedPreferences,
+) : DatabaseDowngradeHelper {
+    fun forceEnableDowngrade() {
+        isDatabaseDowngradeEnabled = true
+        appContext.showToast("Force-downgrade enabled. Re-open the app.")
+        exitProcess(0)
+    }
+
+    private var isDatabaseDowngradeEnabled: Boolean
+        get() {
+            val forceDowngradeEnabledAt = sharedPreferences.getLong(FORCE_DOWNGRADE_KEY, 0)
+            return forceDowngradeEnabledAt > System.currentTimeMillis() - DOWNGRADE_TIMEOUT.inWholeMilliseconds
+        }
+        set(value) {
+            sharedPreferences.edit(commit = true) {
+                if (value) {
+                    putLong(FORCE_DOWNGRADE_KEY, System.currentTimeMillis())
+                } else {
+                    remove(FORCE_DOWNGRADE_KEY)
+                }
+            }
+        }
+
+    @Throws(DatabaseDowngradeException::class)
+    override fun onDowngrade(oldVersion: Int) {
+        if (isDatabaseDowngradeEnabled) {
+            isDatabaseDowngradeEnabled = false
+        } else {
+            throw DatabaseDowngradeException(oldVersion)
+        }
+    }
+
+    companion object {
+        private val DOWNGRADE_TIMEOUT = 30.seconds
+        private const val FORCE_DOWNGRADE_KEY = "_dev_force_db_downgrade"
+    }
+}

+ 3 - 3
app/src/foss_based/assets/license.html

@@ -51,7 +51,7 @@
 <body>
 
 <p class="maincopyright">
-    Copyright © Threema GmbH.<br/>
+    Copyright © Threema AG.<br/>
     All rights reserved.
 </p>
 
@@ -262,12 +262,12 @@
 
 
 <h2>saltyrtc-client-java</h2>
-<p>Copyright © Threema GmbH</p>
+<p>Copyright © Threema AG</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>saltyrtc-task-webrtc-java</h2>
-<p>Copyright © Threema GmbH</p>
+<p>Copyright © Threema AG</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 

+ 6 - 7
app/src/google_services_based/java/ch/threema/app/push/PushService.java

@@ -15,18 +15,17 @@ import com.google.firebase.messaging.RemoteMessage;
 
 import org.slf4j.Logger;
 
-import java.util.Date;
+import java.time.Instant;
 import java.util.Map;
 import java.util.concurrent.ExecutionException;
 
 import ch.threema.app.utils.PushUtil;
 import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
-import ch.threema.domain.protocol.csp.ProtocolDefines;
+import static ch.threema.base.TestUtilKt.isInDeviceTest;
 
-import static ch.threema.common.TimeExtensionsKt.now;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class PushService extends FirebaseMessagingService {
     private static final Logger logger = getThreemaLogger("PushService");
@@ -75,8 +74,8 @@ public class PushService extends FirebaseMessagingService {
 
         // Log message sent time
         try {
-            Date sentDate = new Date(remoteMessage.getSentTime());
-            Date receivedDate = now();
+            Instant sentDate = Instant.ofEpochMilli(remoteMessage.getSentTime());
+            Instant receivedDate = Instant.now();
 
             logger.info("*** Message sent     : {}", sentDate);
             logger.info("*** Message received : {}", receivedDate);
@@ -105,7 +104,7 @@ public class PushService extends FirebaseMessagingService {
     public static boolean playServicesInstalled(Context context) {
         GoogleApiAvailability apiAvailability = com.google.android.gms.common.GoogleApiAvailability.getInstance();
         int resultCode = apiAvailability.isGooglePlayServicesAvailable(context);
-        return TestUtil.isInDeviceTest() || (resultCode == ConnectionResult.SUCCESS);
+        return isInDeviceTest() || (resultCode == ConnectionResult.SUCCESS);
     }
 
     /**

+ 5 - 5
app/src/google_services_based/java/ch/threema/app/services/VoiceActionService.java

@@ -20,9 +20,9 @@ import androidx.annotation.RequiresApi;
 import androidx.core.app.NotificationCompat;
 import androidx.core.app.ServiceCompat;
 import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.MessageReceiver;
+import ch.threema.app.notifications.NotificationIDs;
 import ch.threema.app.services.notification.NotificationService;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.utils.RuntimeUtil;
@@ -42,7 +42,6 @@ public class VoiceActionService extends SearchActionVerificationClientService {
     private LockAppService lockAppService;
 
     private static final String CHANNEL_ID_GOOGLE_ASSISTANT = "Voice_Actions";
-    private static final int NOTIFICATION_ID = 10000;
 
     private static final int FG_SERVICE_TYPE =
         Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
@@ -77,9 +76,10 @@ public class VoiceActionService extends SearchActionVerificationClientService {
                 .setLocalOnly(true);
         ServiceCompat.startForeground(
             this,
-            NOTIFICATION_ID,
+            NotificationIDs.VOICE_ACTION_NOTIFICATION_ID,
             notificationBuilder.build(),
-            FG_SERVICE_TYPE);
+            FG_SERVICE_TYPE
+        );
     }
 
     @RequiresApi(Build.VERSION_CODES.O)
@@ -166,7 +166,7 @@ public class VoiceActionService extends SearchActionVerificationClientService {
     }
 
     protected void instantiate() {
-        ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+        ServiceManager serviceManager = ServiceManager.get();
         if (serviceManager != null) {
             try {
                 this.messageService = serviceManager.getMessageService();

+ 6 - 5
app/src/hms_services_based/java/ch/threema/app/push/PushService.java

@@ -12,7 +12,7 @@ import com.huawei.hms.push.RemoteMessage;
 
 import org.slf4j.Logger;
 
-import java.util.Date;
+import java.time.Instant;
 import java.util.Map;
 import java.util.Objects;
 
@@ -20,8 +20,9 @@ import androidx.annotation.Nullable;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.utils.PushUtil;
 import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
+
+import static ch.threema.base.TestUtilKt.isInDeviceTest;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 
@@ -65,9 +66,9 @@ public class PushService extends HmsMessageService {
         logger.info("Received HMS message: {}", remoteMessage.getMessageId());
         // Log message sent time
         try {
-            Date sentDate = new Date(remoteMessage.getSentTime());
+            Instant sentDate = Instant.ofEpochMilli(remoteMessage.getSentTime());
             logger.info("*** Message sent     :  {}", sentDate);
-            logger.info("*** Message received : {}", new Date());
+            logger.info("*** Message received : {}", Instant.now());
             logger.info("*** Original priority: {}", remoteMessage.getOriginalUrgency());
             logger.info("*** Current priority: {}", remoteMessage.getUrgency());
         } catch (Exception ignore) {
@@ -83,7 +84,7 @@ public class PushService extends HmsMessageService {
      * check for specific huawei services
      */
     public static boolean hmsServicesInstalled(Context context) {
-        return TestUtil.isInDeviceTest() || (HuaweiMobileServicesUtil.isHuaweiMobileServicesAvailable(context) == ConnectionResult.SUCCESS);
+        return isInDeviceTest() || (HuaweiMobileServicesUtil.isHuaweiMobileServicesAvailable(context) == ConnectionResult.SUCCESS);
     }
 
     /**

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

@@ -1 +1,5 @@
+- Anhebung des Android-SDK-Levels auf 36 (Android 16)
+- Behebung eines Fehlers im Zusammenhang mit reproduzierbaren Builds
+- Behebung eines Fehlers im Zusammenhang mit der Vertrauensstufe von bekannten Threema-Kontakten
+- Verschiedene UX-Verbesserungen
 - Verbesserungen und Behebung verschiedener Fehler

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

@@ -1 +1,5 @@
-- Various under-the-hood improvements and bug fixes
+- Increased the Android SDK level to 36 (Android 16)
+- Fixed a bug in relation to reproducible builds
+- Fixed a bug in relation to the verification level of known Threema contacts
+- Various UX improvements
+- Various improvements and bug fixes

+ 38 - 44
app/src/main/AndroidManifest.xml

@@ -180,7 +180,6 @@
         android:allowBackup="false"
         android:appCategory="social"
         android:dataExtractionRules="@xml/data_extraction_rules"
-        android:enableOnBackInvokedCallback="true"
         android:hardwareAccelerated="true"
         android:hasFragileUserData="true"
         android:icon="@mipmap/ic_launcher"
@@ -231,7 +230,6 @@
         <activity
             android:name=".activities.ComposeMessageActivity"
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
-            android:enableOnBackInvokedCallback="false"
             android:exported="true"
             android:launchMode="singleTop"
             android:parentActivityName=".home.HomeActivity"
@@ -245,6 +243,13 @@
                 <data android:mimeType="@string/contacts_mime_type" />
             </intent-filter>
         </activity>
+        <activity
+            android:name=".conversation.ConversationActivity"
+            android:exported="true"
+            android:launchMode="singleTop"
+            android:parentActivityName=".home.HomeActivity"
+            android:theme="@style/Theme.Threema.WithToolbar"
+            android:windowSoftInputMode="adjustNothing" />
         <activity
             android:name=".activities.RecipientListActivity"
             android:configChanges="uiMode"
@@ -387,17 +392,13 @@
         <activity
             android:name=".activities.WorkIntroActivity"
             android:exported="false"
-            android:screenOrientation="sensorPortrait"
-            android:theme="@style/Theme.Threema.Wizard">
-
-        </activity>
+            android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
             android:name=".activities.EnterSerialActivity"
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
             android:exported="true"
             android:launchMode="singleTop"
-            android:screenOrientation="sensorPortrait"
-            android:theme="@style/Theme.Threema.Wizard"
+            android:theme="@style/Theme.Threema.WithToolbar"
             android:windowSoftInputMode="adjustPan">
             <intent-filter
                 android:autoVerify="true"
@@ -449,7 +450,7 @@
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
-            android:name=".activities.ServerMessageActivity"
+            android:name=".servermessage.ServerMessageActivity"
             android:label="server_message"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
@@ -460,6 +461,15 @@
             android:name=".activities.BackupAdminActivity"
             android:configChanges="uiMode"
             android:theme="@style/Theme.Threema.WithToolbar" />
+        <activity
+            android:name=".backupcenter.BackupCenterActivity"
+            android:theme="@style/Theme.Threema.WithToolbar" />
+        <activity
+            android:name=".backupcenter.LocalDataBackupActivity"
+            android:theme="@style/Theme.Threema.WithToolbar" />
+        <activity
+            android:name=".backupcenter.CreateLocalDataBackupActivity"
+            android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
             android:name=".pinlock.PinLockActivity"
             android:noHistory="true"
@@ -519,15 +529,15 @@
             android:stateNotNeeded="true"
             android:theme="@android:style/Theme.NoDisplay" />
         <activity
-            android:name=".activities.ballot.BallotOverviewActivity"
+            android:name=".activities.poll.PollOverviewActivity"
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
-            android:name=".activities.ballot.BallotMatrixActivity"
+            android:name=".activities.poll.PollMatrixActivity"
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
-            android:name=".activities.ballot.BallotChooserActivity"
+            android:name=".activities.poll.PollChooserActivity"
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
@@ -535,7 +545,7 @@
             android:configChanges="uiMode"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
-            android:name=".activities.ballot.BallotWizardActivity"
+            android:name=".activities.poll.PollWizardActivity"
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
             android:theme="@style/Theme.Threema.WithToolbar"
             android:windowSoftInputMode="adjustResize" />
@@ -566,7 +576,6 @@
             android:name=".webclient.activities.SessionsActivity"
             android:configChanges="uiMode"
             android:launchMode="singleTop"
-            android:parentActivityName=".home.HomeActivity"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
             android:name=".webclient.activities.SessionsIntroActivity"
@@ -576,16 +585,13 @@
             android:name=".webclient.activities.WebDiagnosticsActivity"
             android:configChanges="uiMode"
             android:parentActivityName=".home.HomeActivity"
-            android:screenOrientation="sensorPortrait"
-            android:theme="@style/Theme.Threema.WithToolbar"
-            tools:ignore="LockedOrientationActivity" />
+            android:theme="@style/Theme.Threema.WithToolbar" />
 
         <!-- MD activities -->
         <activity
             android:name=".multidevice.LinkedDevicesActivity"
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
             android:launchMode="singleTop"
-            android:parentActivityName=".home.HomeActivity"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
             android:name=".multidevice.wizard.LinkNewDeviceWizardActivity"
@@ -618,9 +624,7 @@
             android:theme="@style/Theme.Threema.TransparentStatusbar_Fullscreen" />
         <activity
             android:name=".voip.activities.CallActionIntentActivity"
-            android:exported="true"
-            android:screenOrientation="sensorPortrait"
-            tools:ignore="LockedOrientationActivity">
+            android:exported="true">
             <intent-filter tools:ignore="AppLinkUrlError">
                 <!-- Handle calls from phonebook -->
                 <action android:name="android.intent.action.VIEW" />
@@ -631,9 +635,10 @@
         <activity
             android:name=".voip.activities.WebRTCDebugActivity"
             android:parentActivityName=".home.HomeActivity"
-            android:screenOrientation="sensorPortrait"
-            android:theme="@style/Theme.Threema.WithToolbar"
-            tools:ignore="LockedOrientationActivity" />
+            android:theme="@style/Theme.Threema.WithToolbar" />
+        <activity
+            android:name=".troubleshooting.contacts.ContactsDiagnosticsActivity"
+            android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
             android:name=".activities.WhatsNewActivity"
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
@@ -643,35 +648,29 @@
         <!-- Wizard activities -->
         <activity
             android:name=".activities.wizard.WizardIntroActivity"
-            android:screenOrientation="sensorPortrait"
-            android:theme="@style/Theme.Threema.Wizard" />
+            android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
             android:name=".activities.wizard.WizardStartActivity"
-            android:screenOrientation="sensorPortrait"
-            android:theme="@style/Theme.Threema.Wizard" />
+            android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
             android:name=".activities.wizard.WizardBaseActivity"
-            android:screenOrientation="sensorPortrait"
-            android:theme="@style/Theme.Threema.Wizard"
+            android:theme="@style/Theme.Threema.WithToolbar"
             android:windowSoftInputMode="adjustResize" />
         <activity
             android:name=".activities.wizard.WizardFingerPrintActivity"
-            android:screenOrientation="sensorPortrait"
-            android:theme="@style/Theme.Threema.Wizard" />
+            android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
             android:name=".activities.wizard.WizardIDRestoreActivity"
-            android:screenOrientation="sensorPortrait"
-            android:theme="@style/Theme.Threema.Wizard" />
+            android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
             android:name=".activities.wizard.WizardSafeRestoreActivity"
-            android:screenOrientation="sensorPortrait"
-            android:theme="@style/Theme.Threema.Wizard"
+            android:theme="@style/Theme.Threema.WithToolbar"
             android:windowSoftInputMode="stateAlwaysHidden" />
         <activity
             android:name=".activities.wizard.WizardBackupRestoreActivity"
-            android:screenOrientation="sensorPortrait"
-            android:theme="@style/Theme.Threema.Wizard"
+            android:theme="@style/Theme.Threema.WithToolbar"
             android:windowSoftInputMode="stateAlwaysHidden" />
+
         <activity
             android:name=".identitylinks.SMSVerificationLinkActivity"
             android:exported="true"
@@ -781,7 +780,6 @@
             android:name=".camera.CameraActivity"
             android:autoRemoveFromRecents="true"
             android:clearTaskOnLaunch="true"
-            android:screenOrientation="fullSensor"
             android:theme="@style/Theme.Threema.Camera">
             <!-- Declare Huawei notch support -->
             <meta-data
@@ -903,8 +901,7 @@
                     android:path="/debug"
                     android:scheme="https" />
             </intent-filter>
-            <intent-filter
-                android:label="@string/app_name">
+            <intent-filter android:label="@string/app_name">
                 <action android:name="android.intent.action.VIEW" />
 
                 <category android:name="android.intent.category.DEFAULT" />
@@ -1097,9 +1094,6 @@
                 <action android:name="android.os.action.POWER_SAVE_MODE_CHANGED" />
             </intent-filter>
         </receiver>
-        <receiver
-            android:name=".receivers.SendTextToContactBroadcastReceiver"
-            android:exported="false" />
 
         <!-- content providers -->
         <provider

+ 2 - 9
app/src/main/java/ch/threema/app/AppConstants.kt

@@ -1,7 +1,5 @@
 package ch.threema.app
 
-import ch.threema.domain.types.IdentityString
-
 object AppConstants {
 
     const val INTENT_DATA_CONTACT = "identity"
@@ -10,11 +8,10 @@ object AppConstants {
     const val INTENT_DATA_ID_BACKUP = "idbackup"
     const val INTENT_DATA_ID_BACKUP_PW = "idbackuppw"
     const val INTENT_DATA_IS_FORWARD = "is_forward"
-    const val INTENT_DATA_TIMESTAMP = "timestamp"
     const val INTENT_DATA_EDITFOCUS = "editfocus"
     const val INTENT_DATA_GROUP_DATABASE_ID = "group"
+    const val INTENT_DATA_CONVERSATION_ID = "conversation_id"
     const val INTENT_DATA_DISTRIBUTION_LIST_ID = "distribution_list"
-    const val INTENT_DATA_ARCHIVE_FILTER = "archiveFilter"
     const val INTENT_DATA_MESSAGE_ID = "messageid"
     const val EXTRA_VOICE_REPLY = "voicereply"
     const val EXTRA_OUTPUT_FILE = "output"
@@ -24,7 +21,7 @@ object AppConstants {
     const val INTENT_DATA_HIDE_RECENTS = "hiderec"
     const val INTENT_ACTION_FORWARD = "ch.threema.app.intent.FORWARD"
 
-    const val CONFIRM_TAG_CLOSE_BALLOT = "cb"
+    const val CONFIRM_TAG_CLOSE_POLL = "cp"
     const val ECHO_USER_IDENTITY = "ECHOECHO"
     const val PHONE_LINKED_PLACEHOLDER = "***"
     const val EMAIL_LINKED_PLACEHOLDER = "***@***"
@@ -38,8 +35,4 @@ object AppConstants {
     const val MAX_PW_LENGTH_BACKUP = 256
 
     const val ACTIVITY_CONNECTION_LIFETIME = 60_000L
-
-    const val THREEMA_SUPPORT_IDENTITY: IdentityString = "*SUPPORT"
-    const val THREEMA_CHANNEL_IDENTITY: IdentityString = "*THREEMA"
-    const val THREEMA_WORK_SYNC_IDENTITY: IdentityString = "*3MAW0RK"
 }

+ 7 - 6
app/src/main/java/ch/threema/app/GlobalAppState.kt

@@ -1,14 +1,15 @@
 package ch.threema.app
 
-import java.util.Date
+import ch.threema.app.backuprestore.csv.RestoreService
+import java.time.Instant
 
-object GlobalAppState {
-    @JvmStatic
-    var lastLoggedIn: Date? = null
+class GlobalAppState {
+    var lastLoggedIn: Instant? = null
 
-    @JvmStatic
     var isDeviceIdle: Boolean = false
 
-    @JvmStatic
     var isAppResumed: Boolean = false
+
+    val isRestoreRunning: Boolean
+        get() = RestoreService.isRunning()
 }

+ 10 - 6
app/src/main/java/ch/threema/app/GlobalBroadcastReceivers.kt

@@ -10,6 +10,7 @@ import android.os.Build
 import android.os.PowerManager
 import androidx.core.content.getSystemService
 import ch.threema.app.backuprestore.csv.BackupService
+import ch.threema.app.di.injectNullableNonBinding
 import ch.threema.app.receivers.ConnectivityChangeReceiver
 import ch.threema.app.restrictions.AppRestrictionService
 import ch.threema.app.services.LifetimeService
@@ -18,14 +19,17 @@ import ch.threema.base.utils.getThreemaLogger
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
 
 private val logger = getThreemaLogger("GlobalBroadcastReceivers")
 
-object GlobalBroadcastReceivers {
+object GlobalBroadcastReceivers : KoinComponent {
 
+    private val globalAppState: GlobalAppState by inject()
+    private val appRestrictionService: AppRestrictionService by inject()
     private val coroutineScope = CoroutineScope(Dispatchers.IO)
-    private val lifetimeService: LifetimeService?
-        get() = ThreemaApplication.getServiceManager()?.lifetimeService
+    private val lifetimeService: LifetimeService? by injectNullableNonBinding()
 
     @JvmStatic
     fun registerBroadcastReceivers(context: Context) {
@@ -53,7 +57,7 @@ object GlobalBroadcastReceivers {
                     if (powerManager.isDeviceIdleMode) {
                         logger.info("*** Device going to deep sleep")
 
-                        GlobalAppState.isDeviceIdle = true
+                        globalAppState.isDeviceIdle = true
 
                         coroutineScope.launch {
                             try {
@@ -76,7 +80,7 @@ object GlobalBroadcastReceivers {
                                 logger.error("Exception while unpausing connection", e)
                             }
                         }
-                        GlobalAppState.isDeviceIdle = false
+                        globalAppState.isDeviceIdle = false
                     }
                 }
             },
@@ -116,7 +120,7 @@ object GlobalBroadcastReceivers {
                 object : BroadcastReceiver() {
                     override fun onReceive(context: Context, intent: Intent) {
                         logger.info("Restrictions have changed. Updating restrictions")
-                        AppRestrictionService.getInstance().reload()
+                        appRestrictionService.reload()
                     }
                 },
                 IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED),

+ 13 - 796
app/src/main/java/ch/threema/app/GlobalListeners.java

@@ -3,13 +3,13 @@ package ch.threema.app;
 import android.app.ForegroundServiceStartNotAllowedException;
 import android.content.Context;
 import android.content.Intent;
+import android.os.Build;
 import android.os.Handler;
 import android.os.Looper;
 
+import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
-import java.util.Date;
-import java.util.List;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.locks.Lock;
@@ -20,80 +20,34 @@ import androidx.annotation.Nullable;
 import androidx.core.content.ContextCompat;
 import ch.threema.android.ToastDuration;
 import ch.threema.app.androidcontactsync.AndroidContactChangeMonitor;
-import ch.threema.app.listeners.BallotVoteListener;
-import ch.threema.app.listeners.ContactListener;
-import ch.threema.app.listeners.ContactTypingListener;
-import ch.threema.app.listeners.ConversationListener;
-import ch.threema.app.listeners.DistributionListListener;
-import ch.threema.app.listeners.EditMessageListener;
-import ch.threema.app.listeners.GroupListener;
-import ch.threema.app.listeners.MessageDeletedForAllListener;
-import ch.threema.app.listeners.MessageListener;
 import ch.threema.app.listeners.NewSyncedContactsListener;
-import ch.threema.app.listeners.ServerMessageListener;
 import ch.threema.app.listeners.SynchronizeContactsListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.messagereceiver.ContactMessageReceiver;
-import ch.threema.app.messagereceiver.GroupMessageReceiver;
-import ch.threema.app.messagereceiver.MessageReceiver;
-import ch.threema.app.preference.service.PreferenceService;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
-import ch.threema.app.services.ContactService;
-import ch.threema.app.services.ConversationCategoryService;
-import ch.threema.app.services.ConversationService;
-import ch.threema.app.services.GroupService;
-import ch.threema.app.services.MessageService;
-import ch.threema.app.services.UserService;
-import ch.threema.app.services.ballot.BallotService;
 import ch.threema.app.services.notification.NotificationService;
-import ch.threema.app.utils.BallotUtil;
-import ch.threema.app.utils.ConversationNotificationUtil;
-import ch.threema.app.utils.ShortcutUtil;
-import ch.threema.app.utils.TestUtil;
-import ch.threema.app.widget.WidgetUpdater;
-import ch.threema.app.voip.listeners.VoipCallEventListener;
-import ch.threema.app.voip.managers.VoipListenerManager;
 import ch.threema.app.webclient.listeners.WebClientServiceListener;
-import ch.threema.app.webclient.listeners.WebClientWakeUpListener;
 import ch.threema.app.webclient.manager.WebClientListenerManager;
 import ch.threema.app.webclient.services.SessionAndroidService;
-import ch.threema.app.webclient.services.SessionWakeUpServiceImpl;
+import ch.threema.app.webclient.services.SessionWakeUpService;
 import ch.threema.app.webclient.services.instance.DisconnectContext;
 import ch.threema.app.webclient.state.WebClientSessionState;
-import ch.threema.base.ThreemaException;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
-import ch.threema.data.models.ContactModel;
-import ch.threema.data.models.GroupIdentity;
-import ch.threema.data.repositories.ContactModelRepository;
-import ch.threema.domain.protocol.csp.messages.file.FileData;
-import ch.threema.domain.stores.IdentityStore;
-import ch.threema.domain.taskmanager.TriggerSource;
-import ch.threema.localcrypto.exceptions.MasterKeyLockedException;
-import ch.threema.storage.models.AbstractMessageModel;
-import ch.threema.storage.models.ConversationModel;
-import ch.threema.storage.models.DistributionListModel;
-import ch.threema.storage.models.data.MessageContentsType;
-import ch.threema.storage.models.group.GroupModelOld;
-import ch.threema.storage.models.MessageType;
-import ch.threema.storage.models.ServerMessageModel;
 import ch.threema.storage.models.WebClientSessionModel;
-import ch.threema.storage.models.ballot.BallotModel;
-import ch.threema.storage.models.ballot.GroupBallotModel;
-import ch.threema.storage.models.ballot.IdentityBallotModel;
-import ch.threema.storage.models.ballot.LinkBallotModel;
-import ch.threema.storage.models.data.status.GroupStatusDataModel;
-import ch.threema.storage.models.data.status.VoipStatusDataModel;
+import kotlinx.coroutines.sync.Mutex;
+import kotlinx.coroutines.sync.MutexKt;
 
 import static ch.threema.android.ToastKt.showToast;
 
-// TODO(ANDR-3400) This code was moved out from ThreemaApplication and needs some heavy refactoring
+
+// TODO(ANDR-4687) This code was originally moved out from ThreemaApplication and needs some heavy refactoring.
+//  Most of it would be better suited inside a monitor class. For this, all listeners in use need to be migrated to flows.
 public class GlobalListeners {
 
     private static final Logger logger = getThreemaLogger("GlobalListeners");
 
-    public static final Lock onAndroidContactChangeLock = new ReentrantLock();
+    public static final Mutex onAndroidContactChangeMutex = MutexKt.Mutex(false);
 
     private final Handler handler = new Handler(Looper.getMainLooper());
 
@@ -110,8 +64,6 @@ public class GlobalListeners {
         this.appContext = appContext;
         this.androidContactChangeMonitor = androidContactChangeMonitor;
         this.serviceManager = serviceManager;
-
-        webClientWakeUpListener = () -> showToast(appContext, R.string.webclient_protocol_version_to_old, ToastDuration.LONG);
     }
 
     @NonNull
@@ -120,40 +72,16 @@ public class GlobalListeners {
     private final ServiceManager serviceManager;
 
     public void setUp() {
-        ListenerManager.groupListeners.add(groupListener);
-        ListenerManager.distributionListListeners.add(distributionListListener);
-        ListenerManager.messageListeners.add(messageListener);
-        ListenerManager.editMessageListener.add(editMessageListener);
-        ListenerManager.messageDeletedForAllListener.add(messageDeletedForAllListener);
-        ListenerManager.serverMessageListeners.add(serverMessageListener);
-        ListenerManager.contactListeners.add(contactListener);
-        ListenerManager.conversationListeners.add(conversationListener);
-        ListenerManager.ballotVoteListeners.add(ballotVoteListener);
         ListenerManager.synchronizeContactsListeners.add(synchronizeContactsListener);
-        ListenerManager.contactTypingListeners.add(getContactTypingListener());
         ListenerManager.newSyncedContactListener.add(getNewSyncedContactListener());
         WebClientListenerManager.serviceListener.add(webClientServiceListener);
-        WebClientListenerManager.wakeUpListener.add(webClientWakeUpListener);
-        VoipListenerManager.callEventListener.add(voipCallEventListener);
         registerContactNameChangeListener();
     }
 
     public void tearDown() {
-        ListenerManager.groupListeners.remove(groupListener);
-        ListenerManager.distributionListListeners.remove(distributionListListener);
-        ListenerManager.messageListeners.remove(messageListener);
-        ListenerManager.editMessageListener.remove(editMessageListener);
-        ListenerManager.messageDeletedForAllListener.remove(messageDeletedForAllListener);
-        ListenerManager.serverMessageListeners.remove(serverMessageListener);
-        ListenerManager.contactListeners.remove(contactListener);
-        ListenerManager.conversationListeners.remove(conversationListener);
-        ListenerManager.ballotVoteListeners.remove(ballotVoteListener);
         ListenerManager.synchronizeContactsListeners.remove(synchronizeContactsListener);
-        ListenerManager.contactTypingListeners.remove(getContactTypingListener());
         ListenerManager.newSyncedContactListener.remove(getNewSyncedContactListener());
         WebClientListenerManager.serviceListener.remove(webClientServiceListener);
-        WebClientListenerManager.wakeUpListener.remove(webClientWakeUpListener);
-        VoipListenerManager.callEventListener.remove(voipCallEventListener);
         unregisterContactNameChangeListener();
         handler.removeCallbacksAndMessages(null);
         workerExecutor.shutdownNow();
@@ -167,609 +95,6 @@ public class GlobalListeners {
         androidContactChangeMonitor.stop();
     }
 
-    private void showNotesGroupNotice(GroupModelOld groupModel, @GroupService.GroupState int oldState, @GroupService.GroupState int newState) {
-        if (oldState != newState) {
-            try {
-                GroupService groupService = serviceManager.getGroupService();
-                MessageService messageService = serviceManager.getMessageService();
-                GroupStatusDataModel.GroupStatusType type = null;
-
-                if (newState == GroupService.NOTES) {
-                    type = GroupStatusDataModel.GroupStatusType.IS_NOTES_GROUP;
-                } else if (newState == GroupService.PEOPLE && oldState != GroupService.UNDEFINED) {
-                    type = GroupStatusDataModel.GroupStatusType.IS_PEOPLE_GROUP;
-                }
-
-                if (type != null) {
-                    messageService.createGroupStatus(
-                        groupService.createReceiver(groupModel),
-                        type,
-                        null,
-                        null,
-                        null
-                    );
-                }
-            } catch (ThreemaException e) {
-                logger.error("Exception", e);
-            }
-        }
-    }
-
-    private void showConversationNotification(@NonNull AbstractMessageModel newMessage, boolean updateExisting) {
-        try {
-            if (!newMessage.isOutbox() && !newMessage.isStatusMessage() && !newMessage.isRead()) {
-                NotificationService notificationService = serviceManager.getNotificationService();
-                ContactService contactService = serviceManager.getContactService();
-                GroupService groupService = serviceManager.getGroupService();
-                ConversationCategoryService conversationCategoryService = serviceManager.getConversationCategoryService();
-                PreferenceService preferenceService = serviceManager.getPreferenceService();
-
-                if (newMessage.getType() != MessageType.GROUP_CALL_STATUS) {
-                    notificationService.showConversationNotification(
-                        ConversationNotificationUtil.convert(
-                            appContext,
-                            newMessage,
-                            contactService,
-                            groupService,
-                            conversationCategoryService,
-                            preferenceService.getContactNameFormat()
-                        ),
-                        updateExisting
-                    );
-                }
-
-                // update widget on incoming message
-                WidgetUpdater.update();
-            }
-        } catch (ThreemaException e) {
-            logger.error("Exception", e);
-        }
-    }
-
-    @NonNull
-    private final GroupListener groupListener = new GroupListener() {
-        @Override
-        public void onCreate(@NonNull GroupIdentity groupIdentity) {
-            try {
-                GroupModelOld groupModel = getGroupModel(groupIdentity);
-                if (groupModel == null) {
-                    return;
-                }
-                serviceManager.getConversationService().refresh(groupModel);
-                serviceManager.getMessageService().createGroupStatus(
-                    serviceManager.getGroupService().createReceiver(groupModel),
-                    GroupStatusDataModel.GroupStatusType.CREATED,
-                    null,
-                    null,
-                    null
-                );
-            } catch (ThreemaException e) {
-                logger.error("Exception", e);
-            }
-        }
-
-        @Override
-        public void onRename(@NonNull GroupIdentity groupIdentity) {
-            workerExecutor.execute(() -> {
-                try {
-                    GroupModelOld groupModel = getGroupModel(groupIdentity);
-                    if (groupModel == null) {
-                        return;
-                    }
-                    GroupMessageReceiver messageReceiver = serviceManager.getGroupService().createReceiver(groupModel);
-                    serviceManager.getConversationService().refresh(groupModel);
-                    String groupName = groupModel.getName();
-                    if (groupName == null) {
-                        groupName = "";
-                    }
-                    serviceManager.getMessageService().createGroupStatus(
-                        messageReceiver,
-                        GroupStatusDataModel.GroupStatusType.RENAMED,
-                        null,
-                        null,
-                        groupName
-                    );
-                    ShortcutUtil.updatePinnedShortcut(
-                        messageReceiver,
-                        serviceManager.getPreferenceService().getContactNameFormat()
-                    );
-                } catch (ThreemaException e) {
-                    logger.error("Exception", e);
-                }
-            });
-        }
-
-        @Override
-        public void onUpdatePhoto(@NonNull GroupIdentity groupIdentity) {
-            workerExecutor.execute(() -> {
-                try {
-                    GroupModelOld groupModel = getGroupModel(groupIdentity);
-                    if (groupModel == null) {
-                        return;
-                    }
-                    GroupMessageReceiver messageReceiver = serviceManager.getGroupService().createReceiver(groupModel);
-                    serviceManager.getConversationService().refresh(groupModel);
-                    serviceManager.getMessageService().createGroupStatus(
-                        messageReceiver,
-                        GroupStatusDataModel.GroupStatusType.PROFILE_PICTURE_UPDATED,
-                        null,
-                        null,
-                        null
-                    );
-                    ShortcutUtil.updatePinnedShortcut(
-                        messageReceiver,
-                        serviceManager.getPreferenceService().getContactNameFormat()
-                    );
-                } catch (ThreemaException e) {
-                    logger.error("Exception", e);
-                }
-            });
-        }
-
-        @Override
-        public void onNewMember(@NonNull GroupIdentity groupIdentity, String identityNew) {
-            GroupModelOld groupModel = getGroupModel(groupIdentity);
-            if (groupModel == null) {
-                return;
-            }
-            try {
-                final GroupMessageReceiver receiver = serviceManager.getGroupService()
-                    .createReceiver(groupModel);
-                final String myIdentity = serviceManager.getUserService().getIdentity();
-
-                if (!TestUtil.isEmptyOrNull(myIdentity)) {
-                    serviceManager.getMessageService().createGroupStatus(
-                        receiver,
-                        GroupStatusDataModel.GroupStatusType.MEMBER_ADDED,
-                        identityNew,
-                        null,
-                        null
-                    );
-                }
-            } catch (ThreemaException x) {
-                logger.error("Could not create group state after new member was added", x);
-            }
-
-            //reset avatar to recreate it!
-            serviceManager.getAvatarCacheService().reset(groupIdentity);
-        }
-
-        @Override
-        public void onMemberLeave(@NonNull GroupIdentity groupIdentity, @NonNull String identityLeft) {
-            GroupModelOld groupModel = getGroupModel(groupIdentity);
-            if (groupModel == null) {
-                return;
-            }
-            try {
-                final GroupMessageReceiver receiver = serviceManager.getGroupService()
-                    .createReceiver(groupModel);
-
-                serviceManager.getMessageService().createGroupStatus(
-                    receiver,
-                    GroupStatusDataModel.GroupStatusType.MEMBER_LEFT,
-                    identityLeft,
-                    null,
-                    null
-                );
-
-                BallotService ballotService = serviceManager.getBallotService();
-                ballotService.removeVotes(receiver, identityLeft);
-            } catch (ThreemaException e) {
-                logger.error("Exception", e);
-            }
-        }
-
-        @Override
-        public void onMemberKicked(@NonNull GroupIdentity groupIdentity, String identityKicked) {
-            final String myIdentity = serviceManager.getUserService().getIdentity();
-
-            GroupModelOld groupModel = getGroupModel(groupIdentity);
-            if (groupModel == null) {
-                return;
-            }
-
-            if (myIdentity != null && myIdentity.equals(identityKicked)) {
-                // my own member status has changed
-                try {
-                    serviceManager.getNotificationService().cancelGroupCallNotification(groupModel.getId());
-                    serviceManager.getConversationService().refresh(groupModel);
-                } catch (Exception e) {
-                    logger.error("Exception", e);
-                }
-            }
-            try {
-                final GroupMessageReceiver receiver = serviceManager.getGroupService().createReceiver(groupModel);
-
-                serviceManager.getMessageService().createGroupStatus(
-                    receiver,
-                    GroupStatusDataModel.GroupStatusType.MEMBER_KICKED,
-                    identityKicked,
-                    null,
-                    null
-                );
-
-                BallotService ballotService = serviceManager.getBallotService();
-                ballotService.removeVotes(receiver, identityKicked);
-            } catch (ThreemaException e) {
-                logger.error("Exception", e);
-            }
-        }
-
-        @Override
-        public void onUpdate(@NonNull GroupIdentity groupIdentity) {
-            try {
-                GroupModelOld groupModel = getGroupModel(groupIdentity);
-                if (groupModel == null) {
-                    return;
-                }
-                serviceManager.getConversationService().refresh(groupModel);
-            } catch (ThreemaException e) {
-                logger.error("Exception", e);
-            }
-        }
-
-        @Override
-        public void onLeave(@NonNull GroupIdentity groupIdentity) {
-            workerExecutor.execute(() -> {
-                try {
-                    GroupModelOld groupModel = getGroupModel(groupIdentity);
-                    if (groupModel == null) {
-                        return;
-                    }
-                    serviceManager.getConversationService().refresh(groupModel);
-                } catch (ThreemaException e) {
-                    logger.error("Exception", e);
-                }
-            });
-        }
-
-        @Override
-        public void onGroupStateChanged(@NonNull GroupIdentity groupIdentity, @GroupService.GroupState int oldState, @GroupService.GroupState int newState) {
-            logger.debug("onGroupStateChanged: {} -> {}", oldState, newState);
-            GroupModelOld groupModel = getGroupModel(groupIdentity);
-            if (groupModel == null) {
-                return;
-            }
-
-            showNotesGroupNotice(groupModel, oldState, newState);
-        }
-
-        @Nullable
-        private GroupModelOld getGroupModel(@NonNull GroupIdentity groupIdentity) {
-            try {
-                GroupService groupService = serviceManager.getGroupService();
-                groupService.removeFromCache(groupIdentity);
-                GroupModelOld groupModel = groupService.getByGroupIdentity(groupIdentity);
-                if (groupModel == null) {
-                    logger.error("Group model is null");
-                }
-                return groupModel;
-            } catch (MasterKeyLockedException e) {
-                logger.error("Could not get group service", e);
-                return null;
-            }
-        }
-    };
-
-    @NonNull
-    private final DistributionListListener distributionListListener = new DistributionListListener() {
-        @Override
-        public void onCreate(DistributionListModel distributionListModel) {
-            try {
-                serviceManager.getConversationService().refresh(distributionListModel);
-            } catch (ThreemaException e) {
-                logger.error("Exception", e);
-            }
-        }
-
-        @Override
-        public void onModify(DistributionListModel distributionListModel) {
-            workerExecutor.execute(() -> {
-                try {
-                    serviceManager.getConversationService().refresh(distributionListModel);
-                    ShortcutUtil.updatePinnedShortcut(
-                        serviceManager.getDistributionListService().createReceiver(distributionListModel),
-                        serviceManager.getPreferenceService().getContactNameFormat()
-                    );
-                } catch (ThreemaException e) {
-                    logger.error("Exception", e);
-                }
-            });
-        }
-    };
-
-    @NonNull
-    private final MessageListener messageListener = new MessageListener() {
-        @Override
-        public void onNew(AbstractMessageModel newMessage) {
-            logger.debug("MessageListener.onNewMessage");
-            ConversationService conversationService;
-            try {
-                conversationService = serviceManager.getConversationService();
-            } catch (ThreemaException e) {
-                logger.error("Could not get conversation service", e);
-                return;
-            }
-            if (!newMessage.isStatusMessage()) {
-                ConversationModel conversationModel = conversationService.refresh(newMessage);
-                if (conversationModel != null) {
-                    // Show notification only if there is a conversation
-                    showConversationNotification(newMessage, false);
-                }
-            } else if (newMessage.getType() == MessageType.GROUP_CALL_STATUS) {
-                conversationService.refresh(newMessage);
-            }
-        }
-
-        @Override
-        public void onModified(List<AbstractMessageModel> modifiedMessageModels) {
-            logger.debug("MessageListener.onModified");
-            for (final AbstractMessageModel modifiedMessageModel : modifiedMessageModels) {
-                if (modifiedMessageModel.isStatusMessage()) {
-                    continue;
-                }
-                try {
-                    ConversationService conversationService =
-                        serviceManager.getConversationService();
-                    ConversationModel conversationModel =
-                        conversationService.refresh(modifiedMessageModel);
-                    if (conversationModel != null && isImageOrVideoMessage(modifiedMessageModel)) {
-                        // Only show a notification if there is a conversation
-                        showConversationNotification(modifiedMessageModel, true);
-                    }
-                } catch (ThreemaException e) {
-                    logger.error("Exception", e);
-                }
-            }
-        }
-
-        // The following code does not need to be ported to 'develop'
-        private boolean isImageOrVideoMessage(@NonNull AbstractMessageModel message) {
-            var type = message.getType();
-            if (type != MessageType.IMAGE && type != MessageType.VIDEO && type != MessageType.FILE) {
-                return false;
-            }
-            var contentsType = message.getMessageContentsType();
-            if (contentsType != MessageContentsType.IMAGE && contentsType != MessageContentsType.VIDEO) {
-                return false;
-            }
-            return message.getFileData().getRenderingType() == FileData.RENDERING_MEDIA;
-        }
-
-        @Override
-        public void onRemoved(AbstractMessageModel removedMessageModel) {
-            logger.debug("MessageListener.onRemoved");
-            if (!removedMessageModel.isStatusMessage()) {
-                try {
-                    serviceManager.getConversationService().refreshWithDeletedMessage(removedMessageModel);
-                } catch (ThreemaException e) {
-                    logger.error("Exception", e);
-                }
-            }
-        }
-
-        @Override
-        public void onRemoved(List<AbstractMessageModel> removedMessageModels) {
-            logger.debug("MessageListener.onRemoved multi");
-            for (final AbstractMessageModel removedMessageModel : removedMessageModels) {
-                if (!removedMessageModel.isStatusMessage()) {
-                    try {
-                        serviceManager.getConversationService().refreshWithDeletedMessage(removedMessageModel);
-                    } catch (ThreemaException e) {
-                        logger.error("Exception", e);
-                    }
-                }
-            }
-        }
-    };
-
-    @NonNull
-    private final EditMessageListener editMessageListener = message -> showConversationNotification(message, true);
-
-    @NonNull
-    private final MessageDeletedForAllListener messageDeletedForAllListener = message -> showConversationNotification(message, true);
-
-    @NonNull
-    private final ServerMessageListener serverMessageListener = new ServerMessageListener() {
-        @Override
-        public void onAlert(ServerMessageModel serverMessage) {
-            NotificationService notificationService = serviceManager.getNotificationService();
-            notificationService.showServerMessage(serverMessage);
-        }
-
-        @Override
-        public void onError(ServerMessageModel serverMessage) {
-            NotificationService notificationService = serviceManager.getNotificationService();
-            notificationService.showServerMessage(serverMessage);
-        }
-    };
-
-    @NonNull
-    private final ContactListener contactListener = new ContactListener() {
-        @Override
-        public void onModified(final @NonNull String identity) {
-            final ContactModel modifiedContactModel = serviceManager.getModelRepositories().getContacts().getByIdentity(identity);
-            if (modifiedContactModel == null) {
-                return;
-            }
-
-            workerExecutor.execute(() -> {
-                try {
-                    final ConversationService conversationService = serviceManager.getConversationService();
-                    final ContactService contactService = serviceManager.getContactService();
-                    final PreferenceService preferenceService = serviceManager.getPreferenceService();
-
-                    // Refresh conversation cache
-                    conversationService.updateContactConversation(identity);
-                    conversationService.refresh(modifiedContactModel);
-
-                    ContactMessageReceiver messageReceiver = contactService.createReceiver(modifiedContactModel);
-                    if (messageReceiver != null) {
-                        ShortcutUtil.updatePinnedShortcut(messageReceiver, preferenceService.getContactNameFormat());
-                    }
-                } catch (Exception e) {
-                    logger.error("Exception", e);
-                }
-            });
-        }
-
-        @Override
-        public void onAvatarChanged(final @NonNull String identity) {
-            workerExecutor.execute(() -> {
-                try {
-                    ContactMessageReceiver messageReceiver = serviceManager.getContactService().createReceiver(identity);
-                    if (messageReceiver != null) {
-                        ShortcutUtil.updatePinnedShortcut(
-                            messageReceiver,
-                            serviceManager.getPreferenceService().getContactNameFormat()
-                        );
-                    }
-                } catch (ThreemaException e) {
-                    logger.error("Exception", e);
-                }
-            });
-        }
-    };
-
-    @NonNull
-    private final ConversationListener conversationListener = new ConversationListener() {
-        @Override
-        public void onNew(@NonNull ConversationModel conversationModel) {
-        }
-
-        @Override
-        public void onModified(@NonNull ConversationModel modifiedConversationModel) {
-        }
-
-        @Override
-        public void onRemoved(@NonNull ConversationModel conversationModel) {
-            //remove notification!
-            NotificationService notificationService = serviceManager.getNotificationService();
-            notificationService.cancel(conversationModel);
-        }
-
-        @Override
-        public void onModifiedAll() {
-        }
-    };
-
-    @NonNull
-    private final BallotVoteListener ballotVoteListener = new BallotVoteListener() {
-        @Override
-        public void onSelfVote(BallotModel ballotModel) {
-        }
-
-        @Override
-        public void onVoteChanged(BallotModel ballotModel, String votingIdentity, boolean isFirstVote) {
-            //add group state
-
-            //DISABLED
-            ServiceManager s = ThreemaApplication.getServiceManager();
-            if (s != null) {
-                try {
-                    BallotService ballotService = s.getBallotService();
-                    ContactService contactService = s.getContactService();
-                    ContactModelRepository contactModelRepository = s.getModelRepositories().getContacts();
-                    GroupService groupService = s.getGroupService();
-                    MessageService messageService = s.getMessageService();
-                    UserService userService = s.getUserService();
-
-                    if (ballotModel != null) {
-                        LinkBallotModel linkBallotModel = ballotService.getLinkedBallotModel(ballotModel);
-                        if (linkBallotModel != null) {
-                            GroupStatusDataModel.GroupStatusType type = null;
-                            MessageReceiver<? extends AbstractMessageModel> receiver = null;
-                            if (linkBallotModel instanceof GroupBallotModel) {
-                                GroupModelOld groupModel = groupService.getById(((GroupBallotModel) linkBallotModel).getGroupId());
-
-                                // its a group ballot,write status
-                                receiver = groupService.createReceiver(groupModel);
-                                // reset archived status
-                                groupService.setIsArchived(
-                                    groupModel.getCreatorIdentity(),
-                                    groupModel.getApiGroupId(),
-                                    false,
-                                    TriggerSource.LOCAL
-                                );
-
-                            } else if (linkBallotModel instanceof IdentityBallotModel) {
-                                String identity = ((IdentityBallotModel) linkBallotModel).getIdentity();
-
-                                ContactModel contactModel = contactModelRepository.getByIdentity(identity);
-                                if (contactModel != null) {
-                                    receiver = contactService.createReceiver(contactModel);
-                                    // reset archived status
-                                    contactService.setIsArchived(identity, false, TriggerSource.LOCAL);
-                                }
-                            }
-
-                            if (ballotModel.getType() == BallotModel.Type.RESULT_ON_CLOSE) {
-                                // Only show status message for first vote from a voter on private voting
-                                if (isFirstVote) {
-                                    // On private voting, only show default update msg!
-                                    type = GroupStatusDataModel.GroupStatusType.RECEIVED_VOTE;
-                                }
-                            } else if (receiver != null) {
-                                if (isFirstVote) {
-                                    type = GroupStatusDataModel.GroupStatusType.FIRST_VOTE;
-                                } else {
-                                    type = GroupStatusDataModel.GroupStatusType.MODIFIED_VOTE;
-                                }
-                            }
-
-                            if (
-                                linkBallotModel instanceof GroupBallotModel
-                                    && (type == GroupStatusDataModel.GroupStatusType.FIRST_VOTE
-                                    || type == GroupStatusDataModel.GroupStatusType.MODIFIED_VOTE)
-                                    && !BallotUtil.isMine(ballotModel, userService.getIdentity())
-                            ) {
-                                // Only show votes (and vote changes) to the creator of the ballot in a group
-                                return;
-                            }
-
-                            if (type != null && receiver instanceof GroupMessageReceiver) {
-                                messageService.createGroupStatus(
-                                    (GroupMessageReceiver) receiver,
-                                    type,
-                                    votingIdentity,
-                                    ballotModel.getName(),
-                                    null
-                                );
-                            }
-
-                            // now check if every participant has voted
-                            if (isFirstVote
-                                && ballotService.getPendingParticipants(ballotModel.getId()).isEmpty()
-                                && receiver instanceof GroupMessageReceiver
-                            ) {
-                                messageService.createGroupStatus(
-                                    (GroupMessageReceiver) receiver,
-                                    GroupStatusDataModel.GroupStatusType.VOTES_COMPLETE,
-                                    null,
-                                    ballotModel.getName(),
-                                    null
-                                );
-                            }
-                        }
-                    }
-                } catch (ThreemaException x) {
-                    logger.error("Exception", x);
-                }
-            }
-        }
-
-        @Override
-        public void onVoteRemoved(BallotModel ballotModel, String votingIdentity) {
-            //ignore
-        }
-
-        @Override
-        public boolean handle(BallotModel ballotModel) {
-            //handle all
-            return true;
-        }
-    };
-
     @NonNull
     private final SynchronizeContactsListener synchronizeContactsListener = new SynchronizeContactsListener() {
         @Override
@@ -788,25 +113,6 @@ public class GlobalListeners {
         }
     };
 
-    @Nullable
-    private ContactTypingListener contactTypingListener = null;
-
-    private ContactTypingListener getContactTypingListener() {
-        if (contactTypingListener == null) {
-            contactTypingListener = (contactModel, isTyping) -> {
-                //update the conversations
-                try {
-                    serviceManager.getConversationService()
-                        .setIsTyping(contactModel, isTyping);
-                } catch (ThreemaException e) {
-                    logger.error("Exception", e);
-                }
-            };
-        }
-
-        return contactTypingListener;
-    }
-
     @Nullable
     private NewSyncedContactsListener newSyncedContactListener = null;
 
@@ -825,8 +131,8 @@ public class GlobalListeners {
     private final WebClientServiceListener webClientServiceListener = new WebClientServiceListener() {
         @Override
         public void onEnabled() {
-            SessionWakeUpServiceImpl.getInstance()
-                .processPendingWakeupsAsync();
+            SessionWakeUpService sessionWakeUpService = KoinJavaComponent.get(SessionWakeUpService.class);
+            sessionWakeUpService.processPendingWakeupsAsync();
         }
 
         @Override
@@ -854,10 +160,10 @@ public class GlobalListeners {
                     logger.info("SessionAndroidService not running...starting");
                     intent.setAction(SessionAndroidService.ACTION_START);
                     logger.info("sending ACTION_START to SessionAndroidService");
-                    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+                    if (Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
                         // Starting on version S, foreground services cannot be started from the background.
                         // When battery optimizations are disabled (recommended for Threema Web), then no
-                        // exception is thrown. Otherwise we just log it.
+                        // exception is thrown. Otherwise, we just log it.
                         try {
                             ContextCompat.startForegroundService(appContext, intent);
                         } catch (ForegroundServiceStartNotAllowedException exception) {
@@ -909,93 +215,4 @@ public class GlobalListeners {
             });
         }
     };
-
-    @NonNull
-    private final WebClientWakeUpListener webClientWakeUpListener;
-
-    @NonNull
-    private final VoipCallEventListener voipCallEventListener = new VoipCallEventListener() {
-        private final Logger logger = getThreemaLogger("VoipCallEventListener");
-
-        @Override
-        public void onRinging(String peerIdentity) {
-            this.logger.debug("onRinging {}", peerIdentity);
-        }
-
-        @Override
-        public void onStarted(String peerIdentity, boolean outgoing) {
-            final String direction = outgoing ? "to" : "from";
-            this.logger.info("Call {} {} started", direction, peerIdentity);
-        }
-
-        @Override
-        public void onFinished(long callId, @NonNull String peerIdentity, boolean outgoing, int duration) {
-            final String direction = outgoing ? "to" : "from";
-            this.logger.info("Call {} {} finished", direction, peerIdentity);
-            this.saveStatus(peerIdentity,
-                outgoing,
-                VoipStatusDataModel.createFinished(callId, duration),
-                true);
-        }
-
-        @Override
-        public void onRejected(long callId, String peerIdentity, boolean outgoing, byte reason) {
-            final String direction = outgoing ? "to" : "from";
-            this.logger.info("Call {} {} rejected (reason {})", direction, peerIdentity, reason);
-            this.saveStatus(peerIdentity,
-                // on rejected incoming, the outgoing was rejected!
-                !outgoing,
-                VoipStatusDataModel.createRejected(callId, reason),
-                true);
-        }
-
-        @Override
-        public void onMissed(long callId, String peerIdentity, boolean accepted, @Nullable Date date) {
-            this.logger.info("Call from {} missed", peerIdentity);
-            this.saveStatus(peerIdentity,
-                false,
-                VoipStatusDataModel.createMissed(callId, date),
-                accepted);
-        }
-
-        @Override
-        public void onAborted(long callId, String peerIdentity) {
-            this.logger.info("Call to {} aborted", peerIdentity);
-            this.saveStatus(peerIdentity,
-                true,
-                VoipStatusDataModel.createAborted(callId),
-                true);
-        }
-
-        private void saveStatus(
-            @NonNull String identity,
-            boolean isOutbox,
-            @NonNull VoipStatusDataModel status,
-            boolean isRead
-        ) {
-            try {
-                // Services
-                final IdentityStore identityStore = serviceManager.getIdentityStore();
-                final ContactService contactService = serviceManager.getContactService();
-                final ContactModelRepository contactModelRepository = serviceManager.getModelRepositories().getContacts();
-                final MessageService messageService = serviceManager.getMessageService();
-
-                // If an incoming status message is not targeted at our own identity, something's wrong
-                final String ownIdentity = identityStore.getIdentityString();
-                if (TestUtil.compare(identity, ownIdentity) && !isOutbox) {
-                    this.logger.error("Could not save voip status (identity={}, appIdentity={}, outbox={})", identity, ownIdentity, isOutbox);
-                    return;
-                }
-
-                // Create status message
-                final ContactModel contactModel = contactModelRepository.getByIdentity(identity);
-                if (contactModel != null) {
-                final ContactMessageReceiver receiver = contactService.createReceiver(contactModel);
-                messageService.createVoipStatus(status, receiver, isOutbox, isRead);
-                }
-            } catch (ThreemaException e) {
-                logger.error("Exception", e);
-            }
-        }
-    };
 }

+ 43 - 192
app/src/main/java/ch/threema/app/ThreemaApplication.kt

@@ -14,78 +14,51 @@ import androidx.core.content.edit
 import androidx.lifecycle.ProcessLifecycleOwner
 import androidx.preference.PreferenceManager
 import ch.threema.app.AppConstants.ACTIVITY_CONNECTION_LIFETIME
-import ch.threema.app.apptaskexecutor.AppTaskExecutor
 import ch.threema.app.debug.StrictModeMonitor
 import ch.threema.app.di.MasterKeyLockStateChangeHandler
+import ch.threema.app.di.SessionScopeContainer
 import ch.threema.app.di.getOrNull
 import ch.threema.app.di.initDependencyInjection
 import ch.threema.app.drafts.DraftManagerImpl
 import ch.threema.app.errorreporting.ThreemaUncaughtExceptionHandler
 import ch.threema.app.logging.AppVersionLogger
-import ch.threema.app.logging.DebugLogHelper
 import ch.threema.app.logging.ExitReasonLogger
-import ch.threema.app.managers.CoreServiceManagerImpl
-import ch.threema.app.managers.ServiceManager
+import ch.threema.app.monitors.MonitorController
 import ch.threema.app.notifications.NotificationIDs
-import ch.threema.app.passphrase.PassphraseStateMonitor
-import ch.threema.app.preference.service.PreferenceService
-import ch.threema.app.push.PushService
 import ch.threema.app.restrictions.AppRestrictionService
-import ch.threema.app.services.ServiceManagerProvider
 import ch.threema.app.services.ThreemaPushService
 import ch.threema.app.services.avatarcache.AvatarCacheService
 import ch.threema.app.startup.AppProcessLifecycleObserver
+import ch.threema.app.startup.AppStartupError
 import ch.threema.app.startup.AppStartupMonitorImpl
-import ch.threema.app.startup.MasterKeyEventMonitor
-import ch.threema.app.startup.RemoteSecretProtectionStateMonitor
 import ch.threema.app.startup.deleteOrphanedUserData
 import ch.threema.app.startup.models.AppSystem
-import ch.threema.app.stores.EncryptedPreferenceStore
-import ch.threema.app.stores.IdentityProvider
-import ch.threema.app.stores.IdentityStoreImpl
-import ch.threema.app.stores.MutableIdentityProvider
-import ch.threema.app.stores.PreferenceStore
 import ch.threema.app.systemupdates.SystemUpdateException
 import ch.threema.app.systemupdates.SystemUpdateProvider
 import ch.threema.app.systemupdates.SystemUpdater
-import ch.threema.app.tasks.getDebugString
+import ch.threema.app.threemasafe.ThreemaSafeService
 import ch.threema.app.ui.DynamicColorsHelper
-import ch.threema.app.utils.AppVersionProvider.appVersion
 import ch.threema.app.utils.ConfigUtils
-import ch.threema.app.utils.ConnectionIndicatorUtil
-import ch.threema.app.utils.DispatcherProvider
 import ch.threema.app.utils.FileUtil
 import ch.threema.app.utils.LinuxSecureRandom
-import ch.threema.app.utils.PushUtil
-import ch.threema.app.utils.StateBitmapUtil
 import ch.threema.app.voip.Config
-import ch.threema.app.webclient.services.SessionWakeUpServiceImpl
-import ch.threema.app.workers.AutoDeleteWorker
-import ch.threema.app.workers.ContactUpdateWorker
-import ch.threema.app.workers.GatewayProfilePicturesWorker
-import ch.threema.app.workers.ShareTargetUpdateWorker
-import ch.threema.app.workers.WorkSyncWorker
-import ch.threema.base.ThreemaException
-import ch.threema.base.crypto.NonceScope
+import ch.threema.app.webclient.services.SessionWakeUpService
+import ch.threema.app.workers.WorkerStartupScheduler
 import ch.threema.base.utils.getThreemaLogger
-import ch.threema.common.now
-import ch.threema.data.repositories.ModelRepositories
-import ch.threema.domain.protocol.connection.ConnectionState
-import ch.threema.domain.protocol.connection.ServerConnection
-import ch.threema.domain.stores.DHSessionStoreInterface
+import ch.threema.common.DispatcherProvider
+import ch.threema.domain.stores.DHSessionStore
 import ch.threema.libthreema.LogLevel
 import ch.threema.libthreema.initialize as initLibthreema
 import ch.threema.localcrypto.MasterKey
 import ch.threema.localcrypto.MasterKeyManager
 import ch.threema.localcrypto.MasterKeyManagerImpl
+import ch.threema.localcrypto.MasterKeyProvider
 import ch.threema.localcrypto.exceptions.MasterKeyLockedException
 import ch.threema.localcrypto.models.MasterKeyReadResult
 import ch.threema.logging.LibthreemaLogger
 import ch.threema.logging.backend.DebugLogFileBackend
 import ch.threema.logging.backend.DebugLogFileManager
 import ch.threema.storage.DatabaseDowngradeException
-import ch.threema.storage.DatabaseNonceStore
-import ch.threema.storage.DatabaseProvider
 import ch.threema.storage.DatabaseProviderImpl
 import ch.threema.storage.DatabaseService
 import ch.threema.storage.DatabaseState
@@ -110,12 +83,8 @@ private val logger = getThreemaLogger("ThreemaApplication")
 class ThreemaApplication : Application() {
 
     // TODO(ANDR-4187): Move these dependencies and the logic that uses them to a better place
-    private val passphraseStateMonitor: PassphraseStateMonitor by inject()
-    private val remoteSecretProtectionStateMonitor: RemoteSecretProtectionStateMonitor by inject()
-    private val masterKeyEventMonitor: MasterKeyEventMonitor by inject()
-    private val appTaskExecutor: AppTaskExecutor by inject()
     private val appStartupMonitor: AppStartupMonitorImpl by inject()
-    private val identityProvider: IdentityProvider by inject()
+    private val monitorController: MonitorController by inject()
 
     override fun onCreate() {
         if (!checkAppReplacingState(applicationContext)) {
@@ -151,7 +120,6 @@ class ThreemaApplication : Application() {
             updateAppVersionHistory()
         }
         get<ExitReasonLogger>().logExitReason()
-        logger.info("Has identity: {}", identityProvider.getIdentity() != null)
 
         ProcessLifecycleOwner.get().lifecycle.addObserver(get<AppProcessLifecycleObserver>())
 
@@ -164,38 +132,12 @@ class ThreemaApplication : Application() {
         }
 
         coroutineScope.launch {
-            try {
-                withContext(dispatcherProvider.io) {
-                    val result = masterKeyManager.readOrGenerateKey()
-                    if (result == MasterKeyReadResult.NEWLY_GENERATED) {
-                        deleteOrphanedUserData(applicationContext)
-                    }
-                }
-            } catch (e: Exception) {
-                logger.error("Failed to read or generate master key", e)
-                appStartupMonitor.reportUnexpectedAppStartupError("MK-1")
-                return@launch
-            }
+            monitorController.run()
+        }
 
-            // TODO(ANDR-4187): Move all of these coroutines to a better place
-            launch(dispatcherProvider.main) {
-                monitorMasterKey(
-                    masterKeyManager = masterKeyManager,
-                )
-            }
-            launch(dispatcherProvider.worker) {
-                masterKeyEventMonitor.monitorMasterKeyEvents()
-            }
-            launch(dispatcherProvider.worker) {
-                passphraseStateMonitor.monitorPassphraseLock()
-            }
-            launch(dispatcherProvider.worker) {
-                remoteSecretProtectionStateMonitor.monitorRemoteSecretProtectionState()
-            }
-            launch(dispatcherProvider.worker) {
-                appTaskExecutor.start()
-                logger.error("App task executor has stopped")
-            }
+        coroutineScope.launch(dispatcherProvider.main.immediate) {
+            readOrGenerateMasterKey(masterKeyManager)
+            monitorMasterKey(masterKeyManager.masterKeyProvider)
         }
 
         GlobalBroadcastReceivers.registerBroadcastReceivers(applicationContext)
@@ -220,10 +162,21 @@ class ThreemaApplication : Application() {
         LinuxSecureRandom()
     }
 
-    private suspend fun monitorMasterKey(
-        masterKeyManager: MasterKeyManagerImpl,
-    ) = coroutineScope {
-        val masterKeyProvider = masterKeyManager.masterKeyProvider
+    private suspend fun readOrGenerateMasterKey(masterKeyManager: MasterKeyManagerImpl) = withContext(dispatcherProvider.io) {
+        try {
+            val result = masterKeyManager.readOrGenerateKey()
+            if (result == MasterKeyReadResult.NEWLY_GENERATED) {
+                launch {
+                    deleteOrphanedUserData(applicationContext)
+                }
+            }
+        } catch (e: Exception) {
+            logger.error("Failed to read or generate master key", e)
+            appStartupMonitor.reportUnexpectedAppStartupError("MK-1")
+        }
+    }
+
+    private suspend fun monitorMasterKey(masterKeyProvider: MasterKeyProvider) = coroutineScope {
         try {
             while (true) {
                 val masterKey = masterKeyProvider.awaitUnlocked()
@@ -270,20 +223,11 @@ class ThreemaApplication : Application() {
 
             val appContext = instance
 
-            val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(appContext)
+            val sharedPreferences = get<SharedPreferences>()
             resolveMasterKeyDeactivationRaceCondition(appContext, masterKeyManager, sharedPreferences)
             setUpDayNightMode(appContext)
 
-            StateBitmapUtil.init(appContext)
-
-            ConnectionIndicatorUtil.init(appContext)
-
             try {
-                val preferenceStore: PreferenceStore = get()
-                val identityProvider: IdentityProvider = get()
-                val mutableIdentityProvider: MutableIdentityProvider = get()
-                val encryptedPreferenceStore: EncryptedPreferenceStore = get()
-
                 setUpSqlCipher()
                 val databaseProvider: DatabaseProviderImpl = get()
                 coroutineScope.launch {
@@ -295,47 +239,19 @@ class ThreemaApplication : Application() {
                     } catch (e: DatabaseUpdateException) {
                         appStartupMonitor.reportUnexpectedAppStartupError("DB-${e.failedDatabaseUpdateVersion}")
                     } catch (e: DatabaseDowngradeException) {
-                        appStartupMonitor.reportUnexpectedAppStartupError("DB-DG-${e.oldDatabaseVersion}")
+                        appStartupMonitor.reportAppStartupError(AppStartupError.DatabaseDowngrade(e.oldDatabaseVersion))
                     }
                 }
                 val dhSessionStore = createDHSessionStore(appContext, masterKey)
-                val identityStore = IdentityStoreImpl(mutableIdentityProvider, preferenceStore, encryptedPreferenceStore)
 
                 // Since the DB updates are kicked off on a different thread, we have to wait for them to start before we continue.
                 // Otherwise, we might get race-conditions with other threads that might access the DB before the migration thread.
                 databaseProvider.databaseState.first { it != DatabaseState.INIT }
 
-                // Note: the task manager should only be used to schedule tasks once the service manager is set
-                val coreServiceManager = createCoreServiceManager(
-                    appContext,
-                    databaseProvider,
-                    preferenceStore,
-                    encryptedPreferenceStore,
-                    identityStore,
-                )
-
-                val modelRepositories = ModelRepositories(
-                    coreServiceManager = coreServiceManager,
-                    identityProvider = identityProvider,
-                )
-
+                val sessionScopeContainer = SessionScopeContainer(appContext, dhSessionStore)
                 val systemUpdater = SystemUpdater(sharedPreferences)
-                val serviceManager = try {
-                    ServiceManager(
-                        appContext,
-                        modelRepositories,
-                        dhSessionStore,
-                        masterKeyManager.masterKeyProvider,
-                        coreServiceManager,
-                    )
-                } catch (e: ThreemaException) {
-                    logger.error("Could not instantiate service manager", e)
-                    appStartupMonitor.reportUnexpectedAppStartupError("SM-0")
-                    return
-                }
-
                 masterKeyLockStateChangeHandler.onMasterKeyUnlocked(
-                    serviceManager,
+                    sessionScopeContainer,
                     databaseProvider.databaseState,
                     systemUpdater.systemUpdateState,
                 )
@@ -352,11 +268,9 @@ class ThreemaApplication : Application() {
 
                 setDefaultPreferences(appContext, sharedPreferences)
 
-                registerConnectionStateChangedListener(appContext, serviceManager.connection)
-
                 if (ConfigUtils.isWorkBuild()) {
                     coroutineScope.launch {
-                        AppRestrictionService.getInstance().reload()
+                        get<AppRestrictionService>().reload()
                     }
                 }
 
@@ -364,7 +278,7 @@ class ThreemaApplication : Application() {
 
                 // trigger a connection now, just to be sure we're up-to-date and any broken connection
                 // (e.g. from before a reboot) is preempted.
-                with(serviceManager.lifetimeService) {
+                with(sessionScopeContainer.lifetimeService) {
                     acquireConnection("resetConnection")
                     releaseConnectionLinger("resetConnection", ACTIVITY_CONNECTION_LIFETIME)
                 }
@@ -373,9 +287,9 @@ class ThreemaApplication : Application() {
                     appStartupMonitor.awaitSystem(AppSystem.DATABASE_UPDATES)
 
                     markUploadingFilesAsFailed(databaseService = get())
-                    SessionWakeUpServiceImpl.getInstance().processPendingWakeupsAsync()
-                    serviceManager.threemaSafeService.schedulePeriodicUpload()
-                    scheduleWorkers(appContext, serviceManager.preferenceService, preferenceStore)
+                    get<SessionWakeUpService>().processPendingWakeupsAsync()
+                    get<ThreemaSafeService>().schedulePeriodicUpload()
+                    get<WorkerStartupScheduler>().scheduleWorkers()
                 }
 
                 coroutineScope.launch {
@@ -430,66 +344,11 @@ class ThreemaApplication : Application() {
             setupDatabaseLogging()
         }
 
-        private fun createCoreServiceManager(
-            appContext: Context,
-            databaseProvider: DatabaseProvider,
-            preferenceStore: PreferenceStore,
-            encryptedPreferenceStore: EncryptedPreferenceStore,
-            identityStore: IdentityStoreImpl,
-        ) =
-            CoreServiceManagerImpl(
-                appVersion,
-                databaseProvider,
-                preferenceStore,
-                encryptedPreferenceStore,
-                identityStore,
-                nonceDatabaseStoreProvider = {
-                    val databaseNonceStore = DatabaseNonceStore(appContext, identityStore)
-                    databaseNonceStore.migrateIfNeeded()
-                    logger.info("Nonce count (csp): {}", databaseNonceStore.getCount(NonceScope.CSP))
-                    logger.info("Nonce count (d2d): {}", databaseNonceStore.getCount(NonceScope.D2D))
-                    databaseNonceStore
-                },
-                getDebugString = getDebugString,
-            )
-
         private fun cancelNewMessageNotification(context: Context) {
             NotificationManagerCompat.from(context).cancel(NotificationIDs.NEW_MESSAGE_LOCKED_NOTIFICATION_ID)
         }
 
-        private fun registerConnectionStateChangedListener(context: Context, connection: ServerConnection) {
-            // Whenever the connection is established, check whether the push token needs to be updated.
-            connection.addConnectionStateListener { connectionState ->
-                logger.info("ServerConnection state changed: {}", connectionState)
-                if (connectionState == ConnectionState.LOGGEDIN) {
-                    GlobalAppState.lastLoggedIn = now()
-
-                    if (PushService.servicesInstalled(context) && PushUtil.isPushEnabled(context)) {
-                        if (PushUtil.pushTokenNeedsRefresh(context)) {
-                            PushUtil.enqueuePushTokenUpdate(context, false, false)
-                        } else {
-                            logger.debug("Push token is still fresh. No update needed")
-                        }
-                    }
-                }
-            }
-        }
-
-        private fun scheduleWorkers(
-            context: Context,
-            preferenceService: PreferenceService,
-            preferenceStore: PreferenceStore,
-        ) {
-            WorkSyncWorker.schedulePeriodicWorkSync(context, preferenceService)
-            ContactUpdateWorker.schedulePeriodicSync(context, preferenceService)
-            if (preferenceStore.getBoolean(context.getString(R.string.preferences__direct_share))) {
-                ShareTargetUpdateWorker.scheduleShareTargetShortcutUpdate(context)
-            }
-            get<AutoDeleteWorker.Scheduler>().scheduleAutoDelete()
-            get<DebugLogHelper>().updateDebugLogFileDeletionSchedule()
-            GatewayProfilePicturesWorker.schedulePeriodicSync(context)
-        }
-
+        // TODO(ANDR-4800): Move this elsewhere
         private fun resolveMasterKeyDeactivationRaceCondition(
             context: Context,
             masterKeyManager: MasterKeyManager,
@@ -514,6 +373,8 @@ class ThreemaApplication : Application() {
          * This reads all the xml files and applies the default values for each of the preferences. This is done by the preference manager by creating
          * the views and attaching them. As the synchronized settings require the service manager to be available to persist the setting, this method
          * must be called after the service manager has been initialized.
+         *
+         * TODO(ANDR-4800): Move this elsewhere
          */
         private fun setDefaultPreferences(context: Context, sharedPreferences: SharedPreferences) {
             // If device is in AEC exclusion list and the user did not choose a preference yet,
@@ -551,7 +412,7 @@ class ThreemaApplication : Application() {
         private fun createDHSessionStore(
             context: Context,
             masterKey: MasterKey,
-        ): DHSessionStoreInterface {
+        ): DHSessionStore {
             // We create the DH session store here and execute a null operation on it to prevent
             // the app from being launched when the database is downgraded.
             val dhSessionStore = SQLDHSessionStore(context, masterKey.value)
@@ -583,16 +444,6 @@ class ThreemaApplication : Application() {
             }
         }
 
-        // TODO(ANDR-4187): Remove this static method
-        @Deprecated("Do not access service manager directly, use DI instead")
-        @JvmStatic
-        fun getServiceManager(): ServiceManager? = get<ServiceManagerProvider>().getServiceManagerOrNull()
-
-        // TODO(ANDR-4187): Remove this static method
-        @Deprecated("Do not access service manager directly, use DI instead")
-        @JvmStatic
-        fun requireServiceManager(): ServiceManager = get<ServiceManager>()
-
         @Deprecated("Use DI instead")
         @JvmStatic
         fun getAppContext(): Context = instance

+ 3 - 10
app/src/main/java/ch/threema/app/actions/LocationMessageSendAction.java

@@ -11,7 +11,6 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.utils.MessageUtil;
-import ch.threema.base.ThreemaException;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.storage.models.AbstractMessageModel;
 
@@ -48,11 +47,9 @@ public class LocationMessageSendAction extends SendAction {
             return false;
         }
 
-        try {
-            messageService = this.getServiceManager().getMessageService();
-        } catch (ThreemaException e) {
-            actionHandler.onError(e.getMessage());
-            return false;
+        var serviceManager = getServiceManager();
+        if (serviceManager != null) {
+            messageService = serviceManager.getMessageService();
         }
 
         if (messageService == null || location == null) {
@@ -78,10 +75,6 @@ public class LocationMessageSendAction extends SendAction {
                 actionHandler.onError(errorMessage);
             }
 
-            @Override
-            public void onWarning(String warning, boolean continueAction) {
-            }
-
             @Override
             public void onProgress(int progress, int total) {
                 actionHandler.onProgress(progress + receiverIndex, numReceivers);

+ 1 - 5
app/src/main/java/ch/threema/app/actions/SendAction.java

@@ -1,6 +1,5 @@
 package ch.threema.app.actions;
 
-import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
 
 public abstract class SendAction {
@@ -8,9 +7,6 @@ public abstract class SendAction {
         default void onError(String errorMessage) {
         }
 
-        default void onWarning(String warning, boolean continueAction) {
-        }
-
         default void onProgress(int progress, int total) {
         }
 
@@ -21,7 +17,7 @@ public abstract class SendAction {
     private final ServiceManager serviceManager;
 
     public SendAction() {
-        this.serviceManager = ThreemaApplication.getServiceManager();
+        this.serviceManager = ServiceManager.get();
     }
 
     protected ServiceManager getServiceManager() {

+ 7 - 9
app/src/main/java/ch/threema/app/actions/TextMessageSendAction.java

@@ -7,10 +7,10 @@ import java.util.ArrayList;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.utils.MessageUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TextUtil;
-import ch.threema.base.ThreemaException;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+import static ch.threema.common.JavaCompat.isNullOrEmpty;
+
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class TextMessageSendAction extends SendAction {
@@ -44,15 +44,13 @@ public class TextMessageSendAction extends SendAction {
             return false;
         }
 
-        MessageService messageService;
-        try {
-            messageService = this.getServiceManager().getMessageService();
-        } catch (ThreemaException e) {
-            actionHandler.onError(e.getMessage());
-            return false;
+        MessageService messageService = null;
+        var serviceManager = getServiceManager();
+        if (serviceManager != null) {
+            messageService = serviceManager.getMessageService();
         }
 
-        if (messageService == null || TestUtil.isEmptyOrNull(text)) {
+        if (messageService == null || isNullOrEmpty(text)) {
             actionHandler.onError("Nothing to send");
             return false;
         }

+ 12 - 15
app/src/main/java/ch/threema/app/activities/AddContactActivity.java

@@ -15,7 +15,6 @@ import com.google.android.material.snackbar.Snackbar;
 import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
-import java.io.IOException;
 import java.time.Instant;
 
 import androidx.annotation.NonNull;
@@ -46,13 +45,14 @@ import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.app.qrcodes.ContactUrlResult;
 import ch.threema.app.qrcodes.ContactUrlUtil;
+import ch.threema.app.webclient.activities.SessionsActivity;
 import ch.threema.app.webclient.services.WebSessionQRCodeParser;
 import ch.threema.app.webclient.services.WebSessionQRCodeParserImpl;
-import ch.threema.base.utils.Base64;
 
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
-import ch.threema.storage.models.ContactModel;
+import ch.threema.common.Base64;
+import ch.threema.domain.models.AcquaintanceLevel;
 
 import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
@@ -188,7 +188,7 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
     private void startWebClientByQRResult(final byte[] payload) {
         if (payload != null) {
             // start web client session screen with payload data and finish my screen
-            Intent webClientIntent = new Intent(this, ch.threema.app.webclient.activities.SessionsActivity.class);
+            Intent webClientIntent = SessionsActivity.createIntent(this);
             IntentDataUtil.append(payload, webClientIntent);
             this.finish();
             startActivity(webClientIntent);
@@ -215,9 +215,8 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
         backgroundExecutor.execute(
             new BasicAddOrUpdateContactBackgroundTask(
                 identity,
-                ContactModel.AcquaintanceLevel.DIRECT,
-                dependencies.getUserService().getIdentity(),
-                dependencies.getApiConnector(),
+                AcquaintanceLevel.DIRECT,
+                dependencies.getValidContactsLookupSteps(),
                 dependencies.getContactModelRepository(),
                 AddContactRestrictionPolicy.CHECK,
                 dependencies.getAppRestrictions(),
@@ -338,14 +337,12 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
                 // third: try to parse as web client qr
                 try {
                     byte[] base64Payload = Base64.decode(payload);
-                    if (base64Payload != null) {
-                        final WebSessionQRCodeParser webClientQRCodeParser = new WebSessionQRCodeParserImpl();
-                        webClientQRCodeParser.parse(base64Payload); // throws if QR is not valid
-                        // it was a valid web client qr code, exit method
-                        startWebClientByQRResult(base64Payload);
-                        return;
-                    }
-                } catch (IOException | WebSessionQRCodeParser.InvalidQrCodeException x) {
+                    final WebSessionQRCodeParser webClientQRCodeParser = new WebSessionQRCodeParserImpl();
+                    webClientQRCodeParser.parse(base64Payload); // throws if QR is not valid
+                    // it was a valid web client qr code, exit method
+                    startWebClientByQRResult(base64Payload);
+                    return;
+                } catch (IllegalArgumentException | WebSessionQRCodeParser.InvalidQrCodeException x) {
                     // not a valid base64 or web client payload
                     // ignore and continue
                 }

+ 25 - 24
app/src/main/java/ch/threema/app/activities/AppLinksActivity.java

@@ -9,6 +9,7 @@ import org.slf4j.Logger;
 
 import androidx.activity.result.ActivityResultLauncher;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import ch.threema.android.ToastDuration;
 import ch.threema.app.AppConstants;
 import ch.threema.app.BuildConfig;
@@ -24,8 +25,10 @@ import ch.threema.app.utils.executor.BackgroundExecutor;
 
 import static ch.threema.android.ToastKt.showToast;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+
+import ch.threema.data.datatypes.ContactConversationId;
+import ch.threema.domain.models.AcquaintanceLevel;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
-import ch.threema.storage.models.ContactModel;
 import kotlin.Lazy;
 import kotlin.Unit;
 
@@ -96,16 +99,16 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
         finish();
     }
 
-    private void handleContactUrl(String appLinkAction, Uri appLinkData) {
-        final String threemaId = appLinkData.getLastPathSegment();
-        if (threemaId != null) {
-            if (threemaId.equalsIgnoreCase("compose")) {
+    private void handleContactUrl(String appLinkAction, @NonNull Uri appLinkData) {
+        final @Nullable String identity = appLinkData.getLastPathSegment();
+        if (identity != null) {
+            if (identity.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 if (identity.length() == ProtocolDefines.IDENTITY_LEN) {
+                addNewContactAndOpenConversation(identity, appLinkData);
             } else {
                 showToast(this, R.string.invalid_input, ToastDuration.LONG);
             }
@@ -120,13 +123,12 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
         overridePendingTransition(0, 0);
     }
 
-    private void addNewContactAndOpenChat(@NonNull String identity, @NonNull Uri appLinkData) {
+    private void addNewContactAndOpenConversation(@NonNull String identity, @NonNull Uri appLinkData) {
         backgroundExecutor.getValue().execute(
             new BasicAddOrUpdateContactBackgroundTask(
                 identity,
-                ContactModel.AcquaintanceLevel.DIRECT,
-                dependencies.getUserService().getIdentity(),
-                dependencies.getApiConnector(),
+                AcquaintanceLevel.DIRECT,
+                dependencies.getValidContactsLookupSteps(),
                 dependencies.getContactModelRepository(),
                 AddContactRestrictionPolicy.CHECK,
                 dependencies.getAppRestrictions(),
@@ -138,21 +140,20 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
                         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(AppConstants.INTENT_DATA_CONTACT, identity);
-                    intent.putExtra(AppConstants.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
-
+                    final @Nullable String text = appLinkData.getQueryParameter("text");
+                    final @NonNull Intent intent;
                     if (text != null) {
-                        text = text.trim();
-                        intent.putExtra(AppConstants.INTENT_DATA_TEXT, text);
+                        intent = ComposeMessageActivity.createIntent(
+                            AppLinksActivity.this,
+                            new ContactConversationId(identity),
+                            text.trim(),
+                            true
+                        );
+                    } else {
+                        intent = ContactDetailActivity.createIntent(AppLinksActivity.this, identity);
+                        intent.putExtra(AppConstants.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
                     }
-
+                    intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
                     startActivity(intent);
                 }
             }

+ 15 - 9
app/src/main/java/ch/threema/app/activities/BackupAdminActivity.java

@@ -1,6 +1,5 @@
 package ch.threema.app.activities;
 
-import static ch.threema.app.preference.service.PreferenceService.LOCKING_MECH_NONE;
 import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
 
@@ -24,8 +23,6 @@ import com.google.android.material.tabs.TabLayout;
 import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
-import java.time.Instant;
-
 import ch.threema.app.R;
 import ch.threema.app.applock.CheckAppLockContract;
 import ch.threema.app.di.DependencyContainer;
@@ -42,6 +39,7 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
     private static final Logger logger = getThreemaLogger("BackupAdminActivity");
 
     private static final String BUNDLE_IS_UNLOCKED = "biu";
+    private static final String EXTRA_HIDE_DATA_BACKUP = "hide_data_backup";
 
     @NonNull
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
@@ -96,12 +94,12 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
         viewPager.setAdapter(new BackupAdminPagerAdapter(getSupportFragmentManager()));
         tabLayout.setupWithViewPager(viewPager);
 
-        if (dependencies.getPreferenceService().getBackupWarningDismissedTime() == null) {
+        if (!dependencies.getPreferenceService().isBackupWarningDismissed()) {
             ((TextView) findViewById(R.id.notice_text)).setText(R.string.backup_explain_text);
             final View noticeLayout = findViewById(R.id.notice_layout);
             noticeLayout.setVisibility(View.VISIBLE);
             findViewById(R.id.close_button).setOnClickListener(v -> {
-                dependencies.getPreferenceService().setBackupWarningDismissedTime(Instant.now());
+                dependencies.getPreferenceService().setBackupWarningDismissed(true);
                 AnimationUtil.collapse(noticeLayout, null, true);
             });
         } else {
@@ -118,7 +116,7 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
     protected void onResume() {
         super.onResume();
 
-        if (!isUnlocked && !dependencies.getPreferenceService().getLockMechanism().equals(LOCKING_MECH_NONE)) {
+        if (!isUnlocked && dependencies.getPreferenceService().hasLockMechanism()) {
             checkLockLauncher.launch(Unit.INSTANCE);
         }
     }
@@ -136,11 +134,12 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
     }
 
     private boolean threemaSafeUIDisabled() {
-        return ConfigUtils.isWorkRestricted() && safeConfig.isBackupAdminDisabled();
+        return dependencies.getAppRestrictions().isRestricted() && safeConfig.isBackupAdminDisabled();
     }
 
     private boolean dataBackupUIDisabled() {
-        return dependencies.getAppRestrictions().isDataBackupsDisabled();
+        return dependencies.getAppRestrictions().isDataBackupsDisabled() ||
+            getIntent().getBooleanExtra(EXTRA_HIDE_DATA_BACKUP, false);
     }
 
     @Override
@@ -186,6 +185,13 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
 
     @NonNull
     public static Intent createIntent(@NonNull Context context) {
-        return new Intent(context, BackupAdminActivity.class);
+        return createIntent(context, false);
+    }
+
+    @NonNull
+    public static Intent createIntent(@NonNull Context context, boolean hideDataBackup) {
+        Intent intent = new Intent(context, BackupAdminActivity.class);
+        intent.putExtra(EXTRA_HIDE_DATA_BACKUP, hideDataBackup);
+        return intent;
     }
 }

+ 4 - 6
app/src/main/java/ch/threema/app/activities/BackupRestoreProgressActivity.kt

@@ -199,12 +199,10 @@ class BackupRestoreProgressActivity : AppCompatActivity() {
     }
 
     private fun cancelCompleteNotification() {
-        notificationService.cancel(
-            when (progressType) {
-                ProgressType.BACKUP -> BackupService.BACKUP_COMPLETION_NOTIFICATION_ID
-                ProgressType.RESTORE -> RestoreService.RESTORE_COMPLETION_NOTIFICATION_ID
-            },
-        )
+        when (progressType) {
+            ProgressType.BACKUP -> notificationService.cancelBackupCompletionNotification()
+            ProgressType.RESTORE -> notificationService.cancelRestoreCompletionNotification()
+        }
     }
 
     companion object {

+ 2 - 2
app/src/main/java/ch/threema/app/activities/BlockedIdentitiesActivity.kt

@@ -3,7 +3,7 @@ package ch.threema.app.activities
 import android.content.Context
 import ch.threema.android.buildActivityIntent
 import ch.threema.app.R
-import ch.threema.app.ThreemaApplication
+import ch.threema.app.managers.ServiceManager
 import ch.threema.app.utils.logScreenVisibility
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.types.IdentityString
@@ -17,7 +17,7 @@ class BlockedIdentitiesActivity : IdentityListActivity() {
 
     private val identityList: IdentityList? by lazy {
         val blockedIdentitiesService =
-            ThreemaApplication.getServiceManager()?.blockedIdentitiesService ?: return@lazy null
+            ServiceManager.get()?.blockedIdentitiesService ?: return@lazy null
 
         object : IdentityList {
             override fun getAll(): Set<String> {

+ 143 - 57
app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.kt

@@ -12,30 +12,37 @@ import androidx.activity.result.launch
 import ch.threema.android.buildActivityIntent
 import ch.threema.android.buildBundle
 import ch.threema.android.disableExitTransition
+import ch.threema.android.getLongOrNull
+import ch.threema.android.getParcelableExtraCompat
+import ch.threema.android.getStringOrNull
 import ch.threema.android.runTransaction
 import ch.threema.app.AppConstants
+import ch.threema.app.BuildConfig
 import ch.threema.app.R
 import ch.threema.app.applock.CheckAppLockContract
+import ch.threema.app.conversation.ConversationActivity
+import ch.threema.app.conversations.ConversationsFragment
 import ch.threema.app.dialogs.GenericAlertDialog
 import ch.threema.app.dialogs.GenericAlertDialog.DialogClickListener
 import ch.threema.app.fragments.composemessage.ComposeMessageFragment
-import ch.threema.app.fragments.conversations.ConversationsFragment
 import ch.threema.app.preference.SettingsActivity
 import ch.threema.app.preference.service.PreferenceService
 import ch.threema.app.services.ConversationCategoryService
 import ch.threema.app.startup.AppStartupAware
 import ch.threema.app.startup.waitUntilReady
 import ch.threema.app.utils.ConfigUtils
-import ch.threema.app.utils.ConversationUtil.getContactConversationUid
-import ch.threema.app.utils.ConversationUtil.getDistributionListConversationUid
-import ch.threema.app.utils.ConversationUtil.getGroupConversationUid
-import ch.threema.app.utils.IntentDataUtil
+import ch.threema.app.utils.ShortcutUtil
 import ch.threema.app.utils.logScreenVisibility
 import ch.threema.base.utils.getThreemaLogger
-import ch.threema.domain.models.ContactReceiverIdentifier
-import ch.threema.domain.models.DistributionListReceiverIdentifier
-import ch.threema.domain.models.GroupReceiverIdentifier
-import ch.threema.domain.models.ReceiverIdentifier
+import ch.threema.data.datatypes.ContactConversationId
+import ch.threema.data.datatypes.ConversationId
+import ch.threema.data.datatypes.DistributionListConversationId
+import ch.threema.data.datatypes.GroupConversationId
+import ch.threema.domain.types.GroupDatabaseId
+import ch.threema.domain.types.IdentityString
+import ch.threema.storage.models.AbstractMessageModel
+import ch.threema.storage.models.DistributionListMessageModel
+import ch.threema.storage.models.group.GroupMessageModel
 import org.koin.android.ext.android.inject
 
 private val logger = getThreemaLogger("ComposeMessageActivity")
@@ -103,22 +110,26 @@ class ComposeMessageActivity : ThreemaToolbarActivity(), DialogClickListener, Ap
 
         findExistingFragments()
 
+        val conversationId: ConversationId = intent?.extractConversationId()
+            ?: run {
+                logger.warn("Can't open conversation, didn't receive conversation id in intent")
+                return false
+            }
+
         if (findViewById<View>(R.id.messages) != null && conversationsFragment == null) {
-            // add messages fragment in tablet layout
+            // add conversations fragment in tablet layout
             conversationsFragment = ConversationsFragment()
-            getConversationUidFromIntent(intent)?.let { conversationUID ->
-                conversationsFragment!!.setArguments(
-                    buildBundle {
-                        putString(ConversationsFragment.OPENED_CONVERSATION_UID, conversationUID)
-                    },
-                )
-            }
+            conversationsFragment!!.setArguments(
+                buildBundle {
+                    putParcelable(AppConstants.INTENT_DATA_CONVERSATION_ID, conversationId)
+                },
+            )
             supportFragmentManager.runTransaction {
                 add(R.id.messages, conversationsFragment!!, MESSAGES_FRAGMENT_TAG)
             }
         }
 
-        val isHidden = checkHiddenChatLock(intent, checkLockOnCreateLauncher)
+        val isHidden = checkHiddenConversationLock(conversationId, checkLockOnCreateLauncher)
         if (composeMessageFragment == null) {
             composeMessageFragment = ComposeMessageFragment()
             if (isHidden) {
@@ -139,22 +150,6 @@ class ComposeMessageActivity : ThreemaToolbarActivity(), DialogClickListener, Ap
         return true
     }
 
-    private fun getConversationUidFromIntent(intent: Intent?): String? {
-        val identity = IntentDataUtil.getIdentity(intent)
-        if (identity != null) {
-            return getContactConversationUid(identity)
-        }
-        val groupDbId = IntentDataUtil.getGroupId(intent)
-        if (groupDbId != -1L) {
-            return getGroupConversationUid(groupDbId)
-        }
-        val distributionListId = IntentDataUtil.getDistributionListId(intent)
-        if (distributionListId != -1L) {
-            return getDistributionListConversationUid(distributionListId)
-        }
-        return null
-    }
-
     override fun getLayoutResource() = if (ConfigUtils.isTabletLayout(this)) {
         R.layout.activity_compose_message_tablet
     } else {
@@ -175,8 +170,13 @@ class ComposeMessageActivity : ThreemaToolbarActivity(), DialogClickListener, Ap
 
         findExistingFragments()
 
+        val conversationId: ConversationId = intent.extractConversationId()
+            ?: run {
+                logger.warn("Can't open conversation, didn't receive conversation id in new intent")
+                return
+            }
         composeMessageFragment?.let { composeMessageFragment ->
-            if (!checkHiddenChatLock(intent, checkLockOnNewIntentLauncher)) {
+            if (!checkHiddenConversationLock(conversationId, checkLockOnNewIntentLauncher)) {
                 supportFragmentManager.runTransaction {
                     show(composeMessageFragment)
                 }
@@ -221,20 +221,28 @@ class ComposeMessageActivity : ThreemaToolbarActivity(), DialogClickListener, Ap
         }
     }
 
-    private fun checkHiddenChatLock(intent: Intent?, launcher: ActivityResultLauncher<Unit>): Boolean {
-        val messageReceiver = IntentDataUtil.getMessageReceiverFromIntent(applicationContext, intent)
-
-        if (messageReceiver == null) {
-            logger.info("Intent does not have any extras. Check \"Don't keep activities\" option in developer settings.")
-            return false
-        }
-
-        if (conversationCategoryService.isPrivateChat(messageReceiver.uniqueIdString)) {
-            if (ConfigUtils.hasProtection(preferenceService)) {
+    private fun checkHiddenConversationLock(conversationId: ConversationId, launcher: ActivityResultLauncher<Unit>): Boolean {
+        if (conversationCategoryService.isMarkedAsPrivate(conversationId)) {
+            if (preferenceService.hasLockMechanism()) {
                 launcher.launch()
             } else {
-                GenericAlertDialog.newInstance(R.string.hide_chat, R.string.hide_chat_enter_message_explain, R.string.set_lock, R.string.cancel)
-                    .show(supportFragmentManager, DIALOG_TAG_HIDDEN_NOTICE)
+                GenericAlertDialog
+                    .newInstance(
+                        /* title = */
+                        R.string.hide_chat,
+                        /* message = */
+                        R.string.hide_chat_enter_message_explain,
+                        /* positive = */
+                        R.string.set_lock,
+                        /* negative = */
+                        R.string.cancel,
+                    )
+                    .show(
+                        /* manager = */
+                        supportFragmentManager,
+                        /* tag = */
+                        DIALOG_TAG_HIDDEN_NOTICE,
+                    )
             }
             return true
         }
@@ -271,18 +279,96 @@ class ComposeMessageActivity : ThreemaToolbarActivity(), DialogClickListener, Ap
 
         private const val DIALOG_TAG_HIDDEN_NOTICE = "hidden"
 
-        fun createIntent(context: Context, receiverIdentifier: ReceiverIdentifier) = buildActivityIntent<ComposeMessageActivity>(context) {
-            when (receiverIdentifier) {
-                is GroupReceiverIdentifier -> {
-                    putExtra(AppConstants.INTENT_DATA_GROUP_DATABASE_ID, receiverIdentifier.groupDatabaseId)
-                }
-                is DistributionListReceiverIdentifier -> {
-                    putExtra(AppConstants.INTENT_DATA_DISTRIBUTION_LIST_ID, receiverIdentifier.id)
+        @JvmStatic
+        @JvmOverloads
+        @Suppress("KotlinConstantConditions")
+        fun createIntent(
+            context: Context,
+            conversationId: ConversationId,
+            initialText: String? = null,
+            hasInitialFocus: Boolean? = null,
+        ) =
+            if (BuildConfig.NEW_CONVERSATION_SCREEN_ENABLED) {
+                ConversationActivity.createIntent(
+                    context = context,
+                    conversationId = conversationId,
+                    initialText = initialText,
+                    hasInitialFocus = hasInitialFocus,
+                )
+            } else {
+                buildActivityIntent<ComposeMessageActivity>(context) {
+                    putExtra(AppConstants.INTENT_DATA_CONVERSATION_ID, conversationId)
+                    if (initialText != null) {
+                        putExtra(AppConstants.INTENT_DATA_TEXT, initialText)
+                    }
+                    if (hasInitialFocus != null) {
+                        putExtra(AppConstants.INTENT_DATA_EDITFOCUS, hasInitialFocus)
+                    }
                 }
-                is ContactReceiverIdentifier -> {
-                    putExtra(AppConstants.INTENT_DATA_CONTACT, receiverIdentifier.identity)
+            }
+
+        @JvmStatic
+        @JvmOverloads
+        @Suppress("KotlinConstantConditions")
+        fun createIntentJumpToMessage(
+            context: Context,
+            message: AbstractMessageModel,
+            overrideBackToHomeBehavior: Boolean? = null,
+        ) =
+            if (BuildConfig.NEW_CONVERSATION_SCREEN_ENABLED) {
+                ConversationActivity.createIntentJumpToMessage(
+                    context = context,
+                    message = message,
+                    overrideBackToHomeBehavior = overrideBackToHomeBehavior,
+                )
+            } else {
+                buildActivityIntent<ComposeMessageActivity>(context) {
+                    val conversationId: ConversationId =
+                        when (message) {
+                            is GroupMessageModel -> {
+                                GroupConversationId(
+                                    groupDatabaseId = message.groupId.toLong(),
+                                )
+                            }
+                            is DistributionListMessageModel -> {
+                                DistributionListConversationId(
+                                    distributionListId = message.distributionListId,
+                                )
+                            }
+                            else -> {
+                                val identity = message.identity
+                                requireNotNull(identity) { "Message is missing identity" }
+                                ContactConversationId(identity)
+                            }
+                        }
+                    putExtra(AppConstants.INTENT_DATA_CONVERSATION_ID, conversationId)
+                    putExtra(ComposeMessageFragment.EXTRA_API_MESSAGE_ID, message.apiMessageId)
+                    putExtra(ComposeMessageFragment.EXTRA_SEARCH_QUERY, " ")
+                    if (overrideBackToHomeBehavior != null) {
+                        putExtra(ComposeMessageFragment.EXTRA_OVERRIDE_BACK_TO_HOME_BEHAVIOR, overrideBackToHomeBehavior)
+                    }
                 }
             }
-        }
+
+        @JvmStatic
+        fun Intent.extractConversationId(): ConversationId? =
+            getParcelableExtraCompat<ConversationId>(AppConstants.INTENT_DATA_CONVERSATION_ID)
+                ?: run {
+                    // Persisted intents from shortcuts do not support parcelable extras, so conversation-id can not be used directly
+                    val calledFromShortcut = getBooleanExtra(ShortcutUtil.EXTRA_CALLED_FROM_SHORTCUT, false)
+                    if (!calledFromShortcut) {
+                        return null
+                    }
+                    getStringOrNull(AppConstants.INTENT_DATA_CONTACT)?.let { identity: IdentityString ->
+                        return ContactConversationId(identity)
+                    }
+                    getLongOrNull(AppConstants.INTENT_DATA_GROUP_DATABASE_ID)?.let { groupDatabaseId: GroupDatabaseId ->
+                        return GroupConversationId(groupDatabaseId)
+                    }
+                    getLongOrNull(AppConstants.INTENT_DATA_DISTRIBUTION_LIST_ID)?.let { distributionListId ->
+                        return DistributionListConversationId(distributionListId)
+                    }
+                    null
+                }
     }
 }

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

@@ -15,8 +15,8 @@ import androidx.appcompat.app.AppCompatDelegate
 import androidx.core.view.ViewCompat
 import ch.threema.android.buildActivityIntent
 import ch.threema.android.getIntOrNull
-import ch.threema.android.getParcelable
-import ch.threema.android.getSerializable
+import ch.threema.android.getParcelableExtraCompat
+import ch.threema.android.getSerializableExtraCompat
 import ch.threema.app.R
 import ch.threema.app.ui.InsetSides.Companion.lbr
 import ch.threema.app.ui.applyDeviceInsetsAsPadding
@@ -228,7 +228,7 @@ class CropImageActivity : ThreemaToolbarActivity() {
         private fun Intent.toCropImageParameters(): CropImageParameters {
             val sourceUri = data
             check(sourceUri != null)
-            val saveUri = getParcelable<Uri>(MediaStore.EXTRA_OUTPUT)
+            val saveUri = getParcelableExtraCompat<Uri>(MediaStore.EXTRA_OUTPUT)
             check(saveUri != null)
 
             return CropImageParameters(
@@ -241,7 +241,7 @@ class CropImageActivity : ThreemaToolbarActivity() {
                 additionalFlip = getIntExtra(EXTRA_ADDITIONAL_FLIP, BitmapUtil.FLIP_NONE),
                 additionalRotation = getIntExtra(EXTRA_ADDITIONAL_ROTATION, 0),
                 compressFormat = getCompressFormat(),
-                shape = getSerializable<Shape>(EXTRA_SHAPE) as? Shape? ?: Shape.RECTANGLE,
+                shape = getSerializableExtraCompat<Shape>(EXTRA_SHAPE) ?: Shape.RECTANGLE,
                 sourceUri = sourceUri,
                 saveUri = saveUri,
             )

+ 0 - 10
app/src/main/java/ch/threema/app/activities/DisableBatteryOptimizationsActivity.java

@@ -51,11 +51,6 @@ public class DisableBatteryOptimizationsActivity extends ThreemaActivity impleme
      */
     public static final String EXTRA_CANCEL_LABEL = "cancel";
 
-    /**
-     * Set this to true if the activity is called from the wizard. Default false.
-     */
-    public static final String EXTRA_WIZARD = "wizard";
-
     /**
      * Set this to true to disable the rationale dialog before guiding the user through the disable process
      */
@@ -82,11 +77,6 @@ public class DisableBatteryOptimizationsActivity extends ThreemaActivity impleme
 
         Intent intent = getIntent();
 
-        final boolean usedInWizard = intent.getBooleanExtra(EXTRA_WIZARD, false);
-        if (usedInWizard) {
-            setTheme(R.style.Theme_Threema_Wizard_Translucent);
-        }
-
         if (ConfigUtils.getMIUIVersion() >= 11) {
             String bodyText = getString(R.string.miui_battery_optimization, getString(R.string.app_name));
             GenericAlertDialog.newInstance(R.string.battery_optimizations_title, bodyText, R.string.ok, 0).show(getSupportFragmentManager(), DIALOG_TAG_MIUI_WARNING);

+ 8 - 5
app/src/main/java/ch/threema/app/activities/DistributionListAddActivity.kt

@@ -16,6 +16,7 @@ import ch.threema.app.ui.SingleToast
 import ch.threema.app.utils.RuntimeUtil
 import ch.threema.app.utils.logScreenVisibility
 import ch.threema.base.utils.getThreemaLogger
+import ch.threema.data.datatypes.DistributionListConversationId
 import ch.threema.domain.types.IdentityString
 import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.DistributionListModel
@@ -101,11 +102,13 @@ class DistributionListAddActivity : MemberChooseActivity(), TextEntryDialogClick
         ).show(supportFragmentManager, DIALOG_TAG_ENTER_NAME)
     }
 
-    private fun launchComposeActivity() {
-        distributionListModel?.let { distributionListModelNotNull ->
-            val intent = Intent(this@DistributionListAddActivity, ComposeMessageActivity::class.java)
+    private fun launchConversationActivity() {
+        distributionListModel?.let { distributionListModel ->
+            val intent = ComposeMessageActivity.createIntent(
+                context = this@DistributionListAddActivity,
+                conversationId = DistributionListConversationId(distributionListModel.id),
+            )
             intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
-            distributionListService.createReceiver(distributionListModelNotNull).prepareIntent(intent)
             startActivity(intent)
             finish()
         }
@@ -131,7 +134,7 @@ class DistributionListAddActivity : MemberChooseActivity(), TextEntryDialogClick
                 )
             }
             RuntimeUtil.runOnUiThread {
-                this.launchComposeActivity()
+                this.launchConversationActivity()
             }
         } catch (e: Exception) {
             logger.error("Failed to edit distribution list", e)

+ 30 - 25
app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java

@@ -5,7 +5,6 @@ import android.content.Context;
 import android.content.Intent;
 import android.content.res.Configuration;
 import android.net.Uri;
-import android.os.AsyncTask;
 import android.os.Bundle;
 import android.text.Editable;
 import android.text.InputFilter;
@@ -28,9 +27,10 @@ import org.slf4j.Logger;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.core.text.HtmlCompat;
+import ch.threema.android.LifecycleAwareAsyncTask;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
-import ch.threema.app.activities.wizard.components.WizardButtonXml;
+import ch.threema.app.ui.interop.ButtonPrimaryXml;
 import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.restrictions.AppRestrictionService;
@@ -39,13 +39,12 @@ import ch.threema.app.services.license.LicenseServiceUser;
 import ch.threema.domain.models.SerialCredentials;
 import ch.threema.domain.models.UserCredentials;
 import ch.threema.app.ui.InsetSides;
-import ch.threema.app.ui.SimpleTextWatcher;
+import ch.threema.android.textwatchers.SimpleTextWatcher;
 import ch.threema.app.ui.SpacingValues;
 import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.EditTextUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.app.utils.executor.BackgroundTask;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
@@ -54,6 +53,8 @@ import kotlin.Lazy;
 
 import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
+import static ch.threema.common.JavaCompat.isNullOrBlank;
+import static ch.threema.common.JavaCompat.isNullOrEmpty;
 import static ch.threema.common.LazyKt.lazy;
 
 // this should NOT extend ThreemaToolbarActivity
@@ -67,7 +68,7 @@ public class EnterSerialActivity extends ThreemaActivity {
     private TextView stateTextView = null;
     private EditText licenseKeyOrUsernameText, passwordText, serverText;
     private MaterialButton unlockButton;
-    private WizardButtonXml loginButtonCompose;
+    private ButtonPrimaryXml loginButtonCompose;
 
     @NonNull
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
@@ -95,7 +96,7 @@ public class EnterSerialActivity extends ThreemaActivity {
 
         ViewExtensionsKt.applyDeviceInsetsAsPadding(
             findViewById(R.id.layout_parent_top),
-            InsetSides.vertical(),
+            InsetSides.all(),
             SpacingValues.symmetric(R.dimen.wizard_contents_padding, R.dimen.wizard_contents_padding_horizontal)
         );
 
@@ -246,11 +247,11 @@ public class EnterSerialActivity extends ThreemaActivity {
             if (ConfigUtils.isOnPremBuild()) {
                 String server = getConfiguredOnPremServerUrl();
 
-                if (!TestUtil.isEmptyOrNull(username) && !TestUtil.isEmptyOrNull(password) && !TestUtil.isEmptyOrNull(server)) {
+                if (!isNullOrEmpty(username) && !isNullOrEmpty(password) && !isNullOrEmpty(server)) {
                     check(new UserCredentials(username, password), server);
                 }
             } else {
-                if (!TestUtil.isEmptyOrNull(username) && !TestUtil.isEmptyOrNull(password)) {
+                if (!isNullOrEmpty(username) && !isNullOrEmpty(password)) {
                     check(new UserCredentials(username, password), null);
                 }
             }
@@ -281,7 +282,7 @@ public class EnterSerialActivity extends ThreemaActivity {
 
     private void checkPrivateActivationLink(@NonNull Uri data) {
         final String key = data.getQueryParameter("key");
-        if (!TestUtil.isEmptyOrNull(key)) {
+        if (!isNullOrEmpty(key)) {
             check(new SerialCredentials(key), null);
         }
     }
@@ -344,12 +345,12 @@ public class EnterSerialActivity extends ThreemaActivity {
             serverText.setText(effectiveServerUrl != null ? getBaseUrl(effectiveServerUrl) : null);
 
             // Check license if credentials and server url are available
-            if (!TestUtil.isEmptyOrNull(effectiveUsername) && !TestUtil.isEmptyOrNull(effectivePassword) && !TestUtil.isEmptyOrNull(effectiveServerUrl)) {
+            if (!isNullOrEmpty(effectiveUsername) && !isNullOrEmpty(effectivePassword) && !isNullOrEmpty(effectiveServerUrl)) {
                 check(new UserCredentials(effectiveUsername, effectivePassword), effectiveServerUrl);
             }
         } else {
             // Check license if credentials are available
-            if (!TestUtil.isEmptyOrNull(effectiveUsername) && !TestUtil.isEmptyOrNull(effectivePassword)) {
+            if (!isNullOrEmpty(effectiveUsername) && !isNullOrEmpty(effectivePassword)) {
                 check(new UserCredentials(effectiveUsername, effectivePassword), null);
             }
         }
@@ -370,7 +371,7 @@ public class EnterSerialActivity extends ThreemaActivity {
             String effectivePassword = configuredPassword != null ? configuredPassword : passwordText.getText().toString();
             String effectiveServerUrl = configuredServerUrl != null ? configuredServerUrl : serverText.getText().toString();
 
-            if (!TestUtil.isEmptyOrNull(effectiveUsername) && !TestUtil.isEmptyOrNull(effectivePassword) && !TestUtil.isEmptyOrNull(effectiveServerUrl)) {
+            if (!isNullOrEmpty(effectiveUsername) && !isNullOrEmpty(effectivePassword) && !isNullOrEmpty(effectiveServerUrl)) {
                 this.check(new UserCredentials(effectiveUsername, effectivePassword), effectiveServerUrl);
             } else {
                 this.setLoginButtonEnabled(true);
@@ -383,7 +384,7 @@ public class EnterSerialActivity extends ThreemaActivity {
             String effectiveUsername = configuredUsername != null ? configuredUsername : licenseKeyOrUsernameText.getText().toString();
             String effectivePassword = configuredPassword != null ? configuredPassword : passwordText.getText().toString();
 
-            if (!TestUtil.isEmptyOrNull(effectiveUsername) && !TestUtil.isEmptyOrNull(effectivePassword)) {
+            if (!isNullOrEmpty(effectiveUsername) && !isNullOrEmpty(effectivePassword)) {
                 this.check(new UserCredentials(effectiveUsername, effectivePassword), null);
             } else {
                 this.setLoginButtonEnabled(true);
@@ -423,15 +424,15 @@ public class EnterSerialActivity extends ThreemaActivity {
     protected void onSaveInstanceState(@NonNull Bundle outState) {
         super.onSaveInstanceState(outState);
 
-        if (licenseKeyOrUsernameText != null && !TestUtil.isBlankOrNull(licenseKeyOrUsernameText.getText())) {
+        if (licenseKeyOrUsernameText != null && !isNullOrBlank(licenseKeyOrUsernameText.getText())) {
             outState.putString(BUNDLE_LICENSE_KEY, licenseKeyOrUsernameText.getText().toString());
         }
 
-        if (passwordText != null && !TestUtil.isBlankOrNull(passwordText.getText())) {
+        if (passwordText != null && !isNullOrBlank(passwordText.getText())) {
             outState.putString(BUNDLE_PASSWORD, passwordText.getText().toString());
         }
 
-        if (serverText != null && !TestUtil.isBlankOrNull(serverText.getText())) {
+        if (serverText != null && !isNullOrBlank(serverText.getText())) {
             outState.putString(BUNDLE_SERVER, serverText.getText().toString());
         }
     }
@@ -448,26 +449,30 @@ public class EnterSerialActivity extends ThreemaActivity {
             preferenceService.setLicensePassword(((UserCredentials) credentials).password);
         }
 
-        new AsyncTask<Void, Void, String>() {
+        new LifecycleAwareAsyncTask<Void, String>() {
             @Override
             protected void onPreExecute() {
                 GenericProgressDialog.newInstance(R.string.flavored__checking_serial, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_CHECKING);
             }
 
             @Override
-            protected String doInBackground(Void... voids) {
+            protected String doInBackground(Void params) {
                 String error = getString(R.string.error);
                 try {
                     LicenseService licenseService = dependencies.getLicenseService();
                     error = licenseService.validate(credentials);
                     if (error == null) {
                         // validated
-                        if (ConfigUtils.isWorkBuild()) {
-                            AppRestrictionService.getInstance()
-                                .fetchAndStoreWorkMDMSettings(
-                                    dependencies.getApiConnector(),
-                                    (UserCredentials) credentials
-                                );
+                        if (ConfigUtils.isWorkBuild() && credentials != null) {
+                            UserCredentials userCredentials = (UserCredentials) credentials;
+
+                            var result = dependencies.getApiConnector().fetchWorkData(userCredentials.username, userCredentials.password, new String[]{});
+                            if (result.responseCode > 0) {
+                                logger.error("Failed to fetch2 work data. Server response code = {}", result.responseCode);
+                            } else {
+                                AppRestrictionService appRestrictionService = KoinJavaComponent.get(AppRestrictionService.class);
+                                appRestrictionService.storeWorkMDMSettings(result.mdm);
+                            }
                         }
                     }
                 } catch (Exception e) {
@@ -486,7 +491,7 @@ public class EnterSerialActivity extends ThreemaActivity {
                     changeState(error);
                 }
             }
-        }.execute();
+        }.execute(this, null);
     }
 
     /**

+ 2 - 2
app/src/main/java/ch/threema/app/activities/ExcludedSyncIdentitiesActivity.kt

@@ -3,7 +3,7 @@ package ch.threema.app.activities
 import android.content.Context
 import ch.threema.android.buildActivityIntent
 import ch.threema.app.R
-import ch.threema.app.ThreemaApplication
+import ch.threema.app.managers.ServiceManager
 import ch.threema.app.utils.logScreenVisibility
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.taskmanager.TriggerSource
@@ -17,7 +17,7 @@ class ExcludedSyncIdentitiesActivity : IdentityListActivity() {
     }
 
     private val identityList: IdentityList? by lazy {
-        val excludedSyncIdentitiesService = ThreemaApplication.getServiceManager()?.excludedSyncIdentitiesService
+        val excludedSyncIdentitiesService = ServiceManager.get()?.excludedSyncIdentitiesService
             ?: return@lazy null
 
         object : IdentityList {

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

@@ -3,6 +3,7 @@ package ch.threema.app.activities
 import android.content.Context
 import android.content.Intent
 import android.os.Bundle
+import android.view.WindowManager
 import androidx.appcompat.app.AppCompatActivity
 import androidx.lifecycle.lifecycleScope
 import ch.threema.android.buildActivityIntent
@@ -16,9 +17,9 @@ import ch.threema.app.preference.service.PreferenceService
 import ch.threema.app.services.UserService
 import ch.threema.app.startup.finishAndRestartLaterIfNotReady
 import ch.threema.app.utils.DialogUtil
-import ch.threema.app.utils.DispatcherProvider
 import ch.threema.app.utils.logScreenVisibility
 import ch.threema.base.utils.getThreemaLogger
+import ch.threema.common.DispatcherProvider
 import ch.threema.domain.identitybackup.IdentityBackup
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -40,6 +41,7 @@ class ExportIDActivity : AppCompatActivity(), PasswordEntryDialogClickListener {
         if (finishAndRestartLaterIfNotReady()) {
             return
         }
+        window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
 
         val dialogFragment = PasswordEntryDialog.newInstance(
             /* title = */
@@ -64,6 +66,8 @@ class ExportIDActivity : AppCompatActivity(), PasswordEntryDialogClickListener {
             0,
             /* showForgotPwHint = */
             PasswordEntryDialog.ForgotHintType.NONE,
+            /* requestKey = */
+            null,
         )
         dialogFragment.show(supportFragmentManager, DIALOG_TAG_SET_ID_BACKUP_PW)
     }

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

@@ -15,6 +15,7 @@ import android.util.TypedValue;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
+import android.view.WindowManager;
 import android.webkit.WebView;
 import android.webkit.WebViewClient;
 import android.widget.ImageView;
@@ -46,11 +47,11 @@ import ch.threema.app.ui.QRCodePopup;
 import ch.threema.app.ui.TooltipPopup;
 import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.TestUtil;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
+import static ch.threema.common.JavaCompat.isNullOrEmpty;
 
 public class ExportIDResultActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener, LifecycleOwner {
     private static final Logger logger = getThreemaLogger("ExportIDResultActivity");
@@ -79,6 +80,7 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
             finish();
             return;
         }
+        getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
 
         MaterialToolbar toolbar = findViewById(R.id.toolbar);
         setSupportActionBar(toolbar);
@@ -101,7 +103,7 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
         this.backupData = this.getIntent().getStringExtra(AppConstants.INTENT_DATA_ID_BACKUP);
         this.identity = this.getIntent().getStringExtra(AppConstants.INTENT_DATA_CONTACT);
 
-        if (TestUtil.isEmptyOrNull(this.backupData)) {
+        if (isNullOrEmpty(this.backupData)) {
             finish();
             return;
         }

+ 7 - 3
app/src/main/java/ch/threema/app/activities/GroupAdd2Activity.java

@@ -17,7 +17,6 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
 import androidx.appcompat.app.ActionBar;
-import ch.threema.app.AppConstants;
 import androidx.compose.ui.platform.ComposeView;
 import ch.threema.app.R;
 import ch.threema.app.compose.common.interop.ComposeJavaBridge;
@@ -29,7 +28,10 @@ import ch.threema.app.profilepicture.CheckedProfilePicture;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.utils.CoroutinesExtensionKt;
+
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+
+import ch.threema.data.datatypes.GroupConversationId;
 import ch.threema.data.models.GroupModel;
 import kotlin.Unit;
 import kotlinx.coroutines.Deferred;
@@ -137,8 +139,10 @@ public class GroupAdd2Activity extends GroupEditActivity implements ContactEditD
     @AnyThread
     private void onGroupCreatedSuccessfully(@NonNull GroupModel newModel) {
         RuntimeUtil.runOnUiThread(() -> {
-            Intent intent = new Intent(this, ComposeMessageActivity.class);
-            intent.putExtra(AppConstants.INTENT_DATA_GROUP_DATABASE_ID, newModel.getDatabaseId());
+            final Intent intent = ComposeMessageActivity.createIntent(
+                this,
+                new GroupConversationId(newModel.getDatabaseId())
+            );
             setResult(RESULT_OK);
             startActivity(intent);
             finish();

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

@@ -81,7 +81,7 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
         this.appendMembers = false;
         this.excludedIdentities = new ArrayList<>();
         try {
-            long groupId = IntentDataUtil.getGroupId(this.getIntent());
+            long groupId = IntentDataUtil.getGroupDatabaseId(this.getIntent());
             if (groupId > 0) {
                 this.groupModel = dependencies.getGroupService().getById(groupId);
                 this.appendMembers = (this.groupModel != null && dependencies.getGroupService().isGroupCreator(this.groupModel));

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

@@ -2,6 +2,7 @@ package ch.threema.app.activities;
 
 import android.annotation.SuppressLint;
 import android.app.Activity;
+import android.content.Context;
 import android.content.Intent;
 import android.graphics.Color;
 import android.graphics.Paint;
@@ -25,10 +26,10 @@ import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
 import java.io.File;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -47,10 +48,12 @@ import androidx.core.graphics.Insets;
 import androidx.core.view.ViewCompat;
 import androidx.core.view.WindowInsetsCompat;
 import androidx.fragment.app.Fragment;
+import androidx.lifecycle.Lifecycle;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.Observer;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
+import ch.threema.android.FlowJavaCompat;
 import ch.threema.app.AppConstants;
 import ch.threema.app.R;
 import ch.threema.app.adapters.GroupDetailAdapter;
@@ -63,6 +66,9 @@ import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.dialogs.TextEntryDialog;
 import ch.threema.app.dialogs.loadingtimeout.LoadingWithTimeoutDialogXml;
 import ch.threema.app.emojis.EmojiEditText;
+import ch.threema.app.eventbus.GlobalEventFlows;
+import ch.threema.app.eventbus.events.ContactEvent;
+import ch.threema.app.eventbus.events.GroupEvent;
 import ch.threema.app.groupflows.GroupChanges.ProfilePictureChange;
 import ch.threema.app.groupflows.GroupFlowResult;
 import ch.threema.app.groupflows.GroupChanges;
@@ -70,9 +76,7 @@ import ch.threema.app.groupflows.GroupCreateProperties;
 import ch.threema.app.groupflows.GroupDisbandIntent;
 import ch.threema.app.groupflows.GroupLeaveIntent;
 import ch.threema.app.home.HomeActivity;
-import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
-import ch.threema.app.listeners.GroupListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.mediagallery.MediaGalleryActivity;
 import ch.threema.app.profilepicture.CheckedProfilePicture;
@@ -81,28 +85,26 @@ import ch.threema.app.ui.AvatarEditView;
 import ch.threema.app.ui.GroupDetailViewModel;
 import ch.threema.app.ui.ResumePauseHandler;
 import ch.threema.app.ui.SelectorDialogItem;
-import ch.threema.app.ui.SimpleTextWatcher;
+import ch.threema.android.textwatchers.SimpleTextWatcher;
 import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.DisplayableGroupParticipant;
 import ch.threema.app.utils.DisplayableGroupParticipants;
 import ch.threema.app.utils.GroupCallUtil;
-import ch.threema.app.utils.GroupUtil;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.app.voip.groupcall.GroupCallDescription;
 import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.base.utils.CoroutinesExtensionKt;
 
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
-import ch.threema.data.models.GroupIdentity;
+import ch.threema.data.datatypes.ContactConversationId;
+import ch.threema.data.datatypes.GroupConversationId;
 import ch.threema.data.models.GroupModel;
 import ch.threema.data.models.GroupModelData;
-import ch.threema.localcrypto.exceptions.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.group.GroupModelOld;
 import kotlin.Unit;
@@ -113,12 +115,21 @@ import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.NONE;
 import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
 import static ch.threema.app.groupflows.GroupFlowResultKt.GROUP_FLOWS_LOADING_DIALOG_TIMEOUT_SECONDS;
+import static ch.threema.common.JavaCompat.areEqual;
 
 public class GroupDetailActivity extends GroupEditActivity implements SelectorDialog.SelectorDialogClickListener,
     GenericAlertDialog.DialogClickListener,
     TextEntryDialog.TextEntryDialogClickListener,
     GroupDetailAdapter.OnGroupDetailsClickListener {
     private static final Logger logger = getThreemaLogger("GroupDetailActivity");
+
+    @NonNull
+    public static Intent createIntent(@NonNull Context context, long groupDatabaseId) {
+        final @NonNull Intent intent = new Intent(context, GroupDetailActivity.class);
+        intent.putExtra(AppConstants.INTENT_DATA_GROUP_DATABASE_ID, groupDatabaseId);
+        return intent;
+    }
+
     // static values
     private final int MODE_EDIT = 1;
     private final int MODE_READONLY = 2;
@@ -140,6 +151,8 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
     @NonNull
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
+    @NonNull
+    private final GlobalEventFlows globalEventFlows = KoinJavaComponent.get(GlobalEventFlows.class);
 
     private GroupModel groupModel;
     private GroupDetailViewModel groupDetailViewModel;
@@ -174,14 +187,14 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         @Override
         public void onAvatarSet(File avatarFile1) {
             groupDetailViewModel.setAvatarFile(avatarFile1);
-            groupDetailViewModel.setIsAvatarRemoved(false);
+            groupDetailViewModel.setAvatarRemoved(false);
             updateFloatingActionButtonAndMenu();
         }
 
         @Override
         public void onAvatarRemoved() {
             groupDetailViewModel.setAvatarFile(null);
-            groupDetailViewModel.setIsAvatarRemoved(true);
+            groupDetailViewModel.setAvatarRemoved(true);
             avatarEditView.setDefaultGroupAvatar();
             updateFloatingActionButtonAndMenu();
         }
@@ -206,71 +219,6 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         }
     };
 
-    private final ContactListener contactListener = new ContactListener() {
-        @Override
-        public void onModified(final @NonNull String identity) {
-            if (this.shouldHandleChange(identity)) {
-                resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
-            }
-        }
-
-        @Override
-        public void onAvatarChanged(final @NonNull String identity) {
-            if (this.shouldHandleChange(identity)) {
-                this.onModified(identity);
-            }
-        }
-
-        private boolean shouldHandleChange(@NonNull String identity) {
-            return groupDetailViewModel.containsModel(identity);
-        }
-    };
-
-    private final GroupListener groupListener = new GroupListener() {
-        @Override
-        public void onCreate(@NonNull GroupIdentity groupIdentity) {
-            resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
-        }
-
-        @Override
-        public void onRename(@NonNull GroupIdentity groupIdentity) {
-            resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
-        }
-
-        @Override
-        public void onUpdatePhoto(@NonNull GroupIdentity groupIdentity) {
-            resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
-        }
-
-        @Override
-        public void onRemove(long groupDbId) {
-            resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
-        }
-
-        @Override
-        public void onNewMember(@NonNull GroupIdentity groupIdentity, String identityNew) {
-            resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
-        }
-
-        @Override
-        public void onMemberLeave(@NonNull GroupIdentity groupIdentity, @NonNull String identityLeft) {
-            if (identityLeft.equals(myIdentity)) {
-                finish();
-            } else {
-                resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
-            }
-        }
-
-        @Override
-        public void onMemberKicked(@NonNull GroupIdentity groupIdentity, String identityKicked) {
-            if (identityKicked.equals(myIdentity)) {
-                finish();
-            } else {
-                resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
-            }
-        }
-    };
-
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -332,7 +280,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
             finish();
             return;
         }
-        groupModel = dependencies.getGroupModelRepository().getByLocalGroupDbId(groupId);
+        groupModel = dependencies.getGroupModelRepository().getByGroupDatabaseId(groupId);
         if (groupModel == null) {
             logger.warn("Group model couldn't be found");
             finish();
@@ -363,7 +311,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
         if (savedInstanceState == null) {
             // new instance
-            this.groupDetailViewModel.setGroupMembers(displayableGroupParticipantList);
+            this.groupDetailViewModel.setGroupParticipants(displayableGroupParticipantList);
             this.groupDetailViewModel.setGroupName(groupModelData.name);
             String groupDesc = groupModelData.groupDescription;
             if (groupDesc == null || groupDesc.isEmpty()) {
@@ -377,7 +325,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         }
 
         this.avatarEditView.setHires(true);
-        if (groupDetailViewModel.getIsAvatarRemoved()) {
+        if (groupDetailViewModel.isAvatarRemoved()) {
             this.avatarEditView.setDefaultGroupAvatar();
         } else {
             if (groupDetailViewModel.getAvatarFile() != null) {
@@ -442,13 +390,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
         groupDetailRecyclerView.setLayoutManager(new LinearLayoutManager(this));
 
-        try {
-            setupAdapter();
-        } catch (MasterKeyLockedException e) {
-            logger.error("Could not setup group detail adapter", e);
-            finish();
-            return;
-        }
+        setupAdapter();
 
         Fragment dialogFragment = getSupportFragmentManager().findFragmentByTag(DIALOG_TAG_CHANGE_GROUP_DESC);
         if (dialogFragment instanceof GroupDescEditDialog) {
@@ -467,7 +409,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
             groupDetailAdapter.setGroupMembers(groupParticipants);
 
         // Observe the LiveData, passing in this activity as the LifecycleOwner and the observer.
-        groupDetailViewModel.getGroupMembers().observe(this, groupMemberObserver);
+        groupDetailViewModel.getGroupParticipants().observe(this, groupMemberObserver);
         groupDetailViewModel.onDataChanged();
 
         @ColorInt int color = dependencies.getGroupService().getGroupProfilePictureColor(groupModel);
@@ -481,8 +423,8 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         }
 
         ListenerManager.contactSettingsListeners.add(this.contactSettingsListener);
-        ListenerManager.groupListeners.add(this.groupListener);
-        ListenerManager.contactListeners.add(this.contactListener);
+        FlowJavaCompat.collect(this, Lifecycle.State.CREATED, globalEventFlows.getContacts(), this::handleContactEvent);
+        FlowJavaCompat.collect(this, Lifecycle.State.CREATED, globalEventFlows.getGroups(), this::handleGroupEvent);
     }
 
     @Override
@@ -528,7 +470,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         });
     }
 
-    private void setupAdapter() throws MasterKeyLockedException {
+    private void setupAdapter() {
         this.groupDetailAdapter = new GroupDetailAdapter(
             this,
             this.groupModel,
@@ -546,6 +488,41 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         this.groupDetailAdapter.setOnClickListener(this);
     }
 
+    @UiThread
+    private void handleContactEvent(@NonNull ContactEvent event) {
+        if (
+            (event instanceof ContactEvent.ContactUpdated || event instanceof ContactEvent.ContactProfilePictureUpdated) &&
+                groupDetailViewModel.containsParticipant(event.getIdentityString())
+        ) {
+            resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
+        }
+    }
+
+    @UiThread
+    private void handleGroupEvent(@NonNull GroupEvent event) {
+        if (event instanceof GroupEvent.MemberKicked) {
+            if (((GroupEvent.MemberKicked) event).getIdentityString().equals(myIdentity)) {
+                finish();
+            } else {
+                resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
+            }
+        } else if (event instanceof GroupEvent.MemberLeft) {
+            if (((GroupEvent.MemberLeft) event).getIdentityString().equals(myIdentity)) {
+                finish();
+            } else {
+                resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
+            }
+        } else if (
+            event instanceof GroupEvent.NewMember ||
+                event instanceof GroupEvent.NewGroup ||
+                event instanceof GroupEvent.GroupRemoved ||
+                event instanceof GroupEvent.GroupRenamed ||
+                event instanceof GroupEvent.GroupProfilePictureUpdated
+        ) {
+            resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
+        }
+    }
+
     @Override
     public int getLayoutResource() {
         return R.layout.activity_group_detail;
@@ -576,11 +553,11 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
                 return o1.getDisplayableContactOrUser().getDisplayName().compareTo(o2.getDisplayableContactOrUser().getDisplayName());
             }
         });
-        groupDetailViewModel.setGroupMembers(displayableGroupParticipants);
+        groupDetailViewModel.setGroupParticipants(displayableGroupParticipants);
     }
 
     private void removeMemberFromGroup(@NonNull String identity) {
-        this.groupDetailViewModel.removeGroupContact(identity);
+        this.groupDetailViewModel.removeGroupParticipant(identity);
         updateFloatingActionButtonAndMenu();
     }
 
@@ -638,7 +615,11 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
             // The group sync option is only available for the creator when other members are in the group
             groupSyncMenu.setVisible(isCreator && isMember && hasOtherMembers);
 
-            mediaGalleryMenu.setVisible(!dependencies.getConversationCategoryService().isPrivateChat(GroupUtil.getUniqueIdString(this.groupModel)));
+            mediaGalleryMenu.setVisible(
+                !dependencies.getConversationCategoryService().isMarkedAsPrivate(
+                    new GroupConversationId(groupModel.getDatabaseId())
+                )
+            );
 
             menu.findItem(R.id.action_send_message).setVisible(!hasChanges());
         }
@@ -669,10 +650,13 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
             return true;
         } else if (itemId == R.id.action_send_message) {
             if (groupModel != null) {
-                Intent intent = new Intent(this, ComposeMessageActivity.class);
+                Intent intent = ComposeMessageActivity.createIntent(
+                    this,
+                    new GroupConversationId(groupId),
+                    null,
+                    true
+                );
                 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
-                intent.putExtra(AppConstants.INTENT_DATA_GROUP_DATABASE_ID, groupId);
-                intent.putExtra(AppConstants.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
                 startActivity(intent);
                 finish();
             }
@@ -725,7 +709,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
                     R.string.no)
                 .show(getSupportFragmentManager(), DIALOG_TAG_CLONE_GROUP_CONFIRM);
         } else if (itemId == R.id.menu_gallery) {
-            if (groupId > 0 && !dependencies.getConversationCategoryService().isPrivateChat(GroupUtil.getUniqueIdString(this.groupModel))) {
+            if (groupId > 0 && !dependencies.getConversationCategoryService().isMarkedAsPrivate(new GroupConversationId(groupModel.getDatabaseId()))) {
                 Intent mediaGalleryIntent = new Intent(this, MediaGalleryActivity.class);
                 mediaGalleryIntent.putExtra(AppConstants.INTENT_DATA_GROUP_DATABASE_ID, groupId);
                 startActivity(mediaGalleryIntent);
@@ -991,9 +975,11 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     ) {
         RuntimeUtil.runOnUiThread(() -> {
             loadingDialog.dismiss();
-            Intent intent = new Intent(GroupDetailActivity.this, ComposeMessageActivity.class);
+            Intent intent = ComposeMessageActivity.createIntent(
+                GroupDetailActivity.this,
+                new GroupConversationId(groupModel.getDatabaseId())
+            );
             intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
-            intent.putExtra(AppConstants.INTENT_DATA_GROUP_DATABASE_ID, groupModel.getDatabaseId());
             startActivity(intent);
             finish();
         });
@@ -1019,9 +1005,11 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     private void showConversation(String identity) {
-        Intent intent = new Intent(this, ComposeMessageActivity.class);
+        Intent intent = ComposeMessageActivity.createIntent(
+            this,
+            new ContactConversationId(identity)
+        );
         intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
-        intent.putExtra(AppConstants.INTENT_DATA_CONTACT, identity);
         startActivity(intent);
     }
 
@@ -1215,7 +1203,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
         byte[] oldAvatarBytes;
         try {
-            oldAvatarBytes = dependencies.getFileService().getGroupProfilePictureBytes(groupModel);
+            oldAvatarBytes = dependencies.getFileService().getGroupProfilePictureBytes(groupModel.getDatabaseId());
         } catch (Exception e) {
             logger.error("Could not get group avatar", e);
             oldAvatarBytes = null;
@@ -1253,7 +1241,10 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         if (resultCode == Activity.RESULT_OK) {
             if (requestCode == ThreemaActivity.ACTIVITY_ID_GROUP_ADD) {
                 // some users were added
-                groupDetailViewModel.addGroupContacts(IntentDataUtil.getContactIdentities(data));
+                var newContactIdentities = IntentDataUtil.getContactIdentities(data);
+                if (newContactIdentities != null) {
+                    groupDetailViewModel.addGroupContacts(newContactIdentities);
+                }
                 sortGroupMembers();
             } else {
                 if (this.avatarEditView != null) {
@@ -1272,8 +1263,6 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     @Override
     public void onDestroy() {
         ListenerManager.contactSettingsListeners.remove(this.contactSettingsListener);
-        ListenerManager.groupListeners.remove(this.groupListener);
-        ListenerManager.contactListeners.remove(this.contactListener);
 
         if (this.resumePauseHandler != null) {
             this.resumePauseHandler.onDestroy(this);
@@ -1332,7 +1321,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         if (newGroupDesc.equals(groupModelData.groupDescription)) {
             return;
         }
-        groupDetailViewModel.setGroupDescTimestamp(new Date());
+        groupDetailViewModel.setGroupDescTimestamp(Instant.now());
 
 
         // delete group description
@@ -1481,10 +1470,10 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         ArrayList<SelectorDialogItem> items = new ArrayList<>();
         ArrayList<Integer> optionsMap = new ArrayList<>();
 
-        items.add(new SelectorDialogItem(getString(R.string.show_contact), R.drawable.ic_outline_visibility));
+        items.add(new SelectorDialogItem(getString(R.string.show_contact), R.drawable.ic_visibility));
         optionsMap.add(SELECTOR_OPTION_CONTACT_DETAIL);
 
-        if (!TestUtil.compare(myIdentity, identity)) {
+        if (!areEqual(myIdentity, identity)) {
             items.add(new SelectorDialogItem(String.format(getString(R.string.chat_with), shortName), R.drawable.ic_chat_bubble));
             optionsMap.add(SELECTOR_OPTION_CHAT);
 
@@ -1501,7 +1490,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
                     optionsMap.add(SELECTOR_OPTION_REMOVE);
                 }
             }
-            SelectorDialog selectorDialog = SelectorDialog.newInstance(null, items, null);
+            SelectorDialog selectorDialog = SelectorDialog.newInstance(null, items, null, (String) null);
             SelectorInfo selectorInfo = new SelectorInfo();
             selectorInfo.contactModel = contactModel;
             selectorInfo.view = v;
@@ -1548,10 +1537,10 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     private void observeNewGroupModel() {
-        LiveData<GroupModelData> groupModelDataLiveData = groupDetailViewModel.group;
+        LiveData<GroupModelData> groupModelDataLiveData = groupDetailViewModel.getGroup();
         if (groupModelDataLiveData == null) {
             GroupModel newGroupModel =
-                dependencies.getGroupModelRepository().getByLocalGroupDbId(this.groupId);
+                dependencies.getGroupModelRepository().getByGroupDatabaseId(this.groupId);
 
             if (newGroupModel == null) {
                 logger.error("Group model is null");
@@ -1560,7 +1549,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
             }
 
             groupDetailViewModel.setGroup(newGroupModel);
-            groupModelDataLiveData = groupDetailViewModel.group;
+            groupModelDataLiveData = groupDetailViewModel.getGroup();
             if (groupModelDataLiveData == null) {
                 logger.error("Live data is null");
                 finish();

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

@@ -26,9 +26,9 @@ import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
 import ch.threema.app.adapters.IdentityListAdapter;
-import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.dialogs.TextEntryDialog;
-import ch.threema.app.managers.ListenerManager;
+import ch.threema.app.eventbus.GlobalEventBuses;
+import ch.threema.app.eventbus.events.ContactEvent;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.EmptyView;
 import ch.threema.app.ui.InsetSides;
@@ -48,7 +48,7 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
     private EmptyRecyclerView recyclerView;
 
     @NonNull
-    private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
+    private final GlobalEventBuses globalEventBuses = KoinJavaComponent.get(GlobalEventBuses.class);
 
     public interface IdentityList {
         /**
@@ -235,7 +235,7 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
         //add identity to list!
         this.getIdentityListHandle().addIdentity(identity);
 
-        fireOnModifiedContact(identity);
+        globalEventBuses.getContacts().emit(ContactEvent.ContactUpdated.javaCreate(identity));
 
         this.updateListAdapter();
     }
@@ -248,7 +248,7 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
         //remove identity from list!
         this.getIdentityListHandle().removeIdentity(identity);
 
-        fireOnModifiedContact(identity);
+        globalEventBuses.getContacts().emit(ContactEvent.ContactUpdated.javaCreate(identity));
 
         this.updateListAdapter();
     }
@@ -302,12 +302,6 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
         }
     }
 
-    private void fireOnModifiedContact(final String identity) {
-        if (isSessionScopeReady()) {
-            ListenerManager.contactListeners.handle(listener -> listener.onModified(identity));
-        }
-    }
-
     @Override
     public void onYes(@NonNull String tag, final @NonNull String text) {
         if (text.length() == ProtocolDefines.IDENTITY_LEN) {

+ 21 - 20
app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java

@@ -22,7 +22,6 @@ import android.graphics.Typeface;
 import android.graphics.drawable.Drawable;
 import android.media.FaceDetector;
 import android.net.Uri;
-import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Bundle;
 import android.view.KeyEvent;
@@ -80,6 +79,7 @@ import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
+import ch.threema.android.LifecycleAwareAsyncTask;
 import ch.threema.app.AppConstants;
 import ch.threema.app.R;
 import ch.threema.app.di.DependencyContainer;
@@ -118,11 +118,12 @@ import ch.threema.app.utils.BitmapWorkerTaskParams;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.EditTextUtil;
+import ch.threema.app.utils.FileProviderUtil;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.app.utils.TestUtil;
 
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+import static ch.threema.common.JavaCompat.isNullOrEmpty;
 
 import ch.threema.data.models.GroupModel;
 
@@ -364,7 +365,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
             switch (requestCode) {
                 case REQUEST_CODE_STICKER_SELECTOR:
                     final String stickerPath = data.getStringExtra(StickerSelectorActivity.EXTRA_STICKER_PATH);
-                    if (!TestUtil.isEmptyOrNull(stickerPath)) {
+                    if (!isNullOrEmpty(stickerPath)) {
                         addSticker(stickerPath);
                     }
                     break;
@@ -380,9 +381,9 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
     private void addSticker(final String stickerPath) {
         paintView.setActive(false);
 
-        new AsyncTask<Void, Void, Bitmap>() {
+        new LifecycleAwareAsyncTask<Void, Bitmap>() {
             @Override
-            protected Bitmap doInBackground(Void... params) {
+            protected Bitmap doInBackground(Void params) {
                 try {
                     Bitmap bitmap = BitmapFactory.decodeStream(getAssets().open(stickerPath));
                     boolean isFlippedHorizontally = isFlippedHorizontally();
@@ -411,7 +412,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
                     });
                 }
             }
-        }.execute();
+        }.execute(this, null);
     }
 
     private void addText(final String text) {
@@ -500,7 +501,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
         this.motionView = findViewById(R.id.motion_view);
 
         this.brushIcon = AppCompatResources.getDrawable(this, R.drawable.ic_brush);
-        this.pencilIcon = AppCompatResources.getDrawable(this, R.drawable.ic_pencil_outline);
+        this.pencilIcon = AppCompatResources.getDrawable(this, R.drawable.ic_pencil);
         this.highlighterIcon = AppCompatResources.getDrawable(this, R.drawable.ic_ink_highlighter_outline);
 
         this.penColor = getResources().getColor(R.color.material_red);
@@ -756,7 +757,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
                     onLoaded.run();
                 }
             }
-        }.execute(bitmapParams);
+        }.execute(this, bitmapParams);
     }
 
     private void resizeView(View view, int width, int height) {
@@ -783,7 +784,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
     private void blurFaces(final boolean useEmoji) {
         this.paintView.setActive(false);
 
-        new AsyncTask<Void, Void, List<FaceItem>>() {
+        new LifecycleAwareAsyncTask<Void, List<FaceItem>>() {
             int numFaces = -1;
             int originalImageWidth, originalImageHeight;
 
@@ -793,7 +794,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
             }
 
             @Override
-            protected List<FaceItem> doInBackground(Void... voids) {
+            protected List<FaceItem> doInBackground(Void params) {
                 BitmapFactory.Options options;
                 Bitmap bitmap, orgBitmap;
                 List<FaceItem> faceItemList = new ArrayList<>();
@@ -929,7 +930,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
                 DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_BLUR_FACES, true);
             }
-        }.execute();
+        }.execute(this, null);
     }
 
     @Override
@@ -1307,7 +1308,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
             protected void onPreExecute() {
                 super.onPreExecute();
 
-                String message = String.format(ConfigUtils.getSafeQuantityString(ImagePaintActivity.this, R.plurals.saving_media, 1, 1));
+                String message = getResources().getQuantityString(R.plurals.saving_media, 1, 1);
                 String title = getString(R.string.draw);
                 GenericProgressDialog.newInstance(title, message).show(getSupportFragmentManager(), DIALOG_TAG_SAVING_IMAGE);
             }
@@ -1318,16 +1319,16 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
                 motionView.renderOverlay(canvas);
                 paintView.renderOverlay(canvas, clipWidth, clipHeight);
 
-                new AsyncTask<Bitmap, Void, Boolean>() {
+                new LifecycleAwareAsyncTask<Bitmap, Boolean>() {
 
                     @Override
-                    protected Boolean doInBackground(Bitmap... params) {
+                    protected Boolean doInBackground(Bitmap bitmap) {
                         try {
                             File output = new File(outputUri.getPath());
 
                             FileOutputStream outputStream = new FileOutputStream(output);
                             Matrix matrix = currentOrientation.getTransformationMatrix();
-                            Bitmap transformed = Bitmap.createBitmap(params[0], 0, 0, params[0].getWidth(), params[0].getHeight(), matrix, true);
+                            Bitmap transformed = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
                             transformed.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
                             outputStream.flush();
                             outputStream.close();
@@ -1347,9 +1348,9 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
                             Toast.makeText(ImagePaintActivity.this, R.string.error_saving_file, Toast.LENGTH_SHORT).show();
                         }
                     }
-                }.execute(bitmap);
+                }.execute(ImagePaintActivity.this, bitmap);
             }
-        }.execute(bitmapParams);
+        }.execute(this, bitmapParams);
     }
 
     private void initializeCaptionEditText() {
@@ -1394,7 +1395,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
     }
 
     private void initializeMentions() {
-        GroupModel groupModel = dependencies.getGroupModelRepository().getByLocalGroupDbId(groupId);
+        GroupModel groupModel = dependencies.getGroupModelRepository().getByGroupDatabaseId(groupId);
 
         if (groupModel == null) {
             logger.error("Cannot enable mention popup: no group model with id {} found", groupId);
@@ -1601,14 +1602,14 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
     private void crop() {
         try {
             FileService fileService = dependencies.getFileService();
-            cropFile = fileService.createTempFile(".crop", ".png");
+            cropFile = fileService.createShareableTempFile(".crop", ".png");
 
             CropImageActivity.CropImageParameters cropImageParameters =
                 new CropImageActivity.CropImageParameters(
                     /* sourceUri = */
                     imageUri,
                     /* saveUri = */
-                    Uri.fromFile(cropFile)
+                    FileProviderUtil.getUriForFile(this, cropFile)
                 );
             // The flip and rotation to load the image 'correctly'
             cropImageParameters.setFlip(mediaItem.getFlip());

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

@@ -26,7 +26,7 @@ import java.util.Objects;
 
 import ch.threema.app.R;
 import ch.threema.app.motionviews.widget.TextEntity;
-import ch.threema.app.ui.SimpleTextWatcher;
+import ch.threema.android.textwatchers.SimpleTextWatcher;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.EditTextUtil;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;

+ 90 - 45
app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java

@@ -16,10 +16,10 @@ import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
-import android.widget.FrameLayout;
 import android.widget.TextView;
 import android.widget.Toast;
 
+import com.google.android.material.appbar.AppBarLayout;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
 
@@ -28,6 +28,8 @@ import androidx.annotation.Nullable;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.app.AppCompatDelegate;
 import androidx.appcompat.view.menu.MenuBuilder;
+import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.constraintlayout.widget.ConstraintSet;
 import androidx.core.app.ActivityCompat;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentManager;
@@ -44,11 +46,11 @@ import java.io.File;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
-import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.function.Consumer;
 
 import ch.threema.app.AppConstants;
 import ch.threema.app.R;
+import ch.threema.app.asynctasks.SaveMediaAsyncTask;
 import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.dialogs.ExpandableTextEntryDialog;
 import ch.threema.app.emojis.EmojiMarkupUtil;
@@ -73,7 +75,6 @@ import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
@@ -86,6 +87,8 @@ import ch.threema.storage.models.data.MessageContentsType;
 
 import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
+import static ch.threema.common.JavaCompat.isNullOrBlank;
+import static ch.threema.common.JavaCompat.isNullOrEmpty;
 
 public class MediaViewerActivity extends ThreemaToolbarActivity implements ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener {
 
@@ -109,6 +112,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
     private LockableViewPager pager;
     private File currentMediaFile;
     private ActionBar actionBar;
+    private AppBarLayout appBarLayout;
 
     private AbstractMessageModel currentMessageModel;
     private MessageReceiver currentReceiver;
@@ -123,7 +127,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
     private MediaViewFragment[] fragments;
     private File[] decryptedFileCache;
 
-    private FrameLayout captionContainer;
+    private ConstraintLayout captionContainer;
     private TextView caption;
     private final Handler loadingFragmentHandler = new Handler();
     private MenuItem saveMenuItem, shareMenuItem, viewMenuItem;
@@ -157,20 +161,21 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         if (!super.initActivity(savedInstanceState)) {
             return false;
         }
-        logger.debug("initActivity");
         showSystemUi();
 
         Intent intent = getIntent();
 
         final @Nullable String messageType = IntentDataUtil.getAbstractMessageType(intent);
         final int messageId = IntentDataUtil.getAbstractMessageId(intent);
-        if (TestUtil.isEmptyOrNull(messageType) || messageId <= 0) {
+        if (isNullOrEmpty(messageType) || messageId <= 0) {
             finish();
             return false;
         }
 
         this.emojiMarkupUtil = EmojiMarkupUtil.getInstance();
 
+        appBarLayout = findViewById(R.id.appbar);
+
         this.actionBar = getSupportActionBar();
         if (this.actionBar == null) {
             finish();
@@ -196,8 +201,8 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
             return false;
         }
 
-        this.isPrivateChat = dependencies.getConversationCategoryService().isPrivateChat(
-            this.currentReceiver.getUniqueIdString()
+        this.isPrivateChat = dependencies.getConversationCategoryService().isMarkedAsPrivate(
+            this.currentReceiver.getConversationId()
         );
 
         final @MessageContentsType int[] filter = intent.hasExtra(EXTRA_FILTER)
@@ -242,7 +247,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
 
                 @Override
                 public MessageType[] types() {
-                    return new MessageType[]{MessageType.IMAGE, MessageType.VIDEO, MessageType.FILE, MessageType.VOICEMESSAGE};
+                    return new MessageType[]{MessageType.FILE};
                 }
 
                 @Override
@@ -317,7 +322,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         return R.layout.activity_media_viewer;
     }
 
-    private void updateActionBarTitle(AbstractMessageModel messageModel) {
+    private void updateActionBarTitle(@Nullable AbstractMessageModel messageModel) {
         String title = NameUtil.getContactDisplayNameOrNickname(
             this,
             messageModel,
@@ -345,14 +350,28 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         } else {
             getToolbar().setTitle(null);
         }
+    }
 
+    private void updateCaption(@Nullable AbstractMessageModel messageModel) {
         String captionText = MessageUtil.getCaptionText(messageModel);
-        if (!TestUtil.isEmptyOrNull(captionText)) {
+        if (!isNullOrEmpty(captionText)) {
             this.caption.setText(emojiMarkupUtil.addMarkup(this, captionText));
         } else {
             this.caption.setText("");
         }
-        this.captionContainer.setVisibility(TestUtil.isEmptyOrNull(captionText) ? View.GONE : View.VISIBLE);
+        this.captionContainer.setVisibility(isNullOrEmpty(captionText) ? View.GONE : View.VISIBLE);
+
+        var mimeType = messageModel != null ? messageModel.getFileData().getMimeType() : null;
+        captionContainer.setPadding(
+            captionContainer.getPaddingLeft(),
+            captionContainer.getPaddingTop(),
+            captionContainer.getPaddingRight(),
+            getResources().getDimensionPixelSize(
+                MimeUtil.isVideoFile(mimeType) || MimeUtil.isAudioFile(mimeType)
+                    ? R.dimen.media_viewer_caption_border_bottom_for_playable_media
+                    : R.dimen.media_viewer_caption_border_bottom
+            )
+        );
     }
 
     private void updateMenus() {
@@ -389,7 +408,8 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
             this.currentPosition = index;
             this.currentMessageModel = this.messageModels.get(this.currentPosition);
 
-            updateActionBarTitle(this.currentMessageModel);
+            updateActionBarTitle(currentMessageModel);
+            updateCaption(currentMessageModel);
 
             final @Nullable MediaViewFragment currentMediaViewFragment = this.getCurrentFragment();
             for (@Nullable MediaViewFragment mediaViewFragment : fragments) {
@@ -460,9 +480,10 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         AbstractMessageModel messageModel = this.getCurrentMessageModel();
         if (messageModel != null) {
             if (currentMediaFile == null) {
+                logger.warn("Failed to save media, current file was null");
                 Toast.makeText(this, R.string.media_file_not_found, Toast.LENGTH_LONG).show();
             } else {
-                dependencies.getFileService().saveMedia(this, null, new CopyOnWriteArrayList<>(Collections.singletonList(messageModel)), true);
+                new SaveMediaAsyncTask(dependencies.getFileService(), this, null, Collections.singletonList(messageModel), true).execute();
             }
         }
     }
@@ -484,7 +505,15 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
     @Override
     public void onYes(String tag, Object data, String text) {
         AbstractMessageModel messageModel = (AbstractMessageModel) data;
-        Uri shareUri = dependencies.getFileService().copyToShareFile(messageModel, currentMediaFile);
+        if (messageModel == null) {
+            logger.error("Failed to share media file, message was null");
+            return;
+        }
+        if (currentMediaFile == null) {
+            logger.error("Failed to share media file, currentMediaFile was null");
+            return;
+        }
+        Uri shareUri = dependencies.getFileService().copyDecryptedMessageFileToShareDirectory(messageModel, currentMediaFile);
         dependencies.getMessageService().shareMediaMessages(this,
             new ArrayList<>(Collections.singletonList(messageModel)),
             new ArrayList<>(Collections.singletonList(shareUri)), text);
@@ -496,7 +525,15 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
 
     public void viewMediaInGallery() {
         AbstractMessageModel messageModel = this.getCurrentMessageModel();
-        Uri shareUri = dependencies.getFileService().copyToShareFile(messageModel, currentMediaFile);
+        if (messageModel == null) {
+            logger.error("Failed to view media message, message was null");
+            return;
+        }
+        if (currentMediaFile == null) {
+            logger.error("Failed to view media message, currentMediaFile was null");
+            return;
+        }
+        Uri shareUri = dependencies.getFileService().copyDecryptedMessageFileToShareDirectory(messageModel, currentMediaFile);
         dependencies.getMessageService().viewMediaMessage(this, messageModel, shareUri);
     }
 
@@ -522,11 +559,16 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         }
     }
 
-    private void showInChat(AbstractMessageModel messageModel) {
+    private void showInChat(@Nullable AbstractMessageModel messageModel) {
         if (messageModel == null) {
             return;
         }
-        startActivityForResult(IntentDataUtil.getJumpToMessageIntent(this, messageModel), ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE);
+        final Intent intent = ComposeMessageActivity.createIntentJumpToMessage(this, messageModel);
+        intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+        startActivityForResult(
+            intent,
+            ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE
+        );
         finish();
     }
 
@@ -561,6 +603,9 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         if (this.captionContainer != null) {
             this.captionContainer.setVisibility(View.GONE);
         }
+        if (appBarLayout != null) {
+            findViewById(R.id.appbar).setVisibility(View.GONE);
+        }
     }
 
     public void showUi() {
@@ -568,9 +613,12 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
 
         showSystemUi();
         actionBar.show();
-        if (this.captionContainer != null && !TestUtil.isBlankOrNull(caption.getText())) {
+        if (this.captionContainer != null && !isNullOrBlank(caption.getText())) {
             this.captionContainer.setVisibility(View.VISIBLE);
         }
+        if (appBarLayout != null) {
+            findViewById(R.id.appbar).setVisibility(View.VISIBLE);
+        }
     }
 
     @Override
@@ -736,34 +784,21 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
                     intent.removeExtra(EXTRA_ID_IMMEDIATE_PLAY);
                 }
 
-                switch (messageModel.getType()) {
-                    case VIDEO:
-                        mediaViewFragment = new VideoViewFragment();
-                        break;
-                    case FILE:
-                        String mimeType = messageModel.getFileData().getMimeType();
-                        if (MimeUtil.isSupportedImageFile(mimeType)) {
-                            mediaViewFragment = new ImageViewFragment();
-                        } else if (MimeUtil.isVideoFile(mimeType)) {
-                            mediaViewFragment = new VideoViewFragment();
-                        } else if (MimeUtil.isAudioFile(mimeType)) {
-                            if (MimeUtil.isMidiFile(mimeType) || MimeUtil.isFlacFile(mimeType)) {
-                                mediaViewFragment = new MediaPlayerViewFragment();
-                            } else {
-                                args.putBoolean(EXTRA_IS_VOICE_MESSAGE, messageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE);
-                                args.putBoolean(EXTRA_IS_PRIVATE_CHAT, mediaViewerActivity.isPrivateChat);
-                                mediaViewFragment = new AudioViewFragment();
-                            }
-                        } else {
-                            mediaViewFragment = new FileViewFragment();
-                        }
-                        break;
-                    case VOICEMESSAGE:
+                String mimeType = messageModel.getFileData().getMimeType();
+                if (MimeUtil.isSupportedImageFile(mimeType)) {
+                    mediaViewFragment = new ImageViewFragment();
+                } else if (MimeUtil.isVideoFile(mimeType)) {
+                    mediaViewFragment = new VideoViewFragment();
+                } else if (MimeUtil.isAudioFile(mimeType)) {
+                    if (MimeUtil.isMidiFile(mimeType) || MimeUtil.isFlacFile(mimeType)) {
+                        mediaViewFragment = new MediaPlayerViewFragment();
+                    } else {
+                        args.putBoolean(EXTRA_IS_VOICE_MESSAGE, messageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE);
                         args.putBoolean(EXTRA_IS_PRIVATE_CHAT, mediaViewerActivity.isPrivateChat);
                         mediaViewFragment = new AudioViewFragment();
-                        break;
-                    default:
-                        mediaViewFragment = new ImageViewFragment();
+                    }
+                } else {
+                    mediaViewFragment = new FileViewFragment();
                 }
 
                 args.putInt("position", position);
@@ -866,5 +901,15 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         super.onConfigurationChanged(newConfig);
 
         ConfigUtils.adjustToolbar(this, getToolbar());
+
+        if (captionContainer != null) {
+            ConstraintSet constraintSet = new ConstraintSet();
+            constraintSet.clone(captionContainer);
+            constraintSet.constrainMaxHeight(
+                R.id.caption_card,
+                getResources().getDimensionPixelSize(R.dimen.media_viewer_caption_max_height)
+            );
+            constraintSet.applyTo(captionContainer);
+        }
     }
 }

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

@@ -56,10 +56,11 @@ import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.SnackbarUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.ContactModel;
 
 import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
+import static ch.threema.common.JavaCompat.isNullOrBlank;
+import static ch.threema.common.JavaCompat.isNullOrEmpty;
 
 abstract public class MemberChooseActivity extends ThreemaToolbarActivity implements SearchView.OnQueryTextListener, MemberListFragment.SelectionListener {
     private final static int FRAGMENT_USERS = 0;
@@ -257,7 +258,7 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
                 if (searchView != null) {
                     if (searchMenuItem != null) {
                         CharSequence query = searchView.getQuery();
-                        if (TestUtil.isBlankOrNull(query)) {
+                        if (isNullOrBlank(query)) {
                             invalidateOptionsMenu();
                             if (searchMenuItem.isActionViewExpanded()) {
                                 searchMenuItem.collapseActionView();
@@ -310,7 +311,7 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
                 }
                 return false;
             });
-            if (!TestUtil.isEmptyOrNull(queryText)) {
+            if (!isNullOrEmpty(queryText)) {
                 this.searchMenuItem.expandActionView();
                 this.searchView.setIconified(false);
                 this.searchView.setQuery(queryText, true);

+ 17 - 10
app/src/main/java/ch/threema/app/activities/PermissionRequestActivity.kt

@@ -14,6 +14,7 @@ import android.widget.LinearLayout
 import android.widget.LinearLayout.LayoutParams
 import android.widget.Space
 import android.widget.TextView
+import androidx.activity.OnBackPressedCallback
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.coordinatorlayout.widget.CoordinatorLayout
 import androidx.core.app.ActivityCompat
@@ -158,6 +159,19 @@ class PermissionRequestActivity : ThreemaActivity() {
         logPermissionStates()
 
         handleDeviceInsets()
+
+        onBackPressedDispatcher.addCallback(
+            this,
+            object : OnBackPressedCallback(true) {
+                override fun handleOnBackPressed() {
+                    if (getFirstPendingPermissionStatePosition() == null) {
+                        finishWithSuccess()
+                    } else {
+                        finishWithoutSuccess()
+                    }
+                }
+            },
+        )
     }
 
     fun handleDeviceInsets() {
@@ -175,15 +189,6 @@ class PermissionRequestActivity : ThreemaActivity() {
         }
     }
 
-    @Deprecated("Deprecated in Java")
-    override fun onBackPressed() {
-        if (getFirstPendingPermissionStatePosition() == null) {
-            finishWithSuccess()
-        } else {
-            finishWithoutSuccess()
-        }
-    }
-
     private fun initializePermissionRequests() {
         val requests = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
             intent.getParcelableArrayListExtra(
@@ -369,7 +374,7 @@ class PermissionRequestActivity : ThreemaActivity() {
     private fun finishWithoutSuccess() {
         logger.info("Some required permissions are not granted")
         logPermissionStates()
-        setResult(RESULT_CANCELED)
+        setResult(RESULT_CANCELED_BY_USER)
         finish()
     }
 
@@ -447,6 +452,8 @@ class PermissionRequestActivity : ThreemaActivity() {
     }
 
     companion object {
+        const val RESULT_CANCELED_BY_USER = 2
+
         private const val INTENT_PERMISSION_REQUESTS = "permission_requests_extra"
 
         fun createIntent(context: Context, requests: ArrayList<PermissionRequest>) = buildActivityIntent<PermissionRequestActivity>(context) {

+ 367 - 329
app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java

@@ -9,7 +9,6 @@ import android.content.pm.PackageManager;
 import android.database.Cursor;
 import android.location.Location;
 import android.net.Uri;
-import android.os.AsyncTask;
 import android.os.BadParcelableException;
 import android.os.BaseBundle;
 import android.os.Build;
@@ -17,7 +16,6 @@ import android.os.Bundle;
 import android.os.Parcelable;
 import android.provider.DocumentsContract;
 import android.text.TextUtils;
-import android.text.format.DateUtils;
 import android.util.Pair;
 import android.util.SparseArray;
 import android.view.Menu;
@@ -31,11 +29,15 @@ import com.google.android.material.search.SearchBar;
 import com.google.android.material.snackbar.Snackbar;
 import com.google.android.material.tabs.TabLayout;
 
+import org.jetbrains.annotations.NotNull;
 import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
 import java.io.File;
+import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -58,6 +60,8 @@ import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentPagerAdapter;
 import androidx.viewpager.widget.ViewPager;
+import ch.threema.android.LifecycleAwareAsyncTask;
+import ch.threema.android.ToastDuration;
 import ch.threema.app.AppConstants;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
@@ -68,6 +72,7 @@ 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.asynctasks.LoadDecryptedMessageFileAsyncTask;
 import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.dialogs.CancelableHorizontalProgressDialog;
 import ch.threema.app.dialogs.ExpandableTextEntryDialog;
@@ -83,10 +88,8 @@ import ch.threema.app.fragments.WorkUserListFragment;
 import ch.threema.app.home.HomeActivity;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.messagereceiver.SendingPermissionValidationResult;
-import ch.threema.app.services.FileService;
 import ch.threema.app.preference.service.PreferenceService;
 import ch.threema.app.startup.AppStartupAware;
-import ch.threema.app.ui.LongToast;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.ui.SingleToast;
 import ch.threema.app.ui.ThreemaSearchView;
@@ -102,11 +105,13 @@ import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.NavigationUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.ShortcutUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.executor.BackgroundExecutor;
 
+import static ch.threema.android.ToastKt.showToast;
 import static ch.threema.app.startup.AppStartupUtilKt.waitUntilReady;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+
+import ch.threema.domain.models.AcquaintanceLevel;
 import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.domain.protocol.csp.messages.location.Poi;
 import ch.threema.storage.models.AbstractMessageModel;
@@ -116,7 +121,9 @@ import ch.threema.storage.models.group.GroupModelOld;
 import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.data.LocationDataModel;
 import ch.threema.storage.models.data.MessageContentsType;
+
 import java.util.concurrent.CompletableFuture;
+
 import kotlin.Lazy;
 import kotlin.Unit;
 
@@ -126,6 +133,9 @@ 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 static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
+import static ch.threema.common.JavaCompat.copyStream;
+import static ch.threema.common.JavaCompat.isNullOrBlank;
+import static ch.threema.common.JavaCompat.isNullOrEmpty;
 import static ch.threema.common.LazyKt.lazy;
 
 public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
@@ -180,14 +190,14 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             for (int i = 0; i < mediaItems.size(); i++) {
                 MediaItem mediaItem = mediaItems.get(i);
 
-                if (TestUtil.isEmptyOrNull(mediaItem.getFilename())) {
+                if (isNullOrEmpty(mediaItem.getFilename())) {
                     mediaItem.setFilename(FileUtil.getFilenameFromUri(getContentResolver(), mediaItem));
                 }
 
                 if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(mediaItem.getUri().getScheme())) {
                     try {
                         File file = dependencies.getFileService().createTempFile("rcpt", null);
-                        FileUtil.copyFile(mediaItem.getUri(), file, getContentResolver());
+                        copyFile(mediaItem.getUri(), file, getContentResolver());
                         mediaItem.setUri(Uri.fromFile(file));
                         mediaItem.setDeleteAfterUse(true);
                     } catch (IOException e) {
@@ -198,6 +208,18 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
         }
     };
 
+    @WorkerThread
+    private static void copyFile(@NonNull Uri source, @NonNull File dest, @NonNull ContentResolver contentResolver) {
+        try (InputStream inputStream = contentResolver.openInputStream(source);
+             OutputStream outputStream = new FileOutputStream(dest)) {
+            if (inputStream != null) {
+                copyStream(inputStream, outputStream);
+            }
+        } catch (Exception e) {
+            logger.error("Failed to copy file", e);
+        }
+    }
+
     @Override
     public boolean onQueryTextSubmit(String query) {
         // Do something
@@ -321,7 +343,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
                 .setIcon(R.drawable.ic_group_outline)
                 .setContentDescription(R.string.title_tab_groups));
             tabLayout.addTab(tabLayout.newTab()
-                .setIcon(R.drawable.ic_bullhorn_outline)
+                .setIcon(R.drawable.ic_distribution_list)
                 .setContentDescription(R.string.title_tab_distribution_list));
 
             tabs.add(FRAGMENT_USERS);
@@ -349,7 +371,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
                     if (searchView != null) {
                         if (searchMenuItem != null) {
                             CharSequence query = searchView.getQuery();
-                            if (TestUtil.isBlankOrNull(query)) {
+                            if (isNullOrBlank(query)) {
                                 invalidateOptionsMenu();
                                 if (searchMenuItem.isActionViewExpanded()) {
                                     searchMenuItem.collapseActionView();
@@ -425,281 +447,291 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     }
 
     @Override
-    protected void onNewIntent(Intent intent) {
+    protected void onNewIntent(@NonNull Intent intent) {
         logger.debug("onNewIntent");
-
         super.onNewIntent(intent);
 
         resetValues();
+        setIntent(intent);
+
+        try {
+            this.hideRecents = intent.getBooleanExtra(AppConstants.INTENT_DATA_HIDE_RECENTS, false);
+            this.multiSelect = intent.getBooleanExtra(INTENT_DATA_MULTISELECT, true);
+            this.multiSelectIdentities = intent.getBooleanExtra(INTENT_DATA_MULTISELECT_FOR_COMPOSE, false);
+        } catch (BadParcelableException e) {
+            logger.error("Exception", e);
+        }
 
-        if (intent != null) {
-            setIntent(intent);
-
-            try {
-                this.hideRecents = intent.getBooleanExtra(AppConstants.INTENT_DATA_HIDE_RECENTS, false);
-                this.multiSelect = intent.getBooleanExtra(INTENT_DATA_MULTISELECT, true);
-                this.multiSelectIdentities = intent.getBooleanExtra(INTENT_DATA_MULTISELECT_FOR_COMPOSE, false);
-            } catch (BadParcelableException e) {
-                logger.error("Exception", e);
-            }
-
-            String identity = IntentDataUtil.getIdentity(intent);
-            long groupId = IntentDataUtil.getGroupId(intent);
-            long distributionListID = IntentDataUtil.getDistributionListId(intent);
-
-            if (TestUtil.isEmptyOrNull(identity) && groupId == -1 && distributionListID == -1) {
-                // maybe a shortcut?
-                String id = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID);
-                if (!TestUtil.isEmptyOrNull(id)) {
-                    BaseBundle bundle = ShortcutUtil.getShareTargetExtrasFromShortcutId(id);
-                    if (bundle != null) {
-                        if (bundle.containsKey(AppConstants.INTENT_DATA_CONTACT)) {
-                            identity = bundle.getString(AppConstants.INTENT_DATA_CONTACT);
-                        } else if (bundle.containsKey(AppConstants.INTENT_DATA_GROUP_DATABASE_ID)) {
-                            groupId = bundle.getLong(AppConstants.INTENT_DATA_GROUP_DATABASE_ID);
-                        } else if (bundle.containsKey(AppConstants.INTENT_DATA_DISTRIBUTION_LIST_ID)) {
-                            distributionListID = bundle.getLong(AppConstants.INTENT_DATA_DISTRIBUTION_LIST_ID);
-                        }
+        String identity = IntentDataUtil.getIdentity(intent);
+        long groupId = IntentDataUtil.getGroupDatabaseId(intent);
+        long distributionListID = IntentDataUtil.getDistributionListId(intent);
+
+        if (isNullOrEmpty(identity) && groupId == -1 && distributionListID == -1) {
+            // maybe a shortcut?
+            String id = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID);
+            if (!isNullOrEmpty(id)) {
+                BaseBundle bundle = ShortcutUtil.getShareTargetExtrasFromShortcutId(id);
+                if (bundle != null) {
+                    if (bundle.containsKey(AppConstants.INTENT_DATA_CONTACT)) {
+                        identity = bundle.getString(AppConstants.INTENT_DATA_CONTACT);
+                    } else if (bundle.containsKey(AppConstants.INTENT_DATA_GROUP_DATABASE_ID)) {
+                        groupId = bundle.getLong(AppConstants.INTENT_DATA_GROUP_DATABASE_ID);
+                    } else if (bundle.containsKey(AppConstants.INTENT_DATA_DISTRIBUTION_LIST_ID)) {
+                        distributionListID = bundle.getLong(AppConstants.INTENT_DATA_DISTRIBUTION_LIST_ID);
                     }
                 }
             }
+        }
 
-            if (groupId > 0 || distributionListID > 0 || !TestUtil.isEmptyOrNull(identity)) {
-                hideUi = true;
-            }
+        if (groupId > 0 || distributionListID > 0 || !isNullOrEmpty(identity)) {
+            hideUi = true;
+        }
 
-            String action = intent.getAction();
-            if (action != null) {
-                // called from other app via regular send intent
-                if (action.equals(Intent.ACTION_SEND)) {
-                    String type = intent.getType();
-                    Uri uri = null;
-                    Parcelable parcelable = intent.getParcelableExtra(Intent.EXTRA_STREAM);
+        String action = intent.getAction();
+        if (action != null) {
+            // called from other app via regular send intent
+            if (action.equals(Intent.ACTION_SEND)) {
+                String type = intent.getType();
+                Uri uri = null;
+                Parcelable parcelable = intent.getParcelableExtra(Intent.EXTRA_STREAM);
+
+                if (parcelable != null) {
+                    if (!(parcelable instanceof Uri)) {
+                        parcelable = Uri.parse(parcelable.toString());
+                    }
+                    uri = (Uri) parcelable;
+                }
 
-                    if (parcelable != null) {
-                        if (!(parcelable instanceof Uri)) {
-                            parcelable = Uri.parse(parcelable.toString());
+                if (type != null && (uri != null || MimeUtil.isText(type))) {
+                    if (type.equals(MimeUtil.MIME_TYPE_EMAIL)) {
+                        // email attachments
+                        //  extract file type from uri path
+                        String mimeType = FileUtil.getMimeTypeFromUri(this, uri);
+                        if (!isNullOrEmpty(mimeType)) {
+                            type = mimeType;
                         }
-                        uri = (Uri) parcelable;
-                    }
 
-                    if (type != null && (uri != null || MimeUtil.isText(type))) {
-                        if (type.equals(MimeUtil.MIME_TYPE_EMAIL)) {
-                            // email attachments
-                            //  extract file type from uri path
-                            String mimeType = FileUtil.getMimeTypeFromUri(this, uri);
-                            if (!TestUtil.isEmptyOrNull(mimeType)) {
-                                type = mimeType;
+                        // email body text - can be null
+                        CharSequence charSequence = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
+                        if (charSequence != null) {
+                            String textIntent = charSequence.toString();
+                            if (!(textIntent.contains("---") && textIntent.contains("WhatsApp"))) {
+                                // whatsapp forwards media as rfc822 with a footer
+                                // strip this footer
+                                mediaItems.add(new MediaItem(uri, TYPE_TEXT, MimeUtil.MIME_TYPE_TEXT, textIntent));
+                            }
+                        }
+                    }
+                    if (type.equals(MimeUtil.MIME_TYPE_TEXT)) {
+                        String textIntent = getTextFromIntent(intent);
+                        if (GeoLocationUtil.isValidGeoUri(uri)) {
+                            mediaItems.add(new MediaItem(uri, MediaItem.TYPE_LOCATION, MimeUtil.MIME_TYPE_ANY, textIntent));
+                        } else if (uri != null) {
+                            // default to sending text as file
+                            type = "x-text/plain";
+
+                            String guessedType = getMimeTypeFromContentUri(uri);
+                            if (guessedType != null) {
+                                type = guessedType;
                             }
 
-                            // email body text - can be null
-                            CharSequence charSequence = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
-                            if (charSequence != null) {
-                                String textIntent = charSequence.toString();
-                                if (!(textIntent.contains("---") && textIntent.contains("WhatsApp"))) {
-                                    // whatsapp forwards media as rfc822 with a footer
-                                    // strip this footer
-                                    mediaItems.add(new MediaItem(uri, TYPE_TEXT, MimeUtil.MIME_TYPE_TEXT, textIntent));
-                                }
+                            addMediaItemSharedFromOtherApp(type, uri, textIntent);
+                            if (textIntent != null) {
+                                captionText = textIntent;
                             }
+                        } else if (textIntent != null) {
+                            mediaItems.add(new MediaItem(null, TYPE_TEXT, MimeUtil.MIME_TYPE_TEXT, textIntent));
                         }
-                        if (type.equals(MimeUtil.MIME_TYPE_TEXT)) {
-                            String textIntent = getTextFromIntent(intent);
-                            if (GeoLocationUtil.isValidGeoUri(uri)) {
-                                mediaItems.add(new MediaItem(uri, MediaItem.TYPE_LOCATION, MimeUtil.MIME_TYPE_ANY, textIntent));
-                            } else if (uri != null) {
-                                // default to sending text as file
-                                type = "x-text/plain";
-
-                                String guessedType = getMimeTypeFromContentUri(uri);
-                                if (guessedType != null) {
+                    } else {
+                        if (uri != null) {
+                            // guess the correct mime type as ACTION_SEND may have been called with a generic mime type such as "image/*" which should be overridden
+                            String guessedType = getMimeTypeFromContentUri(uri);
+                            if (guessedType != null) {
+                                if (!guessedType.equals(MimeUtil.MIME_TYPE_DEFAULT) || type.equals("*")) {
                                     type = guessedType;
                                 }
-
-                                addMediaItemSharedFromOtherApp(type, uri, textIntent);
-                                if (textIntent != null) {
-                                    captionText = textIntent;
-                                }
-                            } else if (textIntent != null) {
-                                mediaItems.add(new MediaItem(null, TYPE_TEXT, MimeUtil.MIME_TYPE_TEXT, textIntent));
                             }
-                        } else {
-                            if (uri != null) {
-                                // guess the correct mime type as ACTION_SEND may have been called with a generic mime type such as "image/*" which should be overridden
-                                String guessedType = getMimeTypeFromContentUri(uri);
-                                if (guessedType != null) {
-                                    if (!guessedType.equals(MimeUtil.MIME_TYPE_DEFAULT) || type.equals("*")) {
-                                        type = guessedType;
-                                    }
-                                }
 
-                                String textIntent = getTextFromIntent(intent);
-                                // don't add fixed caption to media item because we want it to be editable when sending a zip file (share chat)
-                                if (type.equals(MimeUtil.MIME_TYPE_ZIP) && textIntent != null) {
-                                    captionText = textIntent;
-                                    mediaItems.add(new MediaItem(uri, MediaItem.TYPE_FILE, MimeUtil.MIME_TYPE_ZIP, textIntent));
-                                } else { // if text was shared along with the media item, add that too
-                                    addMediaItemSharedFromOtherApp(type, uri, textIntent);
-                                }
+                            String textIntent = getTextFromIntent(intent);
+                            // don't add fixed caption to media item because we want it to be editable when sending a zip file (share chat)
+                            if (type.equals(MimeUtil.MIME_TYPE_ZIP) && textIntent != null) {
+                                captionText = textIntent;
+                                mediaItems.add(new MediaItem(uri, MediaItem.TYPE_FILE, MimeUtil.MIME_TYPE_ZIP, textIntent));
+                            } else { // if text was shared along with the media item, add that too
+                                addMediaItemSharedFromOtherApp(type, uri, textIntent);
                             }
                         }
-                    } else {
-                        // try ClipData
-                        ClipData clipData = intent.getClipData();
-                        if (clipData != null && clipData.getItemCount() > 0) {
-                            for (int i = 0; i < clipData.getItemCount(); i++) {
-                                Uri uri1 = clipData.getItemAt(i).getUri();
-                                CharSequence text = clipData.getItemAt(i).getText();
-
-                                if (uri1 != null) {
-                                    addMediaItemSharedFromOtherApp(type, uri1, null);
-                                } else if (!TestUtil.isBlankOrNull(text)) {
-                                    mediaItems.add(new MediaItem(uri, TYPE_TEXT, MimeUtil.MIME_TYPE_TEXT, text.toString()));
-                                }
+                    }
+                } else {
+                    // try ClipData
+                    ClipData clipData = intent.getClipData();
+                    if (clipData != null && clipData.getItemCount() > 0) {
+                        for (int i = 0; i < clipData.getItemCount(); i++) {
+                            Uri uri1 = clipData.getItemAt(i).getUri();
+                            CharSequence text = clipData.getItemAt(i).getText();
+
+                            if (uri1 != null) {
+                                addMediaItemSharedFromOtherApp(type, uri1, null);
+                            } else if (!isNullOrBlank(text)) {
+                                mediaItems.add(new MediaItem(uri, TYPE_TEXT, MimeUtil.MIME_TYPE_TEXT, text.toString()));
                             }
                         }
-
-                        if (mediaItems.isEmpty()) {
-                            Toast.makeText(this, getString(R.string.invalid_data), Toast.LENGTH_LONG).show();
-                            finish();
-                        }
                     }
 
-                    if (!TestUtil.isEmptyOrNull(identity)) {
-                        prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(dependencies.getContactService().getByIdentity(identity))));
-                    } else if (groupId > 0) {
-                        prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(dependencies.getGroupService().getById(groupId))));
-                    } else if (distributionListID > 0) {
-                        prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(dependencies.getDistributionListService().getById(distributionListID))));
-                    }
-                } else if (action.equals(Intent.ACTION_SENDTO)) {
-                    // called from contact app or quickcontactbadge
-                    if (dependencies.getLockAppService().isLocked()) {
+                    if (mediaItems.isEmpty()) {
+                        Toast.makeText(this, getString(R.string.invalid_data), Toast.LENGTH_LONG).show();
                         finish();
-                        return;
                     }
+                }
+
+                if (!isNullOrEmpty(identity)) {
+                    prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(dependencies.getContactService().getByIdentity(identity))));
+                } else if (groupId > 0) {
+                    prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(dependencies.getGroupService().getById(groupId))));
+                } else if (distributionListID > 0) {
+                    prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(dependencies.getDistributionListService().getById(distributionListID))));
+                }
+            } else if (action.equals(Intent.ACTION_SENDTO)) {
+                // called from contact app or quickcontactbadge
+                if (dependencies.getLockAppService().isLocked()) {
+                    finish();
+                    return;
+                }
 
-                    // try to extract identity from intent data
-                    Uri uri = intent.getData();
+                // try to extract identity from intent data
+                Uri uri = intent.getData();
 
-                    // skip user selection if recipient is already known
-                    if (uri != null && "smsto".equals(uri.getScheme())) {
-                        mediaItems.add(new MediaItem(uri, TYPE_TEXT, MimeUtil.MIME_TYPE_TEXT, intent.getStringExtra("sms_body")));
+                // skip user selection if recipient is already known
+                if (uri != null && "smsto".equals(uri.getScheme())) {
+                    mediaItems.add(new MediaItem(uri, TYPE_TEXT, MimeUtil.MIME_TYPE_TEXT, intent.getStringExtra("sms_body")));
 
-                        final ContactModel contactModel = ContactLookupUtil.phoneNumberToContact(this, dependencies.getContactService(), uri.getSchemeSpecificPart());
+                    final ContactModel contactModel = ContactLookupUtil.phoneNumberToContact(this, dependencies.getContactService(), uri.getSchemeSpecificPart());
 
-                        if (contactModel != null) {
-                            prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)), false);
-                        } else {
-                            finish();
-                        }
-                        return;
+                    if (contactModel != null) {
+                        prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)), false);
+                    } else {
+                        finish();
                     }
-                } else if (action.equals(Intent.ACTION_VIEW)) {
-                    // called from action URL
-                    Uri dataUri = intent.getData();
-
-                    if (dataUri != null) {
-                        String scheme = dataUri.getScheme();
-                        String host = dataUri.getHost();
-
-                        if (scheme != null && host != null) {
-                            if (
-                                (BuildConfig.uriScheme.equals(scheme) && "compose".equals(host))
-                                    ||
-                                    ("https".equals(scheme) && (BuildConfig.actionUrl.equals(host) || BuildConfig.contactActionUrl.equals(host)) && "/compose".equals(dataUri.getPath()))
-                            ) {
-                                if (dependencies.getLockAppService().isLocked()) {
-                                    finish();
-                                    return;
-                                }
+                    return;
+                }
+            } else if (action.equals(Intent.ACTION_VIEW)) {
+                // called from action URL
+                Uri dataUri = intent.getData();
+
+                boolean handled = false;
+                if (dataUri != null) {
+                    String scheme = dataUri.getScheme();
+                    String host = dataUri.getHost();
+
+                    if (scheme != null && host != null) {
+                        if (
+                            (BuildConfig.uriScheme.equals(scheme) && "compose".equals(host))
+                                ||
+                                ("https".equals(scheme) && (BuildConfig.actionUrl.equals(host) || BuildConfig.contactActionUrl.equals(host)) && "/compose".equals(dataUri.getPath()))
+                        ) {
+                            if (dependencies.getLockAppService().isLocked()) {
+                                finish();
+                                return;
+                            }
 
-                                String text = dataUri.getQueryParameter("text");
-                                if (!TestUtil.isEmptyOrNull(text)) {
-                                    mediaItems.add(new MediaItem(dataUri, TYPE_TEXT, MimeUtil.MIME_TYPE_TEXT, text));
-                                }
+                            String text = dataUri.getQueryParameter("text");
+                            if (!isNullOrEmpty(text)) {
+                                mediaItems.add(new MediaItem(dataUri, TYPE_TEXT, MimeUtil.MIME_TYPE_TEXT, text));
+                            }
 
-                                String targetIdentity;
-                                String queryParameter = dataUri.getQueryParameter("id");
-                                if (queryParameter != null) {
-                                    targetIdentity = queryParameter.toUpperCase();
-                                    ContactModel contactModel = dependencies.getContactService().getByIdentity(targetIdentity);
+                            String targetIdentity;
+                            String queryParameter = dataUri.getQueryParameter("id");
+                            if (queryParameter != null) {
+                                targetIdentity = queryParameter.toUpperCase();
+                                ContactModel contactModel = dependencies.getContactService().getByIdentity(targetIdentity);
 
-                                    if (contactModel == null) {
-                                        addNewContact(targetIdentity);
-                                    } else {
-                                        prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)), false);
-                                    }
-                                }
-                            } else {
-                                // redirect to chat
-                                MessageReceiver messageReceiver = IntentDataUtil.getMessageReceiverFromExtras(intent.getExtras(), dependencies.getContactService(), dependencies.getGroupService(), dependencies.getDistributionListService());
-                                if (messageReceiver != null) {
-                                    Intent composeIntent = IntentDataUtil.getComposeIntentForReceivers(this, new ArrayList<>(Collections.singletonList(messageReceiver)));
-                                    startComposeActivity(composeIntent);
+                                if (contactModel == null) {
+                                    addNewContact(targetIdentity);
+                                } else {
+                                    prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)), false);
                                 }
-                                return;
                             }
+                            handled = true;
                         }
                     }
-                } else if (action.equals(Intent.ACTION_SEND_MULTIPLE) && intent.hasExtra(Intent.EXTRA_STREAM)) {
-                    // called from other app with multiple media payload
-                    String type = intent.getType();
-
-                    ArrayList<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
-                    if (uris != null) {
-                        for (int i = 0; i < uris.size(); i++) {
-                            if (i < MAX_FORWARDABLE_ITEMS) {
-                                Uri uri = uris.get(i);
-                                if (uri != null) {
-                                    String mimeType = FileUtil.getMimeTypeFromUri(this, uri);
-                                    if (mimeType == null) {
-                                        mimeType = type;
-                                    }
-                                    addMediaItemSharedFromOtherApp(mimeType, uri, null);
+                }
+                if (!handled) {
+                    // redirect to chat
+                    final @Nullable MessageReceiver messageReceiver = IntentDataUtil.getMessageReceiverFromExtras(
+                        intent.getExtras(),
+                        dependencies.getContactService(),
+                        dependencies.getGroupService(),
+                        dependencies.getDistributionListService()
+                    );
+                    if (messageReceiver != null) {
+                        Intent composeIntent = ComposeMessageActivity.createIntent(
+                            this,
+                            messageReceiver.getConversationId(),
+                            null,
+                            true
+                        );
+                        composeIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+                        startComposeActivity(composeIntent);
+                    }
+                    return;
+                }
+            } else if (action.equals(Intent.ACTION_SEND_MULTIPLE) && intent.hasExtra(Intent.EXTRA_STREAM)) {
+                // called from other app with multiple media payload
+                String type = intent.getType();
+
+                ArrayList<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
+                if (uris != null) {
+                    for (int i = 0; i < uris.size(); i++) {
+                        if (i < MAX_FORWARDABLE_ITEMS) {
+                            Uri uri = uris.get(i);
+                            if (uri != null) {
+                                String mimeType = FileUtil.getMimeTypeFromUri(this, uri);
+                                if (mimeType == null) {
+                                    mimeType = type;
                                 }
-                            } else {
-                                Toast.makeText(getApplicationContext(), getString(R.string.max_selectable_media_exceeded, MAX_FORWARDABLE_ITEMS), Toast.LENGTH_LONG).show();
-                                break;
+                                addMediaItemSharedFromOtherApp(mimeType, uri, null);
                             }
+                        } else {
+                            Toast.makeText(getApplicationContext(), getString(R.string.max_selectable_media_exceeded, MAX_FORWARDABLE_ITEMS), Toast.LENGTH_LONG).show();
+                            break;
                         }
+                    }
 
-                        if (!TestUtil.isEmptyOrNull(identity)) {
-                            prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(dependencies.getContactService().getByIdentity(identity))));
-                        }
+                    if (!isNullOrEmpty(identity)) {
+                        prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(dependencies.getContactService().getByIdentity(identity))));
+                    }
 
-                        if (groupId > 0) {
-                            prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(dependencies.getGroupService().getById(groupId))));
-                        }
-                    } else {
-                        finish();
-                        return;
+                    if (groupId > 0) {
+                        prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(dependencies.getGroupService().getById(groupId))));
                     }
-                } else if (action.equals(AppConstants.INTENT_ACTION_FORWARD)) {
-                    // internal forward using message id instead of media URI
-                    ArrayList<Integer> messageIds = IntentDataUtil.getAbstractMessageIds(intent);
-                    String originalMessageType = IntentDataUtil.getAbstractMessageType(intent);
-
-                    if (messageIds != null && !messageIds.isEmpty()) {
-                        for (int messageId : messageIds) {
-                            AbstractMessageModel model = dependencies.getMessageService().getMessageModelFromId(messageId, originalMessageType);
-                            if (model != null && model.getType() != MessageType.BALLOT) {
-                                originalMessageModels.add(model);
-                            }
+                } else {
+                    finish();
+                    return;
+                }
+            } else if (action.equals(AppConstants.INTENT_ACTION_FORWARD)) {
+                // internal forward using message id instead of media URI
+                ArrayList<Integer> messageIds = IntentDataUtil.getAbstractMessageIds(intent);
+                String originalMessageType = IntentDataUtil.getAbstractMessageType(intent);
+
+                if (messageIds != null && !messageIds.isEmpty()) {
+                    for (int messageId : messageIds) {
+                        AbstractMessageModel model = dependencies.getMessageService().getMessageModelFromId(messageId, originalMessageType);
+                        if (model != null && model.getType() != MessageType.POLL) {
+                            originalMessageModels.add(model);
                         }
                     }
-
-                    isInternallyForwardingMediaFiles = originalMessageModels.stream().allMatch(
-                        messageModel ->
-                            messageModel.getMessageContentsType() == MessageContentsType.FILE ||
-                                messageModel.getMessageContentsType() == MessageContentsType.IMAGE ||
-                                messageModel.getMessageContentsType() == MessageContentsType.VIDEO ||
-                                messageModel.getMessageContentsType() == MessageContentsType.GIF ||
-                                messageModel.getMessageContentsType() == MessageContentsType.AUDIO ||
-                                messageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE ||
-                                messageModel.getMessageContentsType() == MessageContentsType.CONTACT
-                    );
                 }
+
+                isInternallyForwardingMediaFiles = originalMessageModels.stream().allMatch(
+                    messageModel ->
+                        messageModel.getMessageContentsType() == MessageContentsType.FILE ||
+                            messageModel.getMessageContentsType() == MessageContentsType.IMAGE ||
+                            messageModel.getMessageContentsType() == MessageContentsType.VIDEO ||
+                            messageModel.getMessageContentsType() == MessageContentsType.GIF ||
+                            messageModel.getMessageContentsType() == MessageContentsType.AUDIO ||
+                            messageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE ||
+                            messageModel.getMessageContentsType() == MessageContentsType.CONTACT
+                );
             }
         }
 
@@ -717,7 +749,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             try (Cursor cursor = getContentResolver().query(uri, proj, null, null, null)) {
                 if (cursor != null && cursor.moveToFirst()) {
                     String mimeType = cursor.getString(cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE));
-                    if (!TestUtil.isEmptyOrNull(mimeType)) {
+                    if (!isNullOrEmpty(mimeType)) {
                         return mimeType;
                     }
                 }
@@ -725,9 +757,9 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             }
 
             String filemame = FileUtil.getFilenameFromUri(getContentResolver(), uri);
-            if (!TestUtil.isEmptyOrNull(filemame)) {
+            if (!isNullOrEmpty(filemame)) {
                 String mimeType = FileUtil.getMimeTypeFromPath(filemame);
-                if (!TestUtil.isEmptyOrNull(mimeType)) {
+                if (!isNullOrEmpty(mimeType)) {
                     return mimeType;
                 }
             }
@@ -739,11 +771,11 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
         String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
         String text = intent.getStringExtra(Intent.EXTRA_TEXT);
 
-        if (TestUtil.isEmptyOrNull(text)) {
+        if (isNullOrEmpty(text)) {
             return subject;
         }
 
-        if (TestUtil.isEmptyOrNull(subject) || text.startsWith(subject)) {
+        if (isNullOrEmpty(subject) || text.startsWith(subject)) {
             return text;
         }
 
@@ -789,9 +821,8 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             backgroundExecutor.getValue().execute(
                 new BasicAddOrUpdateContactBackgroundTask(
                     identity,
-                    ContactModel.AcquaintanceLevel.DIRECT,
-                    dependencies.getUserService().getIdentity(),
-                    dependencies.getApiConnector(),
+                    AcquaintanceLevel.DIRECT,
+                    dependencies.getValidContactsLookupSteps(),
                     dependencies.getContactModelRepository(),
                     AddContactRestrictionPolicy.CHECK,
                     dependencies.getAppRestrictions(),
@@ -846,7 +877,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
                 }
                 return false;
             });
-            if (!TestUtil.isEmptyOrNull(queryText)) {
+            if (!isNullOrEmpty(queryText)) {
                 this.searchMenuItem.expandActionView();
                 this.searchView.setIconified(false);
                 this.searchView.setQuery(queryText, true);
@@ -884,7 +915,19 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             .map(this::getMessageReceiver)
             .collect(Collectors.toCollection(ArrayList<MessageReceiver>::new));
 
-        Intent intent = IntentDataUtil.getComposeIntentForReceivers(this, messageReceivers);
+        final @NonNull Intent intent;
+        if (!messageReceivers.isEmpty()) {
+            intent = ComposeMessageActivity.createIntent(
+                this,
+                messageReceivers.get(0).getConversationId(),
+                null,
+                true
+            );
+        } else {
+            intent = HomeActivity.createIntent(this);
+
+        }
+        intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
 
         if (!originalMessageModels.isEmpty()) {
             this.forwardMessages(messageReceivers.toArray(new MessageReceiver[0]), intent, keepOriginalCaptions);
@@ -907,9 +950,9 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 
     void forwardSingleMessage(final MessageReceiver[] messageReceivers, final int i, final Intent intent, final boolean keepOriginalCaptions) {
         final AbstractMessageModel messageModel = originalMessageModels.get(i);
-        dependencies.getFileService().loadDecryptedMessageFile(messageModel, new FileService.OnDecryptedFileComplete() {
+        new LoadDecryptedMessageFileAsyncTask(dependencies.getFileService()) {
             @Override
-            public void complete(File decryptedFile) {
+            public void onSuccess(@Nullable File decryptedFile) {
                 RuntimeUtil.runOnUiThread(() -> DialogUtil.updateProgress(getSupportFragmentManager(), DIALOG_TAG_MULTISEND, i));
 
                 if (messageModel.isAvailable()) {
@@ -921,15 +964,6 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
                     String caption = keepOriginalCaptions ? messageModel.getCaption() : captionText;
 
                     switch (messageModel.getType()) {
-                        case IMAGE:
-                            sendForwardedMedia(messageReceivers, uri, caption, TYPE_IMAGE, null, FileData.RENDERING_MEDIA, null, 0L);
-                            break;
-                        case VIDEO:
-                            sendForwardedMedia(messageReceivers, uri, caption, MediaItem.TYPE_VIDEO, null, FileData.RENDERING_MEDIA, null, messageModel.getVideoData().getDuration() * DateUtils.SECOND_IN_MILLIS);
-                            break;
-                        case VOICEMESSAGE:
-                            sendForwardedMedia(messageReceivers, uri, caption, MediaItem.TYPE_VOICEMESSAGE, MimeUtil.MIME_TYPE_AUDIO_AAC, FileData.RENDERING_MEDIA, null, messageModel.getAudioData().getDuration() * DateUtils.SECOND_IN_MILLIS);
-                            break;
                         case FILE:
                             int mediaType = MediaItem.TYPE_FILE;
                             String mimeType = messageModel.getFileData().getMimeType();
@@ -954,21 +988,24 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
                 if (i < originalMessageModels.size() - 1) {
                     forwardSingleMessage(messageReceivers, i + 1, intent, keepOriginalCaptions);
                 } else {
-                    RuntimeUtil.runOnUiThread(() -> DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_MULTISEND, true));
-                    startComposeActivity(intent);
+                    RuntimeUtil.runOnUiThread(() -> {
+                        DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_MULTISEND, true);
+                        startComposeActivity(intent);
+                    });
                 }
             }
 
             @Override
-            public void error(String message) {
-                logger.error("Could not load message file: {}", message);
+            public void onError(@NotNull Exception e) {
+                logger.error("Could not load message file", e);
                 RuntimeUtil.runOnUiThread(() -> {
                     SingleToast.getInstance().showLongText(getString(R.string.an_error_occurred_during_send));
                     DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_MULTISEND, true);
                 });
                 finish();
             }
-        });
+        }
+            .execute(this, messageModel);
     }
 
     @UiThread
@@ -1058,8 +1095,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             return;
         }
 
-        //noinspection deprecation
-        new AsyncTask<Void, Void, Void>() {
+        new LifecycleAwareAsyncTask<Void, Void>() {
 
             @Override
             protected void onPreExecute() {
@@ -1070,75 +1106,68 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             }
 
             @Override
-            protected Void doInBackground(Void... voids) {
-                dependencies.getFileService().loadDecryptedMessageFiles(
-                    originalMessageModels,
-                    new FileService.OnDecryptedFilesComplete() {
-                        @Override
-                        public void complete(ArrayList<Uri> uris) {
-                            ArrayList<MediaItem> mappedMediaItems = new ArrayList<>();
-
-                            for (int i = 0; i < uris.size(); i++) {
-                                final AbstractMessageModel originalMessageModel = originalMessageModels.get(i);
-                                final Uri fileUri = uris.get(i);
-
-                                if (fileUri == null || originalMessageModel == null || !originalMessageModel.isAvailable()) {
-                                    continue;
-                                }
+            protected Void doInBackground(Void params) {
+                try {
+                    var files = dependencies.getFileService().decryptMessageFiles(originalMessageModels);
 
-                                final String mimeType = MimeUtil.getMimeTypeFromMessageModel(originalMessageModel);
-                                final int mediaType = determineMediaType(mimeType, fileUri, originalMessageModel);
+                    ArrayList<MediaItem> mappedMediaItems = new ArrayList<>();
 
-                                final MediaItem mediaItem = new MediaItem(
-                                    fileUri,
-                                    mediaType,
-                                    mimeType,
-                                    originalMessageModel.getCaption()
-                                );
+                    for (int i = 0; i < files.size(); i++) {
+                        final AbstractMessageModel originalMessageModel = originalMessageModels.get(i);
+                        final Uri fileUri = Uri.fromFile(files.get(i));
 
-                                final @Nullable String existingFilename = originalMessageModel.getFileData().getFileName();
-                                if (!TestUtil.isBlankOrNull(existingFilename)) {
-                                    mediaItem.setFilename(existingFilename);
-                                }
-
-                                if (originalMessageModel.getMessageContentsType() == MessageContentsType.IMAGE) {
-                                    if (originalMessageModel.getFileData().getRenderingType() == FileData.RENDERING_DEFAULT) {
-                                        mediaItem.setImageScale(PreferenceService.IMAGE_SCALE_SEND_AS_FILE);
-                                    }
-                                } else if (originalMessageModel.getMessageContentsType() == MessageContentsType.VIDEO) {
-                                    if (originalMessageModel.getFileData().getRenderingType() == FileData.RENDERING_DEFAULT) {
-                                        mediaItem.setVideoSize(PreferenceService.VIDEO_SIZE_SEND_AS_FILE);
-                                    }
-                                } else if (originalMessageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE) {
-                                    mediaItem.setDurationMs(
-                                        originalMessageModel.getFileData().getDurationMs()
-                                    );
-                                }
+                        if (originalMessageModel == null || !originalMessageModel.isAvailable()) {
+                            continue;
+                        }
 
-                                mappedMediaItems.add(mediaItem);
-                            }
+                        final String mimeType = MimeUtil.getMimeTypeFromMessageModel(originalMessageModel);
+                        final int mediaType = determineMediaType(mimeType, fileUri, originalMessageModel);
 
-                            if (mappedMediaItems.isEmpty()) {
-                                return;
-                            }
+                        final MediaItem mediaItem = new MediaItem(
+                            fileUri,
+                            mediaType,
+                            mimeType,
+                            originalMessageModel.getCaption()
+                        );
 
-                            Intent intent = IntentDataUtil.addMessageReceiversToIntent(
-                                new Intent(RecipientListBaseActivity.this, SendMediaActivity.class),
-                                recipientMessageReceivers.toArray(new MessageReceiver[0])
-                            );
-                            intent.putExtra(SendMediaActivity.EXTRA_MEDIA_ITEMS, mappedMediaItems);
-                            intent.putExtra(AppConstants.INTENT_DATA_TEXT, combinedRecipientName);
-                            startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_SEND_MEDIA);
+                        final @Nullable String existingFilename = originalMessageModel.getFileData().getFileName();
+                        if (!isNullOrBlank(existingFilename)) {
+                            mediaItem.setFilename(existingFilename);
                         }
 
-                        @Override
-                        public void error(String message) {
-                            RuntimeUtil.runOnUiThread(
-                                () -> LongToast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show()
+                        if (originalMessageModel.getMessageContentsType() == MessageContentsType.IMAGE) {
+                            if (originalMessageModel.getFileData().getRenderingType() == FileData.RENDERING_DEFAULT) {
+                                mediaItem.setImageScale(PreferenceService.IMAGE_SCALE_SEND_AS_FILE);
+                            }
+                        } else if (originalMessageModel.getMessageContentsType() == MessageContentsType.VIDEO) {
+                            if (originalMessageModel.getFileData().getRenderingType() == FileData.RENDERING_DEFAULT) {
+                                mediaItem.setVideoSize(PreferenceService.VIDEO_SIZE_SEND_AS_FILE);
+                            }
+                        } else if (originalMessageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE) {
+                            mediaItem.setDurationMs(
+                                originalMessageModel.getFileData().getDurationMs()
                             );
                         }
+
+                        mappedMediaItems.add(mediaItem);
                     }
-                );
+
+                    if (mappedMediaItems.isEmpty()) {
+                        return null;
+                    }
+
+                    Intent intent = IntentDataUtil.addMessageReceiversToIntent(
+                        new Intent(RecipientListBaseActivity.this, SendMediaActivity.class),
+                        recipientMessageReceivers.toArray(new MessageReceiver[0])
+                    );
+                    intent.putExtra(SendMediaActivity.EXTRA_MEDIA_ITEMS, mappedMediaItems);
+                    intent.putExtra(AppConstants.INTENT_DATA_TEXT, combinedRecipientName);
+                    startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_SEND_MEDIA);
+
+                } catch (Exception e) {
+                    logger.error("Failed to forward media", e);
+                    showToast(getApplicationContext(), R.string.media_file_not_found, ToastDuration.LONG);
+                }
 
                 return null;
             }
@@ -1160,7 +1189,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
                 );
             }
 
-        }.execute();
+        }.execute(this, null);
     }
 
     @NonNull
@@ -1243,10 +1272,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 
                     if (originalMessageModels.size() == 1) {
                         presetCaption = originalMessageModels.get(0).getCaption();
-                        if (originalMessageModels.get(0).getType() == MessageType.VIDEO ||
-                            originalMessageModels.get(0).getType() == MessageType.IMAGE ||
-                            originalMessageModels.get(0).getType() == MessageType.VOICEMESSAGE ||
-                            originalMessageModels.get(0).getType() == MessageType.FILE) {
+                        if (originalMessageModels.get(0).getType() == MessageType.FILE) {
                             expandable = true;
                         }
                     } else {
@@ -1393,7 +1419,19 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     protected void onActivityResult(int requestCode, int resultCode, Intent data) {
         if (requestCode == ACTIVITY_ID_SEND_MEDIA) {
             if (resultCode == RESULT_OK) {
-                startComposeActivityAsync(IntentDataUtil.getComposeIntentForReceivers(this, (ArrayList<MessageReceiver>) recipientMessageReceivers));
+                final @NonNull Intent intent;
+                if (!recipientMessageReceivers.isEmpty()) {
+                    intent = ComposeMessageActivity.createIntent(
+                        this,
+                        recipientMessageReceivers.get(0).getConversationId(),
+                        null,
+                        true
+                    );
+                } else {
+                    intent = HomeActivity.createIntent(this);
+                }
+                intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+                startComposeActivityAsync(intent);
             } else if (hideUi) {
                 finish();
             }
@@ -1523,7 +1561,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             mediaItem.setRenderingType(renderingType);
         }
         mediaItem.setCaption(caption);
-        if (!TestUtil.isEmptyOrNull(filename)) {
+        if (!isNullOrEmpty(filename)) {
             mediaItem.setFilename(filename);
         }
         if (renderingType == FileData.RENDERING_MEDIA) {
@@ -1576,7 +1614,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             .sendTextMessage(messageReceivers, text, new SendAction.ActionHandler() {
                 @Override
                 public void onError(final String errorMessage) {
-                    if (!TestUtil.isEmptyOrNull(errorMessage)) {
+                    if (!isNullOrEmpty(errorMessage)) {
                         RuntimeUtil.runOnUiThread(() -> Toast.makeText(RecipientListBaseActivity.this, errorMessage, Toast.LENGTH_SHORT).show());
                     }
                     if (hideUi) {
@@ -1603,7 +1641,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
         this.captionText = text;
 
         if (data instanceof ArrayList) {
-            if (!TestUtil.isEmptyOrNull(text)) {
+            if (!isNullOrEmpty(text)) {
                 for (MediaItem mediaItem : mediaItems) {
                     mediaItem.setCaption(text);
                 }

+ 32 - 24
app/src/main/java/ch/threema/app/activities/SendMediaActivity.java

@@ -29,7 +29,6 @@ import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.media.MediaMetadataRetriever;
 import android.net.Uri;
-import android.os.AsyncTask;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.Parcelable;
@@ -78,6 +77,7 @@ import java.util.List;
 import java.util.Objects;
 
 import ch.threema.android.ActivityExtensionsKt;
+import ch.threema.android.LifecycleAwareAsyncTask;
 import ch.threema.android.ToastDuration;
 import ch.threema.app.AppConstants;
 import ch.threema.app.R;
@@ -104,20 +104,23 @@ import ch.threema.app.ui.InsetSides;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.ui.RootViewDeferringInsetsCallback;
 import ch.threema.app.ui.SendButton;
-import ch.threema.app.ui.SimpleTextWatcher;
+import ch.threema.android.textwatchers.SimpleTextWatcher;
 import ch.threema.app.ui.TranslateDeferringInsetsAnimationCallback;
 import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.BitmapUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.EditTextUtil;
+import ch.threema.app.utils.FileProviderUtil;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.MediaAdapterListener;
 import ch.threema.app.utils.MediaAdapterManager;
 import ch.threema.app.utils.MimeUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.app.video.VideoTimelineCache;
+
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+import static ch.threema.common.JavaCompat.isNullOrEmpty;
+
 import ch.threema.app.messagereceiver.SendingPermissionValidationResult;
 import ch.threema.data.models.GroupModel;
 import ch.threema.domain.protocol.csp.messages.file.FileData;
@@ -266,7 +269,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
                     showToast(getApplicationContext(), errorStringRes);
                 }
             }
-            if (allReceiverChatsAreHidden && !dependencies.getConversationCategoryService().isPrivateChat(messageReceiver.getUniqueIdString())) {
+            if (allReceiverChatsAreHidden && !dependencies.getConversationCategoryService().isMarkedAsPrivate(messageReceiver.getConversationId())) {
                 allReceiverChatsAreHidden = false;
             }
         }
@@ -408,7 +411,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
         }
 
         String recipients = getIntent().getStringExtra(AppConstants.INTENT_DATA_TEXT);
-        if (!TestUtil.isEmptyOrNull(recipients)) {
+        if (!isNullOrEmpty(recipients)) {
             this.captionEditText.setHint(R.string.add_caption_hint);
             this.captionEditText.addTextChangedListener(new SimpleTextWatcher() {
                 @Override
@@ -424,10 +427,9 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
         }
 
         if (messageReceivers.size() == 1) {
-            var conversationUid = messageReceivers.get(0).getUniqueIdString();
             draftUpdateTextWatcher = new DraftUpdateTextWatcher(
                 dependencies.getDraftManager(),
-                conversationUid,
+                messageReceivers.get(0).getConversationId(),
                 () -> {
                     var mediaItems = mediaAdapterManager.getItems();
                     return !mediaItems.isEmpty() ? mediaItems.get(0).getCaption() : null;
@@ -700,26 +702,30 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
     @SuppressLint("UnsupportedChromeOsCameraSystemFeature")
     private void reallyLaunchCamera() {
-        File cameraFile = null;
+        File cameraFile;
         File videoFile;
         try {
-            cameraFile = dependencies.getFileService().createTempFile(".camera", ".jpg");
-            this.cameraFilePath = cameraFile.getCanonicalPath();
+            if (useExternalCamera) {
+                cameraFile = dependencies.getFileService().createShareableTempFile(".camera", ".jpg");
+                videoFile = dependencies.getFileService().createShareableTempFile(".video", ".mp4");
+            } else {
+                cameraFile = dependencies.getFileService().createTempFile(".camera", ".jpg");
+                videoFile = dependencies.getFileService().createTempFile(".video", ".mp4");
+            }
 
-            videoFile = dependencies.getFileService().createTempFile(".video", ".mp4");
+            this.cameraFilePath = cameraFile.getCanonicalPath();
             this.videoFilePath = videoFile.getCanonicalPath();
         } catch (IOException e) {
             logger.error("Exception", e);
             finish();
+            return;
         }
 
         final Intent cameraIntent;
         final int requestCode;
         if (!useExternalCamera) {
             // use internal camera
-            cameraIntent = new Intent(this, CameraActivity.class);
-            cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, cameraFilePath);
-            cameraIntent.putExtra(CameraActivity.EXTRA_VIDEO_OUTPUT, videoFilePath);
+            cameraIntent = CameraActivity.createIntent(this, cameraFilePath, videoFilePath);
             requestCode = ThreemaActivity.ACTIVITY_ID_PICK_CAMERA_INTERNAL;
         } else {
             // use external camera
@@ -733,7 +739,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
             }
 
             cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
-            cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, dependencies.getFileService().getShareFileUri(cameraFile, null));
+            cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, FileProviderUtil.getUriForFile(this, cameraFile));
             cameraIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
             requestCode = ThreemaActivity.ACTIVITY_ID_PICK_CAMERA_EXTERNAL;
         }
@@ -879,9 +885,9 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
     @SuppressLint("StaticFieldLeak")
     private void addItemsByMediaItem(List<MediaItem> incomingMediaItems, boolean prepend) {
         if (!incomingMediaItems.isEmpty()) {
-            new AsyncTask<Void, Void, List<MediaItem>>() {
+            new LifecycleAwareAsyncTask<Void, List<MediaItem>>() {
                 @Override
-                protected List<MediaItem> doInBackground(Void... voids) {
+                protected List<MediaItem> doInBackground(Void params) {
                     List<MediaItem> itemList = new ArrayList<>();
 
                     for (MediaItem incomingMediaItem : incomingMediaItems) {
@@ -931,7 +937,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
                         updateMenu();
                     }
                 }
-            }.execute();
+            }.execute(this, null);
         }
     }
 
@@ -957,7 +963,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
                 case ThreemaActivity.ACTIVITY_ID_PICK_CAMERA_INTERNAL:
                     if (intent != null && intent.getBooleanExtra(CameraActivity.EXTRA_VIDEO_RESULT, false)) {
                         // it's a video file
-                        if (!TestUtil.isEmptyOrNull(this.videoFilePath)) {
+                        if (!isNullOrEmpty(this.videoFilePath)) {
                             File videoFile = new File(this.videoFilePath);
                             if (videoFile.exists() && videoFile.length() > 0) {
                                 final Uri videoUri = Uri.fromFile(videoFile);
@@ -969,7 +975,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
                             }
                         }
                     } else {
-                        if (!TestUtil.isEmptyOrNull(this.cameraFilePath)) {
+                        if (!isNullOrEmpty(this.cameraFilePath)) {
                             final Uri cameraUri = Uri.fromFile(new File(this.cameraFilePath));
                             if (cameraUri != null) {
                                 BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.getExifOrientation(this, cameraUri);
@@ -1024,7 +1030,9 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
                 captionEditText.removeTextChangedListener(draftUpdateTextWatcher);
                 draftUpdateTextWatcher.stop();
             }
-            dependencies.getDraftManager().remove(messageReceivers.get(0).getUniqueIdString());
+            dependencies.getDraftManager().remove(
+                messageReceivers.get(0).getConversationId()
+            );
         }
 
         // return last media filter to chat via intermediate hop through MediaAttachActivity
@@ -1078,14 +1086,14 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
         Uri imageUri = currentItem.getUri();
 
         try {
-            tempFile = dependencies.getFileService().createTempFile(".crop", ".png");
+            tempFile = dependencies.getFileService().createShareableTempFile(".crop", ".png");
 
             CropImageActivity.CropImageParameters cropImageParameters =
                 new CropImageActivity.CropImageParameters(
                     /* sourceUri = */
                     imageUri,
                     /* saveUri = */
-                    Uri.fromFile(tempFile)
+                    FileProviderUtil.getUriForFile(this, tempFile)
                 );
             cropImageParameters.setFlip(currentItem.getFlip());
             cropImageParameters.setRotation(currentItem.getRotation());
@@ -1194,7 +1202,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
         captionEditText.setText(null);
 
-        if (!TestUtil.isEmptyOrNull(caption)) {
+        if (!isNullOrEmpty(caption)) {
             captionEditText.append(caption);
         }
     }

+ 0 - 123
app/src/main/java/ch/threema/app/activities/ServerMessageActivity.java

@@ -1,123 +0,0 @@
-package ch.threema.app.activities;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.text.method.LinkMovementMethod;
-import android.view.MenuItem;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.ActionBar;
-import androidx.core.text.HtmlCompat;
-
-import org.koin.android.compat.ViewModelCompat;
-import org.koin.java.KoinJavaComponent;
-import org.slf4j.Logger;
-
-import ch.threema.app.R;
-import ch.threema.app.di.DependencyContainer;
-import ch.threema.app.notifications.NotificationIDs;
-import ch.threema.app.ui.InsetSides;
-import ch.threema.app.ui.ServerMessageViewModel;
-import ch.threema.app.ui.SpacingValues;
-import ch.threema.app.ui.ViewExtensionsKt;
-import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
-
-import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
-import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
-
-public class ServerMessageActivity extends ThreemaActivity {
-    private final static Logger logger = getThreemaLogger("ServerMessageActivity");
-
-    @NonNull
-    private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
-
-    private ServerMessageViewModel viewModel;
-
-    private TextView serverMessageTextView;
-
-    @Override
-    public void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        logScreenVisibility(this, logger);
-        if (finishAndRestartLaterIfNotReady(this)) {
-            return;
-        }
-
-        final ActionBar actionBar = getSupportActionBar();
-        if (actionBar != null) {
-            actionBar.setDisplayHomeAsUpEnabled(true);
-            actionBar.setTitle(R.string.warning);
-        }
-
-        setContentView(R.layout.activity_server_message);
-
-        serverMessageTextView = findViewById(R.id.server_message_text);
-        serverMessageTextView.setMovementMethod(LinkMovementMethod.getInstance());
-
-        handleDeviceInsets();
-
-        viewModel = ViewModelCompat.getViewModel(this, ServerMessageViewModel.class);
-
-        findViewById(R.id.close_button).setOnClickListener(v -> viewModel.markServerMessageAsRead());
-
-        viewModel.getServerMessage().observe(this, serverMessage -> {
-            if (serverMessage == null) {
-                // Cancel the server message notification as the "Another connection..." message
-                // may be received several times. This would open another notification. Because the
-                // message is the same, it is shown only once and therefore has been deleted at this
-                // point.
-                cancelServerMessageNotification();
-                finish();
-                return;
-            }
-            showServerMessage(serverMessage);
-        });
-    }
-
-    private void handleDeviceInsets() {
-        ViewExtensionsKt.applyDeviceInsetsAsPadding(
-            findViewById(R.id.scroll_container),
-            InsetSides.ltr(),
-            SpacingValues.all(R.dimen.grid_unit_x2)
-        );
-        ViewExtensionsKt.applyDeviceInsetsAsMargin(
-            findViewById(R.id.close_button),
-            InsetSides.lbr(),
-            SpacingValues.all(R.dimen.grid_unit_x2)
-        );
-    }
-
-    @Override
-    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
-        if (item.getItemId() == android.R.id.home) {
-            viewModel.markServerMessageAsRead();
-            return true;
-        }
-        return super.onOptionsItemSelected(item);
-    }
-
-    @Override
-    public void onBackPressed() {
-        super.onBackPressed();
-        viewModel.markServerMessageAsRead();
-    }
-
-    private void showServerMessage(@NonNull String message) {
-        if (message.startsWith("Another connection")) {
-            message = getString(R.string.another_connection_instructions, getString(R.string.app_name));
-        }
-
-        serverMessageTextView.setText(HtmlCompat.fromHtml(message, HtmlCompat.FROM_HTML_MODE_COMPACT));
-    }
-
-    private void cancelServerMessageNotification() {
-        dependencies.getNotificationService().cancel(NotificationIDs.SERVER_MESSAGE_NOTIFICATION_ID);
-    }
-
-    @NonNull
-    public static Intent createIntent(@NonNull Context context) {
-        return new Intent(context, ServerMessageActivity.class);
-    }
-}

+ 14 - 15
app/src/main/java/ch/threema/app/activities/ThreemaActivity.java

@@ -2,6 +2,9 @@ package ch.threema.app.activities;
 
 import android.content.Intent;
 
+import org.koin.java.KoinJavaComponent;
+
+import androidx.annotation.NonNull;
 import ch.threema.app.backuprestore.csv.BackupService;
 import ch.threema.app.backuprestore.csv.RestoreService;
 import ch.threema.app.services.ActivityService;
@@ -14,34 +17,33 @@ public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
     final static public int ACTIVITY_ID_PICK_CAMERA_EXTERNAL = 20011;
     final static public int ACTIVITY_ID_PICK_CAMERA_INTERNAL = 20012;
     final static public int ACTIVITY_ID_RESTORE_KEY = 20016;
-    final static public int ACTIVITY_ID_SHARE_CONVERSATION = 20018;
     final static public int ACTIVITY_ID_SEND_MEDIA = 20019;
     final static public int ACTIVITY_ID_ATTACH_MEDIA = 20020;
     final static public int ACTIVITY_ID_GROUP_ADD = 20028;
     final static public int ACTIVITY_ID_GROUP_DETAIL = 20029;
     final static public int ACTIVITY_ID_MEDIA_VIEWER = 20035;
-    public static final int ACTIVITY_ID_CREATE_BALLOT = 20037;
+    public static final int ACTIVITY_ID_CREATE_POLL = 20037;
     final static public int ACTIVITY_ID_BACKUP_PICKER = 20042;
-    final static public int ACTIVITY_ID_COPY_BALLOT = 20043;
+    final static public int ACTIVITY_ID_COPY_POLL = 20043;
     public static final int ACTIVITY_ID_PAINT = 20049;
     public static final int ACTIVITY_ID_PICK_MEDIA = 20050;
     public static final int ACTIVITY_ID_CROP_IMAGE = 20051;
 
-    private boolean isResumed;
+    @NonNull
+    private final ActivityService activityService = KoinJavaComponent.get(ActivityService.class);
 
     @Override
     protected void onPause() {
         super.onPause();
-        if (isPinLockable() && isResumed) {
-            ActivityService.activityPaused(this);
-            isResumed = false;
+        if (isPinLockable()) {
+            activityService.pause(this);
         }
     }
 
     @Override
     protected void onResume() {
-        if (isPinLockable() && ActivityService.activityResumed(this)) {
-            isResumed = true;   /* activityResumed can return false, in which case we should make sure not to call activityPaused the next time to maintain acquire/release balance */
+        if (isPinLockable()) {
+            activityService.resume(this);
         }
 
         if (BackupService.isRunning() || RestoreService.isRunning()) {
@@ -55,11 +57,8 @@ public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
 
     @Override
     protected void onDestroy() {
-        // onPause() and onStop() will not be invoked if finish() is called from within the onCreate() method.
-        // This might occur, for example, if you detect an error during onCreate() and call finish() as a result.
-        // In such a case, though, any cleanup you expected to be done in onPause() and onStop() will not be executed.
-        if (isPinLockable() && isResumed) {
-            ActivityService.activityDestroyed(this);
+        if (isPinLockable()) {
+            activityService.destroy(this);
         }
 
         super.onDestroy();
@@ -68,7 +67,7 @@ public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
     @Override
     public void onUserInteraction() {
         if (isPinLockable()) {
-            ActivityService.activityUserInteract(this);
+            activityService.userInteract(this);
         }
         super.onUserInteraction();
     }

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

@@ -64,7 +64,7 @@ public abstract class ThreemaAppCompatActivity extends AppCompatActivity {
             ConfigUtils.setCurrentDayNightMode(newDayNightMode == UI_MODE_NIGHT_YES ? MODE_NIGHT_YES : MODE_NIGHT_NO);
 
             // Reset avatar cache on theme change so that the default avatars are loaded with the correct (themed) color
-            ServiceManager sm = ThreemaApplication.getServiceManager();
+            ServiceManager sm = ServiceManager.get();
             if (sm != null) {
                 sm.getAvatarCacheService().clear();
             }

+ 67 - 0
app/src/main/java/ch/threema/app/activities/ThreemaComposeActivity.kt

@@ -0,0 +1,67 @@
+package ch.threema.app.activities
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.annotation.CallSuper
+import ch.threema.app.activities.wizard.WizardIntroActivity
+import ch.threema.app.di.DIJavaCompat.isSessionScopeReady
+import ch.threema.app.preference.service.SynchronizedSettingsService
+import ch.threema.app.services.LockAppService
+import ch.threema.app.services.NotificationPreferenceService
+import ch.threema.app.startup.AppStartupAware
+import ch.threema.app.utils.ConfigUtils
+import org.koin.android.ext.android.inject
+
+abstract class ThreemaComposeActivity : ThreemaActivity() {
+
+    private val notificationPreferenceService: NotificationPreferenceService by inject()
+    private val synchronizedSettingsService: SynchronizedSettingsService by inject()
+    private val lockAppService: LockAppService by inject()
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        // The license can not be checked if the session scope is not ready, so we potentially skip the check here.
+        // This isn't ideal, but at the latest in the next ThreemaToolbarActivity, another check will be made.
+        if (isSessionScopeReady() && ConfigUtils.isSerialLicensed() && !ConfigUtils.isSerialLicenseValid()) {
+            startActivity(Intent(this, EnterSerialActivity::class.java))
+            finish()
+            return
+        }
+
+        // TODO(ANDR-4389): Improve app-startup behavior
+        if (this !is AppStartupAware && !initActivity(savedInstanceState)) {
+            finish()
+        }
+    }
+
+    /**
+     * It is called from onCreate() after all the basic initialization has been done.
+     * Override this to do your own initialization.
+     *
+     * @param savedInstanceState the bundle provided to onCreate()
+     * @return true on success, false otherwise, in which case the activity is finished
+     */
+    @CallSuper
+    protected open fun initActivity(savedInstanceState: Bundle?): Boolean {
+        if (!isSessionScopeReady()) {
+            return false
+        }
+
+        if (notificationPreferenceService.getWizardRunning()) {
+            startActivity(WizardIntroActivity.createIntent(this))
+            return false
+        }
+
+        ConfigUtils.applyScreenshotPolicy(
+            /* activity = */
+            this,
+            /* synchronizedSettingsService = */
+            synchronizedSettingsService,
+            /* lockAppService = */
+            lockAppService,
+        )
+
+        return true
+    }
+}

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