Browse Source

Version 6.0.0

Threema 6 months ago
parent
commit
1e19eae246
100 changed files with 5214 additions and 2376 deletions
  1. 12 0
      .editorconfig
  2. 1 0
      .gitignore
  3. 5 1
      README.md
  4. 2 2
      app/assets/license.html
  5. 0 1059
      app/build.gradle
  6. 1016 0
      app/build.gradle.kts
  7. 1 0
      app/libs/arm64-v8a/README.txt
  8. 1 0
      app/libs/armeabi-v7a/README.txt
  9. 1 0
      app/libs/x86/README.txt
  10. 1 0
      app/libs/x86_64/README.txt
  11. 4 4
      app/src/androidTest/java/ch/threema/app/PermissionRuleUtils.kt
  12. 43 30
      app/src/androidTest/java/ch/threema/app/TestCoreServiceManager.kt
  13. 1 2
      app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java
  14. 24 25
      app/src/androidTest/java/ch/threema/app/contacts/AddOrUpdateContactBackgroundTaskTest.kt
  15. 47 33
      app/src/androidTest/java/ch/threema/app/contacts/MarkContactAsDeletedBackgroundTaskTest.kt
  16. 10 7
      app/src/androidTest/java/ch/threema/app/contacts/ReflectedContactSyncTaskTest.kt
  17. 34 19
      app/src/androidTest/java/ch/threema/app/edithistory/EditHistoryTest.kt
  18. 0 238
      app/src/androidTest/java/ch/threema/app/emojis/EmojiUtilTest.kt
  19. 267 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/CreateGroupFlowTest.kt
  20. 444 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/DisbandGroupFlowTest.kt
  21. 27 30
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupControlTest.kt
  22. 9 6
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupConversationListTest.kt
  23. 98 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupFlowTest.kt
  24. 201 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupResyncFlowTest.kt
  25. 30 36
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt
  26. 36 41
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt
  27. 237 157
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt
  28. 18 17
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSyncRequestTest.kt
  29. 0 3
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupTextTest.kt
  30. 485 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/LeaveGroupFlowTest.kt
  31. 319 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/RemoveGroupFlowTest.kt
  32. 517 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/UpdateGroupFlowTest.kt
  33. 33 27
      app/src/androidTest/java/ch/threema/app/processors/IncomingMessageProcessorTest.kt
  34. 73 98
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt
  35. 126 117
      app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt
  36. 6 6
      app/src/androidTest/java/ch/threema/app/service/GroupInviteServiceTest.java
  37. 4 7
      app/src/androidTest/java/ch/threema/app/services/BlockedIdentitiesServiceTest.kt
  38. 2 2
      app/src/androidTest/java/ch/threema/app/services/systemupdate/SystemUpdateHelpersTest.kt
  39. 277 0
      app/src/androidTest/java/ch/threema/app/tasks/GroupCreateTaskTest.kt
  40. 298 67
      app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt
  41. 70 0
      app/src/androidTest/java/ch/threema/app/testutils/AndroidTestUtils.kt
  42. 25 0
      app/src/androidTest/java/ch/threema/app/testutils/TestHelpers.java
  43. 3 1
      app/src/androidTest/java/ch/threema/app/utils/BackgroundErrorNotificationTest.java
  44. 5 6
      app/src/androidTest/java/ch/threema/app/utils/BackgroundExecutorTest.kt
  45. 3 6
      app/src/androidTest/java/ch/threema/app/utils/BundledMessagesSendStepsTest.kt
  46. 1 3
      app/src/androidTest/java/ch/threema/app/utils/GeoLocationUtilTest.kt
  47. 4 6
      app/src/androidTest/java/ch/threema/app/utils/LinkifyUtilTest.kt
  48. 2 2
      app/src/androidTest/java/ch/threema/app/voip/SdpTest.java
  49. 19 35
      app/src/androidTest/java/ch/threema/app/webclient/activities/SessionsActivityTest.java
  50. 1 1
      app/src/androidTest/java/ch/threema/app/webclient/converter/MessageTest.java
  51. 1 1
      app/src/androidTest/java/ch/threema/data/TestDatabaseService.kt
  52. 27 16
      app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt
  53. 13 7
      app/src/androidTest/java/ch/threema/data/repositories/EditHistoryRepositoryTest.kt
  54. 16 17
      app/src/androidTest/java/ch/threema/data/repositories/EmojiReactionsRepositoryTest.kt
  55. 30 25
      app/src/androidTest/java/ch/threema/data/repositories/GroupModelRepositoryTest.kt
  56. 7 10
      app/src/androidTest/java/ch/threema/storage/DatabaseNonceStoreTest.kt
  57. 5 1
      app/src/blue/java/ch/threema/app/compose/theme/color/ColorsDark.kt
  58. 5 0
      app/src/blue/java/ch/threema/app/compose/theme/color/ColorsLight.kt
  59. 1 1
      app/src/blue/java/ch/threema/app/compose/theme/color/CustomColorsDark.kt
  60. 1 1
      app/src/blue/java/ch/threema/app/compose/theme/color/CustomColorsLight.kt
  61. 98 35
      app/src/blue/res/drawable-v24/ic_launcher_foreground.xml
  62. 4 4
      app/src/blue/res/drawable-v24/ic_launcher_monochrome.xml
  63. 0 25
      app/src/blue/res/drawable/ic_finger_with_circles.xml
  64. 7 9
      app/src/blue/res/layout/activity_enter_serial.xml
  65. BIN
      app/src/blue/res/mipmap-hdpi/ic_launcher.png
  66. BIN
      app/src/blue/res/mipmap-mdpi/ic_launcher.png
  67. BIN
      app/src/blue/res/mipmap-xhdpi/ic_launcher.png
  68. BIN
      app/src/blue/res/mipmap-xxhdpi/ic_launcher.png
  69. BIN
      app/src/blue/res/mipmap-xxxhdpi/ic_launcher.png
  70. 0 32
      app/src/blue/res/values-de/strings.xml
  71. 2 5
      app/src/blue/res/values/colors.xml
  72. 0 1
      app/src/blue/res/values/firebase_messaging.xml
  73. 7 0
      app/src/blue/res/values/flavor_specific_strings.xml
  74. 0 4
      app/src/blue/res/values/ic_launcher_colors.xml
  75. 0 31
      app/src/blue/res/values/strings.xml
  76. 7 0
      app/src/blue/res/xml/app_restrictions.xml
  77. 2 2
      app/src/foss_based/assets/license.html
  78. 1 0
      app/src/google_services_based/java/ch/threema/app/push/PushRegistrationWorker.java
  79. 1 0
      app/src/google_services_based/java/ch/threema/app/push/PushService.java
  80. 1 1
      app/src/google_services_based/java/ch/threema/app/services/VoiceActionService.java
  81. 5 1
      app/src/green/java/ch/threema/app/compose/theme/color/ColorsDark.kt
  82. 5 0
      app/src/green/java/ch/threema/app/compose/theme/color/ColorsLight.kt
  83. 1 1
      app/src/green/java/ch/threema/app/compose/theme/color/CustomColorsDark.kt
  84. 1 1
      app/src/green/java/ch/threema/app/compose/theme/color/CustomColorsLight.kt
  85. 98 35
      app/src/green/res/drawable-v24/ic_launcher_foreground.xml
  86. BIN
      app/src/green/res/mipmap-hdpi/ic_launcher.png
  87. BIN
      app/src/green/res/mipmap-mdpi/ic_launcher.png
  88. BIN
      app/src/green/res/mipmap-xhdpi/ic_launcher.png
  89. BIN
      app/src/green/res/mipmap-xxhdpi/ic_launcher.png
  90. BIN
      app/src/green/res/mipmap-xxxhdpi/ic_launcher.png
  91. 1 2
      app/src/green/res/values/firebase_messaging.xml
  92. 0 4
      app/src/green/res/values/ic_launcher_colors.xml
  93. 5 1
      app/src/hms/java/ch/threema/app/compose/theme/color/ColorsDark.kt
  94. 5 0
      app/src/hms/java/ch/threema/app/compose/theme/color/ColorsLight.kt
  95. 1 1
      app/src/hms/java/ch/threema/app/compose/theme/color/CustomColorsDark.kt
  96. 1 1
      app/src/hms/java/ch/threema/app/compose/theme/color/CustomColorsLight.kt
  97. 1 5
      app/src/hms_services_based/java/ch/threema/app/push/HmsTokenUtil.kt
  98. 5 1
      app/src/hms_work/java/ch/threema/app/compose/theme/color/ColorsDark.kt
  99. 5 0
      app/src/hms_work/java/ch/threema/app/compose/theme/color/ColorsLight.kt
  100. 1 1
      app/src/hms_work/java/ch/threema/app/compose/theme/color/CustomColorsDark.kt

+ 12 - 0
.editorconfig

@@ -8,3 +8,15 @@ trim_trailing_whitespace = true
 charset = utf-8
 charset = utf-8
 indent_style = space
 indent_style = space
 indent_size = 4
 indent_size = 4
+
+[{*.kt,*.kts}]
+ktlint_code_style = android_studio
+max_line_length = 150
+ij_kotlin_allow_trailing_comma = true
+ij_kotlin_allow_trailing_comma_on_call_site = true
+ktlint_function_naming_ignore_when_annotated_with = Composable
+
+# The following rules have been intentionally disabled, as they are too aggressive
+# and do not match our preferences
+ktlint_standard_function-signature = disabled
+ktlint_standard_no-wildcard-imports = disabled

+ 1 - 0
.gitignore

@@ -19,3 +19,4 @@ threema-android-*
 release/
 release/
 .cxx/
 .cxx/
 **/.DS_Store
 **/.DS_Store
+.kotlin

+ 5 - 1
README.md

@@ -167,7 +167,7 @@ Prerequisites:
 - Android SDK
 - Android SDK
 - Android NDK
 - Android NDK
 - bash shell
 - bash shell
-- protobuf compiler version 21.12
+- protobuf compiler
 - Rust compiler and cargo (including the target architectures)
 - Rust compiler and cargo (including the target architectures)
 
 
 The best way to install all required target architectures for Rust is
 The best way to install all required target architectures for Rust is
@@ -184,6 +184,10 @@ The application APK can be built using Gradle Wrapper:
     # Threema Store variant
     # Threema Store variant
     ./gradlew assembleStore_threemaDebug
     ./gradlew assembleStore_threemaDebug
 
 
+By default no universal apk will be built. Use `-PbuildUniversalApk` if a universal apk should be
+built as well. The flag `-PnoAbiSplits` can be used to skip building abi specific apk and only
+build a universal apk.
+
 *NOTE:* Threema for Android is developed on Linux machines, we cannot offer any
 *NOTE:* Threema for Android is developed on Linux machines, we cannot offer any
 assistance for building on macOS, Windows, or other operating systems.
 assistance for building on macOS, Windows, or other operating systems.
 
 

+ 2 - 2
app/assets/license.html

@@ -309,14 +309,14 @@ POSSIBILITY OF SUCH DAMAGE.</p>
 
 
 <h2>saltyrtc-client-java</h2>
 <h2>saltyrtc-client-java</h2>
 
 
-<p>Copyright (c) 2016-2024 Threema GmbH</p>
+<p>Copyright (c) 2016-2025 Threema GmbH</p>
 
 
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 
 
 <h2>saltyrtc-task-webrtc-java</h2>
 <h2>saltyrtc-task-webrtc-java</h2>
 
 
-<p>Copyright (c) 2016-2024 Threema GmbH</p>
+<p>Copyright (c) 2016-2025 Threema GmbH</p>
 
 
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 

+ 0 - 1059
app/build.gradle

@@ -1,1059 +0,0 @@
-import org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs
-
-plugins {
-    id 'org.sonarqube'
-    id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
-    id 'org.mozilla.rust-android-gradle.rust-android' version "0.9.3"
-}
-
-apply plugin: 'com.android.application'
-apply plugin: 'kotlin-android'
-apply plugin: 'kotlin-kapt'
-
-// only apply the plugin if we are dealing with a AppGallery build
-if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")) {
-    println "enabling hms plugin"
-    apply plugin: 'com.huawei.agconnect'
-}
-
-// version codes
-
-// Only use the scheme "<major>.<minor>.<patch>" for the app_version
-def app_version = "5.8.2"
-
-// beta_suffix with leading dash (e.g. `-beta1`)
-// should be one of (alpha|beta|rc) and an increasing number or empty for a regular release.
-// Note: in nightly builds this will be overwritten with a nightly version "-n12345"
-def beta_suffix = ""
-
-def defaultVersionCode = 1052
-
-/**
- * Return the git hash, if git is installed.
- */
-def getGitHash = { ->
-    def stdout = new ByteArrayOutputStream()
-    def stderr = new ByteArrayOutputStream()
-    try {
-        exec {
-            commandLine 'git', 'rev-parse', '--short', 'HEAD'
-            standardOutput = stdout
-            errorOutput = stderr
-            ignoreExitValue true
-        }
-    } catch (ignored) { /* If git binary is not found, carry on */
-    }
-    def hash = stdout.toString().trim()
-    return (hash.isEmpty()) ? "?" : hash
-}
-
-/**
- * Look up the keystore with the specified name in a `keystore` directory
- * adjacent to this project directory. If it exists, return a signing config.
- * Otherwise, return null.
- */
-def findKeystore = { name ->
-    def basePath = "${projectDir.getAbsolutePath()}/../../keystore"
-    def storePath = "${basePath}/${name}.keystore"
-    def storeFile = new File(storePath)
-    if (storeFile.exists() && storeFile.isFile()) {
-        def propertiesPath = "${basePath}/${name}.properties"
-        def propertiesFile = new File(propertiesPath)
-        if (propertiesFile.exists() && propertiesFile.isFile()) {
-            Properties props = new Properties()
-            propertiesFile.withInputStream { props.load(it) }
-            return [
-                storeFile    : storePath,
-                storePassword: props.storePassword,
-                keyAlias     : props.keyAlias,
-                keyPassword  : props.keyPassword,
-            ]
-        } else {
-            return [
-                storeFile    : storePath,
-                storePassword: null,
-                keyAlias     : null,
-                keyPassword  : null,
-            ]
-        }
-    }
-}
-
-/**
- * Map with keystore paths (if found).
- */
-def keystores = [
-    debug         : findKeystore("debug"),
-    release       : findKeystore("threema"),
-    hms_release   : findKeystore("threema_hms"),
-    onprem_release: findKeystore("onprem"),
-    blue_release  : findKeystore("threema_blue"),
-]
-
-android {
-    // NOTE: When adjusting compileSdkVersion, buildToolsVersion or ndkVersion,
-    //       make sure to adjust them in `scripts/Dockerfile` and
-    //       `.gitlab-ci.yml` as well!
-    compileSdk 34
-    buildToolsVersion = '34.0.0'
-    ndkVersion '25.2.9519653'
-
-    defaultConfig {
-        // https://developer.android.com/training/testing/espresso/setup#analytics
-        testInstrumentationRunnerArguments notAnnotation: 'ch.threema.app.TestFastlaneOnly,ch.threema.app.DangerousTest', disableAnalytics: 'true'
-        minSdkVersion 21
-        //noinspection OldTargetApi
-        targetSdkVersion 34
-        vectorDrawables.useSupportLibrary = true
-        applicationId "ch.threema.app"
-        testApplicationId 'ch.threema.app.test'
-        versionCode defaultVersionCode
-        versionName "${app_version}${beta_suffix}"
-        resValue "string", "app_name", "Threema"
-        // package name used for sync adapter - needs to match mime types below
-        resValue "string", "package_name", applicationId
-        resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.profile"
-        resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.call"
-        buildConfigField "int", "MAX_GROUP_SIZE", "256"
-        buildConfigField "String", "CHAT_SERVER_PREFIX", "\"g-\""
-        buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.g-\""
-        buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.threema.ch\""
-        buildConfigField "int[]", "CHAT_SERVER_PORTS", "{5222, 443}"
-        buildConfigField "String", "MEDIA_PATH", "\"Threema\""
-        buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
-        buildConfigField "boolean", "DISABLE_CERT_PINNING", "false"
-        buildConfigField "boolean", "VIDEO_CALLS_ENABLED", "true"
-        // This public key is pinned for the chat server protocol.
-        buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x45, (byte) 0x0b, (byte) 0x97, (byte) 0x57, (byte) 0x35, (byte) 0x27, (byte) 0x9f, (byte) 0xde, (byte) 0xcb, (byte) 0x33, (byte) 0x13, (byte) 0x64, (byte) 0x8f, (byte) 0x5f, (byte) 0xc6, (byte) 0xee, (byte) 0x9f, (byte) 0xf4, (byte) 0x36, (byte) 0x0e, (byte) 0xa9, (byte) 0x2a, (byte) 0x8c, (byte) 0x17, (byte) 0x51, (byte) 0xc6, (byte) 0x61, (byte) 0xe4, (byte) 0xc0, (byte) 0xd8, (byte) 0xc9, (byte) 0x09 }"
-        buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0xda, (byte) 0x7c, (byte) 0x73, (byte) 0x79, (byte) 0x8f, (byte) 0x97, (byte) 0xd5, (byte) 0x87, (byte) 0xc3, (byte) 0xa2, (byte) 0x5e, (byte) 0xbe, (byte) 0x0a, (byte) 0x91, (byte) 0x41, (byte) 0x7f, (byte) 0x76, (byte) 0xdb, (byte) 0xcc, (byte) 0xcd, (byte) 0xda, (byte) 0x29, (byte) 0x30, (byte) 0xe6, (byte) 0xa9, (byte) 0x09, (byte) 0x0a, (byte) 0xf6, (byte) 0x2e, (byte) 0xba, (byte) 0x6f, (byte) 0x15 }"
-        buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
-        buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.threema.ch/\""
-        buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-apip.threema.ch/\""
-        buildConfigField "String", "WORK_SERVER_URL", "null"
-        buildConfigField "String", "WORK_SERVER_IPV6_URL", "null"
-        buildConfigField "String", "MEDIATOR_SERVER_URL", "\"wss://mediator-{deviceGroupIdPrefix4}.threema.ch/{deviceGroupIdPrefix8}\""
-
-        // Base blob url used for "download" and "done" calls
-        buildConfigField "String", "BLOB_SERVER_URL", "\"https://blobp-{blobIdPrefix8}.threema.ch\""
-        buildConfigField "String", "BLOB_SERVER_IPV6_URL", "\"https://ds-blobp-{blobIdPrefix8}.threema.ch\""
-
-        // Specific blob url used for "upload" calls
-        buildConfigField "String", "BLOB_SERVER_URL_UPLOAD", "\"https://blobp-upload.threema.ch/upload\""
-        buildConfigField "String", "BLOB_SERVER_IPV6_URL_UPLOAD", "\"https://ds-blobp-upload.threema.ch/upload\""
-
-        // Base blob mirror url used for "download", "upload", "done"
-        buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.threema.ch/{deviceGroupIdPrefix8}\""
-
-        buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.threema.ch/\""
-        buildConfigField "String", "SAFE_SERVER_URL", "\"https://safe-{backupIdPrefix8}.threema.ch/\""
-        buildConfigField "String", "WEB_SERVER_URL", "\"https://web.threema.ch/\""
-        buildConfigField "String", "APP_RATING_URL", "\"https://threema.ch/app-rating/android/{rating}\""
-        buildConfigField "byte[]", "THREEMA_PUSH_PUBLIC_KEY", "new byte[] {(byte) 0xfd, (byte) 0x71, (byte) 0x1e, (byte) 0x1a, (byte) 0x0d, (byte) 0xb0, (byte) 0xe2, (byte) 0xf0, (byte) 0x3f, (byte) 0xca, (byte) 0xab, (byte) 0x6c, (byte) 0x43, (byte) 0xda, (byte) 0x25, (byte) 0x75, (byte) 0xb9, (byte) 0x51, (byte) 0x36, (byte) 0x64, (byte) 0xa6, (byte) 0x2a, (byte) 0x12, (byte) 0xbd, (byte) 0x07, (byte) 0x28, (byte) 0xd8, (byte) 0x7f, (byte) 0x71, (byte) 0x25, (byte) 0xcc, (byte) 0x24}"
-        buildConfigField "String", "ONPREM_ID_PREFIX", "\"O\""
-        buildConfigField "String", "LOG_TAG", "\"3ma\""
-        buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
-
-        buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "null"
-        buildConfigField "boolean", "MD_ENABLED", "false"
-        buildConfigField "boolean", "MD_SYNC_DISTRIBUTION_LISTS", "false"
-        buildConfigField "boolean", "EDIT_MESSAGES_ENABLED", "true"
-        buildConfigField "boolean", "DELETE_MESSAGES_ENABLED", "true"
-        buildConfigField "boolean", "EMOJI_REACTIONS_ENABLED", "true"
-        buildConfigField "boolean", "EMOJI_REACTIONS_WEB_ENABLED", "true"
-
-        // config fields for action URLs / deep links
-        buildConfigField "String", "uriScheme", "\"threema\""
-        buildConfigField "String", "actionUrl", "\"go.threema.ch\""
-        buildConfigField "String", "contactActionUrl", "\"threema.id\""
-        buildConfigField "String", "groupLinkActionUrl", "\"threema.group\""
-
-        // duplicated for manifest
-        manifestPlaceholders = [
-            uriScheme         : "threema",
-            contactActionUrl  : "threema.id",
-            groupLinkActionUrl: "threema.group",
-            actionUrl         : "go.threema.ch",
-            callMimeType      : "vnd.android.cursor.item/vnd.ch.threema.app.call",
-        ]
-
-        testInstrumentationRunner 'ch.threema.app.ThreemaTestRunner'
-
-        // Only include language resources for those languages
-        resourceConfigurations += [
-            "en",
-            "be-rBY",
-            "ca",
-            "cs",
-            "de",
-            "es",
-            "fr",
-            "gsw",
-            "hu",
-            "it",
-            "ja",
-            "nl-rNL",
-            "no",
-            "pl",
-            "pt-rBR",
-            "rm",
-            "ru",
-            "sk",
-            "tr",
-            "uk",
-            "zh-rCN",
-            "zh-rTW"
-        ]
-    }
-
-    splits {
-        abi {
-            enable true
-            reset()
-            include 'armeabi-v7a', 'x86', 'arm64-v8a', 'x86_64'
-            exclude 'armeabi', 'mips', 'mips64'
-            universalApk project.hasProperty("buildUniversalApk")
-        }
-    }
-
-    // Assign different version code for each output
-    def abiVersionCodes = ['armeabi-v7a': 2, 'arm64-v8a': 3, 'x86': 8, 'x86_64': 9]
-    android.applicationVariants.all { variant ->
-        variant.outputs.each { output ->
-            def abi = output.getFilter("ABI")
-            output.versionCodeOverride =
-                abiVersionCodes.get(abi, 0) * 1000000 + defaultVersionCode
-        }
-    }
-
-    namespace 'ch.threema.app'
-    flavorDimensions = ["default"]
-    productFlavors {
-        none {}
-        store_google {}
-        store_threema {
-            resValue "string", "shop_download_filename", "Threema-update.apk"
-        }
-        store_google_work {
-            versionName "${app_version}k${beta_suffix}"
-            applicationId "ch.threema.app.work"
-            testApplicationId 'ch.threema.app.work.test'
-            resValue "string", "app_name", "Threema Work"
-            resValue "string", "package_name", applicationId
-            resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.profile"
-            resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.call"
-            buildConfigField "String", "CHAT_SERVER_PREFIX", "\"w-\""
-            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
-            buildConfigField "String", "MEDIA_PATH", "\"ThreemaWork\""
-            buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.threema.ch/\""
-            buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.threema.ch/\""
-            buildConfigField "String", "APP_RATING_URL", "\"https://threema.ch/app-rating/android-work/{rating}\""
-            buildConfigField "String", "LOG_TAG", "\"3mawrk\""
-            buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
-
-            // config fields for action URLs / deep links
-            buildConfigField "String", "uriScheme", "\"threemawork\""
-            buildConfigField "String", "actionUrl", "\"work.threema.ch\""
-
-            manifestPlaceholders = [
-                uriScheme   : "threemawork",
-                actionUrl   : "work.threema.ch",
-                callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.work.call",
-            ]
-        }
-        green {
-            // The app was previously named `sandbox`. The app id remains unchanged to still be able to install updates.
-            applicationId "ch.threema.app.sandbox"
-            testApplicationId 'ch.threema.app.sandbox.test'
-            resValue "string", "app_name", "Threema Green"
-            resValue "string", "package_name", applicationId
-            resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.sandbox.profile"
-            resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.sandbox.call"
-            buildConfigField "String", "MEDIA_PATH", "\"ThreemaGreen\""
-            buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
-            // This public key is pinned for the chat server protocol.
-            buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
-            buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
-            buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
-            buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-apip.test.threema.ch/\""
-            buildConfigField "String", "MEDIATOR_SERVER_URL", "\"wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
-            buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
-            buildConfigField "String", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android/{rating}\""
-            buildConfigField "boolean", "MD_ENABLED", "true"
-
-            buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
-        }
-        sandbox_work {
-            versionName "${app_version}k${beta_suffix}"
-            applicationId "ch.threema.app.sandbox.work"
-            testApplicationId 'ch.threema.app.sandbox.work.test'
-            resValue "string", "app_name", "Threema Sandbox Work"
-            resValue "string", "package_name", applicationId
-            resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.sandbox.work.profile"
-            resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.sandbox.work.call"
-            buildConfigField "String", "CHAT_SERVER_PREFIX", "\"w-\""
-            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
-            buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
-            buildConfigField "String", "MEDIA_PATH", "\"ThreemaWorkSandbox\""
-            // This public key is pinned for the chat server protocol.
-            buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
-            buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
-
-            buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
-            buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-apip.test.threema.ch/\""
-            buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.test.threema.ch/\""
-            buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.test.threema.ch/\""
-            buildConfigField "String", "MEDIATOR_SERVER_URL", "\"wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
-            buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
-            buildConfigField "String", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android-work/{rating}\""
-            buildConfigField "String", "LOG_TAG", "\"3mawrk\""
-            buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
-
-            buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
-
-            // config fields for action URLs / deep links
-            buildConfigField "String", "uriScheme", "\"threemawork\""
-            buildConfigField "String", "actionUrl", "\"work.test.threema.ch\""
-
-            buildConfigField "boolean", "MD_ENABLED", "true"
-
-            manifestPlaceholders = [
-                uriScheme: "threemawork",
-                actionUrl: "work.test.threema.ch",
-            ]
-        }
-        onprem {
-            versionName "${app_version}o${beta_suffix}"
-            applicationId "ch.threema.app.onprem"
-            testApplicationId 'ch.threema.app.onprem.test'
-            resValue "string", "app_name", "Threema OnPrem"
-            resValue "string", "package_name", applicationId
-            resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.onprem.profile"
-            resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.onprem.call"
-            buildConfigField "int", "MAX_GROUP_SIZE", "256"
-            buildConfigField "String", "CHAT_SERVER_PREFIX", "\"\""
-            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"\""
-            buildConfigField "String", "CHAT_SERVER_SUFFIX", "null"
-            buildConfigField "String", "MEDIA_PATH", "\"ThreemaOnPrem\""
-            buildConfigField "boolean", "CHAT_SERVER_GROUPS", "false"
-
-            buildConfigField "byte[]", "SERVER_PUBKEY", "null"
-            buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "null"
-            buildConfigField "String", "DIRECTORY_SERVER_URL", "null"
-            buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "null"
-
-            buildConfigField "String", "BLOB_SERVER_URL", "null"
-            buildConfigField "String", "BLOB_SERVER_IPV6_URL", "null"
-            buildConfigField "String", "BLOB_SERVER_URL_UPLOAD", "null"
-            buildConfigField "String", "BLOB_SERVER_IPV6_URL_UPLOAD", "null"
-            buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "null"
-
-            buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "new String[] {\"ek1qBp4DyRmLL9J5sCmsKSfwbsiGNB4veDAODjkwe/k=\", \"Hrk8aCjwKkXySubI7CZ3y9Sx+oToEHjNkGw98WSRneU=\", \"5pEn1T/5bhecNWrp9NgUQweRfgVtu/I8gRb3VxGP7k4=\"}"
-            buildConfigField "String", "LOG_TAG", "\"3maop\""
-
-            // config fields for action URLs / deep links
-            buildConfigField "String", "uriScheme", "\"threemaonprem\""
-            buildConfigField "String", "actionUrl", "\"onprem.threema.ch\""
-
-            buildConfigField "boolean", "EMOJI_REACTIONS_WEB_ENABLED", "false"
-
-            manifestPlaceholders = [
-                uriScheme   : "threemaonprem",
-                actionUrl   : "onprem.threema.ch",
-                callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.onprem.call",
-            ]
-        }
-        blue {
-            // Essentially like sandbox work, but with a different icon and application id, used for internal testing
-            versionName "${app_version}b${beta_suffix}"
-            // The app was previously named `red`. The app id remains unchanged to still be able to install updates.
-            applicationId "ch.threema.app.red"
-            testApplicationId 'ch.threema.app.blue.test'
-            resValue "string", "app_name", "Threema Blue"
-            resValue "string", "package_name", applicationId
-            resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.blue.profile"
-            resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.blue.call"
-
-            buildConfigField "String", "CHAT_SERVER_PREFIX", "\"w-\""
-            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
-            buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
-            buildConfigField "String", "MEDIA_PATH", "\"ThreemaBlue\""
-            // This public key is pinned for the chat server protocol.
-            buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
-            buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
-            buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
-            buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-apip.test.threema.ch/\""
-            buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.test.threema.ch/\""
-            buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.test.threema.ch/\""
-            buildConfigField "String", "MEDIATOR_SERVER_URL", "\"wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
-            buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
-            buildConfigField "String", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android-work/{rating}\""
-            buildConfigField "String", "LOG_TAG", "\"3mablue\""
-
-            buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
-
-            // config fields for action URLs / deep links
-            buildConfigField "String", "uriScheme", "\"threemablue\""
-            buildConfigField "String", "actionUrl", "\"blue.threema.ch\""
-
-            manifestPlaceholders = [
-                uriScheme   : "threemablue",
-                actionUrl   : "blue.threema.ch",
-                callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.blue.call",
-            ]
-        }
-        hms {
-            applicationId "ch.threema.app.hms"
-        }
-        hms_work {
-            versionName "${app_version}k${beta_suffix}"
-            applicationId "ch.threema.app.work.hms"
-            testApplicationId 'ch.threema.app.work.test.hms'
-            resValue "string", "app_name", "Threema Work"
-            resValue "string", "package_name", "ch.threema.app.work"
-            resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.profile"
-            resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.call"
-            buildConfigField "String", "CHAT_SERVER_PREFIX", "\"w-\""
-            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
-            buildConfigField "String", "MEDIA_PATH", "\"ThreemaWork\""
-            buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.threema.ch/\""
-            buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.threema.ch/\""
-            buildConfigField "String", "APP_RATING_URL", "\"https://threema.ch/app-rating/android-work/{rating}\""
-            buildConfigField "String", "LOG_TAG", "\"3mawrk\""
-            buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
-
-            // config fields for action URLs / deep links
-            buildConfigField "String", "uriScheme", "\"threemawork\""
-            buildConfigField "String", "actionUrl", "\"work.threema.ch\""
-
-            manifestPlaceholders = [
-                uriScheme   : "threemawork",
-                actionUrl   : "work.threema.ch",
-                callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.work.call",
-            ]
-        }
-        libre {
-            versionName "${app_version}l${beta_suffix}"
-            applicationId "ch.threema.app.libre"
-            testApplicationId 'ch.threema.app.libre.test'
-            resValue "string", "package_name", applicationId
-            resValue "string", "app_name", "Threema Libre"
-            buildConfigField "String", "MEDIA_PATH", "\"ThreemaLibre\""
-        }
-    }
-
-    signingConfigs {
-        // Debug config
-        if (keystores.debug != null) {
-            debug {
-                storeFile file(keystores.debug.storeFile)
-            }
-        } else {
-            logger.warn("No debug keystore found. Falling back to locally generated keystore.")
-        }
-
-        // Release config
-        if (keystores.release != null) {
-            release {
-                storeFile file(keystores.release.storeFile)
-                storePassword keystores.release.storePassword
-                keyAlias keystores.release.keyAlias
-                keyPassword keystores.release.keyPassword
-            }
-        } else {
-            logger.warn("No release keystore found. Falling back to locally generated keystore.")
-        }
-
-        // Release config
-        if (keystores.hms_release != null) {
-            hms_release {
-                storeFile file(keystores.hms_release.storeFile)
-                storePassword keystores.hms_release.storePassword
-                keyAlias keystores.hms_release.keyAlias
-                keyPassword keystores.hms_release.keyPassword
-            }
-        } else {
-            logger.warn("No hms keystore found. Falling back to locally generated keystore.")
-        }
-
-        // Onprem release config
-        if (keystores.onprem_release != null) {
-            onprem_release {
-                storeFile file(keystores.onprem_release.storeFile)
-                storePassword keystores.onprem_release.storePassword
-                keyAlias keystores.onprem_release.keyAlias
-                keyPassword keystores.onprem_release.keyPassword
-            }
-        } else {
-            logger.warn("No onprem keystore found. Falling back to locally generated keystore.")
-        }
-
-        // Blue release config
-        if (keystores.blue_release != null) {
-            blue_release {
-                storeFile file(keystores.blue_release.storeFile)
-                storePassword keystores.blue_release.storePassword
-                keyAlias keystores.blue_release.keyAlias
-                keyPassword keystores.blue_release.keyPassword
-            }
-        } else {
-            logger.warn("No blue keystore found. Falling back to locally generated keystore.")
-        }
-
-        // Note: Libre release is signed with HSM, no config here
-    }
-
-    sourceSets {
-        main {
-            assets.srcDirs = ['assets']
-            jniLibs.srcDirs = ['libs']
-            res.srcDir 'src/main/res-rendezvous'
-        }
-
-        // Based on Google services
-        none {
-            java.srcDir 'src/google_services_based/java'
-        }
-        store_google {
-            java.srcDir 'src/google_services_based/java'
-        }
-        store_google_work {
-            java.srcDir 'src/google_services_based/java'
-        }
-        store_threema {
-            java.srcDir 'src/google_services_based/java'
-        }
-        libre {
-            assets.srcDirs = ['src/foss_based/assets']
-            java.srcDir 'src/foss_based/java'
-        }
-        onprem {
-            java.srcDir 'src/google_services_based/java'
-        }
-        green {
-            java.srcDir 'src/google_services_based/java'
-            manifest.srcFile 'src/store_google/AndroidManifest.xml'
-        }
-        sandbox_work {
-            java.srcDir 'src/google_services_based/java'
-            res.srcDir 'src/store_google_work/res'
-            manifest.srcFile 'src/store_google_work/AndroidManifest.xml'
-        }
-        blue {
-            java.srcDir 'src/google_services_based/java'
-            res.srcDir 'src/blue/res'
-        }
-
-        // Based on Huawei services
-        hms {
-            java.srcDir 'src/hms_services_based/java'
-        }
-        hms_work {
-            java.srcDir 'src/hms_services_based/java'
-            res.srcDir 'src/store_google_work/res'
-        }
-
-        // FOSS, no proprietary services
-        libre {
-            assets.srcDirs = ['src/foss_based/assets']
-            java.srcDir 'src/foss_based/java'
-        }
-    }
-
-    buildTypes {
-        debug {
-            debuggable true
-            jniDebuggable false
-            ndk {
-                debugSymbolLevel 'FULL'
-            }
-            enableUnitTestCoverage false
-            enableAndroidTestCoverage false
-
-            if (keystores['debug'] != null) {
-                signingConfig signingConfigs.debug
-            }
-        }
-        release {
-            debuggable false
-            jniDebuggable false
-            minifyEnabled true
-            shrinkResources false // Caused inconsistencies between local and CI builds
-            vcsInfo.include false // For reproducible builds independent from git history
-            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-project.txt'
-            ndk {
-                debugSymbolLevel 'FULL' // 'SYMBOL_TABLE'
-            }
-
-            if (keystores['release'] != null) {
-                productFlavors.store_google.signingConfig signingConfigs.release
-                productFlavors.store_google_work.signingConfig signingConfigs.release
-                productFlavors.store_threema.signingConfig signingConfigs.release
-                productFlavors.green.signingConfig signingConfigs.release
-                productFlavors.sandbox_work.signingConfig signingConfigs.release
-                productFlavors.none.signingConfig signingConfigs.release
-            }
-
-            if (keystores['hms_release'] != null) {
-                productFlavors.hms.signingConfig signingConfigs.hms_release
-                productFlavors.hms_work.signingConfig signingConfigs.hms_release
-            }
-
-            if (keystores['onprem_release'] != null) {
-                productFlavors.onprem.signingConfig signingConfigs.onprem_release
-            }
-
-            if (keystores['blue_release'] != null) {
-                productFlavors.blue.signingConfig signingConfigs.blue_release
-            }
-
-            // Note: Libre release is signed with HSM, no config here
-        }
-    }
-
-    // Only build relevant buildType / flavor combinations
-    variantFilter { variant ->
-        def names = variant.flavors*.name
-
-        if (
-            variant.buildType.name == "release" && (
-                names.contains("green") || names.contains("sandbox_work")
-            )
-        ) {
-            setIgnore(true)
-        }
-    }
-
-    externalNativeBuild {
-        ndkBuild {
-            path 'jni/Android.mk'
-        }
-    }
-
-    packagingOptions {
-        jniLibs {
-            // replacement for extractNativeLibs in AndroidManifest
-            useLegacyPackaging = true
-        }
-        resources {
-            excludes += [
-                'META-INF/DEPENDENCIES.txt',
-                'META-INF/LICENSE.txt',
-                'META-INF/NOTICE.txt',
-                'META-INF/NOTICE',
-                'META-INF/LICENSE',
-                'META-INF/DEPENDENCIES',
-                'META-INF/notice.txt',
-                'META-INF/license.txt',
-                'META-INF/dependencies.txt',
-                'META-INF/LGPL2.1',
-                '**/*.proto',
-                'DebugProbesKt.bin'
-            ]
-        }
-    }
-
-    testOptions {
-        // Disable animations in instrumentation tests
-        animationsDisabled true
-
-        unitTests {
-            all {
-                // All the usual Gradle options.
-                testLogging {
-                    events "passed", "skipped", "failed", "standardOut", "standardError"
-                    outputs.upToDateWhen { false }
-                    exceptionFormat = 'full'
-                }
-
-                jvmArgs = jvmArgs + ['--add-opens=java.base/java.util=ALL-UNNAMED']
-                jvmArgs = jvmArgs + ['--add-opens=java.base/java.util.stream=ALL-UNNAMED']
-                jvmArgs = jvmArgs + ['--add-opens=java.base/java.lang=ALL-UNNAMED']
-                jvmArgs = jvmArgs + ['-Xmx4096m']
-            }
-            // By default, local unit tests throw an exception any time the code you are testing tries to access
-            // Android platform APIs (unless you mock Android dependencies yourself or with a testing
-            // framework like Mockito). However, you can enable the following property so that the test
-            // returns either null or zero when accessing platform APIs, rather than throwing an exception.
-            returnDefaultValues true
-        }
-    }
-
-    compileOptions {
-        coreLibraryDesugaringEnabled true
-        sourceCompatibility JavaVersion.VERSION_11
-        targetCompatibility JavaVersion.VERSION_11
-    }
-
-    java {
-        toolchain {
-            languageVersion.set(JavaLanguageVersion.of(17))
-        }
-    }
-
-    kotlin {
-        jvmToolchain(17)
-    }
-
-    androidResources {
-        noCompress 'png'
-    }
-
-    lint {
-        // if true, stop the gradle build if errors are found
-        abortOnError true
-        // if true, check all issues, including those that are off by default
-        checkAllWarnings true
-        // check dependencies
-        checkDependencies true
-        // set to true to have all release builds run lint on issues with severity=fatal
-        // and abort the build (controlled by abortOnError above) if fatal issues are found
-        checkReleaseBuilds true
-        // turn off checking the given issue id's
-        disable 'TypographyFractions', 'TypographyQuotes', 'RtlHardcoded', 'RtlCompat', 'RtlEnabled'
-        // Set the severity of the given issues to error
-        error 'Wakelock', 'TextViewEdits', 'ResourceAsColor'
-        // Set the severity of the given issues to fatal (which means they will be
-        // checked during release builds (even if the lint target is not included)
-        fatal 'NewApi', 'InlinedApi'
-        // Set the severity of the given issues to ignore (same as disabling the check)
-        ignore 'TypographyQuotes'
-        ignoreWarnings false
-        // if true, don't include source code lines in the error output
-        noLines false
-        // if true, show all locations for an error, do not truncate lists, etc.
-        showAll true
-        // Set the severity of the given issues to warning
-        warning 'MissingTranslation'
-        // if true, treat all warnings as errors
-        warningsAsErrors false
-        // file to write report to (if not specified, defaults to lint-results.xml)
-        xmlOutput file('lint-report.xml')
-        // if true, generate an XML report for use by for example Jenkins
-        xmlReport true
-    }
-
-    buildFeatures {
-        compose true
-        buildConfig true
-    }
-
-    composeOptions {
-        kotlinCompilerExtensionVersion = "1.5.7"
-    }
-}
-
-dependencies {
-    configurations.all {
-        // Prefer modules that are part of this build (multi-project or composite build)
-        // over external modules
-        resolutionStrategy.preferProjectModules()
-
-        // Alternatively, we can fail eagerly on version conflict to see the conflicts
-        //resolutionStrategy.failOnVersionConflict()
-    }
-
-    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
-
-    implementation project(':domain')
-
-    implementation 'net.zetetic:sqlcipher-android:4.5.7@aar'
-
-    implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
-    implementation 'net.sf.opencsv:opencsv:2.3'
-    implementation 'net.lingala.zip4j:zip4j:2.11.5'
-    implementation 'com.getkeepsafe.taptargetview:taptargetview:1.13.3'
-    // commons-io >2.6 requires android 8
-    implementation 'commons-io:commons-io:2.6'
-    implementation 'org.apache.commons:commons-text:1.10.0'
-    implementation "org.slf4j:slf4j-api:$slf4j_version"
-    implementation 'com.vanniktech:android-image-cropper:4.5.0'
-    implementation 'com.datatheorem.android.trustkit:trustkit:1.1.5'
-    implementation 'me.zhanghai.android.fastscroll:library:1.3.0'
-    implementation 'com.googlecode.ez-vcard:ez-vcard:0.11.3'
-    implementation 'com.alexvasilkov:gesture-views:2.8.3'
-
-    // AndroidX / Jetpack support libraries
-    implementation "androidx.preference:preference-ktx:1.2.1"
-    implementation 'androidx.recyclerview:recyclerview:1.3.2'
-    implementation 'androidx.palette:palette-ktx:1.0.0'
-    implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
-    implementation 'androidx.core:core-ktx:1.13.1'
-    implementation 'androidx.appcompat:appcompat:1.7.0'
-    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
-    implementation 'androidx.biometric:biometric:1.1.0'
-    implementation 'androidx.work:work-runtime-ktx:2.9.0'
-    implementation 'androidx.fragment:fragment-ktx:1.8.0'
-    implementation 'androidx.activity:activity-ktx:1.9.0'
-    implementation 'androidx.sqlite:sqlite:2.2.2'
-    implementation "androidx.concurrent:concurrent-futures:1.2.0"
-    implementation "androidx.camera:camera-camera2:1.3.4"
-    implementation "androidx.camera:camera-lifecycle:1.3.4"
-    implementation "androidx.camera:camera-view:1.3.4"
-    implementation 'androidx.camera:camera-video:1.3.4'
-    implementation "androidx.media:media:1.7.0"
-    implementation 'androidx.media3:media3-exoplayer:1.3.1'
-    implementation 'androidx.media3:media3-ui:1.3.1'
-    implementation "androidx.media3:media3-session:1.3.1"
-    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2"
-    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.8.2"
-    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.2"
-    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.2"
-    implementation "androidx.lifecycle:lifecycle-service:2.8.2"
-    implementation "androidx.lifecycle:lifecycle-process:2.8.2"
-    implementation "androidx.lifecycle:lifecycle-common-java8:2.8.2"
-    implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
-    implementation "androidx.paging:paging-runtime-ktx:3.3.0"
-    implementation "androidx.sharetarget:sharetarget:1.2.0"
-    implementation 'androidx.room:room-runtime:2.6.1'
-    implementation 'androidx.window:window:1.3.0'
-    kapt 'androidx.room:room-compiler:2.6.1'
-
-    // Jetpack Compose
-    def composeBom = platform('androidx.compose:compose-bom:2024.06.00')
-    implementation composeBom
-    implementation 'androidx.compose.material3:material3'
-    implementation 'androidx.compose.ui:ui-tooling-preview'
-    implementation 'androidx.activity:activity-compose:1.9.0'
-    implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6'
-    implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.6'
-    debugImplementation 'androidx.compose.ui:ui-tooling'
-    androidTestImplementation composeBom
-
-    implementation 'org.bouncycastle:bcprov-jdk15to18:1.78.1'
-
-    implementation 'com.google.android.material:material:1.12.0'
-    implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on API < 24
-    implementation 'com.googlecode.libphonenumber:libphonenumber:8.13.39'
-    // make sure to update this in domain's build.gradle as well
-
-    // webclient dependencies
-    implementation 'org.msgpack:msgpack-core:0.8.24!!'
-    implementation 'com.fasterxml.jackson.core:jackson-core:2.12.5!!'
-    implementation 'com.neovisionaries:nv-websocket-client:2.9'
-
-    // Backport of Streams and CompletableFuture. Remove once API level 24 is supported.
-    implementation 'net.sourceforge.streamsupport:streamsupport-cfuture:1.7.4'
-
-    implementation('org.saltyrtc:saltyrtc-client:0.14.2') {
-        exclude group: 'org.json'
-    }
-
-    implementation 'org.saltyrtc:chunked-dc:1.0.1'
-    implementation 'ch.threema:webrtc-android:123.0.0'
-    implementation('org.saltyrtc:saltyrtc-task-webrtc:0.18.1') {
-        exclude module: 'saltyrtc-client'
-    }
-
-    // Glide components
-    // Glide 4.15+ does not work on API 21
-    implementation 'com.github.bumptech.glide:glide:4.16.0'
-    kapt 'com.github.bumptech.glide:compiler:4.16.0'
-    annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
-
-    // Kotlin
-    implementation 'androidx.core:core-ktx:1.13.1'
-    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
-    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
-    implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
-    testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
-    androidTestImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
-
-    // use leak canary in debug builds
-    if (!project.hasProperty("noLeakCanary")) {
-        debugImplementation('com.squareup.leakcanary:leakcanary-android:2.13')
-    }
-
-    // test dependencies
-    testImplementation "junit:junit:$junit_version"
-    testImplementation(testFixtures(project(":domain")))
-
-    // custom test helpers, shared between unit test and android tests
-    testImplementation(project(":test-helpers"))
-    androidTestImplementation(project(":test-helpers"))
-
-    // use powermock instead of mockito. it support mocking static classes.
-    def mockitoVersion = '2.0.9'
-    testImplementation "org.powermock:powermock-api-mockito2:${mockitoVersion}"
-    testImplementation "org.powermock:powermock-module-junit4-rule-agent:${mockitoVersion}"
-    testImplementation "org.powermock:powermock-module-junit4-rule:${mockitoVersion}"
-    testImplementation "org.powermock:powermock-module-junit4:${mockitoVersion}"
-
-    // add JSON support to tests without mocking
-    testImplementation 'org.json:json:20220924'
-
-    testImplementation 'com.tngtech.archunit:archunit-junit4:0.18.0'
-
-    androidTestImplementation(testFixtures(project(":domain")))
-    androidTestImplementation 'androidx.test:rules:1.6.0'
-    androidTestImplementation 'tools.fastlane:screengrab:2.1.1', {
-        exclude group: 'androidx.annotation', module: 'annotation'
-    }
-    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0', {
-        exclude group: 'androidx.annotation', module: 'annotation'
-    }
-    androidTestImplementation 'androidx.test:runner:1.4.0', {
-        exclude group: 'androidx.annotation', module: 'annotation'
-    }
-    androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0'
-    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0', {
-        exclude group: 'androidx.annotation', module: 'annotation'
-        exclude group: 'androidx.appcompat', module: 'appcompat'
-        exclude group: 'androidx.legacy', module: 'legacy-support-v4'
-        exclude group: 'com.google.android.material', module: 'material'
-        exclude group: 'androidx.recyclerview', module: 'recyclerview'
-        exclude(group: 'org.checkerframework', module: 'checker')
-        exclude module: "protobuf-lite"
-    }
-    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0', {
-        exclude group: 'androidx.annotation', module: 'annotation'
-    }
-    androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0'
-    androidTestImplementation 'androidx.test:core-ktx:1.6.0'
-    androidTestImplementation "org.mockito:mockito-core:4.8.1"
-    androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlin_coroutines_version"
-
-    // Google Play Services and related libraries
-    def googleDependencies = [
-        // Play services
-        'com.google.android.gms:play-services-base:18.0.1': [],
-
-        // Firebase push
-        'com.google.firebase:firebase-messaging:23.1.2'   : [
-            [group: 'com.google.firebase', module: 'firebase-core'],
-            [group: 'com.google.firebase', module: 'firebase-analytics'],
-            [group: 'com.google.firebase', module: 'firebase-measurement-connector'],
-        ],
-    ]
-    googleDependencies.each {
-        def dependency = it.key
-        def excludes = it.value
-        noneImplementation(dependency) { excludes.each { exclude it } }
-        store_googleImplementation(dependency) { excludes.each { exclude it } }
-        store_google_workImplementation(dependency) { excludes.each { exclude it } }
-        store_threemaImplementation(dependency) { excludes.each { exclude it } }
-        onpremImplementation(dependency) { excludes.each { exclude it } }
-        greenImplementation(dependency) { excludes.each { exclude it } }
-        sandbox_workImplementation(dependency) { excludes.each { exclude it } }
-        blueImplementation(dependency) { excludes.each { exclude it } }
-    }
-
-    // Google Assistant Voice Action verification library
-    noneImplementation(name: 'libgsaverification-client', ext: 'aar')
-    store_googleImplementation(name: 'libgsaverification-client', ext: 'aar')
-    store_google_workImplementation(name: 'libgsaverification-client', ext: 'aar')
-    onpremImplementation(name: 'libgsaverification-client', ext: 'aar')
-    store_threemaImplementation(name: 'libgsaverification-client', ext: 'aar')
-    greenImplementation(name: 'libgsaverification-client', ext: 'aar')
-    sandbox_workImplementation(name: 'libgsaverification-client', ext: 'aar')
-    blueImplementation(name: 'libgsaverification-client', ext: 'aar')
-
-    // Maplibre (may have transitive dependencies on Google location services)
-    def maplibreDependency = 'org.maplibre.gl:android-sdk:11.0.1'
-    noneImplementation maplibreDependency
-    store_googleImplementation maplibreDependency
-    store_google_workImplementation maplibreDependency
-    store_threemaImplementation maplibreDependency
-    libreImplementation maplibreDependency, { exclude group: 'com.google.android.gms' }
-    onpremImplementation maplibreDependency
-    greenImplementation maplibreDependency
-    sandbox_workImplementation maplibreDependency
-    blueImplementation maplibreDependency
-    hmsImplementation maplibreDependency
-    hms_workImplementation maplibreDependency
-
-    // Huawei related libraries (only for hms* build variants)
-    def huaweiDependencies = [
-        // HMS push
-        'com.huawei.hms:push:6.3.0.304': [
-            // Exclude agconnect dependency, we'll replace it with the vendored version below
-            [group: 'com.huawei.agconnect'],
-        ],
-    ]
-    huaweiDependencies.each {
-        def dependency = it.key
-        def excludes = it.value
-        hmsImplementation(dependency) { excludes.each { exclude it } }
-        hms_workImplementation(dependency) { excludes.each { exclude it } }
-    }
-    hmsImplementation(name: 'agconnect-core-1.9.1.301', ext: 'aar')
-    hms_workImplementation(name: 'agconnect-core-1.9.1.301', ext: 'aar')
-}
-
-tasks.withType(KaptGenerateStubs).configureEach {
-    kotlinOptions {
-        jvmTarget = JavaVersion.VERSION_11.toString()
-    }
-}
-
-// Define the cargo attributes. These will be used by the rust-android plugin that will create the
-// 'cargoBuild' task that builds native libraries that will be added to the apk. Note that the
-// kotlin bindings are created in the domain module. Building native libraries with rust-android
-// cannot be done in any other module than 'app'.
-cargo {
-    prebuiltToolchains = true
-    targetDirectory = "$projectDir/build/generated/source/libthreema"
-    module = "$projectDir/../domain/libthreema" // must contain Cargo.toml
-    libname = "libthreema" // must match the Cargo.toml's package name
-    profile = 'release'
-    pythonCommand = 'python3'
-    targets = ["x86_64", "arm64", "arm", "x86"]
-    features {
-        defaultAnd("uniffi")
-    }
-    extraCargoBuildArguments = ["--lib", "--target-dir", "$projectDir/build/generated/source/libthreema"]
-    verbose = false
-}
-
-afterEvaluate {
-    // The `cargoBuild` task isn't available until after evaluation.
-    android.applicationVariants.configureEach { variant ->
-        def variantName = "${variant.name.capitalize()}"
-        // Set the dependency so that cargoBuild is executed before the native libs are merged
-        tasks["merge${variantName}NativeLibs"].dependsOn(tasks["cargoBuild"])
-    }
-}
-
-sonarqube {
-    properties {
-        property "sonar.sources", "src/main/, ../scripts/, ../scripts-internal/"
-        property "sonar.exclusions", "src/main/java/ch/threema/localcrypto/**, src/test/java/ch/threema/localcrypto/**"
-        property "sonar.tests", "src/test/"
-        property "sonar.sourceEncoding", "UTF-8"
-        property "sonar.verbose", "true"
-        property 'sonar.projectKey', 'android-client'
-        property 'sonar.projectName', 'Threema for Android'
-    }
-}
-
-// Set up Gradle tasks to fetch screenshots on UI test failures
-// See https://medium.com/stepstone-tech/how-to-capture-screenshots-for-failed-ui-tests-9927eea6e1e4
-def reportsDirectory = "$buildDir/reports/androidTests/connected"
-def screenshotsDirectory = "/sdcard/testfailures/screenshots/"
-def clearScreenshotsTask = task('clearScreenshots', type: Exec) {
-    executable "${android.getAdbExe().toString()}"
-    args 'shell', 'rm', '-r', screenshotsDirectory
-}
-def createScreenshotsDirectoryTask = task('createScreenshotsDirectory', type: Exec, group: 'reporting') {
-    executable "${android.getAdbExe().toString()}"
-    args 'shell', 'mkdir', '-p', screenshotsDirectory
-}
-def fetchScreenshotsTask = task('fetchScreenshots', type: Exec, group: 'reporting') {
-    executable "${android.getAdbExe().toString()}"
-    args 'pull', screenshotsDirectory + '.', reportsDirectory
-    finalizedBy {
-        clearScreenshotsTask
-    }
-    dependsOn {
-        createScreenshotsDirectoryTask
-    }
-    doFirst {
-        new File(reportsDirectory).mkdirs()
-    }
-}
-tasks.whenTaskAdded { task ->
-    if (task.name == 'connectedDebugAndroidTest') {
-        task.finalizedBy {
-            fetchScreenshotsTask
-        }
-    }
-}

+ 1016 - 0
app/build.gradle.kts

@@ -0,0 +1,1016 @@
+import com.android.build.gradle.internal.api.ApkVariantOutputImpl
+import config.PublicKeys
+import config.setProductNames
+import org.gradle.api.tasks.testing.logging.TestExceptionFormat
+import utils.*
+
+plugins {
+    alias(libs.plugins.sonarqube)
+    alias(libs.plugins.kotlin.serialization)
+    alias(libs.plugins.rust.android)
+    id("com.android.application")
+    id("kotlin-android")
+    alias(libs.plugins.ksp)
+    alias(libs.plugins.compose.compiler)
+    alias(libs.plugins.stem)
+}
+
+// only apply the plugin if we are dealing with an AppGallery build
+if (gradle.startParameter.taskRequests.toString().contains("Hms")) {
+    logger.info("enabling hms plugin")
+    apply {
+        plugin("com.huawei.agconnect")
+    }
+}
+
+/**
+ * Only use the scheme "<major>.<minor>.<patch>" for the appVersion
+ */
+val appVersion = "6.0.0"
+
+/**
+ * betaSuffix with leading dash (e.g. `-beta1`).
+ * Should be one of (alpha|beta|rc) and an increasing number, or empty for a regular release.
+ * Note: in nightly builds this will be overwritten with a nightly version "-n12345"
+ */
+val betaSuffix = ""
+
+val defaultVersionCode = 1070
+
+/**
+ * Map with keystore paths (if found).
+ */
+val keystores: Map<String, KeystoreConfig?> = mapOf(
+    "debug" to findKeystore(projectDir, "debug"),
+    "release" to findKeystore(projectDir, "threema"),
+    "hms_release" to findKeystore(projectDir, "threema_hms"),
+    "onprem_release" to findKeystore(projectDir, "onprem"),
+    "blue_release" to findKeystore(projectDir, "threema_blue"),
+)
+
+android {
+    // NOTE: When adjusting compileSdkVersion, buildToolsVersion or ndkVersion,
+    //       make sure to adjust them in `scripts/Dockerfile` as well!
+    compileSdk = 35
+    buildToolsVersion = "35.0.0"
+    ndkVersion = "25.2.9519653"
+
+    defaultConfig {
+        // https://developer.android.com/training/testing/espresso/setup#analytics
+        with(testInstrumentationRunnerArguments) {
+            put("notAnnotation", "ch.threema.app.TestFastlaneOnly,ch.threema.app.DangerousTest")
+            put("disableAnalytics", "true")
+        }
+        minSdk = 21
+        //noinspection OldTargetApi
+        targetSdk = 34
+        vectorDrawables.useSupportLibrary = true
+        applicationId = "ch.threema.app"
+        testApplicationId = "ch.threema.app.test"
+        versionCode = defaultVersionCode
+        versionName = "$appVersion$betaSuffix"
+
+        setProductNames(
+            appName = "Threema",
+        )
+        // package name used for sync adapter - needs to match mime types below
+        stringResValue("package_name", applicationId!!)
+        stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.profile")
+        stringResValue("call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.call")
+
+        intBuildConfigField("MAX_GROUP_SIZE", 256)
+        stringBuildConfigField("CHAT_SERVER_PREFIX", "g-")
+        stringBuildConfigField("CHAT_SERVER_IPV6_PREFIX", "ds.g-")
+        stringBuildConfigField("CHAT_SERVER_SUFFIX", ".0.threema.ch")
+        intArrayBuildConfigField("CHAT_SERVER_PORTS", intArrayOf(5222, 443))
+        stringBuildConfigField("MEDIA_PATH", "Threema")
+        booleanBuildConfigField("CHAT_SERVER_GROUPS", true)
+        booleanBuildConfigField("DISABLE_CERT_PINNING", false)
+        booleanBuildConfigField("VIDEO_CALLS_ENABLED", true)
+        // This public key is pinned for the chat server protocol.
+        byteArrayBuildConfigField("SERVER_PUBKEY", PublicKeys.prodServer)
+        byteArrayBuildConfigField("SERVER_PUBKEY_ALT", PublicKeys.prodServerAlt)
+        stringBuildConfigField("GIT_HASH", getGitHash())
+        stringBuildConfigField("DIRECTORY_SERVER_URL", "https://apip.threema.ch/")
+        stringBuildConfigField("DIRECTORY_SERVER_IPV6_URL", "https://ds-apip.threema.ch/")
+        stringBuildConfigField("WORK_SERVER_URL", null)
+        stringBuildConfigField("WORK_SERVER_IPV6_URL", null)
+        stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.threema.ch/{deviceGroupIdPrefix8}")
+
+        // Base blob url used for "download" and "done" calls
+        stringBuildConfigField("BLOB_SERVER_URL", "https://blobp-{blobIdPrefix}.threema.ch")
+        stringBuildConfigField("BLOB_SERVER_IPV6_URL", "https://ds-blobp-{blobIdPrefix}.threema.ch")
+
+        // Specific blob url used for "upload" calls
+        stringBuildConfigField("BLOB_SERVER_URL_UPLOAD", "https://blobp-upload.threema.ch/upload")
+        stringBuildConfigField("BLOB_SERVER_IPV6_URL_UPLOAD", "https://ds-blobp-upload.threema.ch/upload")
+
+        // Base blob mirror url used for "download", "upload", "done"
+        stringBuildConfigField("BLOB_MIRROR_SERVER_URL", "https://blob-mirror-{deviceGroupIdPrefix4}.threema.ch/{deviceGroupIdPrefix8}")
+
+        stringBuildConfigField("AVATAR_FETCH_URL", "https://avatar.threema.ch/")
+        stringBuildConfigField("SAFE_SERVER_URL", "https://safe-{backupIdPrefix8}.threema.ch/")
+        stringBuildConfigField("WEB_SERVER_URL", "https://web.threema.ch/")
+        stringBuildConfigField("APP_RATING_URL", "https://threema.ch/app-rating/android/{rating}")
+        stringBuildConfigField("MAP_STYLES_URL", "https://map.threema.ch/styles/streets/style.json")
+        stringBuildConfigField("MAP_POI_URL", "https://poi.threema.ch/around/{latitude}/{longitude}/{radius}/")
+        stringBuildConfigField("MAP_POI_NAMES_URL", "https://poi.threema.ch/names/{latitude}/{longitude}/{query}/")
+        byteArrayBuildConfigField("THREEMA_PUSH_PUBLIC_KEY", PublicKeys.threemaPush)
+        stringBuildConfigField("ONPREM_ID_PREFIX", "O")
+        stringBuildConfigField("LOG_TAG", "3ma")
+        stringBuildConfigField("DEFAULT_APP_THEME", "2")
+
+        stringArrayBuildConfigField("ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", emptyArray())
+        booleanBuildConfigField("MD_SYNC_DISTRIBUTION_LISTS", false)
+        booleanBuildConfigField("EDIT_MESSAGES_ENABLED", true)
+        booleanBuildConfigField("DELETE_MESSAGES_ENABLED", true)
+        booleanBuildConfigField("EMOJI_REACTIONS_ENABLED", true)
+        booleanBuildConfigField("EMOJI_REACTIONS_WEB_ENABLED", true)
+
+        // config fields for action URLs / deep links
+        stringBuildConfigField("uriScheme", "threema")
+        stringBuildConfigField("actionUrl", "go.threema.ch")
+        stringBuildConfigField("contactActionUrl", "threema.id")
+        stringBuildConfigField("groupLinkActionUrl", "threema.group")
+
+        with(manifestPlaceholders) {
+            put("uriScheme", "threema")
+            put("contactActionUrl", "threema.id")
+            put("groupLinkActionUrl", "threema.group")
+            put("actionUrl", "go.threema.ch")
+            put("callMimeType", "vnd.android.cursor.item/vnd.ch.threema.app.call")
+        }
+
+        testInstrumentationRunner = "ch.threema.app.ThreemaTestRunner"
+
+        // Only include language resources for those languages
+        androidResources.localeFilters.addAll(
+            setOf(
+                "en",
+                "be-rBY",
+                "ca",
+                "cs",
+                "de",
+                "es",
+                "fr",
+                "gsw",
+                "hu",
+                "it",
+                "ja",
+                "nl-rNL",
+                "no",
+                "pl",
+                "pt-rBR",
+                "ru",
+                "sk",
+                "tr",
+                "uk",
+                "zh-rCN",
+                "zh-rTW",
+            ),
+        )
+    }
+
+    splits {
+        abi {
+            isEnable = true
+            reset()
+            if (project.hasProperty("noAbiSplits")) {
+                isUniversalApk = true
+            } else {
+                include("armeabi-v7a", "x86", "arm64-v8a", "x86_64")
+                isUniversalApk = project.hasProperty("buildUniversalApk")
+            }
+        }
+    }
+
+    // Assign different version code for each output
+    android.applicationVariants.all {
+        outputs.all {
+            if (this is ApkVariantOutputImpl) {
+                val abi = getFilter("ABI")
+                val abiVersionCode = when (abi) {
+                    "armeabi-v7a" -> 2
+                    "arm64-v8a" -> 3
+                    "x86" -> 8
+                    "x86_64" -> 9
+                    else -> 0
+                }
+                versionCodeOverride = abiVersionCode * 1_000_000 + defaultVersionCode
+            }
+        }
+    }
+
+    namespace = "ch.threema.app"
+    flavorDimensions.add("default")
+    productFlavors {
+        create("none")
+        create("store_google")
+        create("store_threema") {
+            stringResValue("shop_download_filename", "Threema-update.apk")
+        }
+        create("store_google_work") {
+            versionName = "${appVersion}k$betaSuffix"
+            applicationId = "ch.threema.app.work"
+            testApplicationId = "ch.threema.app.work.test"
+            setProductNames(appName = "Threema Work")
+            stringResValue("package_name", applicationId!!)
+            stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.profile")
+            stringResValue("call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.call")
+            stringBuildConfigField("CHAT_SERVER_PREFIX", "w-")
+            stringBuildConfigField("CHAT_SERVER_IPV6_PREFIX", "ds.w-")
+            stringBuildConfigField("MEDIA_PATH", "ThreemaWork")
+            stringBuildConfigField("WORK_SERVER_URL", "https://apip-work.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_IPV6_URL", "https://ds-apip-work.threema.ch/")
+            stringBuildConfigField("APP_RATING_URL", "https://threema.ch/app-rating/android-work/{rating}")
+            stringBuildConfigField("LOG_TAG", "3mawrk")
+            stringBuildConfigField("DEFAULT_APP_THEME", "2")
+
+            // config fields for action URLs / deep links
+            stringBuildConfigField("uriScheme", "threemawork")
+            stringBuildConfigField("actionUrl", "work.threema.ch")
+
+            with(manifestPlaceholders) {
+                put("uriScheme", "threemawork")
+                put("actionUrl", "work.threema.ch")
+                put("callMimeType", "vnd.android.cursor.item/vnd.ch.threema.app.work.call")
+            }
+        }
+        create("green") {
+            applicationId = "ch.threema.app.green"
+            testApplicationId = "ch.threema.app.green.test"
+            setProductNames(appName = "Threema Green")
+            stringResValue("package_name", applicationId!!)
+            stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.green.profile")
+            stringResValue("call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.green.call")
+            stringBuildConfigField("MEDIA_PATH", "ThreemaGreen")
+            stringBuildConfigField("CHAT_SERVER_SUFFIX", ".0.test.threema.ch")
+            // This public key is pinned for the chat server protocol.
+            byteArrayBuildConfigField("SERVER_PUBKEY", PublicKeys.sandboxServer)
+            byteArrayBuildConfigField("SERVER_PUBKEY_ALT", PublicKeys.sandboxServer)
+            stringBuildConfigField("DIRECTORY_SERVER_URL", "https://apip.test.threema.ch/")
+            stringBuildConfigField("DIRECTORY_SERVER_IPV6_URL", "https://ds-apip.test.threema.ch/")
+            stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
+            stringBuildConfigField("AVATAR_FETCH_URL", "https://avatar.test.threema.ch/")
+            stringBuildConfigField("APP_RATING_URL", "https://test.threema.ch/app-rating/android/{rating}")
+            stringBuildConfigField("BLOB_MIRROR_SERVER_URL", "https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
+        }
+        create("sandbox_work") {
+            versionName = "${appVersion}k$betaSuffix"
+            applicationId = "ch.threema.app.sandbox.work"
+            testApplicationId = "ch.threema.app.sandbox.work.test"
+            setProductNames(
+                appName = "Threema Sandbox Work",
+                appNameDesktop = "Threema Blue",
+            )
+            stringResValue("package_name", applicationId!!)
+            stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.sandbox.work.profile")
+            stringResValue("call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.sandbox.work.call")
+            stringBuildConfigField("CHAT_SERVER_PREFIX", "w-")
+            stringBuildConfigField("CHAT_SERVER_IPV6_PREFIX", "ds.w-")
+            stringBuildConfigField("CHAT_SERVER_SUFFIX", ".0.test.threema.ch")
+            stringBuildConfigField("MEDIA_PATH", "ThreemaWorkSandbox")
+            // This public key is pinned for the chat server protocol.
+            byteArrayBuildConfigField("SERVER_PUBKEY", PublicKeys.sandboxServer)
+            byteArrayBuildConfigField("SERVER_PUBKEY_ALT", PublicKeys.sandboxServer)
+            stringBuildConfigField("DIRECTORY_SERVER_URL", "https://apip.test.threema.ch/")
+            stringBuildConfigField("DIRECTORY_SERVER_IPV6_URL", "https://ds-apip.test.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_URL", "https://apip-work.test.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_IPV6_URL", "https://ds-apip-work.test.threema.ch/")
+            stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
+            stringBuildConfigField("AVATAR_FETCH_URL", "https://avatar.test.threema.ch/")
+            stringBuildConfigField("APP_RATING_URL", "https://test.threema.ch/app-rating/android-work/{rating}")
+            stringBuildConfigField("LOG_TAG", "3mawrk")
+            stringBuildConfigField("DEFAULT_APP_THEME", "2")
+            stringBuildConfigField("BLOB_MIRROR_SERVER_URL", "https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
+
+            // config fields for action URLs / deep links
+            stringBuildConfigField("uriScheme", "threemawork")
+            stringBuildConfigField("actionUrl", "work.test.threema.ch")
+
+            stringBuildConfigField("MD_CLIENT_DOWNLOAD_URL", "https://three.ma/mdw")
+
+            with(manifestPlaceholders) {
+                put("uriScheme", "threemawork")
+                put("actionUrl", "work.test.threema.ch")
+            }
+        }
+        create("onprem") {
+            versionName = "${appVersion}o$betaSuffix"
+            applicationId = "ch.threema.app.onprem"
+            testApplicationId = "ch.threema.app.onprem.test"
+            setProductNames(
+                appName = "Threema OnPrem",
+                shortAppName = "Threema",
+                companyName = "Threema",
+            )
+            stringResValue("package_name", applicationId!!)
+            stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.onprem.profile")
+            stringResValue("call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.onprem.call")
+            intBuildConfigField("MAX_GROUP_SIZE", 256)
+            stringBuildConfigField("CHAT_SERVER_PREFIX", "")
+            stringBuildConfigField("CHAT_SERVER_IPV6_PREFIX", "")
+            stringBuildConfigField("CHAT_SERVER_SUFFIX", null)
+            stringBuildConfigField("MEDIA_PATH", "ThreemaOnPrem")
+            booleanBuildConfigField("CHAT_SERVER_GROUPS", false)
+            byteArrayBuildConfigField("SERVER_PUBKEY", null)
+            byteArrayBuildConfigField("SERVER_PUBKEY_ALT", null)
+            stringBuildConfigField("DIRECTORY_SERVER_URL", null)
+            stringBuildConfigField("DIRECTORY_SERVER_IPV6_URL", null)
+            stringBuildConfigField("BLOB_SERVER_URL", null)
+            stringBuildConfigField("BLOB_SERVER_IPV6_URL", null)
+            stringBuildConfigField("BLOB_SERVER_URL_UPLOAD", null)
+            stringBuildConfigField("BLOB_SERVER_IPV6_URL_UPLOAD", null)
+            stringBuildConfigField("BLOB_MIRROR_SERVER_URL", null)
+            stringArrayBuildConfigField("ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", PublicKeys.onPremTrusted)
+            stringBuildConfigField("LOG_TAG", "3maop")
+
+            // config fields for action URLs / deep links
+            stringBuildConfigField("uriScheme", "threemaonprem")
+            stringBuildConfigField("actionUrl", "onprem.threema.ch")
+
+            stringBuildConfigField("MD_CLIENT_DOWNLOAD_URL", "https://three.ma/mdo")
+
+            with(manifestPlaceholders) {
+                put("uriScheme", "threemaonprem")
+                put("actionUrl", "onprem.threema.ch")
+                put("callMimeType", "vnd.android.cursor.item/vnd.ch.threema.app.onprem.call")
+            }
+        }
+        create("blue") {
+            // Essentially like sandbox work, but with a different icon and application id, used for internal testing
+            versionName = "${appVersion}b$betaSuffix"
+            // The app was previously named `red`. The app id remains unchanged to still be able to install updates.
+            applicationId = "ch.threema.app.red"
+            testApplicationId = "ch.threema.app.blue.test"
+            setProductNames(appName = "Threema Blue")
+            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")
+
+            stringBuildConfigField("CHAT_SERVER_PREFIX", "w-")
+            stringBuildConfigField("CHAT_SERVER_IPV6_PREFIX", "ds.w-")
+            stringBuildConfigField("CHAT_SERVER_SUFFIX", ".0.test.threema.ch")
+            stringBuildConfigField("MEDIA_PATH", "ThreemaBlue")
+            // This public key is pinned for the chat server protocol.
+            byteArrayBuildConfigField("SERVER_PUBKEY", PublicKeys.sandboxServer)
+            byteArrayBuildConfigField("SERVER_PUBKEY_ALT", PublicKeys.sandboxServer)
+            stringBuildConfigField("DIRECTORY_SERVER_URL", "https://apip.test.threema.ch/")
+            stringBuildConfigField("DIRECTORY_SERVER_IPV6_URL", "https://ds-apip.test.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_URL", "https://apip-work.test.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_IPV6_URL", "https://ds-apip-work.test.threema.ch/")
+            stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
+            stringBuildConfigField("AVATAR_FETCH_URL", "https://avatar.test.threema.ch/")
+            stringBuildConfigField("APP_RATING_URL", "https://test.threema.ch/app-rating/android-work/{rating}")
+            stringBuildConfigField("LOG_TAG", "3mablue")
+            stringBuildConfigField("BLOB_MIRROR_SERVER_URL", "https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
+
+            // config fields for action URLs / deep links
+            stringBuildConfigField("uriScheme", "threemablue")
+            stringBuildConfigField("actionUrl", "blue.threema.ch")
+
+            with(manifestPlaceholders) {
+                put("uriScheme", "threemablue")
+                put("actionUrl", "blue.threema.ch")
+                put("callMimeType", "vnd.android.cursor.item/vnd.ch.threema.app.blue.call")
+            }
+        }
+        create("hms") {
+            applicationId = "ch.threema.app.hms"
+        }
+        create("hms_work") {
+            versionName = "${appVersion}k$betaSuffix"
+            applicationId = "ch.threema.app.work.hms"
+            testApplicationId = "ch.threema.app.work.test.hms"
+            setProductNames(appName = "Threema Work")
+            stringResValue("package_name", "ch.threema.app.work")
+            stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.profile")
+            stringResValue("call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.call")
+            stringBuildConfigField("CHAT_SERVER_PREFIX", "w-")
+            stringBuildConfigField("CHAT_SERVER_IPV6_PREFIX", "ds.w-")
+            stringBuildConfigField("MEDIA_PATH", "ThreemaWork")
+            stringBuildConfigField("WORK_SERVER_URL", "https://apip-work.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_IPV6_URL", "https://ds-apip-work.threema.ch/")
+            stringBuildConfigField("APP_RATING_URL", "https://threema.ch/app-rating/android-work/{rating}")
+            stringBuildConfigField("LOG_TAG", "3mawrk")
+            stringBuildConfigField("DEFAULT_APP_THEME", "2")
+
+            // config fields for action URLs / deep links
+            stringBuildConfigField("uriScheme", "threemawork")
+            stringBuildConfigField("actionUrl", "work.threema.ch")
+
+            with(manifestPlaceholders) {
+                put("uriScheme", "threemawork")
+                put("actionUrl", "work.threema.ch")
+                put("callMimeType", "vnd.android.cursor.item/vnd.ch.threema.app.work.call")
+            }
+        }
+        create("libre") {
+            versionName = "${appVersion}l$betaSuffix"
+            applicationId = "ch.threema.app.libre"
+            testApplicationId = "ch.threema.app.libre.test"
+            stringResValue("package_name", applicationId!!)
+            setProductNames(
+                appName = "Threema Libre",
+                appNameDesktop = "Threema",
+            )
+            stringBuildConfigField("MEDIA_PATH", "ThreemaLibre")
+        }
+    }
+
+    signingConfigs {
+        // Debug config
+        keystores["debug"]
+            ?.let { keystore ->
+                getByName("debug") {
+                    storeFile = keystore.storeFile
+                }
+            }
+            ?: run {
+                logger.warn("No debug keystore found. Falling back to locally generated keystore.")
+            }
+
+        // Release config
+        keystores["release"]
+            ?.let { keystore ->
+                create("release") {
+                    apply(keystore)
+                }
+            }
+            ?: run {
+                logger.warn("No release keystore found. Falling back to locally generated keystore.")
+            }
+
+        // Release config
+        keystores["hms_release"]
+            ?.let { keystore ->
+                create("hms_release") {
+                    apply(keystore)
+                }
+            }
+            ?: run {
+                logger.warn("No hms keystore found. Falling back to locally generated keystore.")
+            }
+
+        // Onprem release config
+        keystores["onprem_release"]
+            ?.let { keystore ->
+                create("onprem_release") {
+                    apply(keystore)
+                }
+            }
+            ?: run {
+                logger.warn("No onprem keystore found. Falling back to locally generated keystore.")
+            }
+
+        // Blue release config
+        keystores["blue_release"]
+            ?.let { keystore ->
+                create("blue_release") {
+                    apply(keystore)
+                }
+            }
+            ?: run {
+                logger.warn("No blue keystore found. Falling back to locally generated keystore.")
+            }
+
+        // Note: Libre release is signed with HSM, no config here
+    }
+
+    sourceSets {
+        getByName("main") {
+            assets.srcDirs("assets")
+            jniLibs.srcDirs("libs")
+            res.srcDir("src/main/res-rendezvous")
+        }
+
+        // Based on Google services
+        getByName("none") {
+            java.srcDir("src/google_services_based/java")
+        }
+        getByName("store_google") {
+            java.srcDir("src/google_services_based/java")
+        }
+        getByName("store_google_work") {
+            java.srcDir("src/google_services_based/java")
+        }
+        getByName("store_threema") {
+            java.srcDir("src/google_services_based/java")
+        }
+        getByName("libre") {
+            assets.srcDirs("src/foss_based/assets")
+            java.srcDir("src/foss_based/java")
+        }
+        getByName("onprem") {
+            java.srcDir("src/google_services_based/java")
+        }
+        getByName("green") {
+            java.srcDir("src/google_services_based/java")
+            manifest.srcFile("src/store_google/AndroidManifest.xml")
+        }
+        getByName("sandbox_work") {
+            java.srcDir("src/google_services_based/java")
+            res.srcDir("src/store_google_work/res")
+            manifest.srcFile("src/store_google_work/AndroidManifest.xml")
+        }
+        getByName("blue") {
+            java.srcDir("src/google_services_based/java")
+            res.srcDir("src/blue/res")
+        }
+
+        // Based on Huawei services
+        getByName("hms") {
+            java.srcDir("src/hms_services_based/java")
+        }
+        getByName("hms_work") {
+            java.srcDir("src/hms_services_based/java")
+            res.srcDir("src/store_google_work/res")
+        }
+
+        // FOSS, no proprietary services
+        getByName("libre") {
+            assets.srcDirs("src/foss_based/assets")
+            java.srcDir("src/foss_based/java")
+        }
+    }
+
+    buildTypes {
+        debug {
+            isDebuggable = true
+            isJniDebuggable = false
+            ndk {
+                debugSymbolLevel = "FULL"
+            }
+            enableUnitTestCoverage = false
+            enableAndroidTestCoverage = false
+
+            if (keystores["debug"] != null) {
+                signingConfig = signingConfigs["debug"]
+            }
+        }
+        release {
+            isDebuggable = false
+            isJniDebuggable = false
+            isMinifyEnabled = true
+            isShrinkResources = false // Caused inconsistencies between local and CI builds
+            vcsInfo.include = false // For reproducible builds independent from git history
+            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-project.txt")
+            ndk {
+                debugSymbolLevel = "FULL" // 'SYMBOL_TABLE'
+            }
+
+            if (keystores["release"] != null) {
+                val releaseSigningConfig = signingConfigs["release"]
+                productFlavors["store_google"].signingConfig = releaseSigningConfig
+                productFlavors["store_google_work"].signingConfig = releaseSigningConfig
+                productFlavors["store_threema"].signingConfig = releaseSigningConfig
+                productFlavors["green"].signingConfig = releaseSigningConfig
+                productFlavors["sandbox_work"].signingConfig = releaseSigningConfig
+                productFlavors["none"].signingConfig = releaseSigningConfig
+            }
+
+            if (keystores["hms_release"] != null) {
+                val hmsReleaseSigningConfig = signingConfigs["hms_release"]
+                productFlavors["hms"].signingConfig = hmsReleaseSigningConfig
+                productFlavors["hms_work"].signingConfig = hmsReleaseSigningConfig
+            }
+
+            if (keystores["onprem_release"] != null) {
+                productFlavors["onprem"].signingConfig = signingConfigs["onprem_release"]
+            }
+
+            if (keystores["blue_release"] != null) {
+                productFlavors["blue"].signingConfig = signingConfigs["blue_release"]
+            }
+
+            // Note: Libre release is signed with HSM, no config here
+        }
+    }
+
+    externalNativeBuild {
+        ndkBuild {
+            path("jni/Android.mk")
+        }
+    }
+
+    packaging {
+        jniLibs {
+            // replacement for extractNativeLibs in AndroidManifest
+            useLegacyPackaging = true
+        }
+        resources {
+            excludes.addAll(
+                setOf(
+                    "META-INF/DEPENDENCIES.txt",
+                    "META-INF/LICENSE.txt",
+                    "META-INF/NOTICE.txt",
+                    "META-INF/NOTICE",
+                    "META-INF/LICENSE",
+                    "META-INF/DEPENDENCIES",
+                    "META-INF/notice.txt",
+                    "META-INF/license.txt",
+                    "META-INF/dependencies.txt",
+                    "META-INF/LGPL2.1",
+                    "**/*.proto",
+                    "DebugProbesKt.bin",
+                ),
+            )
+        }
+    }
+
+    testOptions {
+        // Disable animations in instrumentation tests
+        animationsDisabled = true
+
+        unitTests {
+            all { test ->
+                test.outputs.upToDateWhen { false }
+                test.testLogging {
+                    events("passed", "skipped", "failed", "standardOut", "standardError")
+                    exceptionFormat = TestExceptionFormat.FULL
+                }
+
+                test.jvmArgs = test.jvmArgs!! + listOf(
+                    "--add-opens=java.base/java.util=ALL-UNNAMED",
+                    "--add-opens=java.base/java.util.stream=ALL-UNNAMED",
+                    "--add-opens=java.base/java.lang=ALL-UNNAMED",
+                    "-Xmx4096m",
+                )
+            }
+            // By default, local unit tests throw an exception any time the code you are testing tries to access
+            // Android platform APIs (unless you mock Android dependencies yourself or with a testing
+            // framework like Mockito). However, you can enable the following property so that the test
+            // returns either null or zero when accessing platform APIs, rather than throwing an exception.
+            isReturnDefaultValues = true
+        }
+    }
+
+    compileOptions {
+        isCoreLibraryDesugaringEnabled = true
+        sourceCompatibility = JavaVersion.VERSION_11
+        targetCompatibility = JavaVersion.VERSION_11
+    }
+
+    java {
+        toolchain {
+            languageVersion.set(JavaLanguageVersion.of(17))
+        }
+    }
+
+    kotlin {
+        jvmToolchain(17)
+    }
+
+    androidResources {
+        noCompress.add("png")
+    }
+
+    lint {
+        // if true, stop the gradle build if errors are found
+        abortOnError = true
+        // if true, check all issues, including those that are off by default
+        checkAllWarnings = true
+        // check dependencies
+        checkDependencies = true
+        // set to true to have all release builds run lint on issues with severity=fatal
+        // and abort the build (controlled by abortOnError above) if fatal issues are found
+        checkReleaseBuilds = true
+        // turn off checking the given issue id's
+        disable.addAll(setOf("TypographyFractions", "TypographyQuotes", "RtlHardcoded", "RtlCompat", "RtlEnabled"))
+        // Set the severity of the given issues to error
+        error.addAll(setOf("Wakelock", "TextViewEdits", "ResourceAsColor"))
+        // Set the severity of the given issues to fatal (which means they will be
+        // checked during release builds (even if the lint target is not included)
+        fatal.addAll(setOf("NewApi", "InlinedApi"))
+        ignoreWarnings = false
+        // if true, don't include source code lines in the error output
+        noLines = false
+        // if true, show all locations for an error, do not truncate lists, etc.
+        showAll = true
+        // Set the severity of the given issues to warning
+        warning.add("MissingTranslation")
+        // if true, treat all warnings as errors
+        warningsAsErrors = false
+        // file to write report to (if not specified, defaults to lint-results.xml)
+        xmlOutput = file("lint-report.xml")
+        // if true, generate an XML report for use by for example Jenkins
+        xmlReport = true
+    }
+
+    buildFeatures {
+        compose = true
+        buildConfig = true
+    }
+}
+
+// Only build relevant buildType / flavor combinations
+androidComponents {
+    beforeVariants { variant ->
+        val name = variant.name
+        if (variant.buildType == "release" && ("green" in name || "sandbox_work" in name)) {
+            variant.enable = false
+        }
+    }
+}
+
+dependencies {
+    configurations.all {
+        // Prefer modules that are part of this build (multi-project or composite build)
+        // over external modules
+        resolutionStrategy.preferProjectModules()
+
+        // Alternatively, we can fail eagerly on version conflict to see the conflicts
+        // resolutionStrategy.failOnVersionConflict()
+    }
+
+    coreLibraryDesugaring(libs.desugarJdkLibs)
+
+    implementation(project(":domain"))
+
+    implementation(libs.sqlcipher.android)
+
+    implementation(libs.subsamplingScaleImageView)
+    implementation(libs.opencsv)
+    implementation(libs.zip4j)
+    implementation(libs.taptargetview)
+    implementation(libs.commonsIo)
+    implementation(libs.commonsText)
+    implementation(libs.slf4j.api)
+    implementation(libs.androidImageCropper)
+    implementation(libs.trustkit)
+    implementation(libs.fastscroll)
+    implementation(libs.ezVcard)
+    implementation(libs.gestureViews)
+
+    // AndroidX / Jetpack support libraries
+    implementation(libs.androidx.preference)
+    implementation(libs.androidx.recyclerview)
+    implementation(libs.androidx.palette)
+    implementation(libs.androidx.swiperefreshlayout)
+    implementation(libs.androidx.core)
+    implementation(libs.androidx.appcompat)
+    implementation(libs.androidx.constraintlayout)
+    implementation(libs.androidx.biometric)
+    implementation(libs.androidx.work.runtime)
+    implementation(libs.androidx.fragment)
+    implementation(libs.androidx.activity)
+    implementation(libs.androidx.sqlite)
+    implementation(libs.androidx.concurrent.futures)
+    implementation(libs.androidx.camera2)
+    implementation(libs.androidx.camera.lifecycle)
+    implementation(libs.androidx.camera.view)
+    implementation(libs.androidx.camera.video)
+    implementation(libs.androidx.media)
+    implementation(libs.androidx.media3.exoplayer)
+    implementation(libs.androidx.media3.ui)
+    implementation(libs.androidx.media3.session)
+    implementation(libs.androidx.lifecycle.viewmodel)
+    implementation(libs.androidx.lifecycle.livedata)
+    implementation(libs.androidx.lifecycle.runtime)
+    implementation(libs.androidx.lifecycle.viewmodel.savedstate)
+    implementation(libs.androidx.lifecycle.service)
+    implementation(libs.androidx.lifecycle.process)
+    implementation(libs.androidx.lifecycle.commonJava8)
+    implementation(libs.androidx.lifecycle.extensions)
+    implementation(libs.androidx.paging.runtime)
+    implementation(libs.androidx.sharetarget)
+    implementation(libs.androidx.room.runtime)
+    implementation(libs.androidx.window)
+    ksp(libs.androidx.room.compiler)
+
+    // Jetpack Compose
+    implementation(platform(libs.compose.bom))
+    implementation(libs.androidx.material3)
+    implementation(libs.androidx.ui.tooling.preview)
+    implementation(libs.androidx.activity.compose)
+    implementation(libs.androidx.lifecycle.viewmodel.compose)
+    implementation(libs.androidx.lifecycle.runtime.compose)
+    debugImplementation(libs.androidx.ui.tooling)
+    androidTestImplementation(platform(libs.compose.bom))
+
+    implementation(libs.bcprov.jdk15to18)
+
+    implementation(libs.material)
+    implementation(libs.zxing)
+    implementation(libs.libphonenumber)
+
+    // webclient dependencies
+    implementation(libs.msgpack.core)
+    implementation(libs.jackson.core)
+    implementation(libs.nvWebsocket.client)
+
+    implementation(libs.streamsupport.cfuture)
+
+    implementation(libs.saltyrtc.client) {
+        exclude(group = "org.json")
+    }
+
+    implementation(libs.chunkedDc)
+    implementation(libs.webrtcAndroid)
+    implementation(libs.saltyrtc.taskWebrtc) {
+        exclude(module = "saltyrtc-client")
+    }
+
+    // Glide components
+    implementation(libs.glide)
+    ksp(libs.glide.compiler)
+    annotationProcessor(libs.glide.compiler)
+
+    // Kotlin
+    implementation(libs.kotlin.stdlib)
+    implementation(libs.kotlinx.coroutines.android)
+    implementation(libs.kotlinx.serialization.json)
+    testImplementation(libs.kotlin.test)
+    androidTestImplementation(libs.kotlin.test)
+
+    // use leak canary in debug builds if requested
+    if (project.hasProperty("leakCanary")) {
+        debugImplementation(libs.leakcanary)
+    }
+
+    // test dependencies
+    testImplementation(libs.junit)
+    testImplementation(testFixtures(project(":domain")))
+
+    // custom test helpers, shared between unit test and android tests
+    testImplementation(project(":test-helpers"))
+    androidTestImplementation(project(":test-helpers"))
+
+    testImplementation(libs.mockito.powermock.api)
+    testImplementation(libs.mockito.powermock.junit4RuleAgent)
+    testImplementation(libs.mockito.powermock.junit4Rule)
+    testImplementation(libs.mockito.powermock.junit4)
+
+    testImplementation(libs.mockk)
+
+    // add JSON support to tests without mocking
+    testImplementation(libs.json)
+
+    testImplementation(libs.archunit.junit4)
+
+    androidTestImplementation(testFixtures(project(":domain")))
+    androidTestImplementation(libs.androidx.test.rules)
+    androidTestImplementation(libs.fastlane.screengrab) {
+        exclude(group = "androidx.annotation", module = "annotation")
+    }
+    androidTestImplementation(libs.androidx.espresso.core) {
+        exclude(group = "androidx.annotation", module = "annotation")
+    }
+    androidTestImplementation(libs.androidx.test.runner) {
+        exclude(group = "androidx.annotation", module = "annotation")
+    }
+    androidTestImplementation(libs.androidx.junit)
+    androidTestImplementation(libs.androidx.espresso.contrib) {
+        exclude(group = "androidx.annotation", module = "annotation")
+        exclude(group = "androidx.appcompat", module = "appcompat")
+        exclude(group = "androidx.legacy", module = "legacy-support-v4")
+        exclude(group = "com.google.android.material", module = "material")
+        exclude(group = "androidx.recyclerview", module = "recyclerview")
+        exclude(group = "org.checkerframework", module = "checker")
+        exclude(module = "protobuf-lite")
+    }
+    androidTestImplementation(libs.androidx.espresso.intents) {
+        exclude(group = "androidx.annotation", module = "annotation")
+    }
+    androidTestImplementation(libs.androidx.test.uiautomator)
+    androidTestImplementation(libs.androidx.test.core)
+    androidTestImplementation(libs.mockito.core)
+    androidTestImplementation(libs.kotlinx.coroutines.test)
+    testImplementation(libs.kotlinx.coroutines.test)
+
+    // Google Play Services and related libraries
+    "noneImplementation"(libs.playServices.base)
+    "store_googleImplementation"(libs.playServices.base)
+    "store_google_workImplementation"(libs.playServices.base)
+    "store_threemaImplementation"(libs.playServices.base)
+    "onpremImplementation"(libs.playServices.base)
+    "greenImplementation"(libs.playServices.base)
+    "sandbox_workImplementation"(libs.playServices.base)
+    "blueImplementation"(libs.playServices.base)
+
+    fun ExternalModuleDependency.excludeFirebaseDependencies() {
+        exclude(group = "com.google.firebase", module = "firebase-core")
+        exclude(group = "com.google.firebase", module = "firebase-analytics")
+        exclude(group = "com.google.firebase", module = "firebase-measurement-connector")
+    }
+    "noneImplementation"(libs.firebase.messaging) { excludeFirebaseDependencies() }
+    "store_googleImplementation"(libs.firebase.messaging) { excludeFirebaseDependencies() }
+    "store_google_workImplementation"(libs.firebase.messaging) { excludeFirebaseDependencies() }
+    "store_threemaImplementation"(libs.firebase.messaging) { excludeFirebaseDependencies() }
+    "onpremImplementation"(libs.firebase.messaging) { excludeFirebaseDependencies() }
+    "greenImplementation"(libs.firebase.messaging) { excludeFirebaseDependencies() }
+    "sandbox_workImplementation"(libs.firebase.messaging) { excludeFirebaseDependencies() }
+    "blueImplementation"(libs.firebase.messaging) { excludeFirebaseDependencies() }
+
+    // Google Assistant Voice Action verification library
+    "noneImplementation"(group = "", name = "libgsaverification-client", ext = "aar")
+    "store_googleImplementation"(group = "", name = "libgsaverification-client", ext = "aar")
+    "store_google_workImplementation"(group = "", name = "libgsaverification-client", ext = "aar")
+    "onpremImplementation"(group = "", name = "libgsaverification-client", ext = "aar")
+    "store_threemaImplementation"(group = "", name = "libgsaverification-client", ext = "aar")
+    "greenImplementation"(group = "", name = "libgsaverification-client", ext = "aar")
+    "sandbox_workImplementation"(group = "", name = "libgsaverification-client", ext = "aar")
+    "blueImplementation"(group = "", name = "libgsaverification-client", ext = "aar")
+
+    // Maplibre (may have transitive dependencies on Google location services)
+    "noneImplementation"(libs.maplibre)
+    "store_googleImplementation"(libs.maplibre)
+    "store_google_workImplementation"(libs.maplibre)
+    "store_threemaImplementation"(libs.maplibre)
+    "libreImplementation"(libs.maplibre) {
+        exclude(group = "com.google.android.gms")
+    }
+    "onpremImplementation"(libs.maplibre)
+    "greenImplementation"(libs.maplibre)
+    "sandbox_workImplementation"(libs.maplibre)
+    "blueImplementation"(libs.maplibre)
+    "hmsImplementation"(libs.maplibre)
+    "hms_workImplementation"(libs.maplibre)
+
+    // Huawei related libraries (only for hms* build variants)
+    // Exclude agconnect dependency, we'll replace it with the vendored version below
+    "hmsImplementation"(libs.hmsPush) {
+        exclude(group = "com.huawei.agconnect")
+    }
+    "hms_workImplementation"(libs.hmsPush) {
+        exclude(group = "com.huawei.agconnect")
+    }
+    "hmsImplementation"(group = "", name = "agconnect-core-1.9.1.301", ext = "aar")
+    "hms_workImplementation"(group = "", name = "agconnect-core-1.9.1.301", ext = "aar")
+}
+
+// Define the cargo attributes. These will be used by the rust-android plugin that will create the
+// 'cargoBuild' task that builds native libraries that will be added to the apk. Note that the
+// kotlin bindings are created in the domain module. Building native libraries with rust-android
+// cannot be done in any other module than 'app'.
+cargo {
+    prebuiltToolchains = true
+    targetDirectory = "$projectDir/build/generated/source/libthreema"
+    module = "$projectDir/../domain/libthreema" // must contain Cargo.toml
+    libname = "libthreema" // must match the Cargo.toml's package name
+    profile = "release"
+    pythonCommand = "python3"
+    targets = listOf("x86_64", "arm64", "arm", "x86")
+    features {
+        defaultAnd(arrayOf("uniffi"))
+    }
+    extraCargoBuildArguments = listOf("--lib", "--target-dir", "$projectDir/build/generated/source/libthreema")
+    verbose = false
+}
+
+afterEvaluate {
+    // The `cargoBuild` task isn't available until after evaluation.
+    android.applicationVariants.configureEach {
+        val variantName = name.replaceFirstChar { it.uppercase() }
+        // Set the dependency so that cargoBuild is executed before the native libs are merged
+        tasks["merge${variantName}NativeLibs"].dependsOn(tasks["cargoBuild"])
+    }
+}
+
+sonarqube {
+    properties {
+        property("sonar.sources", "src/main/, ../scripts/, ../scripts-internal/")
+        property(
+            "sonar.exclusions",
+            "src/main/java/ch/threema/localcrypto/**, src/test/java/ch/threema/localcrypto/**, src/*/res/, src/*/res-rendezvous/",
+        )
+        property("sonar.tests", "src/test/")
+        property("sonar.sourceEncoding", "UTF-8")
+        property("sonar.verbose", "true")
+        property("sonar.projectKey", "android-client")
+        property("sonar.projectName", "Threema for Android")
+    }
+}
+
+androidStem {
+    includeLocalizedOnlyTemplates = true
+}
+
+// Set up Gradle tasks to fetch screenshots on UI test failures
+// See https://medium.com/stepstone-tech/how-to-capture-screenshots-for-failed-ui-tests-9927eea6e1e4
+val reportsDirectory = "${layout.buildDirectory}/reports/androidTests/connected"
+val screenshotsDirectory = "/sdcard/testfailures/screenshots/"
+val clearScreenshotsTask = task<Exec>("clearScreenshots") {
+    executable = android.adbExecutable.toString()
+    args("shell", "rm", "-r", screenshotsDirectory)
+}
+val createScreenshotsDirectoryTask = task<Exec>("createScreenshotsDirectory") {
+    group = "reporting"
+    executable = android.adbExecutable.toString()
+    args("shell", "mkdir", "-p", screenshotsDirectory)
+}
+val fetchScreenshotsTask = task<Exec>("fetchScreenshots") {
+    group = "reporting"
+    executable = android.adbExecutable.toString()
+    args("pull", "$screenshotsDirectory.", reportsDirectory)
+    finalizedBy(clearScreenshotsTask)
+    dependsOn(createScreenshotsDirectoryTask)
+    doFirst {
+        file(reportsDirectory).mkdirs()
+    }
+}
+tasks.whenTaskAdded {
+    if (name == "connectedDebugAndroidTest") {
+        finalizedBy(fetchScreenshotsTask)
+    }
+}

+ 1 - 0
app/libs/arm64-v8a/README.txt

@@ -0,0 +1 @@
+The file 'libjnidispatch.so' can also be extracted from https://github.com/java-native-access/jna/blob/5.13.0/dist/jna.aar

+ 1 - 0
app/libs/armeabi-v7a/README.txt

@@ -0,0 +1 @@
+The file 'libjnidispatch.so' can also be extracted from https://github.com/java-native-access/jna/blob/5.13.0/dist/jna.aar

+ 1 - 0
app/libs/x86/README.txt

@@ -0,0 +1 @@
+The file 'libjnidispatch.so' can also be extracted from https://github.com/java-native-access/jna/blob/5.13.0/dist/jna.aar

+ 1 - 0
app/libs/x86_64/README.txt

@@ -0,0 +1 @@
+The file 'libjnidispatch.so' can also be extracted from https://github.com/java-native-access/jna/blob/5.13.0/dist/jna.aar

+ 4 - 4
app/src/androidTest/java/ch/threema/app/PermissionRuleUtils.kt

@@ -48,7 +48,7 @@ fun getReadWriteExternalStoragePermissionRule(): GrantPermissionRule {
     } else {
     } else {
         GrantPermissionRule.grant(
         GrantPermissionRule.grant(
             Manifest.permission.READ_EXTERNAL_STORAGE,
             Manifest.permission.READ_EXTERNAL_STORAGE,
-            Manifest.permission.WRITE_EXTERNAL_STORAGE
+            Manifest.permission.WRITE_EXTERNAL_STORAGE,
         )
         )
     }
     }
 }
 }
@@ -61,12 +61,12 @@ fun getReadImagesVideosPermissionRule(): GrantPermissionRule {
     return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
     return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
         GrantPermissionRule.grant(
         GrantPermissionRule.grant(
             Manifest.permission.READ_MEDIA_IMAGES,
             Manifest.permission.READ_MEDIA_IMAGES,
-            Manifest.permission.READ_MEDIA_VIDEO
+            Manifest.permission.READ_MEDIA_VIDEO,
         )
         )
     } else {
     } else {
         GrantPermissionRule.grant(
         GrantPermissionRule.grant(
             Manifest.permission.READ_EXTERNAL_STORAGE,
             Manifest.permission.READ_EXTERNAL_STORAGE,
-            Manifest.permission.WRITE_EXTERNAL_STORAGE
+            Manifest.permission.WRITE_EXTERNAL_STORAGE,
         )
         )
     }
     }
 }
 }
@@ -87,7 +87,7 @@ fun getBluetoothPermissionRule(): GrantPermissionRule {
             Manifest.permission.BLUETOOTH_CONNECT
             Manifest.permission.BLUETOOTH_CONNECT
         } else {
         } else {
             Manifest.permission.BLUETOOTH
             Manifest.permission.BLUETOOTH
-        }
+        },
     )
     )
 }
 }
 
 

+ 43 - 30
app/src/androidTest/java/ch/threema/app/TestCoreServiceManager.kt

@@ -21,9 +21,14 @@
 
 
 package ch.threema.app
 package ch.threema.app
 
 
+import androidx.annotation.AnyThread
 import ch.threema.app.managers.CoreServiceManager
 import ch.threema.app.managers.CoreServiceManager
+import ch.threema.app.managers.ServiceManager
+import ch.threema.app.multidevice.LinkedDevice
 import ch.threema.app.multidevice.MultiDeviceManager
 import ch.threema.app.multidevice.MultiDeviceManager
+import ch.threema.app.multidevice.PersistedMultiDeviceProperties
 import ch.threema.app.multidevice.linking.DeviceLinkingStatus
 import ch.threema.app.multidevice.linking.DeviceLinkingStatus
+import ch.threema.app.multidevice.unlinking.DropDeviceResult
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.UserService
 import ch.threema.app.services.UserService
 import ch.threema.app.stores.IdentityStore
 import ch.threema.app.stores.IdentityStore
@@ -36,11 +41,10 @@ import ch.threema.base.crypto.NonceScope
 import ch.threema.base.crypto.NonceStore
 import ch.threema.base.crypto.NonceStore
 import ch.threema.domain.helpers.TransactionAckTaskCodec
 import ch.threema.domain.helpers.TransactionAckTaskCodec
 import ch.threema.domain.models.AppVersion
 import ch.threema.domain.models.AppVersion
-import ch.threema.domain.protocol.connection.csp.DeviceCookieManager
 import ch.threema.domain.protocol.D2mProtocolDefines
 import ch.threema.domain.protocol.D2mProtocolDefines
+import ch.threema.domain.protocol.connection.csp.DeviceCookieManager
 import ch.threema.domain.protocol.connection.d2m.MultiDevicePropertyProvider
 import ch.threema.domain.protocol.connection.d2m.MultiDevicePropertyProvider
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
-import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
 import ch.threema.domain.protocol.connection.data.D2dMessage
 import ch.threema.domain.protocol.connection.data.D2dMessage
 import ch.threema.domain.protocol.connection.data.D2mProtocolVersion
 import ch.threema.domain.protocol.connection.data.D2mProtocolVersion
 import ch.threema.domain.protocol.connection.data.DeviceId
 import ch.threema.domain.protocol.connection.data.DeviceId
@@ -54,13 +58,14 @@ import ch.threema.domain.taskmanager.TaskArchiver
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.storage.DatabaseServiceNew
 import ch.threema.storage.DatabaseServiceNew
+import ch.threema.testhelpers.MUST_NOT_BE_CALLED
+import kotlin.time.Duration
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.runBlocking
 
 
@@ -113,7 +118,7 @@ class TestDeviceCookieManager : DeviceCookieManager {
 }
 }
 
 
 class TestTaskManager(
 class TestTaskManager(
-    val taskCodec: TaskCodec
+    private val taskCodec: TaskCodec,
 ) : TaskManager {
 ) : TaskManager {
     private val taskQueue = Channel<QueueElement<Any>>()
     private val taskQueue = Channel<QueueElement<Any>>()
 
 
@@ -160,43 +165,52 @@ class TestMultiDeviceManager(
     override val isMultiDeviceActive: Boolean = false,
     override val isMultiDeviceActive: Boolean = false,
     override val propertiesProvider: MultiDevicePropertyProvider = TestMultiDevicePropertyProvider,
     override val propertiesProvider: MultiDevicePropertyProvider = TestMultiDevicePropertyProvider,
     override val socketCloseListener: D2mSocketCloseListener = D2mSocketCloseListener { },
     override val socketCloseListener: D2mSocketCloseListener = D2mSocketCloseListener { },
-    override val latestSocketCloseReason: Flow<D2mSocketCloseReason?> = flowOf(),
 ) : MultiDeviceManager {
 ) : MultiDeviceManager {
-    override suspend fun activate(
-        deviceLabel: String,
-        contactService: ContactService,
-        userService: UserService,
-        fsMessageProcessor: ForwardSecurityMessageProcessor,
-        taskCreator: TaskCreator,
-    ) {
-        throw AssertionError("Not supported")
-    }
-
-    override suspend fun deactivate(
-        userService: UserService,
-        fsMessageProcessor: ForwardSecurityMessageProcessor,
-        taskCreator: TaskCreator,
-    ) {
-        throw AssertionError("Not supported")
+    @AnyThread
+    override suspend fun deactivate(serviceManager: ServiceManager, handle: ActiveTaskCodec) {
+        MUST_NOT_BE_CALLED()
     }
     }
 
 
     override suspend fun setDeviceLabel(deviceLabel: String) {
     override suspend fun setDeviceLabel(deviceLabel: String) {
-        throw AssertionError("Not supported")
+        MUST_NOT_BE_CALLED()
     }
     }
 
 
     override suspend fun linkDevice(
     override suspend fun linkDevice(
+        serviceManager: ServiceManager,
         deviceJoinOfferUri: String,
         deviceJoinOfferUri: String,
         taskCreator: TaskCreator,
         taskCreator: TaskCreator,
     ): Flow<DeviceLinkingStatus> {
     ): Flow<DeviceLinkingStatus> {
-        throw AssertionError("Not supported")
+        MUST_NOT_BE_CALLED()
     }
     }
 
 
-    override suspend fun purge(taskCreator: TaskCreator) {
-        throw AssertionError("Not supported")
+    override suspend fun dropDevice(
+        deviceId: DeviceId,
+        taskCreator: TaskCreator,
+        timeout: Duration,
+    ): DropDeviceResult {
+        MUST_NOT_BE_CALLED()
     }
     }
 
 
-    override suspend fun loadLinkedDevicesInfo(taskCreator: TaskCreator): List<String> {
-        throw AssertionError("Not supported")
+    override suspend fun loadLinkedDevices(taskCreator: TaskCreator): Result<List<LinkedDevice>> {
+        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()
     }
     }
 }
 }
 
 
@@ -219,7 +233,6 @@ class TestNonceStore : NonceStore {
     }
     }
 
 
     override fun insertHashedNonces(scope: NonceScope, nonces: List<HashedNonce>) = true
     override fun insertHashedNonces(scope: NonceScope, nonces: List<HashedNonce>) = true
-
 }
 }
 
 
 object TestMultiDevicePropertyProvider : MultiDevicePropertyProvider {
 object TestMultiDevicePropertyProvider : MultiDevicePropertyProvider {
@@ -233,8 +246,8 @@ object TestMultiDevicePropertyProvider : MultiDevicePropertyProvider {
                 D2dMessage.DeviceInfo.Platform.ANDROID,
                 D2dMessage.DeviceInfo.Platform.ANDROID,
                 "",
                 "",
                 "",
                 "",
-                ""
+                "",
             ),
             ),
-            D2mProtocolVersion(UInt.MIN_VALUE, UInt.MAX_VALUE)
+            D2mProtocolVersion(UInt.MIN_VALUE, UInt.MAX_VALUE),
         ) { }
         ) { }
 }
 }

+ 1 - 2
app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java

@@ -374,8 +374,7 @@ public class BackupServiceTest {
                 serviceManager.getContactService(),
                 serviceManager.getContactService(),
                 serviceManager.getConversationService(),
                 serviceManager.getConversationService(),
                 serviceManager.getRingtoneService(),
                 serviceManager.getRingtoneService(),
-                serviceManager.getMutedChatsListService(),
-                serviceManager.getHiddenChatsListService(),
+                serviceManager.getConversationCategoryService(),
                 serviceManager.getProfilePicRecipientsService(),
                 serviceManager.getProfilePicRecipientsService(),
                 serviceManager.getWallpaperService(),
                 serviceManager.getWallpaperService(),
                 serviceManager.getFileService(),
                 serviceManager.getFileService(),

+ 24 - 25
app/src/androidTest/java/ch/threema/app/contacts/AddOrUpdateContactBackgroundTaskTest.kt

@@ -45,30 +45,30 @@ import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.protocol.SSLSocketFactoryFactory
 import ch.threema.domain.protocol.SSLSocketFactoryFactory
+import ch.threema.domain.protocol.Version
 import ch.threema.domain.protocol.api.APIConnector
 import ch.threema.domain.protocol.api.APIConnector
 import ch.threema.domain.protocol.api.APIConnector.FetchIdentityResult
 import ch.threema.domain.protocol.api.APIConnector.FetchIdentityResult
 import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
 import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import com.neilalexander.jnacl.NaCl
 import com.neilalexander.jnacl.NaCl
-import kotlinx.coroutines.runBlocking
-import org.junit.Assert.assertArrayEquals
-import org.junit.Before
 import java.net.HttpURLConnection
 import java.net.HttpURLConnection
+import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.Test
+import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertFalse
 import kotlin.test.assertFalse
 import kotlin.test.assertNotEquals
 import kotlin.test.assertNotEquals
 import kotlin.test.assertTrue
 import kotlin.test.assertTrue
 import kotlin.test.fail
 import kotlin.test.fail
+import kotlinx.coroutines.runBlocking
 
 
 class AddOrUpdateContactBackgroundTaskTest {
 class AddOrUpdateContactBackgroundTaskTest {
-
     private val backgroundExecutor = BackgroundExecutor()
     private val backgroundExecutor = BackgroundExecutor()
     private lateinit var databaseService: TestDatabaseService
     private lateinit var databaseService: TestDatabaseService
     private lateinit var coreServiceManager: CoreServiceManager
     private lateinit var coreServiceManager: CoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
     private lateinit var contactModelRepository: ContactModelRepository
 
 
-    @Before
+    @BeforeTest
     fun before() {
     fun before() {
         databaseService = TestDatabaseService()
         databaseService = TestDatabaseService()
         val serviceManager = ThreemaApplication.requireServiceManager()
         val serviceManager = ThreemaApplication.requireServiceManager()
@@ -99,13 +99,13 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertEquals(newIdentity, it.contactModel.identity)
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data.value!!
                 val data = it.contactModel.data.value!!
                 assertEquals(newIdentity, data.identity)
                 assertEquals(newIdentity, data.identity)
-                assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
+                assertContentEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
                 assertEquals(12u, data.featureMask)
                 assertEquals(12u, data.featureMask)
                 assertEquals(IdentityType.NORMAL, data.identityType)
                 assertEquals(IdentityType.NORMAL, data.identityType)
                 assertEquals(IdentityState.ACTIVE, data.activityState)
                 assertEquals(IdentityState.ACTIVE, data.activityState)
                 assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
                 assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
                 assertEquals(AcquaintanceLevel.DIRECT, data.acquaintanceLevel)
                 assertEquals(AcquaintanceLevel.DIRECT, data.acquaintanceLevel)
-            }
+            },
         )
         )
     }
     }
 
 
@@ -129,13 +129,13 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertEquals(newIdentity, it.contactModel.identity)
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data.value!!
                 val data = it.contactModel.data.value!!
                 assertEquals(newIdentity, data.identity)
                 assertEquals(newIdentity, data.identity)
-                assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
+                assertContentEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
                 assertEquals(12u, data.featureMask)
                 assertEquals(12u, data.featureMask)
                 assertEquals(IdentityType.NORMAL, data.identityType)
                 assertEquals(IdentityType.NORMAL, data.identityType)
                 assertEquals(IdentityState.ACTIVE, data.activityState)
                 assertEquals(IdentityState.ACTIVE, data.activityState)
                 assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
                 assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
                 assertEquals(AcquaintanceLevel.GROUP, data.acquaintanceLevel)
                 assertEquals(AcquaintanceLevel.GROUP, data.acquaintanceLevel)
-            }
+            },
         )
         )
     }
     }
 
 
@@ -158,7 +158,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertEquals(newIdentity, it.contactModel.identity)
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data.value!!
                 val data = it.contactModel.data.value!!
                 assertEquals(newIdentity, data.identity)
                 assertEquals(newIdentity, data.identity)
-                assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
+                assertContentEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
                 assertEquals(127u, data.featureMask)
                 assertEquals(127u, data.featureMask)
                 assertEquals(IdentityType.WORK, data.identityType)
                 assertEquals(IdentityType.WORK, data.identityType)
                 assertEquals(IdentityState.INACTIVE, data.activityState)
                 assertEquals(IdentityState.INACTIVE, data.activityState)
@@ -205,7 +205,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             {
             {
                 assertTrue(it is RemotePublicKeyMismatch)
                 assertTrue(it is RemotePublicKeyMismatch)
             },
             },
-            publicKey = ByteArray(NaCl.PUBLICKEYBYTES).also { it.fill(1) }
+            publicKey = ByteArray(NaCl.PUBLICKEYBYTES).also { it.fill(1) },
         )
         )
     }
     }
 
 
@@ -217,7 +217,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             },
             },
             {
             {
                 assertTrue(it is InvalidThreemaId)
                 assertTrue(it is InvalidThreemaId)
-            }
+            },
         )
         )
     }
     }
 
 
@@ -238,7 +238,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             apiConnectorResult,
             apiConnectorResult,
             {
             {
                 assertTrue(it is ContactCreated)
                 assertTrue(it is ContactCreated)
-            }
+            },
         )
         )
 
 
         // The second time adding the contact should fail
         // The second time adding the contact should fail
@@ -246,7 +246,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             apiConnectorResult,
             apiConnectorResult,
             {
             {
                 assertTrue(it is ContactExists)
                 assertTrue(it is ContactExists)
-            }
+            },
         )
         )
     }
     }
 
 
@@ -279,7 +279,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             {
             {
                 assertTrue(it is AlreadyVerified)
                 assertTrue(it is AlreadyVerified)
             },
             },
-            publicKey = publicKey
+            publicKey = publicKey,
         )
         )
     }
     }
 
 
@@ -303,7 +303,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             {
             {
                 assertTrue(it is ContactCreated)
                 assertTrue(it is ContactCreated)
             },
             },
-            newIdentity = newIdentity
+            newIdentity = newIdentity,
         )
         )
 
 
         val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
         val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
@@ -323,7 +323,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertFalse(it.verificationLevelChanged)
                 assertFalse(it.verificationLevelChanged)
                 assertEquals(AcquaintanceLevel.DIRECT, contactModel.data.value!!.acquaintanceLevel)
                 assertEquals(AcquaintanceLevel.DIRECT, contactModel.data.value!!.acquaintanceLevel)
             },
             },
-            newIdentity = newIdentity
+            newIdentity = newIdentity,
         )
         )
     }
     }
 
 
@@ -347,7 +347,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             {
             {
                 assertTrue(it is ContactCreated)
                 assertTrue(it is ContactCreated)
             },
             },
-            newIdentity = newIdentity
+            newIdentity = newIdentity,
         )
         )
 
 
         val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
         val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
@@ -364,11 +364,11 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertFalse(it.acquaintanceLevelChanged)
                 assertFalse(it.acquaintanceLevelChanged)
                 assertEquals(
                 assertEquals(
                     VerificationLevel.FULLY_VERIFIED,
                     VerificationLevel.FULLY_VERIFIED,
-                    contactModel.data.value!!.verificationLevel
+                    contactModel.data.value!!.verificationLevel,
                 )
                 )
             },
             },
             newIdentity = newIdentity,
             newIdentity = newIdentity,
-            publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+            publicKey = ByteArray(NaCl.PUBLICKEYBYTES),
         )
         )
     }
     }
 
 
@@ -392,7 +392,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             {
             {
                 assertTrue(it is ContactCreated)
                 assertTrue(it is ContactCreated)
             },
             },
-            newIdentity = newIdentity
+            newIdentity = newIdentity,
         )
         )
 
 
         val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
         val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
@@ -414,11 +414,11 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertEquals(AcquaintanceLevel.DIRECT, contactModel.data.value!!.acquaintanceLevel)
                 assertEquals(AcquaintanceLevel.DIRECT, contactModel.data.value!!.acquaintanceLevel)
                 assertEquals(
                 assertEquals(
                     VerificationLevel.FULLY_VERIFIED,
                     VerificationLevel.FULLY_VERIFIED,
-                    contactModel.data.value!!.verificationLevel
+                    contactModel.data.value!!.verificationLevel,
                 )
                 )
             },
             },
             newIdentity = newIdentity,
             newIdentity = newIdentity,
-            publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+            publicKey = ByteArray(NaCl.PUBLICKEYBYTES),
         )
         )
     }
     }
 
 
@@ -524,9 +524,8 @@ class AddOrUpdateContactBackgroundTaskTest {
             ConfigUtils.getSSLSocketFactory(host)
             ConfigUtils.getSSLSocketFactory(host)
         }
         }
 
 
-        return object : APIConnector(false, null, false, sslSocketFactoryFactory) {
+        return object : APIConnector(false, null, false, sslSocketFactoryFactory, Version(), null) {
             override fun fetchIdentity(identity: String) = onIdentityFetchCalled(identity)
             override fun fetchIdentity(identity: String) = onIdentityFetchCalled(identity)
         }
         }
     }
     }
-
 }
 }

+ 47 - 33
app/src/androidTest/java/ch/threema/app/contacts/MarkContactAsDeletedBackgroundTaskTest.kt

@@ -21,17 +21,22 @@
 
 
 package ch.threema.app.contacts
 package ch.threema.app.contacts
 
 
+import androidx.annotation.AnyThread
 import ch.threema.app.DangerousTest
 import ch.threema.app.DangerousTest
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestMultiDevicePropertyProvider
 import ch.threema.app.TestMultiDevicePropertyProvider
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.asynctasks.AndroidContactLinkPolicy
 import ch.threema.app.asynctasks.AndroidContactLinkPolicy
 import ch.threema.app.asynctasks.ContactSyncPolicy
 import ch.threema.app.asynctasks.ContactSyncPolicy
-import ch.threema.app.asynctasks.MarkContactAsDeletedBackgroundTask
 import ch.threema.app.asynctasks.DeleteContactServices
 import ch.threema.app.asynctasks.DeleteContactServices
+import ch.threema.app.asynctasks.MarkContactAsDeletedBackgroundTask
 import ch.threema.app.managers.CoreServiceManager
 import ch.threema.app.managers.CoreServiceManager
+import ch.threema.app.managers.ServiceManager
+import ch.threema.app.multidevice.LinkedDevice
 import ch.threema.app.multidevice.MultiDeviceManager
 import ch.threema.app.multidevice.MultiDeviceManager
+import ch.threema.app.multidevice.PersistedMultiDeviceProperties
 import ch.threema.app.multidevice.linking.DeviceLinkingStatus
 import ch.threema.app.multidevice.linking.DeviceLinkingStatus
+import ch.threema.app.multidevice.unlinking.DropDeviceResult
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.UserService
 import ch.threema.app.services.UserService
 import ch.threema.app.tasks.ReflectContactSyncUpdateTask
 import ch.threema.app.tasks.ReflectContactSyncUpdateTask
@@ -50,8 +55,9 @@ import ch.threema.domain.models.TypingIndicatorPolicy
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
-import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
+import ch.threema.domain.protocol.connection.data.DeviceId
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
+import ch.threema.domain.taskmanager.ActiveTaskCodec
 import ch.threema.domain.taskmanager.QueueSendCompleteListener
 import ch.threema.domain.taskmanager.QueueSendCompleteListener
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskCodec
@@ -59,22 +65,21 @@ import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.testhelpers.MUST_NOT_BE_CALLED
 import ch.threema.testhelpers.MUST_NOT_BE_CALLED
 import com.neilalexander.jnacl.NaCl
 import com.neilalexander.jnacl.NaCl
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.test.runTest
-import org.junit.Before
 import java.util.Date
 import java.util.Date
 import kotlin.test.Test
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertIs
 import kotlin.test.assertIs
 import kotlin.test.assertNotNull
 import kotlin.test.assertNotNull
 import kotlin.test.assertTrue
 import kotlin.test.assertTrue
+import kotlin.time.Duration
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
 
 
 @DangerousTest
 @DangerousTest
 class MarkContactAsDeletedBackgroundTaskTest {
 class MarkContactAsDeletedBackgroundTaskTest {
-
     private val backgroundExecutor = BackgroundExecutor()
     private val backgroundExecutor = BackgroundExecutor()
     private lateinit var testTaskCodec: TransactionAckTaskCodec
     private lateinit var testTaskCodec: TransactionAckTaskCodec
     private val testTaskManager = object : TaskManager {
     private val testTaskManager = object : TaskManager {
@@ -96,7 +101,6 @@ class MarkContactAsDeletedBackgroundTaskTest {
         override fun removeQueueSendCompleteListener(listener: QueueSendCompleteListener) {
         override fun removeQueueSendCompleteListener(listener: QueueSendCompleteListener) {
             // Nothing to do
             // Nothing to do
         }
         }
-
     }
     }
     private lateinit var databaseService: TestDatabaseService
     private lateinit var databaseService: TestDatabaseService
     private val multiDeviceManager = object : MultiDeviceManager {
     private val multiDeviceManager = object : MultiDeviceManager {
@@ -108,23 +112,9 @@ class MarkContactAsDeletedBackgroundTaskTest {
             get() = multiDeviceEnabled
             get() = multiDeviceEnabled
         override val propertiesProvider = TestMultiDevicePropertyProvider
         override val propertiesProvider = TestMultiDevicePropertyProvider
         override val socketCloseListener: D2mSocketCloseListener = D2mSocketCloseListener { }
         override val socketCloseListener: D2mSocketCloseListener = D2mSocketCloseListener { }
-        override val latestSocketCloseReason: Flow<D2mSocketCloseReason?> = flowOf()
 
 
-        override suspend fun activate(
-            deviceLabel: String,
-            contactService: ContactService,
-            userService: UserService,
-            fsMessageProcessor: ForwardSecurityMessageProcessor,
-            taskCreator: TaskCreator,
-        ) {
-            MUST_NOT_BE_CALLED()
-        }
-
-        override suspend fun deactivate(
-            userService: UserService,
-            fsMessageProcessor: ForwardSecurityMessageProcessor,
-            taskCreator: TaskCreator,
-        ) {
+        @AnyThread
+        override suspend fun deactivate(serviceManager: ServiceManager, handle: ActiveTaskCodec) {
             MUST_NOT_BE_CALLED()
             MUST_NOT_BE_CALLED()
         }
         }
 
 
@@ -133,21 +123,44 @@ class MarkContactAsDeletedBackgroundTaskTest {
         }
         }
 
 
         override suspend fun linkDevice(
         override suspend fun linkDevice(
+            serviceManager: ServiceManager,
             deviceJoinOfferUri: String,
             deviceJoinOfferUri: String,
             taskCreator: TaskCreator,
             taskCreator: TaskCreator,
         ): Flow<DeviceLinkingStatus> {
         ): Flow<DeviceLinkingStatus> {
             MUST_NOT_BE_CALLED()
             MUST_NOT_BE_CALLED()
         }
         }
 
 
-        override suspend fun purge(taskCreator: TaskCreator) {
+        override suspend fun dropDevice(
+            deviceId: DeviceId,
+            taskCreator: TaskCreator,
+            timeout: Duration,
+        ): DropDeviceResult {
+            MUST_NOT_BE_CALLED()
+        }
+
+        override suspend fun loadLinkedDevices(taskCreator: TaskCreator): Result<List<LinkedDevice>> {
+            MUST_NOT_BE_CALLED()
+        }
+
+        override suspend fun setProperties(persistedProperties: PersistedMultiDeviceProperties?) {
             MUST_NOT_BE_CALLED()
             MUST_NOT_BE_CALLED()
         }
         }
 
 
-        override suspend fun loadLinkedDevicesInfo(taskCreator: TaskCreator): List<String> {
+        override fun reconnect() {
             MUST_NOT_BE_CALLED()
             MUST_NOT_BE_CALLED()
         }
         }
 
 
+        override suspend fun disableForwardSecurity(
+            handle: ActiveTaskCodec,
+            contactService: ContactService,
+            userService: UserService,
+            fsMessageProcessor: ForwardSecurityMessageProcessor,
+            taskCreator: TaskCreator,
+        ) {
+            MUST_NOT_BE_CALLED()
+        }
     }
     }
+
     private lateinit var coreServiceManager: CoreServiceManager
     private lateinit var coreServiceManager: CoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
     private lateinit var contactModelRepository: ContactModelRepository
     private lateinit var deleteContactServices: DeleteContactServices
     private lateinit var deleteContactServices: DeleteContactServices
@@ -167,12 +180,14 @@ class MarkContactAsDeletedBackgroundTaskTest {
         featureMask = 0u,
         featureMask = 0u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
         androidContactLookupKey = null,
         androidContactLookupKey = null,
         localAvatarExpires = null,
         localAvatarExpires = null,
         isRestored = false,
         isRestored = false,
         profilePictureBlobId = null,
         profilePictureBlobId = null,
         jobTitle = null,
         jobTitle = null,
         department = null,
         department = null,
+        notificationTriggerPolicyOverride = null,
     )
     )
 
 
     @Before
     @Before
@@ -192,8 +207,7 @@ class MarkContactAsDeletedBackgroundTaskTest {
             serviceManager.contactService,
             serviceManager.contactService,
             serviceManager.conversationService,
             serviceManager.conversationService,
             serviceManager.ringtoneService,
             serviceManager.ringtoneService,
-            serviceManager.mutedChatsListService,
-            serviceManager.hiddenChatsListService,
+            serviceManager.conversationCategoryService,
             serviceManager.profilePicRecipientsService,
             serviceManager.profilePicRecipientsService,
             serviceManager.wallpaperService,
             serviceManager.wallpaperService,
             serviceManager.fileService,
             serviceManager.fileService,
@@ -223,7 +237,7 @@ class MarkContactAsDeletedBackgroundTaskTest {
                 deleteContactServices,
                 deleteContactServices,
                 ContactSyncPolicy.INCLUDE,
                 ContactSyncPolicy.INCLUDE,
                 AndroidContactLinkPolicy.REMOVE_LINK,
                 AndroidContactLinkPolicy.REMOVE_LINK,
-            )
+            ),
         ).await()
         ).await()
 
 
         // Assert that the contact's acquaintance level is "group" now
         // Assert that the contact's acquaintance level is "group" now
@@ -248,7 +262,7 @@ class MarkContactAsDeletedBackgroundTaskTest {
                 deleteContactServices,
                 deleteContactServices,
                 ContactSyncPolicy.INCLUDE,
                 ContactSyncPolicy.INCLUDE,
                 AndroidContactLinkPolicy.REMOVE_LINK,
                 AndroidContactLinkPolicy.REMOVE_LINK,
-            )
+            ),
         ).await()
         ).await()
 
 
         // Assert that the there was no task scheduled
         // Assert that the there was no task scheduled
@@ -273,7 +287,7 @@ class MarkContactAsDeletedBackgroundTaskTest {
                 deleteContactServices,
                 deleteContactServices,
                 ContactSyncPolicy.INCLUDE,
                 ContactSyncPolicy.INCLUDE,
                 AndroidContactLinkPolicy.REMOVE_LINK,
                 AndroidContactLinkPolicy.REMOVE_LINK,
-            )
+            ),
         ).await()
         ).await()
 
 
         // Assert that a reflection task has been scheduled
         // Assert that a reflection task has been scheduled

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

@@ -56,9 +56,6 @@ import ch.threema.protobuf.unit
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import com.google.protobuf.kotlin.toByteString
 import com.google.protobuf.kotlin.toByteString
 import com.neilalexander.jnacl.NaCl
 import com.neilalexander.jnacl.NaCl
-import kotlinx.coroutines.runBlocking
-import org.junit.Before
-import org.junit.runner.RunWith
 import java.util.Date
 import java.util.Date
 import kotlin.test.Test
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
 import kotlin.test.assertContentEquals
@@ -66,10 +63,12 @@ import kotlin.test.assertEquals
 import kotlin.test.assertNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
 import kotlin.test.assertTrue
 import kotlin.test.fail
 import kotlin.test.fail
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.runner.RunWith
 
 
 @RunWith(AndroidJUnit4::class)
 @RunWith(AndroidJUnit4::class)
 class ReflectedContactSyncTaskTest {
 class ReflectedContactSyncTaskTest {
-
     private lateinit var databaseService: TestDatabaseService
     private lateinit var databaseService: TestDatabaseService
     private lateinit var taskCodec: TransactionAckTaskCodec
     private lateinit var taskCodec: TransactionAckTaskCodec
     private lateinit var coreServiceManager: TestCoreServiceManager
     private lateinit var coreServiceManager: TestCoreServiceManager
@@ -92,12 +91,14 @@ class ReflectedContactSyncTaskTest {
         featureMask = 511u,
         featureMask = 511u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
         androidContactLookupKey = null,
         androidContactLookupKey = null,
         localAvatarExpires = null,
         localAvatarExpires = null,
         isRestored = false,
         isRestored = false,
         profilePictureBlobId = null,
         profilePictureBlobId = null,
         jobTitle = null,
         jobTitle = null,
         department = null,
         department = null,
+        notificationTriggerPolicyOverride = null,
     )
     )
 
 
     @Before
     @Before
@@ -166,7 +167,7 @@ class ReflectedContactSyncTaskTest {
             assertEquals(contact.readReceiptPolicyOverride.convert(), data.readReceiptPolicy)
             assertEquals(contact.readReceiptPolicyOverride.convert(), data.readReceiptPolicy)
             assertEquals(
             assertEquals(
                 contact.typingIndicatorPolicyOverride.convert(),
                 contact.typingIndicatorPolicyOverride.convert(),
-                data.typingIndicatorPolicy
+                data.typingIndicatorPolicy,
             )
             )
         }
         }
     }
     }
@@ -178,7 +179,7 @@ class ReflectedContactSyncTaskTest {
             contact {
             contact {
                 identity = "01234567"
                 identity = "01234567"
                 nickname = newNickname
                 nickname = newNickname
-            }
+            },
         ) { contactModel ->
         ) { contactModel ->
             assertEquals(newNickname, contactModel.data.value?.nickname)
             assertEquals(newNickname, contactModel.data.value?.nickname)
         }
         }
@@ -240,9 +241,12 @@ class ReflectedContactSyncTaskTest {
     private fun assertAndClearOneTransactionCount() {
     private fun assertAndClearOneTransactionCount() {
         assertEquals(1, taskCodec.transactionBeginCount)
         assertEquals(1, taskCodec.transactionBeginCount)
         assertEquals(1, taskCodec.transactionCommitCount)
         assertEquals(1, taskCodec.transactionCommitCount)
+        // Assert that there are 3 outbound messages (transaction begin, reflect, and commit)
+        assertEquals(3, taskCodec.outboundMessages.size)
 
 
         taskCodec.transactionBeginCount = 0
         taskCodec.transactionBeginCount = 0
         taskCodec.transactionCommitCount = 0
         taskCodec.transactionCommitCount = 0
+        taskCodec.outboundMessages.clear()
     }
     }
 
 
     private fun assertZeroTransactionCount() {
     private fun assertZeroTransactionCount() {
@@ -333,5 +337,4 @@ class ReflectedContactSyncTaskTest {
             TypingIndicatorPolicyOverride.OverrideCase.OVERRIDE_NOT_SET -> fail("Typing indicator policy override not set")
             TypingIndicatorPolicyOverride.OverrideCase.OVERRIDE_NOT_SET -> fail("Typing indicator policy override not set")
             null -> fail("Typing indicator policy override is null")
             null -> fail("Typing indicator policy override is null")
         }
         }
-
 }
 }

+ 34 - 19
app/src/androidTest/java/ch/threema/app/edithistory/EditHistoryTest.kt

@@ -31,11 +31,13 @@ import ch.threema.app.asynctasks.ContactSyncPolicy
 import ch.threema.app.asynctasks.DeleteContactServices
 import ch.threema.app.asynctasks.DeleteContactServices
 import ch.threema.app.asynctasks.EmptyOrDeleteConversationsAsyncTask
 import ch.threema.app.asynctasks.EmptyOrDeleteConversationsAsyncTask
 import ch.threema.app.asynctasks.MarkContactAsDeletedBackgroundTask
 import ch.threema.app.asynctasks.MarkContactAsDeletedBackgroundTask
+import ch.threema.app.groupflows.GroupLeaveIntent
 import ch.threema.app.processors.MessageProcessorProvider
 import ch.threema.app.processors.MessageProcessorProvider
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.GroupService
 import ch.threema.app.services.GroupService
 import ch.threema.app.services.MessageService
 import ch.threema.app.services.MessageService
 import ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.app.utils.executor.BackgroundExecutor
+import ch.threema.data.models.GroupIdentity
 import ch.threema.data.repositories.ContactModelRepository
 import ch.threema.data.repositories.ContactModelRepository
 import ch.threema.data.storage.EditHistoryDao
 import ch.threema.data.storage.EditHistoryDao
 import ch.threema.data.storage.EditHistoryDaoImpl
 import ch.threema.data.storage.EditHistoryDaoImpl
@@ -54,18 +56,18 @@ import ch.threema.storage.factories.MessageModelFactory
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageModel
+import java.util.Date
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.test.runTest
 import org.junit.Test
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runner.RunWith
-import java.util.Date
-import kotlin.test.assertEquals
 
 
 @RunWith(AndroidJUnit4::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @LargeTest
 @DangerousTest
 @DangerousTest
 class EditHistoryTest : MessageProcessorProvider() {
 class EditHistoryTest : MessageProcessorProvider() {
-
     private val messageService: MessageService by lazy { serviceManager.messageService }
     private val messageService: MessageService by lazy { serviceManager.messageService }
     private val contactService: ContactService by lazy { serviceManager.contactService }
     private val contactService: ContactService by lazy { serviceManager.contactService }
     private val groupService: GroupService by lazy { serviceManager.groupService }
     private val groupService: GroupService by lazy { serviceManager.groupService }
@@ -80,8 +82,7 @@ class EditHistoryTest : MessageProcessorProvider() {
             contactService,
             contactService,
             serviceManager.conversationService,
             serviceManager.conversationService,
             serviceManager.ringtoneService,
             serviceManager.ringtoneService,
-            serviceManager.mutedChatsListService,
-            serviceManager.hiddenChatsListService,
+            serviceManager.conversationCategoryService,
             serviceManager.profilePicRecipientsService,
             serviceManager.profilePicRecipientsService,
             serviceManager.wallpaperService,
             serviceManager.wallpaperService,
             serviceManager.fileService,
             serviceManager.fileService,
@@ -276,12 +277,12 @@ class EditHistoryTest : MessageProcessorProvider() {
 
 
         BackgroundExecutor().executeDeferred(
         BackgroundExecutor().executeDeferred(
             MarkContactAsDeletedBackgroundTask(
             MarkContactAsDeletedBackgroundTask(
-                setOf(messageModel.identity),
+                setOf(messageModel.identity!!),
                 contactModelRepository,
                 contactModelRepository,
                 deleteContactServices,
                 deleteContactServices,
                 ContactSyncPolicy.INCLUDE,
                 ContactSyncPolicy.INCLUDE,
                 AndroidContactLinkPolicy.KEEP,
                 AndroidContactLinkPolicy.KEEP,
-            )
+            ),
         ).await()
         ).await()
 
 
         messageModel.assertHistorySize(0)
         messageModel.assertHistorySize(0)
@@ -299,7 +300,18 @@ class EditHistoryTest : MessageProcessorProvider() {
 
 
         messageModel.assertHistorySize(1)
         messageModel.assertHistorySize(1)
 
 
-        groupService.leaveOrDissolveAndRemoveFromLocal(groupA.groupModel)
+        val groupModel = serviceManager.modelRepositories.groups.getByGroupIdentity(
+            GroupIdentity(
+                creatorIdentity = groupA.groupCreator.identity,
+                groupId = groupA.apiGroupId.toLong(),
+            ),
+        )
+        assertNotNull(groupModel)
+        serviceManager.groupFlowDispatcher.runLeaveGroupFlow(
+            fragmentManager = null,
+            intent = GroupLeaveIntent.LEAVE_AND_REMOVE,
+            groupModel = groupModel,
+        ).await()
 
 
         messageModel.assertHistorySize(0)
         messageModel.assertHistorySize(0)
     }
     }
@@ -312,9 +324,12 @@ class EditHistoryTest : MessageProcessorProvider() {
             mode,
             mode,
             arrayOf(receiver),
             arrayOf(receiver),
             serviceManager.conversationService,
             serviceManager.conversationService,
-            serviceManager.groupService,
             serviceManager.distributionListService,
             serviceManager.distributionListService,
-            null, null
+            serviceManager.modelRepositories.groups,
+            serviceManager.groupFlowDispatcher,
+            myContact.identity,
+            null,
+            null,
         ) { deferred.complete(Unit) }.execute()
         ) { deferred.complete(Unit) }.execute()
         deferred.await()
         deferred.await()
     }
     }
@@ -331,7 +346,7 @@ class EditHistoryTest : MessageProcessorProvider() {
 
 
         return messageModelFactory.getByApiMessageIdAndIdentity(
         return messageModelFactory.getByApiMessageIdAndIdentity(
             message.messageId,
             message.messageId,
-            message.fromIdentity
+            message.fromIdentity,
         )!!
         )!!
     }
     }
 
 
@@ -339,8 +354,8 @@ class EditHistoryTest : MessageProcessorProvider() {
         val editMessage = EditMessage(
         val editMessage = EditMessage(
             EditMessageData(
             EditMessageData(
                 MessageId.fromString(apiMessageId).messageIdLong,
                 MessageId.fromString(apiMessageId).messageIdLong,
-                "$body Edited"
-            )
+                "$body Edited",
+            ),
         ).apply {
         ).apply {
             fromIdentity = identity
             fromIdentity = identity
             toIdentity = myContact.identity
             toIdentity = myContact.identity
@@ -350,7 +365,7 @@ class EditHistoryTest : MessageProcessorProvider() {
 
 
     private suspend fun MessageModel.receiveDelete() {
     private suspend fun MessageModel.receiveDelete() {
         val deleteMessage = DeleteMessage(
         val deleteMessage = DeleteMessage(
-            DeleteMessageData(MessageId.fromString(apiMessageId).messageIdLong)
+            DeleteMessageData(MessageId.fromString(apiMessageId).messageIdLong),
         ).apply {
         ).apply {
             fromIdentity = identity
             fromIdentity = identity
             toIdentity = myContact.identity
             toIdentity = myContact.identity
@@ -372,7 +387,7 @@ class EditHistoryTest : MessageProcessorProvider() {
 
 
         return groupMessageModelFactory.getByApiMessageIdAndIdentity(
         return groupMessageModelFactory.getByApiMessageIdAndIdentity(
             message.messageId,
             message.messageId,
-            message.fromIdentity
+            message.fromIdentity,
         )!!
         )!!
     }
     }
 
 
@@ -380,8 +395,8 @@ class EditHistoryTest : MessageProcessorProvider() {
         val editMessage = GroupEditMessage(
         val editMessage = GroupEditMessage(
             EditMessageData(
             EditMessageData(
                 MessageId.fromString(apiMessageId).messageIdLong,
                 MessageId.fromString(apiMessageId).messageIdLong,
-                "$body Edited"
-            )
+                "$body Edited",
+            ),
         ).apply {
         ).apply {
             apiGroupId = groupA.apiGroupId
             apiGroupId = groupA.apiGroupId
             groupCreator = groupA.groupCreator.identity
             groupCreator = groupA.groupCreator.identity
@@ -393,7 +408,7 @@ class EditHistoryTest : MessageProcessorProvider() {
 
 
     private suspend fun GroupMessageModel.receiveDelete() {
     private suspend fun GroupMessageModel.receiveDelete() {
         val deleteMessage = GroupDeleteMessage(
         val deleteMessage = GroupDeleteMessage(
-            DeleteMessageData(MessageId.fromString(apiMessageId).messageIdLong)
+            DeleteMessageData(MessageId.fromString(apiMessageId).messageIdLong),
         ).apply {
         ).apply {
             apiGroupId = groupA.apiGroupId
             apiGroupId = groupA.apiGroupId
             groupCreator = groupA.groupCreator.identity
             groupCreator = groupA.groupCreator.identity
@@ -429,7 +444,7 @@ class EditHistoryTest : MessageProcessorProvider() {
     private fun <T : AbstractMessageModel> T.assertHistorySize(size: Int) {
     private fun <T : AbstractMessageModel> T.assertHistorySize(size: Int) {
         assertEquals(
         assertEquals(
             size,
             size,
-            editHistoryDao.findAllByMessageUid(uid).size
+            editHistoryDao.findAllByMessageUid(uid!!).size,
         )
         )
     }
     }
 }
 }

+ 0 - 238
app/src/androidTest/java/ch/threema/app/emojis/EmojiUtilTest.kt

@@ -1,238 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2024-2025 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.emojis
-
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import java.util.LinkedList
-
-class EmojiUtilTest {
-    @Test
-    fun isFullyQualifiedEmoji_fullyQualifiedEmoji() {
-        val fullyQualifiedEmoji: MutableList<String> = LinkedList()
-        fullyQualifiedEmoji.add("\uD83D\uDE00") // 😀
-        fullyQualifiedEmoji.add("\uD83D\uDE00") // 😀
-        fullyQualifiedEmoji.add("☺\uFE0F") // ☺️
-        fullyQualifiedEmoji.add("\uD83E\uDEE5") // 🫥
-        fullyQualifiedEmoji.add("\uD83D\uDE35\u200D\uD83D\uDCAB") // 😵‍💫
-        fullyQualifiedEmoji.add("\uD83D\uDC80") // 💀
-        fullyQualifiedEmoji.add("\uD83D\uDC9A") // 💚
-        fullyQualifiedEmoji.add("\uD83D\uDD73\uFE0F") // 🕳️
-        fullyQualifiedEmoji.add("\uD83D\uDC4B\uD83C\uDFFD") // 👋🏽
-        fullyQualifiedEmoji.add("\uD83E\uDD1D\uD83C\uDFFE") // 🤝🏾
-        fullyQualifiedEmoji.add("\uD83E\uDEF1\uD83C\uDFFB\u200D\uD83E\uDEF2\uD83C\uDFFD") // 🫱🏻‍🫲🏽
-        fullyQualifiedEmoji.add("\uD83D\uDC71\uD83C\uDFFF") // 👱🏿
-        fullyQualifiedEmoji.add("\uD83D\uDEB6\u200D♂\uFE0F\u200D➡\uFE0F") // 🚶‍♂️‍➡️
-        fullyQualifiedEmoji.add("\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDBD") // 👨🏽‍🦽
-        fullyQualifiedEmoji.add("\uD83C\uDFC3\u200D♂\uFE0F\u200D➡\uFE0F") // 🏃‍♂️‍➡️
-        fullyQualifiedEmoji.add("\uD83E\uDDD7\uD83C\uDFFB") // 🧗🏻
-        fullyQualifiedEmoji.add("\uD83E\uDD38\uD83C\uDFFE\u200D♀\uFE0F") // 🤸🏾‍♀️
-        fullyQualifiedEmoji.add("\uD83E\uDDD1\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1\uD83C\uDFFB") // 🧑🏽‍🤝‍🧑🏻
-        fullyQualifiedEmoji.add("\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D\uD83D\uDC69\uD83C\uDFFC") // 👩🏾‍🤝‍👩🏼
-        fullyQualifiedEmoji.add("\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDD1D\u200D\uD83D\uDC68\uD83C\uDFFD") // 👨🏻‍🤝‍👨🏽
-        fullyQualifiedEmoji.add("\uD83E\uDDD1\uD83C\uDFFB\u200D❤\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF") // 🧑🏻‍❤️‍💋‍🧑🏿
-        fullyQualifiedEmoji.add("\uD83D\uDC69\uD83C\uDFFF\u200D❤\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE") // 👩🏿‍❤️‍💋‍👨🏾
-        fullyQualifiedEmoji.add("\uD83D\uDC91\uD83C\uDFFC") // 💑🏼
-        fullyQualifiedEmoji.add("\uD83D\uDC68\uD83C\uDFFD\u200D❤\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE") // 👨🏽‍❤️‍👨🏾
-        fullyQualifiedEmoji.add("\uD83D\uDC69\uD83C\uDFFD\u200D❤\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC") // 👩🏽‍❤️‍👩🏼
-        fullyQualifiedEmoji.add("\uD83E\uDDD1\u200D\uD83E\uDDD1\u200D\uD83E\uDDD2\u200D\uD83E\uDDD2") // 🧑‍🧑‍🧒‍🧒
-        fullyQualifiedEmoji.add("\uD83D\uDC29") // 🐩
-        fullyQualifiedEmoji.add("\uD83E\uDD9B") // 🦛
-        fullyQualifiedEmoji.add("\uD83D\uDC1F") // 🐟
-        fullyQualifiedEmoji.add("\uD83E\uDD52") // 🥒
-        fullyQualifiedEmoji.add("\uD83E\uDED5") // 🫕
-        fullyQualifiedEmoji.add("⛺")
-        fullyQualifiedEmoji.add("\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08") // 🏳️‍🌈
-        fullyQualifiedEmoji.add("\uD83C\uDFF3\uFE0F\u200D⚧\uFE0F") // 🏳️‍⚧️
-        fullyQualifiedEmoji.add("\uD83C\uDFF4\u200D☠\uFE0F") // 🏴‍☠️
-        fullyQualifiedEmoji.add("\uD83C\uDDE8\uD83C\uDDED") // 🇨🇭
-
-
-        fullyQualifiedEmoji.forEach { emojiSequence ->
-            assertTrue(EmojiUtil.isFullyQualifiedEmoji(emojiSequence))
-        }
-    }
-
-    @Test
-    fun isFullyQualifiedEmoji_unqualifiedEmoji() {
-        val unqualifiedEmoji: MutableList<String> = LinkedList()
-
-        unqualifiedEmoji.add("☺")
-        unqualifiedEmoji.add("☠")
-        unqualifiedEmoji.add("❤")
-        unqualifiedEmoji.add("\uD83D\uDD73") // 🕳
-        unqualifiedEmoji.add("\uD83D\uDC41\u200D\uD83D\uDDE8\uFE0F") // 👁‍🗨️
-        unqualifiedEmoji.add("\uD83D\uDC41\u200D\uD83D\uDDE8") // 👁‍🗨
-        unqualifiedEmoji.add("\uD83D\uDDE8") // 🗨
-        unqualifiedEmoji.add("\uD83D\uDDEF") // 🗯
-        unqualifiedEmoji.add("\uD83D\uDD75\u200D♀\uFE0F") // 🕵‍♀️
-        unqualifiedEmoji.add("\uD83D\uDD74") // 🕴
-        unqualifiedEmoji.add("\uD83C\uDFCC") // 🏌
-        unqualifiedEmoji.add("\uD83D\uDD78") // 🕸
-        unqualifiedEmoji.add("\uD83C\uDF36") // 🌶
-        unqualifiedEmoji.add("\uD83C\uDF7D") // 🍽
-        unqualifiedEmoji.add("\uD83D\uDDFA") // 🗺
-        unqualifiedEmoji.add("\uD83C\uDFD4") // 🏔
-        unqualifiedEmoji.add("\uD83C\uDF29") // 🌩
-        unqualifiedEmoji.add("\uD83C\uDF2B") // 🌫
-        unqualifiedEmoji.add("\uD83C\uDF2C") // 🌬
-        unqualifiedEmoji.add("♣")
-        unqualifiedEmoji.add("\uD83D\uDD8C") // 🖌
-        unqualifiedEmoji.add("\uD83D\uDDD2") // 🗒
-        unqualifiedEmoji.add("\uD83D\uDD87") // 🖇
-        unqualifiedEmoji.add("\uD83D\uDDDD") // 🗝
-        unqualifiedEmoji.add("⛏")
-        unqualifiedEmoji.add("✖")
-        unqualifiedEmoji.add("♾")
-        unqualifiedEmoji.add("⁉")
-        unqualifiedEmoji.add("♻")
-        unqualifiedEmoji.add("❇")
-        unqualifiedEmoji.add("®")
-        unqualifiedEmoji.add("™")
-        unqualifiedEmoji.add("0⃣")
-        unqualifiedEmoji.add("Ⓜ")
-        unqualifiedEmoji.add("\uD83C\uDFF3\u200D\uD83C\uDF08") // 🏳‍🌈
-        unqualifiedEmoji.add("\uD83C\uDFF3\u200D⚧") // 🏳‍⚧
-
-        unqualifiedEmoji.forEach { emojiSequence ->
-            assertFalse(EmojiUtil.isFullyQualifiedEmoji(emojiSequence))
-        }
-    }
-
-    @Test
-    fun isFullyQualifiedEmoji_minimallyQualifiedEmoji() {
-        val minimallyQualified: MutableList<String> = LinkedList()
-
-        minimallyQualified.add("\uD83D\uDE36\u200D\uD83C\uDF2B") // 😶‍🌫
-        minimallyQualified.add("\uD83D\uDE42\u200D↔") // 🙂‍↔
-        minimallyQualified.add("\uD83D\uDE42\u200D↕") // 🙂‍↕
-        minimallyQualified.add("\uD83D\uDC41\uFE0F\u200D\uD83D\uDDE8") // 👁️‍🗨
-        minimallyQualified.add("\uD83E\uDDDE\u200D♀") // 🧞‍♀
-        minimallyQualified.add("\uD83C\uDFC3\uD83C\uDFFE\u200D♀\u200D➡\uFE0F") // 🏃🏾‍♀‍➡️
-        minimallyQualified.add("\uD83C\uDFC3\uD83C\uDFFF\u200D♂\uFE0F\u200D➡") // 🏃🏿‍♂️‍➡
-        minimallyQualified.add("\uD83D\uDC6F\u200D♂") // 👯‍♂
-        minimallyQualified.add("\uD83C\uDFCA\u200D♀") // 🏊‍♀
-        minimallyQualified.add("\uD83E\uDD3D\uD83C\uDFFC\u200D♂") // 🤽🏼‍♂
-        minimallyQualified.add("\uD83E\uDD3D\uD83C\uDFFD\u200D♀") // 🤽🏽‍♀
-        minimallyQualified.add("\uD83D\uDC69\u200D❤\u200D\uD83D\uDC8B\u200D\uD83D\uDC68") // 👩‍❤‍💋‍👨
-        minimallyQualified.add("\uD83D\uDC69\uD83C\uDFFC\u200D❤\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB") // 👩🏼‍❤‍💋‍👨🏻
-        minimallyQualified.add("\uD83D\uDC68\uD83C\uDFFB\u200D❤\u200D\uD83D\uDC68\uD83C\uDFFD") // 👨🏻‍❤‍👨🏽
-        minimallyQualified.add("\uD83D\uDC68\uD83C\uDFFD\u200D❤\u200D\uD83D\uDC68\uD83C\uDFFE") // 👨🏽‍❤‍👨🏾
-        minimallyQualified.add("\uD83D\uDC69\u200D❤\u200D\uD83D\uDC69") // 👩‍❤‍👩
-        minimallyQualified.add("\uD83D\uDC69\uD83C\uDFFE\u200D❤\u200D\uD83D\uDC69\uD83C\uDFFC") // 👩🏾‍❤‍👩🏼
-        minimallyQualified.add("\uD83C\uDFF4\u200D☠") // 🏴‍☠
-
-        minimallyQualified.forEach { emojiSequence ->
-            assertFalse(EmojiUtil.isFullyQualifiedEmoji(emojiSequence))
-        }
-    }
-
-    @Test
-    fun isThumbsUpEmoji_thumbsUp() {
-        assertTrue(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4D")); // 👍
-        assertTrue(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4D\uD83C\uDFFB")); // 👍🏻
-        assertTrue(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4D\uD83C\uDFFC")); // 👍🏼
-        assertTrue(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4D\uD83C\uDFFD")); // 👍🏽
-        assertTrue(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4D\uD83C\uDFFE")); // 👍🏾
-        assertTrue(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4D\uD83C\uDFFF")); // 👍🏿
-    }
-
-    @Test
-    fun isThumbsUpEmoji_thumbsDown() {
-        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4E")); // 👎
-        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4E\uD83C\uDFFB")); // 👎🏻
-        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4E\uD83C\uDFFC")); // 👎🏼
-        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4E\uD83C\uDFFD")); // 👎🏽
-        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4E\uD83C\uDFFE")); // 👎🏾
-        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4E\uD83C\uDFFF")); // 👎🏿
-    }
-
-    @Test
-    fun isThumbsUpEmoji_otherEmoji() {
-        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83E\uDD70")); // 🥰
-        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC7E")); // 👾
-        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDCAA")); // 💪
-        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83E\uDEF1\uD83C\uDFFF\u200D\uD83E\uDEF2\uD83C\uDFFB")); // 🫱🏿‍🫲🏻
-        assertFalse(EmojiUtil.isThumbsUpEmoji("✊"));
-        assertFalse(EmojiUtil.isThumbsUpEmoji("✅"));
-        assertFalse(EmojiUtil.isThumbsUpEmoji("❌"));
-    }
-
-    @Test
-    fun isThumbsDownEmoji_thumbsDown() {
-        assertTrue(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4E")); // 👎
-        assertTrue(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4E\uD83C\uDFFB")); // 👎🏻
-        assertTrue(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4E\uD83C\uDFFC")); // 👎🏼
-        assertTrue(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4E\uD83C\uDFFD")); // 👎🏽
-        assertTrue(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4E\uD83C\uDFFE")); // 👎🏾
-        assertTrue(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4E\uD83C\uDFFF")); // 👎🏿
-    }
-
-    @Test
-    fun isThumbsDownEmoji_thumbsUp() {
-        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4D")); // 👍
-        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4D\uD83C\uDFFB")); // 👍🏻
-        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4D\uD83C\uDFFC")); // 👍🏼
-        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4D\uD83C\uDFFD")); // 👍🏽
-        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4D\uD83C\uDFFE")); // 👍🏾
-        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4D\uD83C\uDFFF")); // 👍🏿
-    }
-
-    @Test
-    fun isThumbsDownEmoji_otherEmoji() {
-        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83E\uDD70")); // 🥰
-        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC7E")); // 👾
-        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDCAA")); // 💪
-        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83E\uDEF1\uD83C\uDFFF\u200D\uD83E\uDEF2\uD83C\uDFFB")); // 🫱🏿‍🫲🏻
-        assertFalse(EmojiUtil.isThumbsDownEmoji("✊"));
-        assertFalse(EmojiUtil.isThumbsDownEmoji("✅"));
-        assertFalse(EmojiUtil.isThumbsDownEmoji("❌"));
-    }
-
-    @Test
-    fun isThumbsUpOrDownEmoji_thumbsUpDown() {
-        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4D")); // 👍
-        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4D\uD83C\uDFFB")); // 👍🏻
-        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4D\uD83C\uDFFC")); // 👍🏼
-        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4D\uD83C\uDFFD")); // 👍🏽
-        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4D\uD83C\uDFFE")); // 👍🏾
-        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4D\uD83C\uDFFF")); // 👍🏿
-        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4E")); // 👎
-        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4E\uD83C\uDFFB")); // 👎🏻
-        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4E\uD83C\uDFFC")); // 👎🏼
-        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4E\uD83C\uDFFD")); // 👎🏽
-        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4E\uD83C\uDFFE")); // 👎🏾
-        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4E\uD83C\uDFFF")); // 👎🏿
-    }
-
-    @Test
-    fun isThumbsUpOrDownEmoji_otherEmoji() {
-        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("\uD83E\uDD70")); // 🥰
-        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC7E")); // 👾
-        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDCAA")); // 💪
-        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("\uD83E\uDEF1\uD83C\uDFFF\u200D\uD83E\uDEF2\uD83C\uDFFB")); // 🫱🏿‍🫲🏻
-        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("✊"));
-        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("✅"));
-        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("❌"));
-    }
-}

+ 267 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/CreateGroupFlowTest.kt

@@ -0,0 +1,267 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024-2025 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.groupmanagement
+
+import android.text.format.DateUtils
+import ch.threema.app.DangerousTest
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.groupflows.GroupCreateProperties
+import ch.threema.app.groupflows.ProfilePicture
+import ch.threema.app.tasks.GroupCreateTask
+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.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.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.taskmanager.Task
+import ch.threema.domain.taskmanager.TaskCodec
+import ch.threema.storage.models.ContactModel
+import ch.threema.storage.models.GroupModel.UserState
+import java.util.Date
+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.test.fail
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * This test asserts that the corresponding tasks have been scheduled when running the create group
+ * flow.
+ */
+@DangerousTest
+class CreateGroupFlowTest : GroupFlowTest() {
+    private val myContact: TestContact = TestHelpers.TEST_CONTACT
+
+    private val initialContactModelData = ContactModelData(
+        identity = "12345678",
+        publicKey = ByteArray(32),
+        createdAt = Date(),
+        firstName = "",
+        lastName = "",
+        verificationLevel = VerificationLevel.SERVER_VERIFIED,
+        workVerificationLevel = WorkVerificationLevel.NONE,
+        nickname = null,
+        identityType = IdentityType.NORMAL,
+        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        activityState = IdentityState.ACTIVE,
+        syncState = ContactSyncState.INITIAL,
+        featureMask = 255u,
+        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
+        profilePictureBlobId = null,
+        androidContactLookupKey = null,
+        localAvatarExpires = null,
+        isRestored = false,
+        jobTitle = null,
+        department = null,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    @Before
+    fun setup() {
+        clearDatabaseAndCaches(serviceManager)
+
+        assert(myContact.identity == TestHelpers.ensureIdentity(serviceManager))
+
+        // Note that we use from sync to prevent any reflection. This is only acceptable in tests.
+        serviceManager.modelRepositories.contacts.createFromSync(initialContactModelData)
+    }
+
+    @Test
+    fun testKnownMember() = runTest {
+        val memberIdentity = initialContactModelData.identity
+
+        // Assert that the member exists as a contact
+        assertNotNull(contactModelRepository.getByIdentity(memberIdentity))
+
+        testAndAssertSuccessfulGroupCreation(
+            GroupCreateProperties(
+                name = "Test",
+                profilePicture = ProfilePicture(null as ByteArray?),
+                members = setOf(memberIdentity),
+            ),
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testKnownMemberNonMd() = runTest {
+        val memberIdentity = initialContactModelData.identity
+
+        // Assert that the member exists as a contact
+        assertNotNull(contactModelRepository.getByIdentity(memberIdentity))
+
+        testAndAssertSuccessfulGroupCreation(
+            GroupCreateProperties(
+                name = "Test",
+                profilePicture = ProfilePicture(null as ByteArray?),
+                members = setOf(memberIdentity),
+            ),
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testNotesGroupMd() = runTest {
+        testAndAssertSuccessfulGroupCreation(
+            GroupCreateProperties(
+                name = "Test",
+                profilePicture = ProfilePicture(null as ByteArray?),
+                members = emptySet(),
+            ),
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testNotesGroupNonMd() = runTest {
+        testAndAssertSuccessfulGroupCreation(
+            GroupCreateProperties(
+                name = "Test",
+                profilePicture = ProfilePicture(null as ByteArray?),
+                members = emptySet(),
+            ),
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testUnknownMemberMd() = runTest {
+        val unknownIdentity = "0UNKNOWN"
+
+        // Assert that the identity is really unknown
+        assertNull(contactModelRepository.getByIdentity(unknownIdentity))
+
+        val groupModel = testGroupCreation(
+            GroupCreateProperties(
+                name = "Test",
+                profilePicture = ProfilePicture(null as ByteArray?),
+                members = setOf(initialContactModelData.identity, unknownIdentity),
+            ),
+            ReflectionExpectation.REFLECTION_FAIL,
+        )
+
+        assertNull(groupModel)
+    }
+
+    private suspend fun testAndAssertSuccessfulGroupCreation(
+        groupCreateProperties: GroupCreateProperties,
+        reflectionExpectation: ReflectionExpectation,
+    ): GroupModel? {
+        val groupModel = testGroupCreation(groupCreateProperties, reflectionExpectation)
+        groupModel.assertCreatedFrom(groupCreateProperties)
+        groupModel.assertNewGroup()
+        return groupModel
+    }
+
+    private suspend fun testGroupCreation(
+        groupCreateProperties: GroupCreateProperties,
+        reflectionExpectation: ReflectionExpectation,
+    ): GroupModel? {
+        val scheduledTaskAssertions: MutableList<(Task<*, TaskCodec>) -> Unit> = mutableListOf()
+        // If multi device is enabled, then we expect a reflection
+        if (reflectionExpectation.setupConfig == SetupConfig.MULTI_DEVICE_ENABLED) {
+            scheduledTaskAssertions.add { task ->
+                assertIs<ReflectGroupSyncCreateTask>(task)
+            }
+        }
+
+        // If the group is not a notes group, we expect that a task is scheduled that sends out csp
+        // messages.
+        val isNotesGroup = groupCreateProperties.members.isEmpty()
+        val reflectionFails = reflectionExpectation == ReflectionExpectation.REFLECTION_FAIL
+        if (!isNotesGroup && !reflectionFails) {
+            scheduledTaskAssertions.add { task ->
+                assertIs<GroupCreateTask>(task)
+            }
+        }
+
+        // Prepare task manager and group flow dispatcher
+        val taskManager = ControlledTaskManager(scheduledTaskAssertions)
+        val groupFlowDispatcher = getGroupFlowDispatcher(
+            reflectionExpectation.setupConfig,
+            taskManager,
+        )
+
+        // Run create group flow
+        val groupModel = groupFlowDispatcher.runCreateGroupFlow(
+            null,
+            ThreemaApplication.getAppContext(),
+            groupCreateProperties,
+        ).await()
+
+        // Assert that all expected tasks have been scheduled
+        taskManager.pendingTaskAssertions.size.let { size ->
+            if (size > 0) {
+                fail("There are $size pending task assertions left")
+            }
+        }
+
+        return groupModel
+    }
+
+    private fun GroupModel?.assertCreatedFrom(groupCreateProperties: GroupCreateProperties) {
+        assertNotNull(this)
+        data.value.assertCreatedFrom(groupCreateProperties)
+    }
+
+    private fun GroupModelData?.assertCreatedFrom(groupCreateProperties: GroupCreateProperties) {
+        assertNotNull(this)
+        assertEquals(groupCreateProperties.name, name)
+        assertEquals(groupCreateProperties.members, otherMembers)
+    }
+
+    private fun GroupModel?.assertNewGroup() {
+        assertNotNull(this)
+        data.value.assertNewGroup()
+    }
+
+    private fun GroupModelData?.assertNewGroup() {
+        assertNotNull(this)
+        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
+        }
+        assertFalse(isArchived)
+        assertNull(groupDescription)
+        assertNull(groupDescriptionChangedAt)
+    }
+}

+ 444 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/DisbandGroupFlowTest.kt

@@ -0,0 +1,444 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024-2025 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.groupmanagement
+
+import ch.threema.app.DangerousTest
+import ch.threema.app.groupflows.GroupDisbandIntent
+import ch.threema.app.tasks.OutgoingGroupDisbandTask
+import ch.threema.app.tasks.ReflectGroupSyncDeleteTask
+import ch.threema.app.tasks.ReflectLocalGroupLeaveOrDisband
+import ch.threema.app.testutils.TestHelpers
+import ch.threema.app.testutils.clearDatabaseAndCaches
+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.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.taskmanager.Task
+import ch.threema.domain.taskmanager.TaskCodec
+import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import ch.threema.storage.models.GroupModel.UserState
+import java.util.Date
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+@DangerousTest
+class DisbandGroupFlowTest : GroupFlowTest() {
+    private val myContact = TestHelpers.TEST_CONTACT
+
+    private val initialContactData = ContactModelData(
+        identity = "12345678",
+        publicKey = ByteArray(32),
+        createdAt = Date(),
+        firstName = "",
+        lastName = "",
+        verificationLevel = VerificationLevel.SERVER_VERIFIED,
+        workVerificationLevel = WorkVerificationLevel.NONE,
+        nickname = null,
+        identityType = IdentityType.NORMAL,
+        acquaintanceLevel = AcquaintanceLevel.DIRECT,
+        activityState = IdentityState.ACTIVE,
+        syncState = ContactSyncState.INITIAL,
+        featureMask = 255u,
+        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
+        profilePictureBlobId = null,
+        androidContactLookupKey = null,
+        localAvatarExpires = null,
+        isRestored = false,
+        jobTitle = null,
+        department = null,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    private val myInitialGroupModelData = GroupModelData(
+        groupIdentity = GroupIdentity(myContact.identity, 42),
+        name = "MyExistingGroup",
+        createdAt = Date(),
+        synchronizedAt = null,
+        lastUpdate = Date(),
+        isArchived = false,
+        groupDescription = null,
+        groupDescriptionChangedAt = null,
+        otherMembers = setOf(initialContactData.identity),
+        userState = UserState.MEMBER,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    private val initialGroupModelData = myInitialGroupModelData.copy(
+        groupIdentity = GroupIdentity(initialContactData.identity, 43),
+        name = "ExistingGroup",
+    )
+
+    private val myInitialLeftGroupModelData = myInitialGroupModelData.copy(
+        groupIdentity = GroupIdentity(myContact.identity, 44),
+        name = "LeftGroup",
+        userState = UserState.LEFT,
+    )
+
+    private val myInitialKickedGroupModelData = myInitialGroupModelData.copy(
+        groupIdentity = GroupIdentity(myContact.identity, 45),
+        name = "KickedGroup",
+        userState = UserState.KICKED,
+    )
+
+    private val myInitialNotesGroupModelData = myInitialGroupModelData.copy(
+        groupIdentity = GroupIdentity(myContact.identity, 46),
+        name = "NotesGroup",
+        userState = UserState.MEMBER,
+        otherMembers = emptySet(),
+    )
+
+    @Before
+    fun setup() {
+        clearDatabaseAndCaches(serviceManager)
+
+        assert(myContact.identity == TestHelpers.ensureIdentity(serviceManager))
+
+        // Note that we use from sync to prevent any reflection. This is only acceptable in tests.
+        contactModelRepository.createFromSync(initialContactData)
+        groupModelRepository.apply {
+            createFromSync(myInitialGroupModelData)
+            createFromSync(initialGroupModelData)
+            createFromSync(myInitialLeftGroupModelData)
+            createFromSync(myInitialKickedGroupModelData)
+            createFromSync(myInitialNotesGroupModelData)
+        }
+    }
+
+    @Test
+    fun testGroupDisbandMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertEquals(myContact.identity, groupModel.groupIdentity.creatorIdentity)
+        assertSuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testGroupDisbandNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertEquals(myContact.identity, groupModel.groupIdentity.creatorIdentity)
+        assertSuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testGroupDisbandAndRemoveMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertEquals(myContact.identity, groupModel.groupIdentity.creatorIdentity)
+        assertSuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testGroupDisbandAndRemoveNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertEquals(myContact.identity, groupModel.groupIdentity.creatorIdentity)
+        assertSuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testGroupDisbandForeignGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertNotEquals(myContact.identity, groupModel.groupIdentity.creatorIdentity)
+        assertUnsuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND,
+            ReflectionExpectation.REFLECTION_FAIL,
+        )
+    }
+
+    @Test
+    fun testDisbandForeignGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertNotEquals(myContact.identity, groupModel.groupIdentity.creatorIdentity)
+        assertUnsuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testDisbandLeftGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialLeftGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertEquals(UserState.LEFT, groupModel.data.value?.userState)
+        assertUnsuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND,
+            ReflectionExpectation.REFLECTION_FAIL,
+        )
+    }
+
+    @Test
+    fun testDisbandLeftGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialLeftGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertEquals(UserState.LEFT, groupModel.data.value?.userState)
+        assertUnsuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testDisbandRemovedGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+
+        groupModelRepository.persistRemovedGroup(groupModel.groupIdentity)
+        assertNull(groupModel.data.value)
+
+        assertUnsuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testDisbandRemovedGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+
+        groupModelRepository.persistRemovedGroup(groupModel.groupIdentity)
+        assertNull(groupModel.data.value)
+
+        assertUnsuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testDisbandNotesGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialNotesGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertEquals(myContact.identity, groupModel.groupIdentity.creatorIdentity)
+        assertEquals(emptySet(), groupModel.data.value?.otherMembers)
+        assertEquals(UserState.MEMBER, groupModel.data.value?.userState)
+
+        assertSuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testDisbandNotesGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialNotesGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertEquals(myContact.identity, groupModel.groupIdentity.creatorIdentity)
+        assertEquals(emptySet(), groupModel.data.value?.otherMembers)
+        assertEquals(UserState.MEMBER, groupModel.data.value?.userState)
+
+        assertSuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testDisbandAndRemoveNotesGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialNotesGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertEquals(myContact.identity, groupModel.groupIdentity.creatorIdentity)
+        assertEquals(emptySet(), groupModel.data.value?.otherMembers)
+        assertEquals(UserState.MEMBER, groupModel.data.value?.userState)
+
+        assertSuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testDisbandAndRemoveNotesGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialNotesGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertEquals(myContact.identity, groupModel.groupIdentity.creatorIdentity)
+        assertEquals(emptySet(), groupModel.data.value?.otherMembers)
+        assertEquals(UserState.MEMBER, groupModel.data.value?.userState)
+
+        assertSuccessfulDisband(
+            groupModel,
+            GroupDisbandIntent.DISBAND_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    private suspend fun assertSuccessfulDisband(
+        groupModel: GroupModel,
+        intent: GroupDisbandIntent,
+        reflectionExpectation: ReflectionExpectation,
+    ) {
+        assertTrue {
+            runGroupDisband(groupModel, intent, reflectionExpectation)
+        }
+
+        when (intent) {
+            GroupDisbandIntent.DISBAND -> assertEquals(
+                UserState.LEFT,
+                groupModel.data.value?.userState,
+            )
+
+            GroupDisbandIntent.DISBAND_AND_REMOVE -> assertNull(groupModel.data.value)
+        }
+    }
+
+    private suspend fun assertUnsuccessfulDisband(
+        groupModel: GroupModel,
+        intent: GroupDisbandIntent,
+        reflectionExpectation: ReflectionExpectation,
+    ) {
+        val groupModelDataBefore = groupModel.data.value
+        assertFalse {
+            runGroupDisband(groupModel, intent, reflectionExpectation)
+        }
+        val groupModelDataAfter = groupModel.data.value
+        // Assert that the group model has not changed
+        assertEquals(groupModelDataBefore, groupModelDataAfter)
+    }
+
+    private suspend fun runGroupDisband(
+        groupModel: GroupModel,
+        intent: GroupDisbandIntent,
+        reflectionExpectation: ReflectionExpectation,
+    ): Boolean {
+        val groupModelData = groupModel.data.value
+
+        // Prepare task manager and group flow dispatcher
+        val taskManager = ControlledTaskManager(
+            getExpectedTaskAssertions(groupModelData, intent, reflectionExpectation),
+        )
+        val groupFlowDispatcher = getGroupFlowDispatcher(
+            reflectionExpectation.setupConfig,
+            taskManager,
+        )
+
+        // Run disband group flow
+        val result = groupFlowDispatcher.runDisbandGroupFlow(
+            null,
+            intent,
+            groupModel,
+        ).await()
+
+        taskManager.pendingTaskAssertions.size.let { size ->
+            if (size > 0) {
+                fail("There are $size pending task assertions left")
+            }
+        }
+
+        return result
+    }
+
+    private fun getExpectedTaskAssertions(
+        groupModelData: GroupModelData?,
+        intent: GroupDisbandIntent,
+        reflectionExpectation: ReflectionExpectation,
+    ): MutableList<(Task<*, TaskCodec>) -> Unit> {
+        if (groupModelData == null ||
+            groupModelData.groupIdentity.creatorIdentity != myContact.identity ||
+            groupModelData.userState != UserState.MEMBER
+        ) {
+            return mutableListOf()
+        }
+
+        val scheduledTaskAssertions: MutableList<(Task<*, TaskCodec>) -> Unit> = mutableListOf()
+        // If multi device is enabled, then we expect a reflection
+        if (reflectionExpectation.setupConfig == SetupConfig.MULTI_DEVICE_ENABLED) {
+            scheduledTaskAssertions.add { task ->
+                when (intent) {
+                    GroupDisbandIntent.DISBAND -> assertIs<ReflectLocalGroupLeaveOrDisband>(task)
+                    GroupDisbandIntent.DISBAND_AND_REMOVE -> assertIs<ReflectGroupSyncDeleteTask>(
+                        task,
+                    )
+                }
+            }
+        }
+
+        // If the reflection fails, we do not expect a task that sends out csp messages
+        if (reflectionExpectation != ReflectionExpectation.REFLECTION_FAIL) {
+            scheduledTaskAssertions.add { task ->
+                assertIs<OutgoingGroupDisbandTask>(task)
+            }
+        }
+
+        return scheduledTaskAssertions
+    }
+}

+ 27 - 30
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupControlTest.kt

@@ -33,25 +33,22 @@ import ch.threema.app.activities.HomeActivity
 import ch.threema.app.processors.MessageProcessorProvider
 import ch.threema.app.processors.MessageProcessorProvider
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.domain.protocol.csp.messages.AbstractGroupMessage
 import ch.threema.domain.protocol.csp.messages.AbstractGroupMessage
-import ch.threema.domain.protocol.csp.messages.GroupSetupMessage
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
+import ch.threema.domain.protocol.csp.messages.GroupSetupMessage
 import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
 import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
 import ch.threema.domain.stores.IdentityStoreInterface
 import ch.threema.domain.stores.IdentityStoreInterface
-import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
 
 
 /**
 /**
  * A collection of basic data and utility functions to test group control messages. If the common
  * A collection of basic data and utility functions to test group control messages. If the common
  * group receive steps should not be executed for a certain message type, the common group step
  * group receive steps should not be executed for a certain message type, the common group step
  * receive methods should be overridden.
  * receive methods should be overridden.
  */
  */
-@ExperimentalCoroutinesApi
 abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProvider() {
 abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProvider() {
-
     /**
     /**
      * Create a message of the tested group message type. This is used to create a message that will
      * Create a message of the tested group message type. This is used to create a message that will
      * be used to test the common group receive steps.
      * be used to test the common group receive steps.
@@ -79,11 +76,11 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
     }
     }
 
 
     /**
     /**
-     * Check step 2.1 of the common group receive steps: The group could not be found and the user
-     * is the creator of the group (as alleged by the message). The message should be discarded.
+     * Check common group receive steps: The group could not be found and the user is the creator of
+     * the group (as alleged by the message). The message should be discarded.
      */
      */
     @Test
     @Test
-    open fun testCommonGroupReceiveStep2_1() = runTest {
+    open fun testCommonGroupReceiveStepUnknownGroupUserCreator() = runTest {
         val (message, identityStore) = getMyUnknownGroupMessage()
         val (message, identityStore) = getMyUnknownGroupMessage()
         setupAndProcessMessage(message, identityStore)
         setupAndProcessMessage(message, identityStore)
 
 
@@ -93,11 +90,11 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
     }
     }
 
 
     /**
     /**
-     * Check step 2.2 of the common group receive steps: The group could not be found and the user
-     * is not the creator. In this case, a group sync request should be sent.
+     * Check common group receive steps: The group could not be found and the user is not the
+     * creator. In this case, a group sync request should be sent.
      */
      */
     @Test
     @Test
-    open fun testCommonGroupReceiveStep2_2() = runTest {
+    open fun testCommonGroupReceiveStepUnknownGroupUserNotCreator() = runTest {
         val (message, identityStore) = getUnknownGroupMessage()
         val (message, identityStore) = getUnknownGroupMessage()
         setupAndProcessMessage(message, identityStore)
         setupAndProcessMessage(message, identityStore)
 
 
@@ -112,12 +109,12 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
     }
     }
 
 
     /**
     /**
-     * Check step 3.1 of the common group receive steps: The group is marked as left and the user is
-     * the creator of the group. In this case, a group setup with an empty member list should be
-     * sent back to the sender.
+     * Check common group receive steps: The group is marked as left and the user is the creator of
+     * the group. In this case, a group setup with an empty member list should be sent back to the
+     * sender.
      */
      */
     @Test
     @Test
-    open fun testCommonGroupReceiveStep3_1() = runTest {
+    open fun testCommonGroupReceiveStepLeftGroupUserCreator() = runTest {
         val (message, identityStore) = getMyLeftGroupMessage()
         val (message, identityStore) = getMyLeftGroupMessage()
         setupAndProcessMessage(message, identityStore)
         setupAndProcessMessage(message, identityStore)
 
 
@@ -127,18 +124,18 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
         assertEquals(myContact.identity, firstMessage.fromIdentity)
         assertEquals(myContact.identity, firstMessage.fromIdentity)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
-        assertArrayEquals(emptyArray<String>(), firstMessage.members)
+        assertContentEquals(emptyArray<String>(), firstMessage.members)
 
 
         assertTrue(sentMessagesInsideTask.isEmpty())
         assertTrue(sentMessagesInsideTask.isEmpty())
         assertTrue(sentMessagesNewTask.isEmpty())
         assertTrue(sentMessagesNewTask.isEmpty())
     }
     }
 
 
     /**
     /**
-     * Check step 3.2 of the common group receive steps: The group is marked left and the user is
-     * not the creator of the group. In this case, a group leave should be sent back to the sender.
+     * Check common group receive steps: The group is marked left and the user is not the creator of
+     * the group. In this case, a group leave should be sent back to the sender.
      */
      */
     @Test
     @Test
-    open fun testCommonGroupReceiveStep3_2() = runTest {
+    open fun testCommonGroupReceiveStepLeftGroupUserNotCreator() = runTest {
         // First, test the common group receive steps for a message from the group creator
         // First, test the common group receive steps for a message from the group creator
         val (firstIncomingMessage, firstIdentityStore) = getLeftGroupMessageFromCreator()
         val (firstIncomingMessage, firstIdentityStore) = getLeftGroupMessageFromCreator()
         setupAndProcessMessage(firstIncomingMessage, firstIdentityStore)
         setupAndProcessMessage(firstIncomingMessage, firstIdentityStore)
@@ -168,12 +165,12 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
     }
     }
 
 
     /**
     /**
-     * Check step 4.1 of the common group receive steps: The sender is not a member of the group and
-     * the user is the creator of the group. In this case, a group setup with an empty members list
-     * should be sent back to the sender.
+     * Check common group receive steps: The sender is not a member of the group and the user is the
+     * creator of the group. In this case, a group setup with an empty members list should be sent
+     * back to the sender.
      */
      */
     @Test
     @Test
-    open fun testCommonGroupReceiveStep4_1() = runTest {
+    open fun testCommonGroupReceiveStepSenderNotMemberUserCreator() = runTest {
         val (message, identityStore) = getSenderNotMemberOfMyGroupMessage()
         val (message, identityStore) = getSenderNotMemberOfMyGroupMessage()
         setupAndProcessMessage(message, identityStore)
         setupAndProcessMessage(message, identityStore)
 
 
@@ -183,18 +180,18 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
         assertEquals(myContact.identity, firstMessage.fromIdentity)
         assertEquals(myContact.identity, firstMessage.fromIdentity)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
-        assertArrayEquals(emptyArray<String>(), firstMessage.members)
+        assertContentEquals(emptyArray<String>(), firstMessage.members)
 
 
         assertTrue(sentMessagesInsideTask.isEmpty())
         assertTrue(sentMessagesInsideTask.isEmpty())
         assertTrue(sentMessagesNewTask.isEmpty())
         assertTrue(sentMessagesNewTask.isEmpty())
     }
     }
 
 
     /**
     /**
-     * Check step 4.2 of the common group receive steps: The sender is not a member of the group and
-     * the user is not the creator of the group. The message should be discarded.
+     * Check common group receive steps: The sender is not a member of the group and the user is not
+     * the creator of the group. The message should be discarded.
      */
      */
     @Test
     @Test
-    open fun testCommonGroupReceiveStep4_2() = runTest {
+    open fun testCommonGroupReceiveStepSenderNotMemberUserNotCreator() = runTest {
         val (message, identityStore) = getSenderNotMemberMessage()
         val (message, identityStore) = getSenderNotMemberMessage()
         setupAndProcessMessage(message, identityStore)
         setupAndProcessMessage(message, identityStore)
 
 

+ 9 - 6
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupConversationListTest.kt

@@ -34,26 +34,30 @@ import junit.framework.TestCase
  * This class provides a utility method to verify that the correct group names are displayed.
  * This class provides a utility method to verify that the correct group names are displayed.
  */
  */
 abstract class GroupConversationListTest<T : AbstractGroupMessage> : GroupControlTest<T>() {
 abstract class GroupConversationListTest<T : AbstractGroupMessage> : GroupControlTest<T>() {
-
     /**
     /**
      * Assert that in the given scenario the expected groups are listed.
      * Assert that in the given scenario the expected groups are listed.
      */
      */
     protected fun assertGroupConversations(
     protected fun assertGroupConversations(
         scenario: ActivityScenario<HomeActivity>,
         scenario: ActivityScenario<HomeActivity>,
-        expectedGroups: List<TestGroup>
+        expectedGroups: List<TestGroup>,
+        errorMessage: String = "",
     ) {
     ) {
         Thread.sleep(500)
         Thread.sleep(500)
 
 
         scenario.onActivity { activity ->
         scenario.onActivity { activity ->
             val adapter = activity.findViewById<RecyclerView>(R.id.list)?.adapter
             val adapter = activity.findViewById<RecyclerView>(R.id.list)?.adapter
-            assertGroups(expectedGroups, adapter as MessageListAdapter)
+            assertGroups(expectedGroups, adapter as MessageListAdapter, errorMessage)
         }
         }
     }
     }
 
 
     /**
     /**
      * Assert that the given recycler view shows the given
      * Assert that the given recycler view shows the given
      */
      */
-    private fun assertGroups(testGroups: List<TestGroup>, adapter: MessageListAdapter) {
+    private fun assertGroups(
+        testGroups: List<TestGroup>,
+        adapter: MessageListAdapter,
+        errorMessage: String,
+    ) {
         val expectedGroupNames: Set<String> = testGroups.map { it.groupName }.toSet()
         val expectedGroupNames: Set<String> = testGroups.map { it.groupName }.toSet()
 
 
         val actualGroupNames = (0 until adapter.itemCount)
         val actualGroupNames = (0 until adapter.itemCount)
@@ -61,7 +65,6 @@ abstract class GroupConversationListTest<T : AbstractGroupMessage> : GroupContro
             .map { it.receiver.displayName }
             .map { it.receiver.displayName }
             .toSet()
             .toSet()
 
 
-        TestCase.assertEquals(expectedGroupNames, actualGroupNames)
+        TestCase.assertEquals(errorMessage, expectedGroupNames, actualGroupNames)
     }
     }
-
 }
 }

+ 98 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupFlowTest.kt

@@ -0,0 +1,98 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024-2025 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.groupmanagement
+
+import ch.threema.app.TestMultiDeviceManager
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.services.GroupFlowDispatcher
+import ch.threema.domain.taskmanager.TaskManager
+
+enum class SetupConfig {
+    MULTI_DEVICE_ENABLED,
+    MULTI_DEVICE_DISABLED,
+}
+
+enum class ReflectionExpectation(val setupConfig: SetupConfig) {
+    /**
+     * In case multi device is enabled and the reflection is expected to succeed.
+     */
+    REFLECTION_SUCCESS(SetupConfig.MULTI_DEVICE_ENABLED),
+
+    /**
+     * In case multi device is enabled and the reflection is expected to fail due to an unfulfilled
+     * precondition.
+     */
+    REFLECTION_FAIL(SetupConfig.MULTI_DEVICE_ENABLED),
+
+    /**
+     * In case multi device is disabled and the reflection is expected to be skipped.
+     */
+    REFLECTION_SKIPPED(SetupConfig.MULTI_DEVICE_DISABLED),
+}
+
+abstract class GroupFlowTest {
+    protected val serviceManager by lazy { ThreemaApplication.requireServiceManager() }
+
+    protected val contactModelRepository by lazy { serviceManager.modelRepositories.contacts }
+    protected val groupModelRepository by lazy { serviceManager.modelRepositories.groups }
+
+    private val testMultiDeviceManagerEnabled by lazy {
+        TestMultiDeviceManager(
+            isMdDisabledOrSupportsFs = false,
+            isMultiDeviceActive = true,
+        )
+    }
+
+    private val testMultiDeviceManagerDisabled by lazy {
+        TestMultiDeviceManager(
+            isMdDisabledOrSupportsFs = true,
+            isMultiDeviceActive = false,
+        )
+    }
+
+    protected fun getGroupFlowDispatcher(
+        setupConfig: SetupConfig,
+        taskManager: TaskManager,
+    ) = GroupFlowDispatcher(
+        serviceManager.modelRepositories.contacts,
+        serviceManager.modelRepositories.groups,
+        serviceManager.contactService,
+        serviceManager.groupService,
+        serviceManager.groupCallManager,
+        serviceManager.userService,
+        serviceManager.contactStore,
+        serviceManager.identityStore,
+        serviceManager.forwardSecurityMessageProcessor,
+        serviceManager.nonceFactory,
+        serviceManager.blockedIdentitiesService,
+        serviceManager.preferenceService,
+        when (setupConfig) {
+            SetupConfig.MULTI_DEVICE_ENABLED -> testMultiDeviceManagerEnabled
+            SetupConfig.MULTI_DEVICE_DISABLED -> testMultiDeviceManagerDisabled
+        },
+        serviceManager.apiService,
+        serviceManager.apiConnector,
+        serviceManager.fileService,
+        serviceManager.databaseServiceNew,
+        taskManager,
+    )
+}

+ 201 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupResyncFlowTest.kt

@@ -0,0 +1,201 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024-2025 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.groupmanagement
+
+import ch.threema.app.DangerousTest
+import ch.threema.app.tasks.ActiveGroupStateResyncTask
+import ch.threema.app.testutils.TestHelpers
+import ch.threema.app.testutils.clearDatabaseAndCaches
+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.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.taskmanager.Task
+import ch.threema.domain.taskmanager.TaskCodec
+import ch.threema.storage.models.ContactModel
+import ch.threema.storage.models.GroupModel.UserState
+import java.util.Date
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+@DangerousTest
+class GroupResyncFlowTest : GroupFlowTest() {
+    private val myContact = TestHelpers.TEST_CONTACT
+
+    private val initialContactData = ContactModelData(
+        identity = "12345678",
+        publicKey = ByteArray(32),
+        createdAt = Date(),
+        firstName = "",
+        lastName = "",
+        verificationLevel = VerificationLevel.SERVER_VERIFIED,
+        workVerificationLevel = WorkVerificationLevel.NONE,
+        nickname = null,
+        identityType = IdentityType.NORMAL,
+        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        activityState = IdentityState.ACTIVE,
+        syncState = ContactSyncState.INITIAL,
+        featureMask = 255u,
+        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
+        profilePictureBlobId = null,
+        androidContactLookupKey = null,
+        localAvatarExpires = null,
+        isRestored = false,
+        jobTitle = null,
+        department = null,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    private val myInitialGroupModelData = GroupModelData(
+        groupIdentity = GroupIdentity(myContact.identity, 42),
+        name = "MyExistingGroup",
+        createdAt = Date(),
+        synchronizedAt = null,
+        lastUpdate = Date(),
+        isArchived = false,
+        groupDescription = null,
+        groupDescriptionChangedAt = null,
+        otherMembers = emptySet(),
+        userState = UserState.MEMBER,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    private val initialGroupModelData = myInitialGroupModelData.copy(
+        groupIdentity = GroupIdentity(initialContactData.identity, 43),
+        name = "ExistingGroup",
+    )
+
+    @Before
+    fun setup() {
+        clearDatabaseAndCaches(serviceManager)
+
+        assert(myContact.identity == TestHelpers.ensureIdentity(serviceManager))
+
+        // Note that we use from sync to prevent any reflection. This is only acceptable in tests.
+        contactModelRepository.createFromSync(initialContactData)
+        groupModelRepository.apply {
+            createFromSync(myInitialGroupModelData)
+            createFromSync(initialGroupModelData)
+        }
+    }
+
+    @Test
+    fun testGroupResyncMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertSuccessfulGroupResync(groupModel, SetupConfig.MULTI_DEVICE_ENABLED)
+    }
+
+    @Test
+    fun testGroupResyncNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertSuccessfulGroupResync(groupModel, SetupConfig.MULTI_DEVICE_DISABLED)
+    }
+
+    @Test
+    fun testForeignGroupResyncMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulGroupResync(groupModel, SetupConfig.MULTI_DEVICE_ENABLED)
+    }
+
+    @Test
+    fun testForeignGroupResyncNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulGroupResync(groupModel, SetupConfig.MULTI_DEVICE_DISABLED)
+    }
+
+    private suspend fun assertSuccessfulGroupResync(
+        groupModel: GroupModel,
+        setupConfig: SetupConfig,
+    ) {
+        assertTrue {
+            runGroupResync(groupModel, setupConfig)
+        }
+    }
+
+    private suspend fun assertUnsuccessfulGroupResync(
+        groupModel: GroupModel,
+        setupConfig: SetupConfig,
+    ) {
+        assertFalse {
+            runGroupResync(groupModel, setupConfig)
+        }
+    }
+
+    private suspend fun runGroupResync(groupModel: GroupModel, setupConfig: SetupConfig): Boolean {
+        val groupModelData = groupModel.data.value
+
+        // Prepare task manager and group flow dispatcher
+        val taskManager = ControlledTaskManager(
+            getExpectedTaskAssertions(groupModelData),
+        )
+        val groupFlowDispatcher = getGroupFlowDispatcher(
+            setupConfig,
+            taskManager,
+        )
+
+        // Run group resync flow
+        val result = groupFlowDispatcher.runGroupResyncFlow(groupModel).await()
+
+        taskManager.pendingTaskAssertions.size.let { size ->
+            if (size > 0) {
+                fail("There are $size pending task assertions left")
+            }
+        }
+
+        return result
+    }
+
+    private fun getExpectedTaskAssertions(groupModelData: GroupModelData?): MutableList<(Task<*, TaskCodec>) -> Unit> {
+        if (groupModelData == null ||
+            groupModelData.groupIdentity.creatorIdentity != myContact.identity ||
+            groupModelData.userState != UserState.MEMBER
+        ) {
+            return mutableListOf()
+        }
+
+        return mutableListOf({ task -> assertIs<ActiveGroupStateResyncTask>(task) })
+    }
+}

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

@@ -30,30 +30,25 @@ import ch.threema.app.listeners.GroupListener
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.data.models.GroupIdentity
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
 import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
 import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
-import ch.threema.storage.models.GroupModel
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertFalse
 import junit.framework.TestCase.assertFalse
 import junit.framework.TestCase.assertTrue
 import junit.framework.TestCase.assertTrue
 import junit.framework.TestCase.fail
 import junit.framework.TestCase.fail
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.test.runTest
 import org.junit.After
 import org.junit.After
-import org.junit.Assert.assertArrayEquals
 import org.junit.Test
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runner.RunWith
 
 
 /**
 /**
  * Tests that incoming group leave messages are handled correctly.
  * Tests that incoming group leave messages are handled correctly.
  */
  */
-@ExperimentalCoroutinesApi
 @RunWith(AndroidJUnit4::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @LargeTest
 @DangerousTest
 @DangerousTest
 class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
-
     /**
     /**
      * Test that contact A leaving my group works as expected.
      * Test that contact A leaving my group works as expected.
      */
      */
@@ -131,27 +126,27 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
 
     override fun createMessageForGroup() = GroupLeaveMessage()
     override fun createMessageForGroup() = GroupLeaveMessage()
 
 
-    override fun testCommonGroupReceiveStep2_1() {
+    override fun testCommonGroupReceiveStepUnknownGroupUserCreator() {
         // The common group receive steps are not executed for group leave messages
         // The common group receive steps are not executed for group leave messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep2_2() {
+    override fun testCommonGroupReceiveStepUnknownGroupUserNotCreator() {
         // The common group receive steps are not executed for group leave messages
         // The common group receive steps are not executed for group leave messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep3_1() {
+    override fun testCommonGroupReceiveStepLeftGroupUserCreator() {
         // The common group receive steps are not executed for group leave messages
         // The common group receive steps are not executed for group leave messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep3_2() {
+    override fun testCommonGroupReceiveStepLeftGroupUserNotCreator() {
         // The common group receive steps are not executed for group leave messages
         // The common group receive steps are not executed for group leave messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep4_1() {
+    override fun testCommonGroupReceiveStepSenderNotMemberUserCreator() {
         // The common group receive steps are not executed for group leave messages
         // The common group receive steps are not executed for group leave messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep4_2() {
+    override fun testCommonGroupReceiveStepSenderNotMemberUserNotCreator() {
         // The common group receive steps are not executed for group leave messages
         // The common group receive steps are not executed for group leave messages
     }
     }
 
 
@@ -166,7 +161,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
 
         assertEquals(
         assertEquals(
             group.members.map { it.identity },
             group.members.map { it.identity },
-            serviceManager.groupService.getGroupIdentities(group.groupModel).toList()
+            serviceManager.groupService.getGroupMemberIdentities(group.groupModel).toList(),
         )
         )
 
 
         val leaveTracker = GroupLeaveTracker(group, contact.identity, expectStateChange)
         val leaveTracker = GroupLeaveTracker(group, contact.identity, expectStateChange)
@@ -183,11 +178,11 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
 
         assertEquals(
         assertEquals(
             group.members.size - 1,
             group.members.size - 1,
-            serviceManager.groupService.countMembers(group.groupModel)
+            serviceManager.groupService.countMembers(group.groupModel),
         )
         )
         assertEquals(
         assertEquals(
             group.members.map { it.identity }.filter { it != contact.identity },
             group.members.map { it.identity }.filter { it != contact.identity },
-            serviceManager.groupService.getGroupIdentities(group.groupModel).toList()
+            serviceManager.groupService.getGroupMemberIdentities(group.groupModel).toList(),
         )
         )
 
 
         // Assert that no message has been sent as a response to a group leave
         // Assert that no message has been sent as a response to a group leave
@@ -247,7 +242,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
 
     private fun assertGroupIdentities(expectedMemberList: List<String>, group: TestGroup) {
     private fun assertGroupIdentities(expectedMemberList: List<String>, group: TestGroup) {
         if (serviceManager.groupService.getByApiGroupIdAndCreator(
         if (serviceManager.groupService.getByApiGroupIdAndCreator(
-                group.apiGroupId, group.groupCreator.identity
+                group.apiGroupId, group.groupCreator.identity,
             ) != null
             ) != null
         ) {
         ) {
             // We check the expected members if the group is available in the database. If there is
             // We check the expected members if the group is available in the database. If there is
@@ -255,7 +250,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             // retrieve a group model.
             // retrieve a group model.
             assertEquals(
             assertEquals(
                 expectedMemberList,
                 expectedMemberList,
-                serviceManager.groupService.getGroupIdentities(group.groupModel).toList()
+                serviceManager.groupService.getGroupMemberIdentities(group.groupModel).toList(),
             )
             )
         }
         }
     }
     }
@@ -263,7 +258,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
     private fun assertMemberCount(expectedMemberCount: Int, group: TestGroup) {
     private fun assertMemberCount(expectedMemberCount: Int, group: TestGroup) {
         if (serviceManager.groupService.getByApiGroupIdAndCreator(
         if (serviceManager.groupService.getByApiGroupIdAndCreator(
                 group.apiGroupId,
                 group.apiGroupId,
-                group.groupCreator.identity
+                group.groupCreator.identity,
             ) != null
             ) != null
         ) {
         ) {
             // We only check the expected members if the group is available in the database.
             // We only check the expected members if the group is available in the database.
@@ -271,7 +266,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             // model.
             // model.
             assertEquals(
             assertEquals(
                 expectedMemberCount,
                 expectedMemberCount,
-                serviceManager.groupService.countMembers(group.groupModel)
+                serviceManager.groupService.countMembers(group.groupModel),
             )
             )
         }
         }
     }
     }
@@ -284,43 +279,43 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
         private var memberHasLeft = false
         private var memberHasLeft = false
 
 
         private val groupListener = object : GroupListener {
         private val groupListener = object : GroupListener {
-            override fun onCreate(newGroupModel: GroupModel?) = fail()
+            override fun onCreate(groupIdentity: GroupIdentity) = fail()
 
 
-            override fun onRename(groupModel: GroupModel?) = fail()
+            override fun onRename(groupIdentity: GroupIdentity) = fail()
 
 
-            override fun onUpdatePhoto(groupModel: GroupModel?) = fail()
+            override fun onUpdatePhoto(groupIdentity: GroupIdentity) = fail()
 
 
-            override fun onRemove(groupModel: GroupModel?) = fail()
+            override fun onRemove(groupDbId: Long) = fail()
 
 
             override fun onNewMember(
             override fun onNewMember(
-                group: GroupModel?,
-                newIdentity: String?,
+                groupIdentity: GroupIdentity,
+                identityNew: String,
             ) = fail()
             ) = fail()
 
 
             override fun onMemberLeave(
             override fun onMemberLeave(
-                groupModel: GroupModel?,
-                identity: String?,
+                groupIdentity: GroupIdentity,
+                identityLeft: String,
             ) {
             ) {
                 assertFalse(memberHasLeft)
                 assertFalse(memberHasLeft)
                 group?.let {
                 group?.let {
-                    assertArrayEquals(it.apiGroupId.groupId, groupModel?.apiGroupId?.groupId)
-                    assertEquals(it.groupCreator.identity, groupModel?.creatorIdentity)
-                    assertEquals(leavingIdentity, identity)
+                    assertEquals(it.apiGroupId.toLong(), groupIdentity.groupId)
+                    assertEquals(it.groupCreator.identity, groupIdentity.creatorIdentity)
+                    assertEquals(leavingIdentity, identityLeft)
                 }
                 }
                 memberHasLeft = true
                 memberHasLeft = true
             }
             }
 
 
             override fun onMemberKicked(
             override fun onMemberKicked(
-                group: GroupModel?,
-                identity: String?,
+                groupIdentity: GroupIdentity,
+                identityKicked: String,
             ) = fail()
             ) = fail()
 
 
-            override fun onUpdate(groupModel: GroupModel?) = fail()
+            override fun onUpdate(groupIdentity: GroupIdentity) = fail()
 
 
-            override fun onLeave(groupModel: GroupModel?) = fail()
+            override fun onLeave(groupIdentity: GroupIdentity) = fail()
 
 
             override fun onGroupStateChanged(
             override fun onGroupStateChanged(
-                groupModel: GroupModel?,
+                groupIdentity: GroupIdentity,
                 oldState: Int,
                 oldState: Int,
                 newState: Int,
                 newState: Int,
             ) {
             ) {
@@ -359,5 +354,4 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             groupListeners.remove(groupListener)
             groupListeners.remove(groupListener)
         }
         }
     }
     }
-
 }
 }

+ 36 - 41
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt

@@ -28,27 +28,22 @@ import ch.threema.app.listeners.GroupListener
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.data.models.GroupIdentity
 import ch.threema.domain.models.GroupId
 import ch.threema.domain.models.GroupId
 import ch.threema.domain.protocol.csp.messages.GroupNameMessage
 import ch.threema.domain.protocol.csp.messages.GroupNameMessage
-import ch.threema.storage.models.GroupModel
 import junit.framework.TestCase.*
 import junit.framework.TestCase.*
-import kotlinx.coroutines.*
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.test.runTest
 import org.junit.After
 import org.junit.After
-import org.junit.Assert.assertArrayEquals
 import org.junit.Test
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runner.RunWith
-import java.util.*
 
 
 /**
 /**
  * Tests that incoming group name messages are handled correctly.
  * Tests that incoming group name messages are handled correctly.
  */
  */
-@ExperimentalCoroutinesApi
 @RunWith(AndroidJUnit4::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @LargeTest
 @DangerousTest
 @DangerousTest
 class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
 class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
-
     override fun createMessageForGroup(): GroupNameMessage {
     override fun createMessageForGroup(): GroupNameMessage {
         return GroupNameMessage()
         return GroupNameMessage()
             .apply { groupName = "New Group Name" }
             .apply { groupName = "New Group Name" }
@@ -72,7 +67,7 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
                 groupA.groupCreator,
                 groupA.groupCreator,
                 groupA.members,
                 groupA.members,
                 "GroupARenamed",
                 "GroupARenamed",
-                myContact.identity
+                myContact.identity,
             )
             )
 
 
         val renameTracker = GroupRenameTracker(groupARenamed).apply { start() }
         val renameTracker = GroupRenameTracker(groupARenamed).apply { start() }
@@ -81,7 +76,7 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
             groupARenamed.groupName,
             groupARenamed.groupName,
             groupARenamed.groupCreator.identity,
             groupARenamed.groupCreator.identity,
             groupARenamed.apiGroupId,
             groupARenamed.apiGroupId,
-            groupARenamed.groupCreator
+            groupARenamed.groupCreator,
         )
         )
 
 
         // Process the group rename message
         // Process the group rename message
@@ -114,16 +109,18 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
                 groupA.groupCreator,
                 groupA.groupCreator,
                 groupA.members,
                 groupA.members,
                 "GroupARenamed",
                 "GroupARenamed",
-                myContact.identity
+                myContact.identity,
             )
             )
 
 
         val renameTracker = GroupRenameTracker(null).apply { start() }
         val renameTracker = GroupRenameTracker(null).apply { start() }
 
 
         val message = createEncryptedRenameMessage(
         val message = createEncryptedRenameMessage(
-            groupARenamed.groupName,
-            groupARenamed.groupCreator.identity, // Note that this will be ignored anyway
-            groupARenamed.apiGroupId,
-            contactB // Not the creator of this group!
+            newGroupName = groupARenamed.groupName,
+            // Note that this will be ignored anyway
+            groupCreatorIdentity = groupARenamed.groupCreator.identity,
+            apiGroupId = groupARenamed.apiGroupId,
+            // Not the creator of this group!
+            fromContact = contactB,
         )
         )
 
 
         // Process the group rename message
         // Process the group rename message
@@ -135,36 +132,36 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
         assertGroupConversations(activityScenario, initialGroups)
         assertGroupConversations(activityScenario, initialGroups)
     }
     }
 
 
-    override fun testCommonGroupReceiveStep2_1() {
+    override fun testCommonGroupReceiveStepUnknownGroupUserCreator() {
         // Don't test this as a group name message always comes from the group creator which would
         // Don't test this as a group name message always comes from the group creator which would
         // be this user in this test
         // be this user in this test
     }
     }
 
 
-    override fun testCommonGroupReceiveStep2_2() {
-        runWithoutGroupRename { super.testCommonGroupReceiveStep2_2() }
+    override fun testCommonGroupReceiveStepUnknownGroupUserNotCreator() {
+        runWithoutGroupRename { super.testCommonGroupReceiveStepUnknownGroupUserNotCreator() }
     }
     }
 
 
-    override fun testCommonGroupReceiveStep3_1() {
+    override fun testCommonGroupReceiveStepLeftGroupUserCreator() {
         // Don't test this step. The group rename message is always sent as creator of the group
         // 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
         // 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.
         // message comes from this user itself - which is impossible.
     }
     }
 
 
-    override fun testCommonGroupReceiveStep3_2() {
-        runWithoutGroupRename { super.testCommonGroupReceiveStep3_2() }
+    override fun testCommonGroupReceiveStepLeftGroupUserNotCreator() {
+        runWithoutGroupRename { super.testCommonGroupReceiveStepLeftGroupUserNotCreator() }
     }
     }
 
 
-    override fun testCommonGroupReceiveStep4_1() {
+    override fun testCommonGroupReceiveStepSenderNotMemberUserCreator() {
         // Don't test this step. The group rename message is always sent as creator of the group
         // 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
         // and therefore the sender of the message is never missing in the group. However, the group
-        // model is (very likely) not found and therefore handled in step 2.1 of the common group
+        // model is (very likely) not found and therefore handled earlier in the common group
         // receive steps.
         // receive steps.
     }
     }
 
 
-    override fun testCommonGroupReceiveStep4_2() {
+    override fun testCommonGroupReceiveStepSenderNotMemberUserNotCreator() {
         // Don't test this step. The group rename message is always sent as creator of the group
         // 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
         // and therefore the sender of the message is never missing in the group. However, the group
-        // model is (very likely) not found and therefore handled in step 2.2 of the common group
+        // model is (very likely) not found and therefore handled earlier in the common group
         // receive steps.
         // receive steps.
     }
     }
 
 
@@ -202,61 +199,60 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
         private var hasBeenRenamed = false
         private var hasBeenRenamed = false
 
 
         private val groupListener = object : GroupListener {
         private val groupListener = object : GroupListener {
-            override fun onCreate(newGroupModel: GroupModel?) {
+            override fun onCreate(groupIdentity: GroupIdentity) {
                 fail()
                 fail()
             }
             }
 
 
-            override fun onRename(groupModel: GroupModel?) {
+            override fun onRename(groupIdentity: GroupIdentity) {
                 assertFalse(hasBeenRenamed)
                 assertFalse(hasBeenRenamed)
                 group?.let {
                 group?.let {
-                    assertArrayEquals(it.apiGroupId.groupId, groupModel?.apiGroupId?.groupId)
-                    assertEquals(it.groupCreator.identity, groupModel?.creatorIdentity)
-                    assertEquals(it.groupName, groupModel?.name)
+                    assertEquals(it.apiGroupId.toLong(), groupIdentity.groupId)
+                    assertEquals(it.groupCreator.identity, groupIdentity.creatorIdentity)
                 }
                 }
                 hasBeenRenamed = true
                 hasBeenRenamed = true
             }
             }
 
 
-            override fun onUpdatePhoto(groupModel: GroupModel?) {
+            override fun onUpdatePhoto(groupIdentity: GroupIdentity) {
                 fail()
                 fail()
             }
             }
 
 
-            override fun onRemove(groupModel: GroupModel?) {
+            override fun onRemove(groupDbId: Long) {
                 fail()
                 fail()
             }
             }
 
 
             override fun onNewMember(
             override fun onNewMember(
-                group: GroupModel?,
-                newIdentity: String?,
+                groupIdentity: GroupIdentity,
+                identityNew: String?,
             ) {
             ) {
                 fail()
                 fail()
             }
             }
 
 
             override fun onMemberLeave(
             override fun onMemberLeave(
-                group: GroupModel?,
-                identity: String?,
+                groupIdentity: GroupIdentity,
+                identityLeft: String,
             ) {
             ) {
                 fail()
                 fail()
             }
             }
 
 
             override fun onMemberKicked(
             override fun onMemberKicked(
-                group: GroupModel?,
-                identity: String?,
+                groupIdentity: GroupIdentity,
+                identityKicked: String?,
             ) {
             ) {
                 fail()
                 fail()
             }
             }
 
 
-            override fun onUpdate(groupModel: GroupModel?) {
+            override fun onUpdate(groupIdentity: GroupIdentity) {
                 fail()
                 fail()
             }
             }
 
 
-            override fun onLeave(groupModel: GroupModel?) {
+            override fun onLeave(groupIdentity: GroupIdentity) {
                 fail()
                 fail()
             }
             }
 
 
             override fun onGroupStateChanged(
             override fun onGroupStateChanged(
-                groupModel: GroupModel?,
+                groupIdentity: GroupIdentity,
                 oldState: Int,
                 oldState: Int,
-                newState: Int
+                newState: Int,
             ) {
             ) {
                 fail()
                 fail()
             }
             }
@@ -291,5 +287,4 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
             groupListeners.remove(groupListener)
             groupListeners.remove(groupListener)
         }
         }
     }
     }
-
 }
 }

+ 237 - 157
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt

@@ -28,17 +28,27 @@ import ch.threema.app.listeners.GroupListener
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.data.models.ContactModelData
+import ch.threema.data.models.GroupIdentity
+import ch.threema.domain.models.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.csp.messages.GroupSetupMessage
 import ch.threema.domain.protocol.csp.messages.GroupSetupMessage
-import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
+import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.GroupModel
 import ch.threema.storage.models.GroupModel
+import com.neilalexander.jnacl.NaCl
+import java.util.Date
 import junit.framework.TestCase
 import junit.framework.TestCase
-import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.test.runTest
 import org.junit.After
 import org.junit.After
-import org.junit.Assert
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNotNull
 import org.junit.Assert.assertTrue
 import org.junit.Assert.assertTrue
 import org.junit.Assert.fail
 import org.junit.Assert.fail
 import org.junit.Test
 import org.junit.Test
@@ -48,11 +58,12 @@ import org.junit.runner.RunWith
  * Runs different tests that verify that incoming group setup messages are handled according to the
  * Runs different tests that verify that incoming group setup messages are handled according to the
  * protocol.
  * protocol.
  */
  */
-@ExperimentalCoroutinesApi
 @RunWith(AndroidJUnit4::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @LargeTest
 @DangerousTest
 @DangerousTest
 class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
 class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
+    private val groupService by lazy { serviceManager.groupService }
+    private val groupModelRepository by lazy { serviceManager.modelRepositories.groups }
 
 
     override fun createMessageForGroup() = GroupSetupMessage()
     override fun createMessageForGroup() = GroupSetupMessage()
 
 
@@ -64,7 +75,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         val scenario = startScenario()
         val scenario = startScenario()
 
 
         // Assert initial group conversations
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "initial groups")
 
 
         val setupTracker = GroupSetupTracker(
         val setupTracker = GroupSetupTracker(
             groupAUnknown,
             groupAUnknown,
@@ -84,7 +95,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, groupAUnknown.groupCreator.identityStore)
         processMessage(message, groupAUnknown.groupCreator.identityStore)
 
 
         // Assert that group conversations did not appear, disappear, or change their name
         // Assert that group conversations did not appear, disappear, or change their name
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "no changes")
 
 
         // Assert that no message is sent
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
         assertEquals(0, sentMessagesInsideTask.size)
@@ -104,7 +115,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         val scenario = startScenario()
         val scenario = startScenario()
 
 
         // Assert initial group conversations
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "epect initial group")
 
 
         val setupTracker = GroupSetupTracker(
         val setupTracker = GroupSetupTracker(
             groupAUnknown,
             groupAUnknown,
@@ -124,7 +135,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, groupAUnknown.groupCreator.identityStore)
         processMessage(message, groupAUnknown.groupCreator.identityStore)
 
 
         // Assert that group conversations did not appear, disappear, or change their name
         // Assert that group conversations did not appear, disappear, or change their name
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "no changes")
 
 
         // Assert that no message is sent
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
         assertEquals(0, sentMessagesInsideTask.size)
@@ -149,46 +160,17 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         serviceManager.blockedIdentitiesService.blockIdentity(contactA.identity)
         serviceManager.blockedIdentitiesService.blockIdentity(contactA.identity)
         serviceManager.blockedIdentitiesService.blockIdentity(contactB.identity)
         serviceManager.blockedIdentitiesService.blockIdentity(contactB.identity)
 
 
-        val setupTracker = GroupSetupTracker(
-            newAGroup,
+        val newGroup = TestGroup(
+            newAGroup.apiGroupId,
+            newAGroup.groupCreator,
+            newAGroup.members,
+            // Note that this will be the group name because we only test the group setup message
+            // that is not followed by a group rename
+            "Me, 12345678, ABCDEFGH",
             myContact.identity,
             myContact.identity,
-            expectCreate = false,
-            expectKick = false,
-            emptyList(),
-            emptyList(),
-        )
-        setupTracker.start()
-
-        // Create the group setup message
-        val message = createGroupSetupMessage(newAGroup)
-        // Create message box from contact A (group creator)
-        processMessage(message, newAGroup.groupCreator.identityStore)
-
-        // Assert that group conversations did not appear, disappear, or change their name
-        assertGroupConversations(scenario, initialGroups)
-
-        // Assert that a group leave message is sent to the created and all provided members
-        // including those that are blocked
-        assertEquals(2, sentMessagesInsideTask.size)
-        val first = sentMessagesInsideTask.first() as GroupLeaveMessage
-        assertEquals(myContact.identity, first.fromIdentity)
-        assertEquals(newAGroup.apiGroupId, first.apiGroupId)
-        assertEquals(newAGroup.groupCreator.identity, first.groupCreator)
-        val second = sentMessagesInsideTask.last() as GroupLeaveMessage
-        assertEquals(myContact.identity, second.fromIdentity)
-        assertEquals(newAGroup.apiGroupId, second.apiGroupId)
-        assertEquals(newAGroup.groupCreator.identity, second.groupCreator)
-        // Assert that one message is for contact A and the other for contact B
-        assertTrue(
-            (first.toIdentity == contactA.identity && second.toIdentity == contactB.identity)
-                || (first.toIdentity == contactB.identity && second.toIdentity == contactA.identity)
         )
         )
 
 
-        // Assert that no action has been triggered
-        setupTracker.assertAllNewMembersAdded()
-        setupTracker.assertAllKickedMembersRemoved()
-        setupTracker.assertCreateLeave()
-        setupTracker.stop()
+        testNewGroup(newGroup)
     }
     }
 
 
     /**
     /**
@@ -199,13 +181,13 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         val scenario = startScenario()
         val scenario = startScenario()
 
 
         // Assert initial group conversations
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "initial groups")
 
 
         // Assert that the user is a member of groupAB
         // Assert that the user is a member of groupAB
-        val beforeKicked = serviceManager.groupService.getById(groupAB.groupModel.id)
+        val beforeKicked = groupService.getById(groupAB.groupModel.id)
         assertNotNull(beforeKicked)
         assertNotNull(beforeKicked)
-        assertEquals(GroupModel.UserState.MEMBER, beforeKicked!!.userState)
-        assertTrue(serviceManager.groupService.isGroupMember(beforeKicked))
+        assertEquals(GroupModel.UserState.MEMBER, beforeKicked.userState)
+        assertTrue(groupService.isGroupMember(beforeKicked))
 
 
         val setupTracker = GroupSetupTracker(
         val setupTracker = GroupSetupTracker(
             groupAB,
             groupAB,
@@ -225,13 +207,14 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, groupAB.groupCreator.identityStore)
         processMessage(message, groupAB.groupCreator.identityStore)
 
 
         // Assert that the user state has been changed to 'kicked'
         // Assert that the user state has been changed to 'kicked'
-        val afterKicked = serviceManager.groupService.getById(groupAB.groupModel.id)
+        val afterKicked = groupModelRepository.getByGroupIdentity(
+            GroupIdentity(groupAB.groupCreator.identity, groupAB.apiGroupId.toLong()),
+        )
         assertNotNull(afterKicked)
         assertNotNull(afterKicked)
-        assertEquals(GroupModel.UserState.KICKED, afterKicked!!.userState)
-        assertFalse(serviceManager.groupService.isGroupMember(afterKicked))
+        assertEquals(GroupModel.UserState.KICKED, afterKicked.data.value?.userState)
 
 
         // Assert that group conversations did not appear, disappear, or change their name
         // Assert that group conversations did not appear, disappear, or change their name
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "no changes")
 
 
         // Assert that no message is sent
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
         assertEquals(0, sentMessagesInsideTask.size)
@@ -272,7 +255,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, groupAB.groupCreator.identityStore)
         processMessage(message, groupAB.groupCreator.identityStore)
 
 
         // Assert that group conversations did not appear, disappear, or change their name
         // Assert that group conversations did not appear, disappear, or change their name
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "no changes")
 
 
         // Assert that no message is sent
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
         assertEquals(0, sentMessagesInsideTask.size)
@@ -289,82 +272,17 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
      */
      */
     @Test
     @Test
     fun testNewGroup() = runTest {
     fun testNewGroup() = runTest {
-        val scenario = startScenario()
-
-        // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
-
         val newGroup = TestGroup(
         val newGroup = TestGroup(
-            newAGroup.apiGroupId,
-            newAGroup.groupCreator,
-            newAGroup.members,
+            newBGroup.apiGroupId,
+            newBGroup.groupCreator,
+            newBGroup.members,
             // Note that this will be the group name because we only test the group setup message
             // Note that this will be the group name because we only test the group setup message
             // that is not followed by a group rename
             // that is not followed by a group rename
-            "Me, 12345678, ABCDEFGH",
+            "Me, ABCDEFGH",
             myContact.identity,
             myContact.identity,
         )
         )
 
 
-        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)
-        processMessage(message, newGroup.groupCreator.identityStore)
-
-        // Assert that the new group appears in the list
-        assertGroupConversations(scenario, initialGroups + newGroup)
-
-        // 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 = serviceManager.groupService.getByApiGroupIdAndCreator(
-            newGroup.apiGroupId,
-            newGroup.groupCreator.identity
-        )
-        assertNotNull(group!!)
-        val expectedMemberCount = newGroup.members.size
-        // Assert that there is one more member than member models (as the user is not stored into
-        // the database).
-        assertEquals(
-            expectedMemberCount,
-            serviceManager.databaseServiceNew.groupMemberModelFactory.getByGroupId(group.id).size + 1
-        )
-        assertEquals(
-            expectedMemberCount,
-            serviceManager.databaseServiceNew.groupMemberModelFactory.countMembersWithoutUser(group.id)
-                .toInt() + 1
-        )
-
-        // Assert that the group service returns the member lists including the user
-        assertEquals(expectedMemberCount, serviceManager.groupService.getMembers(group).size)
-        assertEquals(
-            expectedMemberCount,
-            serviceManager.groupService.getGroupIdentities(group).size
-        )
-        assertEquals(
-            expectedMemberCount,
-            serviceManager.groupService.getMembersWithoutUser(group).size + 1
-        )
-        assertEquals(expectedMemberCount, serviceManager.groupService.countMembers(group))
-        assertEquals(
-            expectedMemberCount,
-            serviceManager.groupService.countMembersWithoutUser(group) + 1
-        )
+        testNewGroup(newGroup)
     }
     }
 
 
     /**
     /**
@@ -375,7 +293,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         val scenario = startScenario()
         val scenario = startScenario()
 
 
         // Assert initial group conversations
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "initial groups")
 
 
         val setupTracker = GroupSetupTracker(
         val setupTracker = GroupSetupTracker(
             groupAB,
             groupAB,
@@ -426,7 +344,8 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         val newGroup = TestGroup(
         val newGroup = TestGroup(
             newAGroup.apiGroupId,
             newAGroup.apiGroupId,
             newAGroup.groupCreator,
             newAGroup.groupCreator,
-            newAGroup.members + TestContact(invalidMemberId), // Note that this ID is not valid
+            // Note that this ID is not valid
+            newAGroup.members + TestContact(invalidMemberId),
             // Note that this will be the group name because we only test the group setup message
             // Note that this will be the group name because we only test the group setup message
             // that is not followed by a group rename
             // that is not followed by a group rename
             "Me, 12345678, ABCDEFGH",
             "Me, 12345678, ABCDEFGH",
@@ -450,7 +369,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, newGroup.groupCreator.identityStore)
         processMessage(message, newGroup.groupCreator.identityStore)
 
 
         // Assert that the new group appears in the list
         // Assert that the new group appears in the list
-        assertGroupConversations(scenario, initialGroups + newGroup)
+        assertGroupConversations(scenario, listOf(newGroup) + initialGroups)
 
 
         // Assert that no message is sent
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
         assertEquals(0, sentMessagesInsideTask.size)
@@ -462,6 +381,141 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         setupTracker.stop()
         setupTracker.stop()
     }
     }
 
 
+    @Test
+    fun testGroupContainingRevokedButKnownContact() = runTest {
+        val scenario = startScenario()
+
+        // Assert initial group conversations
+        assertGroupConversations(scenario, initialGroups)
+
+        // Add a revoked contact
+        serviceManager.modelRepositories.contacts.createFromLocal(revokedContactModelData)
+
+        val newGroup = TestGroup(
+            newAGroup.apiGroupId,
+            newAGroup.groupCreator,
+            // Note that the activity state of this contact is INVALID
+            newAGroup.members + TestContact(revokedContactModelData.identity),
+            // Note that this will be the group name because we only test the group setup message
+            // that is not followed by a group rename
+            "Me, 12345678, ABCDEFGH",
+            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)
+
+        // Check that the group setup message contains the revoked ID. Otherwise this test does not make sense.
+        assertTrue(message.members.contains(revokedContactModelData.identity))
+
+        // Create message box from contact A (group creator)
+        processMessage(message, newGroup.groupCreator.identityStore)
+
+        // Assert that the new group appears in the list
+        assertGroupConversations(scenario, listOf(newGroup) + initialGroups)
+
+        // 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)
+        val data = newGroupModel.data.value
+        assertNotNull(data)
+        assertFalse(data.otherMembers.contains(revokedContactModelData.identity))
+    }
+
+    private suspend fun testNewGroup(newGroup: TestGroup) {
+        assertNull(
+            groupModelRepository.getByCreatorIdentityAndId(
+                newGroup.groupCreator.identity,
+                newGroup.apiGroupId,
+            )?.data?.value,
+        )
+
+        val scenario = startScenario()
+
+        // Assert initial group conversations
+        assertGroupConversations(scenario, initialGroups, "initial groups")
+
+        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)
+        processMessage(message, newGroup.groupCreator.identityStore)
+
+        // Assert that the new group model exists
+        val groupModel = groupModelRepository.getByCreatorIdentityAndId(
+            creatorIdentity = newGroup.groupCreator.identity,
+            groupId = newGroup.apiGroupId,
+        )
+        assertNotNull(groupModel)
+
+        // 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,
+            newGroup.groupCreator.identity,
+        )
+        assertNotNull(group!!)
+        val expectedMemberCount = newGroup.members.size
+        // Assert that there is one more member than member models (as the user is not stored into
+        // the database).
+        assertEquals(
+            expectedMemberCount,
+            serviceManager.databaseServiceNew.groupMemberModelFactory.getByGroupId(group.id).size + 1,
+        )
+        assertEquals(
+            expectedMemberCount,
+            serviceManager.databaseServiceNew.groupMemberModelFactory.countMembersWithoutUser(group.id)
+                .toInt() + 1,
+        )
+
+        // Assert that the group service returns the member lists including the user
+        assertEquals(expectedMemberCount, groupService.getMembers(group).size)
+        assertEquals(expectedMemberCount, groupService.getGroupMemberIdentities(group).size)
+        assertEquals(expectedMemberCount, groupService.getMembersWithoutUser(group).size + 1)
+        assertEquals(expectedMemberCount, groupService.countMembers(group))
+        assertEquals(expectedMemberCount, groupService.countMembersWithoutUser(group) + 1)
+
+        // Assert that the new group appears in the list
+        assertGroupConversations(scenario, listOf(newGroup) + initialGroups)
+    }
+
     private fun createGroupSetupMessage(testGroup: TestGroup) = GroupSetupMessage()
     private fun createGroupSetupMessage(testGroup: TestGroup) = GroupSetupMessage()
         .apply {
         .apply {
             apiGroupId = testGroup.apiGroupId
             apiGroupId = testGroup.apiGroupId
@@ -488,64 +542,64 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         private var kickedMembersRemoved = mutableListOf<String>()
         private var kickedMembersRemoved = mutableListOf<String>()
 
 
         private val groupListener = object : GroupListener {
         private val groupListener = object : GroupListener {
-            override fun onCreate(newGroupModel: GroupModel?) {
+            override fun onCreate(groupIdentity: GroupIdentity) {
                 assertTrue(expectCreate)
                 assertTrue(expectCreate)
                 assertFalse(hasBeenCreated)
                 assertFalse(hasBeenCreated)
                 group?.let {
                 group?.let {
-                    Assert.assertArrayEquals(
-                        it.apiGroupId.groupId,
-                        newGroupModel?.apiGroupId?.groupId
+                    assertEquals(
+                        it.apiGroupId.toLong(),
+                        groupIdentity.groupId,
                     )
                     )
-                    TestCase.assertEquals(it.groupCreator.identity, newGroupModel?.creatorIdentity)
+                    TestCase.assertEquals(it.groupCreator.identity, groupIdentity.creatorIdentity)
                 }
                 }
                 hasBeenCreated = true
                 hasBeenCreated = true
             }
             }
 
 
-            override fun onRename(groupModel: GroupModel?) = fail()
+            override fun onRename(groupIdentity: GroupIdentity) = fail()
 
 
-            override fun onUpdatePhoto(groupModel: GroupModel?) = fail()
+            override fun onUpdatePhoto(groupIdentity: GroupIdentity) = fail()
 
 
-            override fun onRemove(groupModel: GroupModel?) = fail()
+            override fun onRemove(groupDbId: Long) = fail()
 
 
             override fun onNewMember(
             override fun onNewMember(
-                group: GroupModel?,
-                newIdentity: String?,
+                groupIdentity: GroupIdentity,
+                identityNew: String?,
             ) {
             ) {
-                assertTrue("Did not expect member $newIdentity", newMembers.contains(newIdentity))
-                newMembersAdded.add(newIdentity!!)
+                assertTrue("Did not expect member $identityNew", newMembers.contains(identityNew))
+                newMembersAdded.add(identityNew!!)
             }
             }
 
 
             override fun onMemberLeave(
             override fun onMemberLeave(
-                group: GroupModel?,
-                identity: String?,
+                groupIdentity: GroupIdentity,
+                identityLeft: String,
             ) = fail()
             ) = fail()
 
 
             override fun onMemberKicked(
             override fun onMemberKicked(
-                group: GroupModel?,
-                identity: String?,
+                groupIdentity: GroupIdentity,
+                identityKicked: String,
             ) {
             ) {
-                assertTrue(kickedMembers.contains(identity))
-                kickedMembersRemoved.add(identity!!)
+                assertTrue(kickedMembers.contains(identityKicked))
+                kickedMembersRemoved.add(identityKicked)
 
 
-                if (identity == myIdentity) {
+                if (identityKicked == myIdentity) {
                     assertTrue(expectKick)
                     assertTrue(expectKick)
                     assertFalse(hasBeenKicked)
                     assertFalse(hasBeenKicked)
                     hasBeenKicked = true
                     hasBeenKicked = true
                 }
                 }
             }
             }
 
 
-            override fun onUpdate(groupModel: GroupModel?) {
+            override fun onUpdate(groupIdentity: GroupIdentity) {
                 // This should only be called if the receiver has been changed (a member has been
                 // This should only be called if the receiver has been changed (a member has been
                 // added or kicked)
                 // added or kicked)
                 assertTrue(newMembers.isNotEmpty() || kickedMembers.isNotEmpty())
                 assertTrue(newMembers.isNotEmpty() || kickedMembers.isNotEmpty())
             }
             }
 
 
-            override fun onLeave(groupModel: GroupModel?) = fail()
+            override fun onLeave(groupIdentity: GroupIdentity) = fail()
 
 
             override fun onGroupStateChanged(
             override fun onGroupStateChanged(
-                groupModel: GroupModel?,
+                groupIdentity: GroupIdentity,
                 oldState: Int,
                 oldState: Int,
-                newState: Int
+                newState: Int,
             ) {
             ) {
             }
             }
         }
         }
@@ -590,27 +644,53 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         GroupSetupTracker.stopAllListeners()
         GroupSetupTracker.stopAllListeners()
     }
     }
 
 
-    override fun testCommonGroupReceiveStep2_1() {
+    private val revokedContactModelData = ContactModelData(
+        identity = "01238765",
+        publicKey = ByteArray(NaCl.PUBLICKEYBYTES),
+        createdAt = Date(),
+        firstName = "1234",
+        lastName = "8765",
+        nickname = null,
+        verificationLevel = VerificationLevel.FULLY_VERIFIED,
+        workVerificationLevel = WorkVerificationLevel.NONE,
+        identityType = IdentityType.NORMAL,
+        acquaintanceLevel = AcquaintanceLevel.DIRECT,
+        activityState = IdentityState.INVALID,
+        syncState = ContactSyncState.INITIAL,
+        featureMask = 0u,
+        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
+        androidContactLookupKey = null,
+        localAvatarExpires = null,
+        isRestored = false,
+        profilePictureBlobId = null,
+        jobTitle = null,
+        department = null,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    override fun testCommonGroupReceiveStepUnknownGroupUserCreator() {
         // The common group receive steps are not executed for group setup messages
         // The common group receive steps are not executed for group setup messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep2_2() {
+    override fun testCommonGroupReceiveStepUnknownGroupUserNotCreator() {
         // The common group receive steps are not executed for group setup messages
         // The common group receive steps are not executed for group setup messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep3_1() {
+    override fun testCommonGroupReceiveStepLeftGroupUserCreator() {
         // The common group receive steps are not executed for group setup messages
         // The common group receive steps are not executed for group setup messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep3_2() {
+    override fun testCommonGroupReceiveStepLeftGroupUserNotCreator() {
         // The common group receive steps are not executed for group setup messages
         // The common group receive steps are not executed for group setup messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep4_1() {
+    override fun testCommonGroupReceiveStepSenderNotMemberUserCreator() {
         // The common group receive steps are not executed for group setup messages
         // The common group receive steps are not executed for group setup messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep4_2() {
+    override fun testCommonGroupReceiveStepSenderNotMemberUserNotCreator() {
         // The common group receive steps are not executed for group setup messages
         // The common group receive steps are not executed for group setup messages
     }
     }
 }
 }

+ 18 - 17
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSyncRequestTest.kt

@@ -28,27 +28,25 @@ import ch.threema.app.DangerousTest
 import ch.threema.app.activities.HomeActivity
 import ch.threema.app.activities.HomeActivity
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.app.testutils.TestHelpers.TestGroup
-import ch.threema.domain.protocol.csp.messages.GroupSetupMessage
 import ch.threema.domain.protocol.csp.messages.GroupDeleteProfilePictureMessage
 import ch.threema.domain.protocol.csp.messages.GroupDeleteProfilePictureMessage
 import ch.threema.domain.protocol.csp.messages.GroupNameMessage
 import ch.threema.domain.protocol.csp.messages.GroupNameMessage
+import ch.threema.domain.protocol.csp.messages.GroupSetupMessage
 import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
 import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
-import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runner.RunWith
 
 
 /**
 /**
  * Tests that incoming group sync request messages are handled correctly.
  * Tests that incoming group sync request messages are handled correctly.
  */
  */
-@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @LargeTest
 @DangerousTest
 @DangerousTest
 class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>() {
 class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>() {
-
     override fun createMessageForGroup() = GroupSyncRequestMessage()
     override fun createMessageForGroup() = GroupSyncRequestMessage()
 
 
     @Test
     @Test
@@ -79,27 +77,27 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
         assertLeftGroupSyncRequest(myLeftGroup, contactA)
         assertLeftGroupSyncRequest(myLeftGroup, contactA)
     }
     }
 
 
-    override fun testCommonGroupReceiveStep2_1() {
+    override fun testCommonGroupReceiveStepUnknownGroupUserCreator() {
         // Common group receive steps are not executed for group sync request messages
         // Common group receive steps are not executed for group sync request messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep2_2() {
+    override fun testCommonGroupReceiveStepUnknownGroupUserNotCreator() {
         // Common group receive steps are not executed for group sync request messages
         // Common group receive steps are not executed for group sync request messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep3_1() {
+    override fun testCommonGroupReceiveStepLeftGroupUserCreator() {
         // Common group receive steps are not executed for group sync request messages
         // Common group receive steps are not executed for group sync request messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep3_2() {
+    override fun testCommonGroupReceiveStepLeftGroupUserNotCreator() {
         // Common group receive steps are not executed for group sync request messages
         // Common group receive steps are not executed for group sync request messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep4_1() {
+    override fun testCommonGroupReceiveStepSenderNotMemberUserCreator() {
         // Common group receive steps are not executed for group sync request messages
         // Common group receive steps are not executed for group sync request messages
     }
     }
 
 
-    override fun testCommonGroupReceiveStep4_2() {
+    override fun testCommonGroupReceiveStepSenderNotMemberUserNotCreator() {
         // Common group receive steps are not executed for group sync request messages
         // Common group receive steps are not executed for group sync request messages
     }
     }
 
 
@@ -120,7 +118,10 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
 
 
         // Check that the first sent message (setup) is correct
         // Check that the first sent message (setup) is correct
         val setupMessage = sentMessagesInsideTask.poll() as GroupSetupMessage
         val setupMessage = sentMessagesInsideTask.poll() as GroupSetupMessage
-        assertArrayEquals(group.members.map { it.identity }.toTypedArray(), setupMessage.members)
+        assertContentEquals(
+            group.membersWithoutCreator.map { it.identity }.toTypedArray(),
+            setupMessage.members,
+        )
         assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
         assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
         assertEquals(contact.identity, setupMessage.toIdentity)
         assertEquals(contact.identity, setupMessage.toIdentity)
         assertEquals(group.groupCreator.identity, setupMessage.groupCreator)
         assertEquals(group.groupCreator.identity, setupMessage.groupCreator)
@@ -134,7 +135,7 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
         assertEquals(group.groupCreator.identity, renameMessage.groupCreator)
         assertEquals(group.groupCreator.identity, renameMessage.groupCreator)
         assertEquals(group.apiGroupId, renameMessage.apiGroupId)
         assertEquals(group.apiGroupId, renameMessage.apiGroupId)
 
 
-        assertTrue("Groups with photo are not supported for testing", group.profilePicture == null)
+        assertNull(group.profilePicture, "Groups with photo are not supported for testing")
 
 
         // Check that the third sent message (set/delete photo) is correct
         // Check that the third sent message (set/delete photo) is correct
         val deletePhotoMessage = sentMessagesInsideTask.poll() as GroupDeleteProfilePictureMessage
         val deletePhotoMessage = sentMessagesInsideTask.poll() as GroupDeleteProfilePictureMessage
@@ -180,7 +181,7 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
         // Check that a setup message has been sent with empty members list
         // Check that a setup message has been sent with empty members list
         assertEquals(1, sentMessagesInsideTask.size)
         assertEquals(1, sentMessagesInsideTask.size)
         val setupMessage = sentMessagesInsideTask.first() as GroupSetupMessage
         val setupMessage = sentMessagesInsideTask.first() as GroupSetupMessage
-        assertArrayEquals(emptyArray(), setupMessage.members)
+        assertContentEquals(emptyArray(), setupMessage.members)
         assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
         assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
         assertEquals(contact.identity, setupMessage.toIdentity)
         assertEquals(contact.identity, setupMessage.toIdentity)
         assertEquals(group.groupCreator.identity, setupMessage.groupCreator)
         assertEquals(group.groupCreator.identity, setupMessage.groupCreator)

+ 0 - 3
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupTextTest.kt

@@ -25,7 +25,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.filters.LargeTest
 import ch.threema.app.DangerousTest
 import ch.threema.app.DangerousTest
 import ch.threema.domain.protocol.csp.messages.GroupTextMessage
 import ch.threema.domain.protocol.csp.messages.GroupTextMessage
-import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.runBlocking
 import org.junit.Assert
 import org.junit.Assert
 import org.junit.Test
 import org.junit.Test
@@ -34,12 +33,10 @@ import org.junit.runner.RunWith
 /**
 /**
  * Tests that the common group receive steps are executed for a group text message.
  * Tests that the common group receive steps are executed for a group text message.
  */
  */
-@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @LargeTest
 @DangerousTest
 @DangerousTest
 class IncomingGroupTextTest : GroupControlTest<GroupTextMessage>() {
 class IncomingGroupTextTest : GroupControlTest<GroupTextMessage>() {
-
     @Test
     @Test
     fun testForwardSecureTextMessages() = runBlocking {
     fun testForwardSecureTextMessages() = runBlocking {
         val firstMessage = GroupTextMessage()
         val firstMessage = GroupTextMessage()

+ 485 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/LeaveGroupFlowTest.kt

@@ -0,0 +1,485 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024-2025 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.groupmanagement
+
+import ch.threema.app.DangerousTest
+import ch.threema.app.groupflows.GroupLeaveIntent
+import ch.threema.app.tasks.OutgoingGroupLeaveTask
+import ch.threema.app.tasks.ReflectGroupSyncDeleteTask
+import ch.threema.app.tasks.ReflectLocalGroupLeaveOrDisband
+import ch.threema.app.testutils.TestHelpers
+import ch.threema.app.testutils.clearDatabaseAndCaches
+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.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.taskmanager.Task
+import ch.threema.domain.taskmanager.TaskCodec
+import ch.threema.storage.models.ContactModel
+import ch.threema.storage.models.GroupModel.UserState
+import java.util.Date
+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.test.fail
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+
+@DangerousTest
+class LeaveGroupFlowTest : GroupFlowTest() {
+    private val myContact = TestHelpers.TEST_CONTACT
+
+    private val initialContactData = ContactModelData(
+        identity = "12345678",
+        publicKey = ByteArray(32),
+        createdAt = Date(),
+        firstName = "",
+        lastName = "",
+        verificationLevel = VerificationLevel.SERVER_VERIFIED,
+        workVerificationLevel = WorkVerificationLevel.NONE,
+        nickname = null,
+        identityType = IdentityType.NORMAL,
+        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        activityState = IdentityState.ACTIVE,
+        syncState = ContactSyncState.INITIAL,
+        featureMask = 255u,
+        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
+        profilePictureBlobId = null,
+        androidContactLookupKey = null,
+        localAvatarExpires = null,
+        isRestored = false,
+        jobTitle = null,
+        department = null,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    private val myInitialGroupModelData = GroupModelData(
+        groupIdentity = GroupIdentity(myContact.identity, 42),
+        name = "MyExistingGroup",
+        createdAt = Date(),
+        synchronizedAt = null,
+        lastUpdate = Date(),
+        isArchived = false,
+        groupDescription = null,
+        groupDescriptionChangedAt = null,
+        otherMembers = emptySet(),
+        userState = UserState.MEMBER,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    private val initialGroupModelData = myInitialGroupModelData.copy(
+        groupIdentity = GroupIdentity(initialContactData.identity, 43),
+        name = "ExistingGroup",
+    )
+
+    private val initialLeftGroupModelData = myInitialGroupModelData.copy(
+        groupIdentity = GroupIdentity(initialContactData.identity, 44),
+        name = "LeftGroup",
+        userState = UserState.LEFT,
+    )
+
+    private val initialKickedGroupModelData = myInitialGroupModelData.copy(
+        groupIdentity = GroupIdentity(initialContactData.identity, 45),
+        name = "KickedGroup",
+        userState = UserState.KICKED,
+    )
+
+    @Before
+    fun setup() {
+        clearDatabaseAndCaches(serviceManager)
+
+        assert(myContact.identity == TestHelpers.ensureIdentity(serviceManager))
+
+        // Note that we use from sync to prevent any reflection. This is only acceptable in tests.
+        contactModelRepository.createFromSync(initialContactData)
+        groupModelRepository.apply {
+            createFromSync(myInitialGroupModelData)
+            createFromSync(initialGroupModelData)
+            createFromSync(initialLeftGroupModelData)
+            createFromSync(initialKickedGroupModelData)
+        }
+    }
+
+    @Test
+    fun testGroupLeaveMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertSuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testGroupLeaveNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertSuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testGroupLeaveAndRemoveMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertSuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testGroupLeaveAndRemoveNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertSuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testGroupLeaveMyGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testLeaveMyGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testGroupLeaveAndRemoveMyGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testLeaveAndRemoveMyGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testLeaveLeftGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialLeftGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testLeaveLeftGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialLeftGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testLeaveAndRemoveLeftGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialLeftGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testLeaveAndRemoveLeftGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialLeftGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testLeaveKickedGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialKickedGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testLeaveKickedGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialKickedGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testLeaveAndRemoveKickedGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialKickedGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testLeaveAndRemoveKickedGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialKickedGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testLeaveRemovedGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialKickedGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+
+        groupModelRepository.persistRemovedGroup(groupModel.groupIdentity)
+        assertNull(groupModel.data.value)
+
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testLeaveRemovedGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialKickedGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+
+        groupModelRepository.persistRemovedGroup(groupModel.groupIdentity)
+        assertNull(groupModel.data.value)
+
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testLeaveAndRemoveRemovedGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialKickedGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+
+        groupModelRepository.persistRemovedGroup(groupModel.groupIdentity)
+        assertNull(groupModel.data.value)
+
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testLeaveAndRemoveRemovedGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialKickedGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+
+        groupModelRepository.persistRemovedGroup(groupModel.groupIdentity)
+        assertNull(groupModel.data.value)
+
+        assertUnsuccessfulLeave(
+            groupModel,
+            GroupLeaveIntent.LEAVE_AND_REMOVE,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    private suspend fun assertSuccessfulLeave(
+        groupModel: GroupModel,
+        intent: GroupLeaveIntent,
+        reflectionExpectation: ReflectionExpectation,
+    ) {
+        assertTrue {
+            runGroupLeave(groupModel, intent, reflectionExpectation)
+        }
+
+        when (intent) {
+            GroupLeaveIntent.LEAVE -> assertEquals(UserState.LEFT, groupModel.data.value?.userState)
+            GroupLeaveIntent.LEAVE_AND_REMOVE -> assertNull(groupModel.data.value)
+        }
+    }
+
+    private suspend fun assertUnsuccessfulLeave(
+        groupModel: GroupModel,
+        intent: GroupLeaveIntent,
+        reflectionExpectation: ReflectionExpectation,
+    ) {
+        val groupModelDataBefore = groupModel.data.value
+        assertFalse {
+            runGroupLeave(groupModel, intent, reflectionExpectation)
+        }
+        val groupModelDataAfter = groupModel.data.value
+        // Assert that the group model has not changed
+        assertEquals(groupModelDataBefore, groupModelDataAfter)
+    }
+
+    private suspend fun runGroupLeave(
+        groupModel: GroupModel,
+        intent: GroupLeaveIntent,
+        reflectionExpectation: ReflectionExpectation,
+    ): Boolean {
+        val groupModelData = groupModel.data.value
+
+        // Prepare task manager and group flow dispatcher
+        val taskManager = ControlledTaskManager(
+            getExpectedTaskAssertions(groupModelData, intent, reflectionExpectation),
+        )
+        val groupFlowDispatcher = getGroupFlowDispatcher(
+            reflectionExpectation.setupConfig,
+            taskManager,
+        )
+
+        // Run leave group flow
+        val result = groupFlowDispatcher.runLeaveGroupFlow(
+            null,
+            intent,
+            groupModel,
+        ).await()
+
+        taskManager.pendingTaskAssertions.size.let { size ->
+            if (size > 0) {
+                fail("There are $size pending task assertions left")
+            }
+        }
+
+        return result
+    }
+
+    private fun getExpectedTaskAssertions(
+        groupModelData: GroupModelData?,
+        intent: GroupLeaveIntent,
+        reflectionExpectation: ReflectionExpectation,
+    ): MutableList<(Task<*, TaskCodec>) -> Unit> {
+        if (groupModelData == null ||
+            groupModelData.groupIdentity.creatorIdentity == myContact.identity ||
+            groupModelData.userState != UserState.MEMBER
+        ) {
+            return mutableListOf()
+        }
+
+        val scheduledTaskAssertions: MutableList<(Task<*, TaskCodec>) -> Unit> = mutableListOf()
+        // If multi device is enabled, then we expect a reflection
+        if (reflectionExpectation.setupConfig == SetupConfig.MULTI_DEVICE_ENABLED) {
+            scheduledTaskAssertions.add { task ->
+                when (intent) {
+                    GroupLeaveIntent.LEAVE -> assertIs<ReflectLocalGroupLeaveOrDisband>(task)
+                    GroupLeaveIntent.LEAVE_AND_REMOVE -> assertIs<ReflectGroupSyncDeleteTask>(task)
+                }
+            }
+        }
+
+        // If the reflection fails, we do not expect a task that sends out csp messages
+        if (reflectionExpectation != ReflectionExpectation.REFLECTION_FAIL) {
+            scheduledTaskAssertions.add { task ->
+                assertIs<OutgoingGroupLeaveTask>(task)
+            }
+        }
+
+        return scheduledTaskAssertions
+    }
+}

+ 319 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/RemoveGroupFlowTest.kt

@@ -0,0 +1,319 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024-2025 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.groupmanagement
+
+import ch.threema.app.DangerousTest
+import ch.threema.app.tasks.ReflectGroupSyncDeleteTask
+import ch.threema.app.testutils.TestHelpers
+import ch.threema.app.testutils.clearDatabaseAndCaches
+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.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.taskmanager.Task
+import ch.threema.domain.taskmanager.TaskCodec
+import ch.threema.storage.models.ContactModel
+import ch.threema.storage.models.GroupModel.UserState
+import java.util.Date
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+
+@DangerousTest
+class RemoveGroupFlowTest : GroupFlowTest() {
+    private val myContact = TestHelpers.TEST_CONTACT
+
+    private val initialContactData = ContactModelData(
+        identity = "12345678",
+        publicKey = ByteArray(32),
+        createdAt = Date(),
+        firstName = "",
+        lastName = "",
+        verificationLevel = VerificationLevel.SERVER_VERIFIED,
+        workVerificationLevel = WorkVerificationLevel.NONE,
+        nickname = null,
+        identityType = IdentityType.NORMAL,
+        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        activityState = IdentityState.ACTIVE,
+        syncState = ContactSyncState.INITIAL,
+        featureMask = 255u,
+        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
+        profilePictureBlobId = null,
+        androidContactLookupKey = null,
+        localAvatarExpires = null,
+        isRestored = false,
+        jobTitle = null,
+        department = null,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    private val myInitialGroupModelData = GroupModelData(
+        groupIdentity = GroupIdentity(myContact.identity, 42),
+        name = "MyExistingGroup",
+        createdAt = Date(),
+        synchronizedAt = null,
+        lastUpdate = Date(),
+        isArchived = false,
+        groupDescription = null,
+        groupDescriptionChangedAt = null,
+        otherMembers = emptySet(),
+        userState = UserState.MEMBER,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    private val myInitialLeftGroupModelData = myInitialGroupModelData.copy(
+        groupIdentity = GroupIdentity(myContact.identity, 43),
+        name = "MyLeftGroup",
+        userState = UserState.LEFT,
+    )
+
+    private val initialGroupModelData = myInitialGroupModelData.copy(
+        groupIdentity = GroupIdentity(initialContactData.identity, 44),
+        name = "MemberGroup",
+        userState = UserState.MEMBER,
+    )
+
+    private val initialLeftGroupModelData = myInitialGroupModelData.copy(
+        groupIdentity = GroupIdentity(initialContactData.identity, 45),
+        name = "LeftGroup",
+        userState = UserState.LEFT,
+    )
+
+    @Before
+    fun setup() {
+        clearDatabaseAndCaches(serviceManager)
+
+        assert(myContact.identity == TestHelpers.ensureIdentity(serviceManager))
+
+        // Note that we use from sync to prevent any reflection. This is only acceptable in tests.
+        contactModelRepository.createFromSync(initialContactData)
+        groupModelRepository.apply {
+            createFromSync(myInitialGroupModelData)
+            createFromSync(myInitialLeftGroupModelData)
+            createFromSync(initialGroupModelData)
+            createFromSync(initialLeftGroupModelData)
+        }
+    }
+
+    /* Tests where the group can be removed. */
+
+    /**
+     * Test that a left group where the user is the creator (a disbanded group) can be removed.
+     */
+    @Test
+    fun testRemoveMyLeftGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialLeftGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertSuccessfulRemove(
+            groupModel,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    /**
+     * Test that a left group where the user is the creator (a disbanded group) can be removed.
+     */
+    @Test
+    fun testRemoveMyLeftGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialLeftGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertSuccessfulRemove(
+            groupModel,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    /**
+     * Test that a left group where the user is not the creator can be removed.
+     */
+    @Test
+    fun testRemoveLeftGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialLeftGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertSuccessfulRemove(
+            groupModel,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    /**
+     * Test that a left group where the user is not the creator can be removed.
+     */
+    @Test
+    fun testRemoveLeftGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialLeftGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertSuccessfulRemove(
+            groupModel,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    /* Tests where the group cannot be removed because the user is still a member */
+
+    /**
+     * Test that a group where the user is member (and creator) cannot be removed.
+     */
+    @Test
+    fun testRemoveMyActiveGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulRemove(
+            groupModel,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    /**
+     * Test that a group where the user is member (and creator) cannot be removed.
+     */
+    @Test
+    fun testRemoveMyActiveGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulRemove(
+            groupModel,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    /**
+     * Test that a group where the user is member (and not the creator) cannot be removed.
+     */
+    @Test
+    fun testRemoveActiveGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulRemove(
+            groupModel,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    /**
+     * Test that a group where the user is member (and not the creator) cannot be removed.
+     */
+    @Test
+    fun testRemoveActiveGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        assertUnsuccessfulRemove(
+            groupModel,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    private suspend fun assertSuccessfulRemove(
+        groupModel: GroupModel,
+        reflectionExpectation: ReflectionExpectation,
+    ) {
+        assertTrue {
+            runGroupRemove(groupModel, reflectionExpectation)
+        }
+
+        assertNull(groupModel.data.value)
+    }
+
+    private suspend fun assertUnsuccessfulRemove(
+        groupModel: GroupModel,
+        reflectionExpectation: ReflectionExpectation,
+    ) {
+        assertFalse {
+            runGroupRemove(groupModel, reflectionExpectation)
+        }
+
+        assertNotNull(groupModel.data.value)
+    }
+
+    private suspend fun runGroupRemove(
+        groupModel: GroupModel,
+        reflectionExpectation: ReflectionExpectation,
+    ): Boolean {
+        val groupModelData = groupModel.data.value
+
+        // Prepare task manager and group flow dispatcher
+        val taskManager = ControlledTaskManager(
+            getExpectedTaskAssertions(groupModelData, reflectionExpectation),
+        )
+        val groupFlowDispatcher = getGroupFlowDispatcher(
+            reflectionExpectation.setupConfig,
+            taskManager,
+        )
+
+        // Run remove group flow
+        val result = groupFlowDispatcher.runRemoveGroupFlow(
+            null,
+            groupModel,
+        ).await()
+
+        taskManager.pendingTaskAssertions.size.let { size ->
+            if (size > 0) {
+                fail("There are $size pending task assertions left")
+            }
+        }
+
+        return result
+    }
+
+    private fun getExpectedTaskAssertions(
+        groupModelData: GroupModelData?,
+        reflectionExpectation: ReflectionExpectation,
+    ): MutableList<(Task<*, TaskCodec>) -> Unit> {
+        if (groupModelData == null || groupModelData.userState == UserState.MEMBER) {
+            return mutableListOf()
+        }
+
+        val scheduledTaskAssertions: MutableList<(Task<*, TaskCodec>) -> Unit> = mutableListOf()
+        // If multi device is enabled, then we expect a reflection task
+        if (reflectionExpectation.setupConfig == SetupConfig.MULTI_DEVICE_ENABLED) {
+            scheduledTaskAssertions.add { task ->
+                assertIs<ReflectGroupSyncDeleteTask>(task)
+            }
+        }
+
+        return scheduledTaskAssertions
+    }
+}

+ 517 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/UpdateGroupFlowTest.kt

@@ -0,0 +1,517 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024-2025 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.groupmanagement
+
+import ch.threema.app.DangerousTest
+import ch.threema.app.groupflows.GroupChanges
+import ch.threema.app.tasks.GroupUpdateTask
+import ch.threema.app.tasks.ReflectLocalGroupUpdate
+import ch.threema.app.testutils.TestHelpers
+import ch.threema.app.testutils.clearDatabaseAndCaches
+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.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.taskmanager.Task
+import ch.threema.domain.taskmanager.TaskCodec
+import ch.threema.storage.models.ContactModel
+import ch.threema.storage.models.GroupModel.UserState
+import java.util.Date
+import kotlin.test.Test
+import kotlin.test.assertContains
+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.test.fail
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+
+@DangerousTest
+class UpdateGroupFlowTest : GroupFlowTest() {
+    private val myContact: TestHelpers.TestContact = TestHelpers.TEST_CONTACT
+
+    /**
+     * A contact that is stored in the database. It is the creator of [initialGroupModelData].
+     */
+    private val initialContactData = ContactModelData(
+        identity = "12345678",
+        publicKey = ByteArray(32),
+        createdAt = Date(),
+        firstName = "",
+        lastName = "",
+        verificationLevel = VerificationLevel.SERVER_VERIFIED,
+        workVerificationLevel = WorkVerificationLevel.NONE,
+        nickname = null,
+        identityType = IdentityType.NORMAL,
+        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        activityState = IdentityState.ACTIVE,
+        syncState = ContactSyncState.INITIAL,
+        featureMask = 255u,
+        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
+        profilePictureBlobId = null,
+        androidContactLookupKey = null,
+        localAvatarExpires = null,
+        isRestored = false,
+        jobTitle = null,
+        department = null,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    /**
+     * A contact that is stored in the database. An initial group member of
+     * [myInitialGroupModelData].
+     */
+    private val initialGroupMemberData = ContactModelData(
+        identity = "TESTTEST",
+        publicKey = ByteArray(32),
+        createdAt = Date(),
+        firstName = "",
+        lastName = "",
+        verificationLevel = VerificationLevel.SERVER_VERIFIED,
+        workVerificationLevel = WorkVerificationLevel.NONE,
+        nickname = null,
+        identityType = IdentityType.NORMAL,
+        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        activityState = IdentityState.ACTIVE,
+        syncState = ContactSyncState.INITIAL,
+        featureMask = 255u,
+        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
+        profilePictureBlobId = null,
+        androidContactLookupKey = null,
+        localAvatarExpires = null,
+        isRestored = false,
+        jobTitle = null,
+        department = null,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    private val myInitialGroupModelData = GroupModelData(
+        groupIdentity = GroupIdentity(myContact.identity, 42),
+        name = "MyExistingGroup",
+        createdAt = Date(),
+        synchronizedAt = null,
+        lastUpdate = Date(),
+        isArchived = false,
+        groupDescription = null,
+        groupDescriptionChangedAt = null,
+        otherMembers = setOf(initialGroupMemberData.identity),
+        userState = UserState.MEMBER,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    private val initialGroupModelData = GroupModelData(
+        groupIdentity = GroupIdentity(initialContactData.identity, 42),
+        name = "ExistingGroup",
+        createdAt = Date(),
+        synchronizedAt = null,
+        lastUpdate = Date(),
+        isArchived = false,
+        groupDescription = null,
+        groupDescriptionChangedAt = null,
+        // User is the only member besides from the creator
+        otherMembers = emptySet(),
+        userState = UserState.MEMBER,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    @Before
+    fun setup() {
+        clearDatabaseAndCaches(serviceManager)
+
+        assert(myContact.identity == TestHelpers.ensureIdentity(serviceManager))
+
+        // Note that we use from sync to prevent any reflection. This is only acceptable in tests.
+        contactModelRepository.apply {
+            createFromSync(initialContactData)
+            createFromSync(initialGroupMemberData)
+        }
+        groupModelRepository.apply {
+            createFromSync(myInitialGroupModelData)
+            createFromSync(initialGroupModelData)
+        }
+    }
+
+    @Test
+    fun testGroupNameModificationMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        val groupChanges = GroupChanges(
+            name = "NewGroupName",
+            profilePictureChange = null,
+            updatedMembers = myInitialGroupModelData.otherMembers,
+            groupModelData = myInitialGroupModelData,
+        )
+
+        assertSuccessfulGroupUpdate(
+            groupModel,
+            groupChanges,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+    }
+
+    @Test
+    fun testGroupNameModificationNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        val groupChanges = GroupChanges(
+            name = "NewGroupName",
+            profilePictureChange = null,
+            updatedMembers = myInitialGroupModelData.otherMembers,
+            groupModelData = myInitialGroupModelData,
+        )
+
+        assertSuccessfulGroupUpdate(
+            groupModel,
+            groupChanges,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+    }
+
+    @Test
+    fun testGroupNameAndAddedMembersModificationMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+
+        // Assert that the new member is not yet a member of the group
+        assertTrue {
+            groupModel.data.value?.otherMembers?.contains(initialContactData.identity) == false
+        }
+
+        val groupChanges = GroupChanges(
+            name = "NewGroupName",
+            profilePictureChange = null,
+            updatedMembers = myInitialGroupModelData.otherMembers + initialContactData.identity,
+            groupModelData = myInitialGroupModelData,
+        )
+
+        assertSuccessfulGroupUpdate(
+            groupModel,
+            groupChanges,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+
+        assertTrue {
+            groupModel.data.value?.otherMembers?.contains(initialContactData.identity) == true
+        }
+    }
+
+    @Test
+    fun testGroupNameAndAddedMembersModificationNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        val groupChanges = GroupChanges(
+            name = "NewGroupName",
+            profilePictureChange = null,
+            updatedMembers = myInitialGroupModelData.otherMembers + initialContactData.identity,
+            groupModelData = myInitialGroupModelData,
+        )
+
+        assertSuccessfulGroupUpdate(
+            groupModel,
+            groupChanges,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+
+        assertTrue {
+            groupModel.data.value?.otherMembers?.contains(initialContactData.identity) == true
+        }
+    }
+
+    @Test
+    fun testGroupNameAndRemovedMembersModificationMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        val groupChanges = GroupChanges(
+            name = "NewGroupName",
+            profilePictureChange = null,
+            updatedMembers = emptySet(),
+            groupModelData = myInitialGroupModelData,
+        )
+
+        assertSuccessfulGroupUpdate(
+            groupModel,
+            groupChanges,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+
+        assertTrue { groupModel.data.value?.otherMembers?.isEmpty() == true }
+    }
+
+    @Test
+    fun testGroupNameAndRemovedMembersModificationNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        val groupChanges = GroupChanges(
+            name = "NewGroupName",
+            profilePictureChange = null,
+            updatedMembers = emptySet(),
+            groupModelData = myInitialGroupModelData,
+        )
+
+        assertSuccessfulGroupUpdate(
+            groupModel,
+            groupChanges,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+
+        assertTrue { groupModel.data.value?.otherMembers?.isEmpty() == true }
+    }
+
+    @Test
+    fun testModificationOfDeletedGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        val groupChanges = GroupChanges(
+            name = "NewGroupName",
+            profilePictureChange = null,
+            updatedMembers = emptySet(),
+            groupModelData = myInitialGroupModelData,
+        )
+
+        // Delete the group before updating it
+        groupModelRepository.persistRemovedGroup(groupModel.groupIdentity)
+        assertNull(groupModel.data.value)
+
+        assertUnsuccessfulGroupUpdate(
+            groupModel,
+            groupChanges,
+            ReflectionExpectation.REFLECTION_SUCCESS,
+        )
+
+        assertNull(groupModel.data.value)
+    }
+
+    @Test
+    fun testModificationOfDeletedGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        val groupChanges = GroupChanges(
+            name = "NewGroupName",
+            profilePictureChange = null,
+            updatedMembers = emptySet(),
+            groupModelData = myInitialGroupModelData,
+        )
+
+        // Delete the group before updating it
+        groupModelRepository.persistRemovedGroup(groupModel.groupIdentity)
+        assertNull(groupModel.data.value)
+
+        assertUnsuccessfulGroupUpdate(
+            groupModel,
+            groupChanges,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+
+        assertNull(groupModel.data.value)
+    }
+
+    @Test
+    fun testGroupNameAndAddedMembersModificationOfForeignGroupMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        val groupChanges = GroupChanges(
+            name = "NewGroupName",
+            profilePictureChange = null,
+            updatedMembers = setOf(initialGroupMemberData.identity),
+            groupModelData = initialGroupModelData,
+        )
+
+        assertUnsuccessfulGroupUpdate(
+            groupModel,
+            groupChanges,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+
+        assertTrue {
+            groupModel.data.value?.otherMembers?.contains(initialGroupMemberData.identity) == false
+        }
+    }
+
+    @Test
+    fun testGroupNameAndAddedMembersModificationOfForeignGroupNonMd() = runTest {
+        val groupModel =
+            groupModelRepository.getByGroupIdentity(initialGroupModelData.groupIdentity)
+        assertNotNull(groupModel)
+        val groupChanges = GroupChanges(
+            name = "NewGroupName",
+            profilePictureChange = null,
+            updatedMembers = setOf(initialGroupMemberData.identity),
+            groupModelData = initialGroupModelData,
+        )
+
+        assertUnsuccessfulGroupUpdate(
+            groupModel,
+            groupChanges,
+            ReflectionExpectation.REFLECTION_SKIPPED,
+        )
+
+        assertTrue {
+            groupModel.data.value?.otherMembers?.contains(initialGroupMemberData.identity) == false
+        }
+    }
+
+    private suspend fun assertSuccessfulGroupUpdate(
+        groupModel: GroupModel,
+        groupChanges: GroupChanges,
+        reflectionExpectation: ReflectionExpectation,
+    ) {
+        assertTrue {
+            runGroupUpdate(
+                groupModel = groupModel,
+                groupChanges = groupChanges,
+                reflectionExpectation = reflectionExpectation,
+                successExpected = true,
+            )
+        }
+        val data = groupModel.data.value
+        assertNotNull(data)
+        data.assertChanges(groupChanges)
+    }
+
+    private suspend fun assertUnsuccessfulGroupUpdate(
+        groupModel: GroupModel,
+        groupChanges: GroupChanges,
+        reflectionExpectation: ReflectionExpectation,
+    ) {
+        assertFalse {
+            runGroupUpdate(
+                groupModel = groupModel,
+                groupChanges = groupChanges,
+                reflectionExpectation = reflectionExpectation,
+                successExpected = false,
+            )
+        }
+    }
+
+    private suspend fun runGroupUpdate(
+        groupModel: GroupModel,
+        groupChanges: GroupChanges,
+        reflectionExpectation: ReflectionExpectation,
+        successExpected: Boolean,
+    ): Boolean {
+        val groupModelData = groupModel.data.value
+
+        // Prepare task manager and group flow dispatcher
+        val taskManager = ControlledTaskManager(
+            getExpectedTaskAssertions(groupModelData, reflectionExpectation, successExpected),
+        )
+        val groupFlowDispatcher = getGroupFlowDispatcher(
+            reflectionExpectation.setupConfig,
+            taskManager,
+        )
+
+        // Run update group flow
+        val result = groupFlowDispatcher.runUpdateGroupFlow(
+            null,
+            groupChanges,
+            groupModel,
+        ).await()
+
+        taskManager.pendingTaskAssertions.size.let { size ->
+            if (size > 0) {
+                fail("There are $size pending task assertions left")
+            }
+        }
+
+        return result
+    }
+
+    private fun getExpectedTaskAssertions(
+        groupModelData: GroupModelData?,
+        reflectionExpectation: ReflectionExpectation,
+        successExpected: Boolean,
+    ): MutableList<(Task<*, TaskCodec>) -> Unit> {
+        if (groupModelData == null) {
+            return mutableListOf()
+        }
+
+        val scheduledTaskAssertions: MutableList<(Task<*, TaskCodec>) -> Unit> = mutableListOf()
+        // If multi device is enabled, then we expect a reflection
+        if (reflectionExpectation.setupConfig == SetupConfig.MULTI_DEVICE_ENABLED) {
+            scheduledTaskAssertions.add { task ->
+                assertIs<ReflectLocalGroupUpdate>(task)
+            }
+        }
+
+        // If the group is not a notes group, we expect that a task is scheduled that sends out csp
+        // messages.
+        val isNotesGroup =
+            groupModelData.otherMembers.isEmpty() && groupModelData.groupIdentity.creatorIdentity == myContact.identity
+        val reflectionFails = reflectionExpectation == ReflectionExpectation.REFLECTION_FAIL
+        if (successExpected && !isNotesGroup && !reflectionFails) {
+            scheduledTaskAssertions.add { task ->
+                assertIs<GroupUpdateTask>(task)
+            }
+        }
+
+        return scheduledTaskAssertions
+    }
+
+    /**
+     * Assert that the changes have been applied to the group model data.
+     */
+    private fun GroupModelData.assertChanges(groupChanges: GroupChanges) {
+        groupChanges.name?.let { newName ->
+            assertEquals(newName, this.name)
+        }
+
+        assertContainsAll(this.otherMembers, groupChanges.addMembers)
+        assertContainsNone(this.otherMembers, groupChanges.removeMembers)
+    }
+
+    private fun <T> assertContainsAll(container: Iterable<T>, elements: Iterable<T>) {
+        elements.forEach {
+            assertContains(container, it)
+        }
+    }
+
+    private fun <T> assertContainsNone(container: Iterable<T>, elements: Iterable<T>) {
+        val intersection = container.intersect(elements.toSet())
+        assertTrue(message = "Elements contained unexpectedly") {
+            intersection.isEmpty()
+        }
+    }
+}

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

@@ -31,7 +31,6 @@ import ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGUSERACK
 import ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGUSERDEC
 import ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGUSERDEC
 import ch.threema.domain.protocol.csp.messages.AbstractMessage
 import ch.threema.domain.protocol.csp.messages.AbstractMessage
 import ch.threema.domain.protocol.csp.messages.DeliveryReceiptMessage
 import ch.threema.domain.protocol.csp.messages.DeliveryReceiptMessage
-import ch.threema.domain.protocol.csp.messages.location.LocationMessage
 import ch.threema.domain.protocol.csp.messages.TextMessage
 import ch.threema.domain.protocol.csp.messages.TextMessage
 import ch.threema.domain.protocol.csp.messages.TypingIndicatorMessage
 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.BallotData
@@ -41,23 +40,23 @@ 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.BallotVote
 import ch.threema.domain.protocol.csp.messages.ballot.PollSetupMessage
 import ch.threema.domain.protocol.csp.messages.ballot.PollSetupMessage
 import ch.threema.domain.protocol.csp.messages.ballot.PollVoteMessage
 import ch.threema.domain.protocol.csp.messages.ballot.PollVoteMessage
+import ch.threema.domain.protocol.csp.messages.location.LocationMessage
 import ch.threema.domain.protocol.csp.messages.location.LocationMessageData
 import ch.threema.domain.protocol.csp.messages.location.LocationMessageData
+import java.util.Date
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertTrue
 import junit.framework.TestCase.assertTrue
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.fail
 import kotlinx.coroutines.test.runTest
 import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertArrayEquals
-import org.junit.Assert.fail
-import org.junit.Test
-import java.util.Date
 
 
 @DangerousTest
 @DangerousTest
 class IncomingMessageProcessorTest : MessageProcessorProvider() {
 class IncomingMessageProcessorTest : MessageProcessorProvider() {
-
     @Test
     @Test
     fun testIncomingTextMessage() = runTest {
     fun testIncomingTextMessage() = runTest {
         assertSuccessfulMessageProcessing(
         assertSuccessfulMessageProcessing(
             TextMessage().also { it.text = "Hello!" }.enrich(),
             TextMessage().also { it.text = "Hello!" }.enrich(),
-            contactA
+            contactA,
         )
         )
     }
     }
 
 
@@ -67,11 +66,11 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
             latitude = 0.0,
             latitude = 0.0,
             longitude = 0.0,
             longitude = 0.0,
             accuracy = null,
             accuracy = null,
-            poi = null
+            poi = null,
         )
         )
         assertSuccessfulMessageProcessing(
         assertSuccessfulMessageProcessing(
             message = LocationMessage(locationMessageData = locationMessageData).enrich(),
             message = LocationMessage(locationMessageData = locationMessageData).enrich(),
-            fromContact = contactA
+            fromContact = contactA,
         )
         )
     }
     }
 
 
@@ -107,9 +106,11 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
         val pollVoteMessage = PollVoteMessage().also { voteMessage ->
         val pollVoteMessage = PollVoteMessage().also { voteMessage ->
             voteMessage.ballotId = ballotId
             voteMessage.ballotId = ballotId
             voteMessage.ballotCreatorIdentity = ballotCreator
             voteMessage.ballotCreatorIdentity = ballotCreator
-            voteMessage.votes.addAll(List(5) { index ->
-                BallotVote(index, 0)
-            })
+            voteMessage.votes.addAll(
+                List(5) { index ->
+                    BallotVote(index, 0)
+                },
+            )
         }.enrich()
         }.enrich()
 
 
         assertSuccessfulMessageProcessing(pollVoteMessage, contactA)
         assertSuccessfulMessageProcessing(pollVoteMessage, contactA)
@@ -125,7 +126,8 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
                 it.receiptType = DELIVERYRECEIPT_MSGRECEIVED
                 it.receiptType = DELIVERYRECEIPT_MSGRECEIVED
                 it.receiptMessageIds = arrayOf(messageId)
                 it.receiptMessageIds = arrayOf(messageId)
                 it.messageId = MessageId(0)
                 it.messageId = MessageId(0)
-            }.enrich(), contactA
+            }.enrich(),
+            contactA,
         )
         )
 
 
         // Test 'read'
         // Test 'read'
@@ -133,7 +135,8 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
             DeliveryReceiptMessage().also {
             DeliveryReceiptMessage().also {
                 it.receiptType = DELIVERYRECEIPT_MSGREAD
                 it.receiptType = DELIVERYRECEIPT_MSGREAD
                 it.receiptMessageIds = arrayOf(messageId)
                 it.receiptMessageIds = arrayOf(messageId)
-            }.enrich(), contactA
+            }.enrich(),
+            contactA,
         )
         )
 
 
         // Test 'userack'
         // Test 'userack'
@@ -141,7 +144,8 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
             DeliveryReceiptMessage().also {
             DeliveryReceiptMessage().also {
                 it.receiptType = DELIVERYRECEIPT_MSGUSERACK
                 it.receiptType = DELIVERYRECEIPT_MSGUSERACK
                 it.receiptMessageIds = arrayOf(messageId)
                 it.receiptMessageIds = arrayOf(messageId)
-            }.enrich(), contactA
+            }.enrich(),
+            contactA,
         )
         )
 
 
         // Test 'userdec'
         // Test 'userdec'
@@ -149,7 +153,8 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
             DeliveryReceiptMessage().also {
             DeliveryReceiptMessage().also {
                 it.receiptType = DELIVERYRECEIPT_MSGUSERDEC
                 it.receiptType = DELIVERYRECEIPT_MSGUSERDEC
                 it.receiptMessageIds = arrayOf(messageId)
                 it.receiptMessageIds = arrayOf(messageId)
-            }.enrich(), contactA
+            }.enrich(),
+            contactA,
         )
         )
 
 
         // Test 'received' with two times the same message id
         // Test 'received' with two times the same message id
@@ -158,7 +163,8 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
                 it.receiptType = DELIVERYRECEIPT_MSGRECEIVED
                 it.receiptType = DELIVERYRECEIPT_MSGRECEIVED
                 it.receiptMessageIds = arrayOf(messageId, messageId)
                 it.receiptMessageIds = arrayOf(messageId, messageId)
                 it.messageId = MessageId(0)
                 it.messageId = MessageId(0)
-            }.enrich(), contactA
+            }.enrich(),
+            contactA,
         )
         )
 
 
         // Test 'received' with many message ids
         // Test 'received' with many message ids
@@ -167,7 +173,8 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
                 it.receiptType = DELIVERYRECEIPT_MSGRECEIVED
                 it.receiptType = DELIVERYRECEIPT_MSGRECEIVED
                 it.receiptMessageIds = Array(100) { MessageId() }
                 it.receiptMessageIds = Array(100) { MessageId() }
                 it.messageId = MessageId(0)
                 it.messageId = MessageId(0)
-            }.enrich(), contactA
+            }.enrich(),
+            contactA,
         )
         )
     }
     }
 
 
@@ -175,11 +182,11 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
     fun testIncomingTypingIndicator() = runTest {
     fun testIncomingTypingIndicator() = runTest {
         assertSuccessfulMessageProcessing(
         assertSuccessfulMessageProcessing(
             TypingIndicatorMessage().also { it.isTyping = true }.enrich(),
             TypingIndicatorMessage().also { it.isTyping = true }.enrich(),
-            contactA
+            contactA,
         )
         )
         assertSuccessfulMessageProcessing(
         assertSuccessfulMessageProcessing(
             TypingIndicatorMessage().also { it.isTyping = false }.enrich(),
             TypingIndicatorMessage().also { it.isTyping = false }.enrich(),
-            contactA
+            contactA,
         )
         )
     }
     }
 
 
@@ -222,17 +229,17 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
         val messageId = message.messageId
         val messageId = message.messageId
         processMessage(
         processMessage(
             message.also { it.fromIdentity = fromContact.identity },
             message.also { it.fromIdentity = fromContact.identity },
-            fromContact.identityStore
+            fromContact.identityStore,
         )
         )
 
 
-        val expectDeliveryReceiptSent = message.sendAutomaticDeliveryReceipt()
-            && !message.hasFlags(ProtocolDefines.MESSAGE_FLAG_NO_DELIVERY_RECEIPTS)
+        val expectDeliveryReceiptSent = message.sendAutomaticDeliveryReceipt() &&
+            !message.hasFlags(ProtocolDefines.MESSAGE_FLAG_NO_DELIVERY_RECEIPTS)
         if (expectDeliveryReceiptSent) {
         if (expectDeliveryReceiptSent) {
             val deliveryReceiptMessage = sentMessagesInsideTask.poll()
             val deliveryReceiptMessage = sentMessagesInsideTask.poll()
             if (deliveryReceiptMessage is DeliveryReceiptMessage) {
             if (deliveryReceiptMessage is DeliveryReceiptMessage) {
-                assertArrayEquals(
+                assertContentEquals(
                     messageId.messageId,
                     messageId.messageId,
-                    deliveryReceiptMessage.receiptMessageIds[0].messageId
+                    deliveryReceiptMessage.receiptMessageIds[0].messageId,
                 )
                 )
                 assertEquals(DELIVERYRECEIPT_MSGRECEIVED, deliveryReceiptMessage.receiptType)
                 assertEquals(DELIVERYRECEIPT_MSGRECEIVED, deliveryReceiptMessage.receiptType)
             } else {
             } else {
@@ -250,7 +257,7 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
     ) {
     ) {
         processMessage(
         processMessage(
             message.also { it.fromIdentity = fromContact.identity },
             message.also { it.fromIdentity = fromContact.identity },
-            fromContact.identityStore
+            fromContact.identityStore,
         )
         )
 
 
         assertTrue(sentMessagesInsideTask.isEmpty())
         assertTrue(sentMessagesInsideTask.isEmpty())
@@ -263,5 +270,4 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
         messageId = MessageId()
         messageId = MessageId()
         return this
         return this
     }
     }
-
 }
 }

+ 73 - 98
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt

@@ -24,7 +24,6 @@ package ch.threema.app.processors
 import android.Manifest
 import android.Manifest
 import android.content.Intent
 import android.content.Intent
 import android.os.Build
 import android.os.Build
-import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.GrantPermissionRule
 import androidx.test.rule.GrantPermissionRule
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.ThreemaApplication
@@ -37,6 +36,7 @@ import ch.threema.app.tasks.TaskArchiverImpl
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.app.testutils.clearDatabaseAndCaches
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.ForwardSecurityStatusSender
 import ch.threema.app.utils.ForwardSecurityStatusSender
 import ch.threema.base.crypto.HashedNonce
 import ch.threema.base.crypto.HashedNonce
@@ -44,6 +44,7 @@ import ch.threema.base.crypto.Nonce
 import ch.threema.base.crypto.NonceFactory
 import ch.threema.base.crypto.NonceFactory
 import ch.threema.base.crypto.NonceScope
 import ch.threema.base.crypto.NonceScope
 import ch.threema.base.crypto.NonceStore
 import ch.threema.base.crypto.NonceStore
+import ch.threema.data.models.GroupIdentity
 import ch.threema.domain.fs.DHSession
 import ch.threema.domain.fs.DHSession
 import ch.threema.domain.helpers.DecryptTaskCodec
 import ch.threema.domain.helpers.DecryptTaskCodec
 import ch.threema.domain.helpers.InMemoryContactStore
 import ch.threema.domain.helpers.InMemoryContactStore
@@ -55,8 +56,8 @@ import ch.threema.domain.models.GroupId
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.protocol.ThreemaFeature
 import ch.threema.domain.protocol.ThreemaFeature
+import ch.threema.domain.protocol.Version
 import ch.threema.domain.protocol.api.APIConnector
 import ch.threema.domain.protocol.api.APIConnector
-import ch.threema.domain.protocol.connection.ConnectionState
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.domain.protocol.csp.coders.MessageBox
 import ch.threema.domain.protocol.csp.coders.MessageBox
 import ch.threema.domain.protocol.csp.coders.MessageCoder
 import ch.threema.domain.protocol.csp.coders.MessageCoder
@@ -76,6 +77,8 @@ import ch.threema.domain.taskmanager.toCspMessage
 import ch.threema.storage.DatabaseServiceNew
 import ch.threema.storage.DatabaseServiceNew
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.GroupMemberModel
 import ch.threema.storage.models.GroupMemberModel
+import java.util.Queue
+import java.util.concurrent.ConcurrentLinkedQueue
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertEquals
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.Deferred
@@ -84,12 +87,8 @@ import org.junit.After
 import org.junit.Before
 import org.junit.Before
 import org.junit.Rule
 import org.junit.Rule
 import org.junit.rules.Timeout
 import org.junit.rules.Timeout
-import java.io.File
-import java.util.Queue
-import java.util.concurrent.ConcurrentLinkedQueue
 
 
 open class MessageProcessorProvider {
 open class MessageProcessorProvider {
-
     protected val myContact: TestContact = TestHelpers.TEST_CONTACT
     protected val myContact: TestContact = TestHelpers.TEST_CONTACT
     protected val contactA = TestContact("12345678")
     protected val contactA = TestContact("12345678")
     protected val contactB = TestContact("ABCDEFGH")
     protected val contactB = TestContact("ABCDEFGH")
@@ -100,7 +99,7 @@ open class MessageProcessorProvider {
         myContact,
         myContact,
         listOf(myContact, contactA, contactB),
         listOf(myContact, contactA, contactB),
         "MyGroup",
         "MyGroup",
-        myContact.identity
+        myContact.identity,
     )
     )
     protected val myGroupWithProfilePicture =
     protected val myGroupWithProfilePicture =
         TestGroup(
         TestGroup(
@@ -109,7 +108,7 @@ open class MessageProcessorProvider {
             listOf(myContact, contactA),
             listOf(myContact, contactA),
             "MyGroupWithPicture",
             "MyGroupWithPicture",
             byteArrayOf(0, 1, 2, 3),
             byteArrayOf(0, 1, 2, 3),
-            myContact.identity
+            myContact.identity,
         )
         )
     protected val groupA =
     protected val groupA =
         TestGroup(GroupId(2), contactA, listOf(myContact, contactA), "GroupA", myContact.identity)
         TestGroup(GroupId(2), contactA, listOf(myContact, contactA), "GroupA", myContact.identity)
@@ -121,7 +120,7 @@ open class MessageProcessorProvider {
             contactA,
             contactA,
             listOf(myContact, contactA, contactB),
             listOf(myContact, contactA, contactB),
             "GroupAB",
             "GroupAB",
-            myContact.identity
+            myContact.identity,
         )
         )
     protected val groupAUnknown =
     protected val groupAUnknown =
         TestGroup(
         TestGroup(
@@ -129,7 +128,7 @@ open class MessageProcessorProvider {
             contactA,
             contactA,
             listOf(myContact, contactA, contactB),
             listOf(myContact, contactA, contactB),
             "GroupAUnknown",
             "GroupAUnknown",
-            myContact.identity
+            myContact.identity,
         )
         )
     protected val groupALeft =
     protected val groupALeft =
         TestGroup(
         TestGroup(
@@ -137,7 +136,7 @@ open class MessageProcessorProvider {
             contactA,
             contactA,
             listOf(contactA, contactB),
             listOf(contactA, contactB),
             "GroupALeft",
             "GroupALeft",
-            myContact.identity
+            myContact.identity,
         )
         )
     protected val myUnknownGroup =
     protected val myUnknownGroup =
         TestGroup(
         TestGroup(
@@ -145,7 +144,7 @@ open class MessageProcessorProvider {
             myContact,
             myContact,
             listOf(myContact, contactA),
             listOf(myContact, contactA),
             "MyUnknownGroup",
             "MyUnknownGroup",
-            myContact.identity
+            myContact.identity,
         )
         )
     protected val myLeftGroup =
     protected val myLeftGroup =
         TestGroup(GroupId(8), myContact, listOf(contactA), "MyLeftGroup", myContact.identity)
         TestGroup(GroupId(8), myContact, listOf(contactA), "MyLeftGroup", myContact.identity)
@@ -155,7 +154,15 @@ open class MessageProcessorProvider {
             contactA,
             contactA,
             listOf(myContact, contactA, contactB),
             listOf(myContact, contactA, contactB),
             "NewAGroup",
             "NewAGroup",
-            myContact.identity
+            myContact.identity,
+        )
+    protected val newBGroup =
+        TestGroup(
+            GroupId(10),
+            contactB,
+            listOf(myContact, contactB),
+            "NewBGroup",
+            myContact.identity,
         )
         )
 
 
     protected val serviceManager: ServiceManager = ThreemaApplication.requireServiceManager()
     protected val serviceManager: ServiceManager = ThreemaApplication.requireServiceManager()
@@ -177,10 +184,19 @@ open class MessageProcessorProvider {
         serviceManager.contactService,
         serviceManager.contactService,
         serviceManager.messageService,
         serviceManager.messageService,
         APIConnector(
         APIConnector(
+            /* ipv6 = */
             false,
             false,
+            /* serverAddressProvider = */
             null,
             null,
-            false
-        ) { host -> ConfigUtils.getSSLSocketFactory(host) },
+            /* isWork = */
+            false,
+            /* sslSocketFactoryFactory = */
+            ConfigUtils::getSSLSocketFactory,
+            /* version = */
+            Version(),
+            /* language = */
+            null,
+        ),
         serviceManager.userService,
         serviceManager.userService,
         serviceManager.modelRepositories.contacts,
         serviceManager.modelRepositories.contacts,
     ) {
     ) {
@@ -200,20 +216,20 @@ open class MessageProcessorProvider {
             contactStore,
             contactStore,
             contactA.identityStore,
             contactA.identityStore,
             NonceFactory(InMemoryNonceStore()),
             NonceFactory(InMemoryNonceStore()),
-            forwardSecurityStatusListener
+            forwardSecurityStatusListener,
         ),
         ),
         contactB.identity to ForwardSecurityMessageProcessor(
         contactB.identity to ForwardSecurityMessageProcessor(
             InMemoryDHSessionStore(),
             InMemoryDHSessionStore(),
             contactStore,
             contactStore,
             contactB.identityStore, NonceFactory(InMemoryNonceStore()),
             contactB.identityStore, NonceFactory(InMemoryNonceStore()),
-            forwardSecurityStatusListener
+            forwardSecurityStatusListener,
         ),
         ),
         contactC.identity to ForwardSecurityMessageProcessor(
         contactC.identity to ForwardSecurityMessageProcessor(
             InMemoryDHSessionStore(),
             InMemoryDHSessionStore(),
             contactStore,
             contactStore,
             contactC.identityStore,
             contactC.identityStore,
             NonceFactory(InMemoryNonceStore()),
             NonceFactory(InMemoryNonceStore()),
-            forwardSecurityStatusListener
+            forwardSecurityStatusListener,
         ),
         ),
     ).toMap()
     ).toMap()
 
 
@@ -263,7 +279,7 @@ open class MessageProcessorProvider {
 
 
     @Rule
     @Rule
     @JvmField
     @JvmField
-    val timeout: Timeout = Timeout.seconds(300)
+    val timeout: Timeout = Timeout.seconds(150)
 
 
     @JvmField
     @JvmField
     @Rule
     @Rule
@@ -279,14 +295,14 @@ open class MessageProcessorProvider {
      */
      */
     @Before
     @Before
     fun setup() {
     fun setup() {
-        assert(myContact.identity == TestHelpers.ensureIdentity(ThreemaApplication.requireServiceManager()))
+        TestHelpers.setIdentity(
+            ThreemaApplication.requireServiceManager(),
+            TestHelpers.TEST_CONTACT,
+        )
 
 
         // Delete persisted tasks as they are not needed for tests
         // Delete persisted tasks as they are not needed for tests
         serviceManager.databaseServiceNew.taskArchiveFactory.deleteAll()
         serviceManager.databaseServiceNew.taskArchiveFactory.deleteAll()
 
 
-        // Then stop connection
-        serviceManager.connection.stop()
-
         // Replace original task manager (save a copy of it)
         // Replace original task manager (save a copy of it)
         originalTaskManager = serviceManager.taskManager
         originalTaskManager = serviceManager.taskManager
 
 
@@ -350,13 +366,13 @@ open class MessageProcessorProvider {
             val initMessageBox = MessageBox.parseBinary(initCspMessage.toOutgoingMessageData().data)
             val initMessageBox = MessageBox.parseBinary(initCspMessage.toOutgoingMessageData().data)
             val init = MessageCoder(
             val init = MessageCoder(
                 contactStore,
                 contactStore,
-                it.identityStore
+                it.identityStore,
             ).decode(initMessageBox) as ForwardSecurityEnvelopeMessage
             ).decode(initMessageBox) as ForwardSecurityEnvelopeMessage
             runBlocking {
             runBlocking {
                 forwardSecurityMessageProcessorMap[it.identity]!!.processInit(
                 forwardSecurityMessageProcessorMap[it.identity]!!.processInit(
                     myContact.contact,
                     myContact.contact,
                     init.data as ForwardSecurityDataInit,
                     init.data as ForwardSecurityDataInit,
-                    globalTaskCodec
+                    globalTaskCodec,
                 )
                 )
             }
             }
 
 
@@ -367,61 +383,17 @@ open class MessageProcessorProvider {
     }
     }
 
 
     /**
     /**
-     * Clean the data after the tests. This includes the deletion of the database entries, the
-     * avatar files, and the blocked contacts.
+     * Set the original task manager again and wait until the connection has been started again.
      */
      */
     @After
     @After
     fun cleanup() {
     fun cleanup() {
-        clearData()
-
         if (this::originalTaskManager.isInitialized) {
         if (this::originalTaskManager.isInitialized) {
             setTaskManager(originalTaskManager)
             setTaskManager(originalTaskManager)
         }
         }
-
-        // We need to start the connection again, as some tests require a running connection
-        serviceManager.connection.start()
-
-        // Wait until the connection has been established. If we do not wait for the connection, the
-        // next test may fail due to a race condition that occurs when the connection is started and
-        // almost immediately stopped again.
-        while (serviceManager.connection.connectionState != ConnectionState.LOGGEDIN) {
-            Thread.sleep(50)
-        }
     }
     }
 
 
     private fun clearData() {
     private fun clearData() {
-        // Clear conversations
-        serviceManager.conversationService.getAll(true).forEach {
-            serviceManager.conversationService.empty(it, true)
-        }
-
-        // Delete database
-        serviceManager.databaseServiceNew.apply {
-            contactModelFactory.deleteAll()
-            messageModelFactory.deleteAll()
-            groupCallModelFactory.deleteAll()
-            groupInviteModelFactory.deleteAll()
-            groupBallotModelFactory.deleteAll()
-            groupMemberModelFactory.deleteAll()
-            groupMessageModelFactory.deleteAll()
-            // Remove group models from group service to empty the group service cache
-            serviceManager.groupService.removeAll()
-            distributionListModelFactory.deleteAll()
-            distributionListMemberModelFactory.deleteAll()
-            distributionListMessageModelFactory.deleteAll()
-            outgoingGroupSyncRequestLogModelFactory.deleteAll()
-            incomingGroupSyncRequestLogModelFactory.deleteAll()
-            ballotModelFactory.deleteAll()
-            ballotChoiceModelFactory.deleteAll()
-            ballotVoteModelFactory.deleteAll()
-            identityBallotModelFactory.deleteAll()
-            webClientSessionModelFactory.deleteAll()
-            conversationTagFactory.deleteAll()
-            outgoingGroupJoinRequestModelFactory.deleteAll()
-            incomingGroupJoinRequestModelFactory.deleteAll()
-            serverMessageModelFactory.deleteAll()
-            taskArchiveFactory.deleteAll()
-        }
+        clearDatabaseAndCaches(serviceManager)
 
 
         // Delete dh sessions
         // Delete dh sessions
         initialContacts.forEach {
         initialContacts.forEach {
@@ -430,12 +402,6 @@ open class MessageProcessorProvider {
 
 
         // Remove files
         // Remove files
         serviceManager.fileService.removeAllAvatars()
         serviceManager.fileService.removeAllAvatars()
-        serviceManager.fileService.remove(
-            File(
-                InstrumentationRegistry.getInstrumentation().context.filesDir,
-                "taskArchive"
-            ), true
-        )
 
 
         // Unblock contacts
         // Unblock contacts
         val blockedIdentitiesService = serviceManager.blockedIdentitiesService
         val blockedIdentitiesService = serviceManager.blockedIdentitiesService
@@ -466,19 +432,22 @@ open class MessageProcessorProvider {
     private fun disableLifetimeService() {
     private fun disableLifetimeService() {
         val field = ServiceManager::class.java.getDeclaredField("lifetimeService")
         val field = ServiceManager::class.java.getDeclaredField("lifetimeService")
         field.isAccessible = true
         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
-        })
+        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
+            },
+        )
     }
     }
 
 
     /**
     /**
@@ -495,7 +464,7 @@ open class MessageProcessorProvider {
                 it,
                 it,
                 databaseService,
                 databaseService,
                 contactStore,
                 contactStore,
-                AcquaintanceLevel.GROUP
+                AcquaintanceLevel.GROUP,
             )
             )
         }
         }
 
 
@@ -510,7 +479,7 @@ open class MessageProcessorProvider {
     ) {
     ) {
         databaseService.contactModelFactory.createOrUpdate(
         databaseService.contactModelFactory.createOrUpdate(
             testContact.contactModel.setAcquaintanceLevel(acquaintanceLevel)
             testContact.contactModel.setAcquaintanceLevel(acquaintanceLevel)
-                .setFeatureMask(ThreemaFeature.FORWARD_SECURITY)
+                .setFeatureMask(ThreemaFeature.FORWARD_SECURITY),
         )
         )
 
 
         contactStore.addCachedContact(testContact.toBasicContact())
         contactStore.addCachedContact(testContact.toBasicContact())
@@ -536,6 +505,13 @@ open class MessageProcessorProvider {
         if (testGroup.profilePicture != null) {
         if (testGroup.profilePicture != null) {
             fileService.writeGroupAvatar(groupModel, testGroup.profilePicture)
             fileService.writeGroupAvatar(groupModel, testGroup.profilePicture)
         }
         }
+
+        // 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()),
+            )
+        }
     }
     }
 
 
     /**
     /**
@@ -548,7 +524,7 @@ open class MessageProcessorProvider {
         val messageBox = createMessageBox(
         val messageBox = createMessageBox(
             message,
             message,
             identityStore,
             identityStore,
-            forwardSecurityMessageProcessorMap[message.fromIdentity]!!
+            forwardSecurityMessageProcessorMap[message.fromIdentity]!!,
         )
         )
 
 
         // Process the group message
         // Process the group message
@@ -559,7 +535,7 @@ open class MessageProcessorProvider {
         // Assert that this message has been acked towards the server
         // Assert that this message has been acked towards the server
         assertEquals(
         assertEquals(
             message.hasFlags(ProtocolDefines.MESSAGE_FLAG_NO_SERVER_ACK),
             message.hasFlags(ProtocolDefines.MESSAGE_FLAG_NO_SERVER_ACK),
-            !localTaskCodec.ackedIncomingMessages.contains(message.messageId)
+            !localTaskCodec.ackedIncomingMessages.contains(message.messageId),
         )
         )
 
 
         while (globalTaskQueue.isNotEmpty()) {
         while (globalTaskQueue.isNotEmpty()) {
@@ -591,7 +567,7 @@ open class MessageProcessorProvider {
                 scope: NonceScope,
                 scope: NonceScope,
                 chunkSize: Int,
                 chunkSize: Int,
                 offset: Int,
                 offset: Int,
-                nonces: MutableList<HashedNonce>
+                nonces: MutableList<HashedNonce>,
             ) {
             ) {
             }
             }
 
 
@@ -600,12 +576,12 @@ open class MessageProcessorProvider {
 
 
         val encapsulated = forwardSecurityMessageProcessor.runFsEncapsulationSteps(
         val encapsulated = forwardSecurityMessageProcessor.runFsEncapsulationSteps(
             contactStore.getContactForIdentityIncludingCache(
             contactStore.getContactForIdentityIncludingCache(
-                msg.toIdentity
+                msg.toIdentity,
             )!!.enhanceToBasicContact(),
             )!!.enhanceToBasicContact(),
             msg,
             msg,
             nonceFactory.next(NonceScope.CSP),
             nonceFactory.next(NonceScope.CSP),
             nonceFactory,
             nonceFactory,
-            globalTaskCodec
+            globalTaskCodec,
         ).outgoingMessages.last().first
         ).outgoingMessages.last().first
 
 
         val messageCoder = MessageCoder(contactStore, identityStore)
         val messageCoder = MessageCoder(contactStore, identityStore)
@@ -630,5 +606,4 @@ open class MessageProcessorProvider {
         IdentityState.ACTIVE,
         IdentityState.ACTIVE,
         IdentityType.NORMAL,
         IdentityType.NORMAL,
     )
     )
-
 }
 }

+ 126 - 117
app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt

@@ -31,6 +31,7 @@ import ch.threema.app.services.PreferenceService
 import ch.threema.app.services.PreferenceServiceImpl
 import ch.threema.app.services.PreferenceServiceImpl
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.app.testutils.clearDatabaseAndCaches
 import ch.threema.data.TestDatabaseService
 import ch.threema.data.TestDatabaseService
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.repositories.ContactModelRepository
 import ch.threema.data.repositories.ContactModelRepository
@@ -49,15 +50,14 @@ import ch.threema.storage.DatabaseServiceNew
 import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.GroupMemberModel
 import ch.threema.storage.models.GroupMemberModel
 import ch.threema.storage.models.GroupModel
 import ch.threema.storage.models.GroupModel
+import java.util.Date
+import kotlin.test.assertEquals
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.runBlocking
 import org.junit.Before
 import org.junit.Before
 import org.junit.Test
 import org.junit.Test
-import java.util.Date
-import kotlin.test.assertEquals
 
 
 @DangerousTest
 @DangerousTest
 class IdentityBlockedStepsTest {
 class IdentityBlockedStepsTest {
-
     private lateinit var contactModelRepository: ContactModelRepository
     private lateinit var contactModelRepository: ContactModelRepository
     private lateinit var contactStore: ContactStore
     private lateinit var contactStore: ContactStore
     private lateinit var groupService: GroupService
     private lateinit var groupService: GroupService
@@ -76,15 +76,18 @@ class IdentityBlockedStepsTest {
 
 
     @Before
     @Before
     fun setup() {
     fun setup() {
-        assert(myContact.identity == TestHelpers.ensureIdentity(ThreemaApplication.requireServiceManager()))
-
         val serviceManager = ThreemaApplication.requireServiceManager()
         val serviceManager = ThreemaApplication.requireServiceManager()
+
+        clearDatabaseAndCaches(serviceManager)
+
+        assert(myContact.identity == TestHelpers.ensureIdentity(serviceManager))
+
         val databaseService = TestDatabaseService()
         val databaseService = TestDatabaseService()
         val coreServiceManager = TestCoreServiceManager(
         val coreServiceManager = TestCoreServiceManager(
             version = ThreemaApplication.getAppVersion(),
             version = ThreemaApplication.getAppVersion(),
             databaseService = databaseService,
             databaseService = databaseService,
             preferenceStore = serviceManager.preferenceStore,
             preferenceStore = serviceManager.preferenceStore,
-            taskManager = TestTaskManager(UnusedTaskCodec())
+            taskManager = TestTaskManager(UnusedTaskCodec()),
         )
         )
         contactModelRepository = ModelRepositories(coreServiceManager).contacts
         contactModelRepository = ModelRepositories(coreServiceManager).contacts
         contactStore = serviceManager.contactStore
         contactStore = serviceManager.contactStore
@@ -124,14 +127,14 @@ class IdentityBlockedStepsTest {
     fun testExplicitlyBlockedContact() {
     fun testExplicitlyBlockedContact() {
         assertEquals(
         assertEquals(
             BlockState.EXPLICITLY_BLOCKED,
             BlockState.EXPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(explicitlyBlockedContact.identity, noBlockPreferenceService)
+            runIdentityBlockedSteps(explicitlyBlockedContact.identity, noBlockPreferenceService),
         )
         )
         assertEquals(
         assertEquals(
             BlockState.EXPLICITLY_BLOCKED,
             BlockState.EXPLICITLY_BLOCKED,
             runIdentityBlockedSteps(
             runIdentityBlockedSteps(
                 explicitlyBlockedContact.identity,
                 explicitlyBlockedContact.identity,
-                blockUnknownPreferenceService
-            )
+                blockUnknownPreferenceService,
+            ),
         )
         )
     }
     }
 
 
@@ -139,7 +142,7 @@ class IdentityBlockedStepsTest {
     fun testImplicitlyBlockedContact() {
     fun testImplicitlyBlockedContact() {
         assertEquals(
         assertEquals(
             BlockState.IMPLICITLY_BLOCKED,
             BlockState.IMPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(unknownContact.identity, blockUnknownPreferenceService)
+            runIdentityBlockedSteps(unknownContact.identity, blockUnknownPreferenceService),
         )
         )
     }
     }
 
 
@@ -147,7 +150,7 @@ class IdentityBlockedStepsTest {
     fun testImplicitlyBlockedSpecialContact() {
     fun testImplicitlyBlockedSpecialContact() {
         assertEquals(
         assertEquals(
             BlockState.NOT_BLOCKED,
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(specialContact.identity, blockUnknownPreferenceService)
+            runIdentityBlockedSteps(specialContact.identity, blockUnknownPreferenceService),
         )
         )
     }
     }
 
 
@@ -155,7 +158,7 @@ class IdentityBlockedStepsTest {
     fun testGroupContactWithGroup() {
     fun testGroupContactWithGroup() {
         assertEquals(
         assertEquals(
             BlockState.NOT_BLOCKED,
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(inGroup.identity, blockUnknownPreferenceService)
+            runIdentityBlockedSteps(inGroup.identity, blockUnknownPreferenceService),
         )
         )
     }
     }
 
 
@@ -163,7 +166,7 @@ class IdentityBlockedStepsTest {
     fun testGroupContactWithoutGroup() {
     fun testGroupContactWithoutGroup() {
         assertEquals(
         assertEquals(
             BlockState.IMPLICITLY_BLOCKED,
             BlockState.IMPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(inNoGroup.identity, blockUnknownPreferenceService)
+            runIdentityBlockedSteps(inNoGroup.identity, blockUnknownPreferenceService),
         )
         )
     }
     }
 
 
@@ -171,7 +174,7 @@ class IdentityBlockedStepsTest {
     fun testGroupContactWithLeftGroup() {
     fun testGroupContactWithLeftGroup() {
         assertEquals(
         assertEquals(
             BlockState.IMPLICITLY_BLOCKED,
             BlockState.IMPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(inLeftGroup.identity, blockUnknownPreferenceService)
+            runIdentityBlockedSteps(inLeftGroup.identity, blockUnknownPreferenceService),
         )
         )
     }
     }
 
 
@@ -199,17 +202,16 @@ class IdentityBlockedStepsTest {
         )
         )
         assertEquals(
         assertEquals(
             BlockState.NOT_BLOCKED,
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(inGroup.identity, noBlockPreferenceService)
+            runIdentityBlockedSteps(inGroup.identity, noBlockPreferenceService),
         )
         )
         assertEquals(
         assertEquals(
             BlockState.NOT_BLOCKED,
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(inNoGroup.identity, noBlockPreferenceService)
+            runIdentityBlockedSteps(inNoGroup.identity, noBlockPreferenceService),
         )
         )
         assertEquals(
         assertEquals(
             BlockState.NOT_BLOCKED,
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(inLeftGroup.identity, noBlockPreferenceService)
+            runIdentityBlockedSteps(inLeftGroup.identity, noBlockPreferenceService),
         )
         )
-
     }
     }
 
 
     private fun runIdentityBlockedSteps(
     private fun runIdentityBlockedSteps(
@@ -227,107 +229,115 @@ class IdentityBlockedStepsTest {
     private fun addKnownContacts() = runBlocking {
     private fun addKnownContacts() = runBlocking {
         contactModelRepository.createFromLocal(
         contactModelRepository.createFromLocal(
             ContactModelData(
             ContactModelData(
-                knownContact.identity,
-                knownContact.publicKey,
-                Date(),
-                "",
-                "",
-                "",
-                0u,
-                VerificationLevel.UNVERIFIED,
-                WorkVerificationLevel.NONE,
-                IdentityType.NORMAL,
-                ContactModel.AcquaintanceLevel.DIRECT,
-                IdentityState.ACTIVE,
-                ContactSyncState.INITIAL,
-                0u,
-                ReadReceiptPolicy.DEFAULT,
-                TypingIndicatorPolicy.DEFAULT,
-                null,
-                null,
-                false,
-                null,
-                null,
-                null,
-            )
+                identity = knownContact.identity,
+                publicKey = knownContact.publicKey,
+                createdAt = Date(),
+                firstName = "",
+                lastName = "",
+                nickname = "",
+                colorIndex = 0u,
+                verificationLevel = VerificationLevel.UNVERIFIED,
+                workVerificationLevel = WorkVerificationLevel.NONE,
+                identityType = IdentityType.NORMAL,
+                acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+                activityState = IdentityState.ACTIVE,
+                syncState = ContactSyncState.INITIAL,
+                featureMask = 0u,
+                readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+                typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+                isArchived = false,
+                androidContactLookupKey = null,
+                localAvatarExpires = null,
+                isRestored = false,
+                profilePictureBlobId = null,
+                jobTitle = null,
+                department = null,
+                notificationTriggerPolicyOverride = null,
+            ),
         )
         )
         contactModelRepository.createFromLocal(
         contactModelRepository.createFromLocal(
             ContactModelData(
             ContactModelData(
-                inGroup.identity,
-                inGroup.publicKey,
-                Date(),
-                "",
-                "",
-                "",
-                0u,
-                VerificationLevel.UNVERIFIED,
-                WorkVerificationLevel.NONE,
-                IdentityType.NORMAL,
-                ContactModel.AcquaintanceLevel.GROUP,
-                IdentityState.ACTIVE,
-                ContactSyncState.INITIAL,
-                0u,
-                ReadReceiptPolicy.DEFAULT,
-                TypingIndicatorPolicy.DEFAULT,
-                null,
-                null,
-                false,
-                null,
-                null,
-                null,
-            )
+                identity = inGroup.identity,
+                publicKey = inGroup.publicKey,
+                createdAt = Date(),
+                firstName = "",
+                lastName = "",
+                nickname = "",
+                colorIndex = 0u,
+                verificationLevel = VerificationLevel.UNVERIFIED,
+                workVerificationLevel = WorkVerificationLevel.NONE,
+                identityType = IdentityType.NORMAL,
+                acquaintanceLevel = ContactModel.AcquaintanceLevel.GROUP,
+                activityState = IdentityState.ACTIVE,
+                syncState = ContactSyncState.INITIAL,
+                featureMask = 0u,
+                readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+                typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+                isArchived = false,
+                androidContactLookupKey = null,
+                localAvatarExpires = null,
+                isRestored = false,
+                profilePictureBlobId = null,
+                jobTitle = null,
+                department = null,
+                notificationTriggerPolicyOverride = null,
+            ),
         )
         )
         contactModelRepository.createFromLocal(
         contactModelRepository.createFromLocal(
             ContactModelData(
             ContactModelData(
-                inNoGroup.identity,
-                inNoGroup.publicKey,
-                Date(),
-                "",
-                "",
-                "",
-                0u,
-                VerificationLevel.UNVERIFIED,
-                WorkVerificationLevel.NONE,
-                IdentityType.NORMAL,
-                ContactModel.AcquaintanceLevel.GROUP,
-                IdentityState.ACTIVE,
-                ContactSyncState.INITIAL,
-                0u,
-                ReadReceiptPolicy.DEFAULT,
-                TypingIndicatorPolicy.DEFAULT,
-                null,
-                null,
-                false,
-                null,
-                null,
-                null,
-            )
+                identity = inNoGroup.identity,
+                publicKey = inNoGroup.publicKey,
+                createdAt = Date(),
+                firstName = "",
+                lastName = "",
+                nickname = "",
+                colorIndex = 0u,
+                verificationLevel = VerificationLevel.UNVERIFIED,
+                workVerificationLevel = WorkVerificationLevel.NONE,
+                identityType = IdentityType.NORMAL,
+                acquaintanceLevel = ContactModel.AcquaintanceLevel.GROUP,
+                activityState = IdentityState.ACTIVE,
+                syncState = ContactSyncState.INITIAL,
+                featureMask = 0u,
+                readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+                typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+                isArchived = false,
+                androidContactLookupKey = null,
+                localAvatarExpires = null,
+                isRestored = false,
+                profilePictureBlobId = null,
+                jobTitle = null,
+                department = null,
+                notificationTriggerPolicyOverride = null,
+            ),
         )
         )
         contactModelRepository.createFromLocal(
         contactModelRepository.createFromLocal(
             ContactModelData(
             ContactModelData(
-                inLeftGroup.identity,
-                inLeftGroup.publicKey,
-                Date(),
-                "",
-                "",
-                "",
-                0u,
-                VerificationLevel.UNVERIFIED,
-                WorkVerificationLevel.NONE,
-                IdentityType.NORMAL,
-                ContactModel.AcquaintanceLevel.GROUP,
-                IdentityState.ACTIVE,
-                ContactSyncState.INITIAL,
-                0u,
-                ReadReceiptPolicy.DEFAULT,
-                TypingIndicatorPolicy.DEFAULT,
-                null,
-                null,
-                false,
-                null,
-                null,
-                null,
-            )
+                identity = inLeftGroup.identity,
+                publicKey = inLeftGroup.publicKey,
+                createdAt = Date(),
+                firstName = "",
+                lastName = "",
+                nickname = "",
+                colorIndex = 0u,
+                verificationLevel = VerificationLevel.UNVERIFIED,
+                workVerificationLevel = WorkVerificationLevel.NONE,
+                identityType = IdentityType.NORMAL,
+                acquaintanceLevel = ContactModel.AcquaintanceLevel.GROUP,
+                activityState = IdentityState.ACTIVE,
+                syncState = ContactSyncState.INITIAL,
+                featureMask = 0u,
+                readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+                typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+                isArchived = false,
+                androidContactLookupKey = null,
+                localAvatarExpires = null,
+                isRestored = false,
+                profilePictureBlobId = null,
+                jobTitle = null,
+                department = null,
+                notificationTriggerPolicyOverride = null,
+            ),
         )
         )
     }
     }
 
 
@@ -338,36 +348,35 @@ class IdentityBlockedStepsTest {
                     .setApiGroupId(GroupId(0))
                     .setApiGroupId(GroupId(0))
                     .setCreatorIdentity(myContact.identity)
                     .setCreatorIdentity(myContact.identity)
                     .setUserState(GroupModel.UserState.MEMBER)
                     .setUserState(GroupModel.UserState.MEMBER)
-                    .setCreatedAt(Date())
+                    .setCreatedAt(Date()),
             )
             )
             create(
             create(
                 GroupModel()
                 GroupModel()
                     .setApiGroupId(GroupId(1))
                     .setApiGroupId(GroupId(1))
                     .setCreatorIdentity(myContact.identity)
                     .setCreatorIdentity(myContact.identity)
                     .setUserState(GroupModel.UserState.LEFT)
                     .setUserState(GroupModel.UserState.LEFT)
-                    .setCreatedAt(Date())
+                    .setCreatedAt(Date()),
             )
             )
         }
         }
         val memberGroup = databaseService.groupModelFactory.getByApiGroupIdAndCreator(
         val memberGroup = databaseService.groupModelFactory.getByApiGroupIdAndCreator(
             GroupId(0).toString(),
             GroupId(0).toString(),
-            myContact.identity
+            myContact.identity,
         )
         )
         val leftGroup = databaseService.groupModelFactory.getByApiGroupIdAndCreator(
         val leftGroup = databaseService.groupModelFactory.getByApiGroupIdAndCreator(
             GroupId(1).toString(),
             GroupId(1).toString(),
-            myContact.identity
+            myContact.identity,
         )
         )
         databaseService.groupMemberModelFactory.apply {
         databaseService.groupMemberModelFactory.apply {
             create(
             create(
                 GroupMemberModel()
                 GroupMemberModel()
                     .setGroupId(memberGroup.id)
                     .setGroupId(memberGroup.id)
-                    .setIdentity(inGroup.identity)
+                    .setIdentity(inGroup.identity),
             )
             )
             create(
             create(
                 GroupMemberModel()
                 GroupMemberModel()
                     .setGroupId(leftGroup.id)
                     .setGroupId(leftGroup.id)
-                    .setIdentity(inLeftGroup.identity)
+                    .setIdentity(inLeftGroup.identity),
             )
             )
         }
         }
     }
     }
-
 }
 }

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

@@ -204,12 +204,12 @@ public class GroupInviteServiceTest {
             }
             }
 
 
             @Override
             @Override
-            public void linkWithEmail(String email) throws Exception {
+            public void linkWithEmail(String email, @NonNull TriggerSource triggerSource) throws Exception {
 
 
             }
             }
 
 
             @Override
             @Override
-            public void unlinkEmail() throws Exception {
+            public void unlinkEmail(@NonNull TriggerSource triggerSource) throws Exception {
 
 
             }
             }
 
 
@@ -219,12 +219,12 @@ public class GroupInviteServiceTest {
             }
             }
 
 
             @Override
             @Override
-            public void checkEmailLinkState() {
+            public void checkEmailLinkState(@NonNull TriggerSource triggerSource) {
 
 
             }
             }
 
 
             @Override
             @Override
-            public Date linkWithMobileNumber(String number) throws Exception {
+            public Date linkWithMobileNumber(String number, @NonNull TriggerSource triggerSource) throws Exception {
                 return null;
                 return null;
             }
             }
 
 
@@ -234,12 +234,12 @@ public class GroupInviteServiceTest {
             }
             }
 
 
             @Override
             @Override
-            public void unlinkMobileNumber() throws Exception {
+            public void unlinkMobileNumber(@NonNull TriggerSource triggerSource) throws Exception {
 
 
             }
             }
 
 
             @Override
             @Override
-            public boolean verifyMobileNumber(String code) throws Exception {
+            public boolean verifyMobileNumber(String code, @NonNull TriggerSource triggerSource) throws Exception {
                 return false;
                 return false;
             }
             }
 
 

+ 4 - 7
app/src/androidTest/java/ch/threema/app/services/BlockedIdentitiesServiceTest.kt

@@ -32,15 +32,13 @@ import ch.threema.base.crypto.NonceFactory
 import ch.threema.domain.helpers.ServerAckTaskCodec
 import ch.threema.domain.helpers.ServerAckTaskCodec
 import kotlin.test.BeforeTest
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.Test
-import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertFalse
 import kotlin.test.assertFalse
 import kotlin.test.assertTrue
 import kotlin.test.assertTrue
 
 
 class BlockedIdentitiesServiceTest {
 class BlockedIdentitiesServiceTest {
-
     private val multiDeviceManager = TestMultiDeviceManager(
     private val multiDeviceManager = TestMultiDeviceManager(
-        isMultiDeviceActive = false
+        isMultiDeviceActive = false,
     )
     )
 
 
     private val taskManager = TestTaskManager(ServerAckTaskCodec())
     private val taskManager = TestTaskManager(ServerAckTaskCodec())
@@ -49,7 +47,7 @@ class BlockedIdentitiesServiceTest {
         ThreemaApplication.getAppContext(),
         ThreemaApplication.getAppContext(),
         PreferenceStore(
         PreferenceStore(
             ThreemaApplication.getAppContext(),
             ThreemaApplication.getAppContext(),
-            ThreemaApplication.getMasterKey()
+            ThreemaApplication.getMasterKey(),
         ),
         ),
         taskManager,
         taskManager,
         multiDeviceManager,
         multiDeviceManager,
@@ -112,14 +110,14 @@ class BlockedIdentitiesServiceTest {
 
 
         assertEquals(
         assertEquals(
             setOf("ABCDEFGH", "12345678"),
             setOf("ABCDEFGH", "12345678"),
-            setOf(onModified.removeFirst(), onModified.removeFirst())
+            setOf(onModified.removeFirst(), onModified.removeFirst()),
         )
         )
 
 
         blockedIdentitiesService.persistBlockedIdentities(setOf("ABCDEFGH", "TESTTEST"))
         blockedIdentitiesService.persistBlockedIdentities(setOf("ABCDEFGH", "TESTTEST"))
 
 
         assertEquals(
         assertEquals(
             setOf("12345678", "TESTTEST"),
             setOf("12345678", "TESTTEST"),
-            setOf(onModified.removeFirst(), onModified.removeFirst())
+            setOf(onModified.removeFirst(), onModified.removeFirst()),
         )
         )
         assertTrue { onModified.isEmpty() }
         assertTrue { onModified.isEmpty() }
     }
     }
@@ -138,5 +136,4 @@ class BlockedIdentitiesServiceTest {
 
 
         assertTrue { onModified.isEmpty() }
         assertTrue { onModified.isEmpty() }
     }
     }
-
 }
 }

+ 2 - 2
app/src/androidTest/java/ch/threema/app/services/systemupdate/SystemUpdateHelpersTest.kt

@@ -21,11 +21,11 @@
 
 
 package ch.threema.app.services.systemupdate
 package ch.threema.app.services.systemupdate
 
 
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
 import net.zetetic.database.sqlcipher.SQLiteDatabase
 import net.zetetic.database.sqlcipher.SQLiteDatabase
 import org.junit.Before
 import org.junit.Before
 import org.junit.Test
 import org.junit.Test
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
 
 
 class SystemUpdateHelpersTest {
 class SystemUpdateHelpersTest {
     private var inMemoryDatabase: SQLiteDatabase = SQLiteDatabase.create(null)
     private var inMemoryDatabase: SQLiteDatabase = SQLiteDatabase.create(null)

+ 277 - 0
app/src/androidTest/java/ch/threema/app/tasks/GroupCreateTaskTest.kt

@@ -0,0 +1,277 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024-2025 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.tasks
+
+import ch.threema.app.DangerousTest
+import ch.threema.app.TestMultiDeviceManager
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.protocol.PredefinedMessageIds
+import ch.threema.app.protocol.RemoveProfilePicture
+import ch.threema.app.services.ApiService
+import ch.threema.app.testutils.TestHelpers
+import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.app.testutils.clearDatabaseAndCaches
+import ch.threema.app.utils.OutgoingCspMessageServices
+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.ContactSyncState
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.protocol.blob.BlobLoader
+import ch.threema.domain.protocol.blob.BlobScope
+import ch.threema.domain.protocol.blob.BlobUploader
+import ch.threema.domain.protocol.connection.data.CspMessage
+import ch.threema.domain.protocol.connection.data.OutboundD2mMessage
+import ch.threema.storage.models.ContactModel
+import ch.threema.storage.models.GroupModel
+import ch.threema.testhelpers.MUST_NOT_BE_CALLED
+import java.util.Date
+import javax.net.ssl.HttpsURLConnection
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+
+@DangerousTest
+class GroupCreateTaskTest {
+    private val myContact: TestContact = TestHelpers.TEST_CONTACT
+
+    private val initialContactModelData = ContactModelData(
+        identity = "12345678",
+        publicKey = ByteArray(32),
+        createdAt = Date(),
+        firstName = "",
+        lastName = "",
+        verificationLevel = VerificationLevel.SERVER_VERIFIED,
+        workVerificationLevel = WorkVerificationLevel.NONE,
+        nickname = null,
+        identityType = IdentityType.NORMAL,
+        acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
+        activityState = IdentityState.ACTIVE,
+        syncState = ContactSyncState.INITIAL,
+        featureMask = 255u,
+        readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+        typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
+        profilePictureBlobId = null,
+        androidContactLookupKey = null,
+        localAvatarExpires = null,
+        isRestored = false,
+        jobTitle = null,
+        department = null,
+        notificationTriggerPolicyOverride = null,
+    )
+
+    private val serviceManager by lazy { ThreemaApplication.requireServiceManager() }
+
+    private val noopApiService = object : ApiService {
+        override fun createUploader(
+            data: ByteArray,
+            shouldPersist: Boolean,
+            blobScope: BlobScope,
+        ): BlobUploader {
+            MUST_NOT_BE_CALLED()
+        }
+
+        override fun createLoader(blobId: ByteArray): BlobLoader {
+            MUST_NOT_BE_CALLED()
+        }
+
+        override fun getAuthToken(): String {
+            MUST_NOT_BE_CALLED()
+        }
+
+        override fun invalidateAuthToken() {
+            MUST_NOT_BE_CALLED()
+        }
+
+        override fun createAvatarURLConnection(identity: String?): HttpsURLConnection {
+            MUST_NOT_BE_CALLED()
+        }
+    }
+
+    private val testMultiDeviceManagerMdEnabled by lazy {
+        TestMultiDeviceManager(
+            isMdDisabledOrSupportsFs = false,
+            isMultiDeviceActive = true,
+        )
+    }
+
+    private val testMultiDeviceManagerMdDisabled by lazy {
+        TestMultiDeviceManager(
+            isMdDisabledOrSupportsFs = true,
+            isMultiDeviceActive = false,
+        )
+    }
+
+    @Before
+    fun setup() {
+        clearDatabaseAndCaches(serviceManager)
+
+        assert(myContact.identity == TestHelpers.ensureIdentity(serviceManager))
+
+        // Note that we use from sync to prevent any reflection. This is only acceptable in tests.
+        serviceManager.modelRepositories.contacts.createFromSync(initialContactModelData)
+
+        // Note that we use from sync to prevent any reflection. This is only acceptable in tests.
+        try {
+            val now = Date()
+            serviceManager.modelRepositories.groups.createFromSync(
+                GroupModelData(
+                    groupIdentity = GroupIdentity(myContact.identity, 42),
+                    name = "My Group",
+                    createdAt = now,
+                    synchronizedAt = null,
+                    lastUpdate = now,
+                    isArchived = false,
+                    userState = GroupModel.UserState.MEMBER,
+                    otherMembers = setOf(initialContactModelData.identity),
+                    groupDescription = null,
+                    groupDescriptionChangedAt = null,
+                    notificationTriggerPolicyOverride = null,
+                ),
+            )
+        } catch (e: GroupCreateException) {
+            // Ignore
+        }
+    }
+
+    @Test
+    fun testSimpleGroupMd() = runTest {
+        val predefinedMessageIds = PredefinedMessageIds()
+        val groupCreateTask = GroupCreateTask(
+            name = "My Group",
+            profilePictureChange = RemoveProfilePicture,
+            members = setOf(initialContactModelData.identity),
+            groupIdentity = GroupIdentity(myContact.identity, 42),
+            predefinedMessageIds = predefinedMessageIds,
+            outgoingCspMessageServices = getOutgoingCspMessageServicesMd(),
+            groupCallManager = serviceManager.groupCallManager,
+            fileService = serviceManager.fileService,
+            apiService = noopApiService,
+            groupModelRepository = serviceManager.modelRepositories.groups,
+        )
+
+        val handle = TransactionAckTaskCodec()
+        groupCreateTask.invoke(handle)
+        assertEquals(1, handle.transactionBeginCount)
+        assertEquals(1, handle.transactionCommitCount)
+
+        handle.outboundMessages.apply {
+            // The transaction start
+            assertIs<OutboundD2mMessage.BeginTransaction>(get(0))
+
+            // The reflected csp messages
+            assertIs<OutboundD2mMessage.Reflect>(get(1))
+            assertIs<OutboundD2mMessage.Reflect>(get(2))
+            assertIs<OutboundD2mMessage.Reflect>(get(3))
+
+            // The sent csp messages
+            assertIs<CspMessage>(get(4))
+            assertIs<CspMessage>(get(5))
+            assertIs<CspMessage>(get(6))
+
+            // The transaction end
+            assertIs<OutboundD2mMessage.CommitTransaction>(get(7))
+
+            // Assert that there are no more messages
+            assertEquals(8, size)
+        }
+    }
+
+    @Test
+    fun testSimpleGroupNonMd() = runTest {
+        val predefinedMessageIds = PredefinedMessageIds()
+        val groupCreateTask = GroupCreateTask(
+            name = "My Group",
+            profilePictureChange = RemoveProfilePicture,
+            members = setOf("12345678"),
+            groupIdentity = GroupIdentity(myContact.identity, 42),
+            predefinedMessageIds = predefinedMessageIds,
+            outgoingCspMessageServices = getOutgoingCspMessageServicesNonMd(),
+            groupCallManager = serviceManager.groupCallManager,
+            fileService = serviceManager.fileService,
+            apiService = noopApiService,
+            groupModelRepository = serviceManager.modelRepositories.groups,
+        )
+
+        val handle = TransactionAckTaskCodec()
+        groupCreateTask.invoke(handle)
+        assertEquals(0, handle.transactionBeginCount)
+        assertEquals(0, handle.transactionCommitCount)
+
+        handle.outboundMessages.apply {
+            assertEquals(6, size)
+            // Empty message and group setup message
+            assertIs<CspMessage>(get(0))
+            assertIs<CspMessage>(get(1))
+
+            // Empty message and group name message
+            assertIs<CspMessage>(get(2))
+            assertIs<CspMessage>(get(3))
+
+            // Empty message and group delete profile picture message
+            assertIs<CspMessage>(get(4))
+            assertIs<CspMessage>(get(5))
+        }
+    }
+
+    private fun getOutgoingCspMessageServicesMd() = OutgoingCspMessageServices(
+        forwardSecurityMessageProcessor = serviceManager.forwardSecurityMessageProcessor,
+        identityStore = serviceManager.identityStore,
+        userService = serviceManager.userService,
+        contactStore = serviceManager.contactStore,
+        contactService = serviceManager.contactService,
+        contactModelRepository = serviceManager.modelRepositories.contacts,
+        groupService = serviceManager.groupService,
+        nonceFactory = serviceManager.nonceFactory,
+        blockedIdentitiesService = serviceManager.blockedIdentitiesService,
+        preferenceService = serviceManager.preferenceService,
+        multiDeviceManager = testMultiDeviceManagerMdEnabled,
+    ).apply {
+        forwardSecurityMessageProcessor.setForwardSecurityEnabled(false)
+    }
+
+    private fun getOutgoingCspMessageServicesNonMd() = OutgoingCspMessageServices(
+        forwardSecurityMessageProcessor = serviceManager.forwardSecurityMessageProcessor,
+        identityStore = serviceManager.identityStore,
+        userService = serviceManager.userService,
+        contactStore = serviceManager.contactStore,
+        contactService = serviceManager.contactService,
+        contactModelRepository = serviceManager.modelRepositories.contacts,
+        groupService = serviceManager.groupService,
+        nonceFactory = serviceManager.nonceFactory,
+        blockedIdentitiesService = serviceManager.blockedIdentitiesService,
+        preferenceService = serviceManager.preferenceService,
+        multiDeviceManager = testMultiDeviceManagerMdDisabled,
+    ).apply {
+        forwardSecurityMessageProcessor.setForwardSecurityEnabled(true)
+    }
+}

+ 298 - 67
app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt

@@ -23,6 +23,8 @@ package ch.threema.app.tasks
 
 
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.ThreemaApplication
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.ContactModelData
+import ch.threema.data.models.GroupIdentity
+import ch.threema.data.models.GroupModelData
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.ContactSyncState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.IdentityType
@@ -34,13 +36,13 @@ import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.ContactModel
 import com.neilalexander.jnacl.NaCl
 import com.neilalexander.jnacl.NaCl
+import java.util.Date
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertNotNull
 import junit.framework.TestCase.assertNotNull
 import junit.framework.TestCase.fail
 import junit.framework.TestCase.fail
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.runBlocking
 import kotlinx.serialization.json.Json
 import kotlinx.serialization.json.Json
 import org.junit.Test
 import org.junit.Test
-import java.util.Date
 
 
 /**
 /**
  * These tests are useful to detect when a task cannot be created out of a persisted representation
  * These tests are useful to detect when a task cannot be created out of a persisted representation
@@ -55,7 +57,8 @@ class PersistableTasksTest {
     fun testContactDeliveryReceiptMessageTask() {
     fun testContactDeliveryReceiptMessageTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingContactDeliveryReceiptMessageTask::class.java,
             OutgoingContactDeliveryReceiptMessageTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingContactDeliveryReceiptMessageTask.OutgoingDeliveryReceiptMessageData\",\"receiptType\":1,\"messageIds\":[\"0000000000000000\"],\"date\":\"1234567890\",\"toIdentity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.OutgoingContactDeliveryReceiptMessageTask.OutgoingDeliveryReceiptMessageData",""" +
+                """"receiptType":1,"messageIds":["0000000000000000"],"date":"1234567890","toIdentity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -63,7 +66,8 @@ class PersistableTasksTest {
     fun testFileMessageTask() {
     fun testFileMessageTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingFileMessageTask::class.java,
             OutgoingFileMessageTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingFileMessageTask.OutgoingFileMessageData\",\"messageModelId\":1,\"receiverType\":0,\"recipientIdentities\":[\"01234567\"],\"thumbnailBlobId\":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]}"
+            """{"type":"ch.threema.app.tasks.OutgoingFileMessageTask.OutgoingFileMessageData","messageModelId":1,"receiverType":0,""" +
+                """"recipientIdentities":["01234567"],"thumbnailBlobId":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]}""",
         )
         )
     }
     }
 
 
@@ -71,7 +75,9 @@ class PersistableTasksTest {
     fun testGroupDeleteProfilePictureTask() {
     fun testGroupDeleteProfilePictureTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingGroupDeleteProfilePictureTask::class.java,
             OutgoingGroupDeleteProfilePictureTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupDeleteProfilePictureTask.OutgoingGroupDeleteProfilePictureData\",\"groupId\":[0,0,0,0,0,0,0,0],\"creatorIdentity\":\"01234567\",\"receiverIdentities\":[\"01234567\"],\"messageId\":[0,0,0,0,0,0,0,0]}"
+            """{"type":"ch.threema.app.tasks.OutgoingGroupDeleteProfilePictureTask.OutgoingGroupDeleteProfilePictureData",""" +
+                """"groupId":[0,0,0,0,0,0,0,0],"creatorIdentity":"01234567","receiverIdentities":["01234567"],""" +
+                """"messageId":[0,0,0,0,0,0,0,0]}""",
         )
         )
     }
     }
 
 
@@ -79,7 +85,8 @@ class PersistableTasksTest {
     fun testGroupDeliveryReceiptMessageTask() {
     fun testGroupDeliveryReceiptMessageTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingGroupDeliveryReceiptMessageTask::class.java,
             OutgoingGroupDeliveryReceiptMessageTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupDeliveryReceiptMessageTask.OutgoingGroupDeliveryReceiptMessageData\",\"messageModelId\":0,\"recipientIdentities\":[\"01234567\",\"01234567\"],\"receiptType\":0}"
+            """{"type":"ch.threema.app.tasks.OutgoingGroupDeliveryReceiptMessageTask.OutgoingGroupDeliveryReceiptMessageData",""" +
+                """"messageModelId":0,"recipientIdentities":["01234567","01234567"],"receiptType":0}""",
         )
         )
     }
     }
 
 
@@ -87,7 +94,9 @@ class PersistableTasksTest {
     fun testGroupLeaveTask() {
     fun testGroupLeaveTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingGroupLeaveTask::class.java,
             OutgoingGroupLeaveTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupLeaveTask.OutgoingGroupLeaveData\",\"groupId\":[0,0,0,0,0,0,0,0],\"creatorIdentity\":\"01234567\",\"receiverIdentities\":[\"01234567\"],\"messageId\":[0,0,0,0,0,0,0,0]}"
+            """{"type":"ch.threema.app.tasks.OutgoingGroupLeaveTask.OutgoingGroupLeaveTaskData",""" +
+                """"groupIdentity":{"creatorIdentity":"01234567","groupId":42},"memberIdentities":["01234567"],""" +
+                """"messageId":[0,0,0,0,0,0,0,0]}""",
         )
         )
     }
     }
 
 
@@ -95,7 +104,9 @@ class PersistableTasksTest {
     fun testGroupNameTask() {
     fun testGroupNameTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingGroupNameTask::class.java,
             OutgoingGroupNameTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupNameTask.OutgoingGroupNameData\",\"groupId\":[0,0,0,0,0,0,0,0],\"creatorIdentity\":\"01234567\",\"groupName\":\"groupName\",\"receiverIdentities\":[\"01234567\"],\"messageId\":[0,0,0,0,0,0,0,0]}"
+            """{"type":"ch.threema.app.tasks.OutgoingGroupNameTask.OutgoingGroupNameData","groupId":[0,0,0,0,0,0,0,0],""" +
+                """"creatorIdentity":"01234567","groupName":"groupName","receiverIdentities":["01234567"],""" +
+                """"messageId":[0,0,0,0,0,0,0,0]}""",
         )
         )
     }
     }
 
 
@@ -103,7 +114,9 @@ class PersistableTasksTest {
     fun testGroupProfilePictureTask() {
     fun testGroupProfilePictureTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingGroupProfilePictureTask::class.java,
             OutgoingGroupProfilePictureTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupProfilePictureTask.OutgoingGroupProfilePictureData\",\"groupId\":[0,0,0,0,0,0,0,0],\"creatorIdentity\":\"01234567\",\"receiverIdentities\":[\"01234567\"],\"messageId\":[0,0,0,0,0,0,0,0]}"
+            """{"type":"ch.threema.app.tasks.OutgoingGroupProfilePictureTask.OutgoingGroupProfilePictureData",""" +
+                """"groupId":[0,0,0,0,0,0,0,0],"creatorIdentity":"01234567","receiverIdentities":["01234567"],""" +
+                """"messageId":[0,0,0,0,0,0,0,0]}""",
         )
         )
     }
     }
 
 
@@ -111,7 +124,9 @@ class PersistableTasksTest {
     fun testGroupSetupTask() {
     fun testGroupSetupTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingGroupSetupTask::class.java,
             OutgoingGroupSetupTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupSetupTask.OutgoingGroupSetupData\",\"groupId\":[0,0,0,0,0,0,0,0],\"creatorIdentity\":\"01234567\",\"memberIdentities\":[\"01234567\"],\"receiverIdentities\":[\"01234567\"],\"messageId\":[0,0,0,0,0,0,0,0]}"
+            """{"type":"ch.threema.app.tasks.OutgoingGroupSetupTask.OutgoingGroupSetupData","groupId":[0,0,0,0,0,0,0,0],""" +
+                """"creatorIdentity":"01234567","memberIdentities":["01234567"],"receiverIdentities":["01234567"],""" +
+                """"messageId":[0,0,0,0,0,0,0,0]}""",
         )
         )
     }
     }
 
 
@@ -119,7 +134,8 @@ class PersistableTasksTest {
     fun testGroupSyncRequestTask() {
     fun testGroupSyncRequestTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingGroupSyncRequestTask::class.java,
             OutgoingGroupSyncRequestTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupSyncRequestTask.OutgoingGroupSyncRequestData\",\"groupId\":[0,0,0,0,0,0,0,0],\"creatorIdentity\":\"01234567\",\"messageId\":[0,0,0,0,0,0,0,0]}"
+            """{"type":"ch.threema.app.tasks.OutgoingGroupSyncRequestTask.OutgoingGroupSyncRequestData",""" +
+                """"groupId":[0,0,0,0,0,0,0,0],"creatorIdentity":"01234567","messageId":[0,0,0,0,0,0,0,0]}""",
         )
         )
     }
     }
 
 
@@ -127,7 +143,8 @@ class PersistableTasksTest {
     fun testGroupSyncTask() {
     fun testGroupSyncTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingGroupSyncTask::class.java,
             OutgoingGroupSyncTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupSyncTask.OutgoingGroupSyncData\",\"groupId\":[0,0,0,0,0,0,0,0],\"creatorIdentity\":\"01234567\",\"receiverIdentities\":[\"01234567\"]}"
+            """{"type":"ch.threema.app.tasks.OutgoingGroupSyncTask.OutgoingGroupSyncData","groupId":[0,0,0,0,0,0,0,0],""" +
+                """"creatorIdentity":"01234567","receiverIdentities":["01234567"]}""",
         )
         )
     }
     }
 
 
@@ -135,7 +152,8 @@ class PersistableTasksTest {
     fun testLocationMessageTask() {
     fun testLocationMessageTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingLocationMessageTask::class.java,
             OutgoingLocationMessageTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingLocationMessageTask.OutgoingLocationMessageTaskData\",\"messageModelId\":0,\"recipientIdentities\":[\"01234567\",\"01234567\"],\"receiverType\":0}"
+            """{"type":"ch.threema.app.tasks.OutgoingLocationMessageTask.OutgoingLocationMessageTaskData","messageModelId":0,""" +
+                """"recipientIdentities":["01234567","01234567"],"receiverType":0}""",
         )
         )
     }
     }
 
 
@@ -143,7 +161,10 @@ class PersistableTasksTest {
     fun testPollSetupMessageTask() {
     fun testPollSetupMessageTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingPollSetupMessageTask::class.java,
             OutgoingPollSetupMessageTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingPollSetupMessageTask.OutgoingPollSetupMessageData\",\"messageModelId\":0,\"recipientIdentities\":[\"01234567\",\"01234567\"],\"receiverType\":0,\"ballotId\":[-58,11,102,-122,-119,-102,19,-10],\"ballotData\":\"{\\\"d\\\":\\\"description\\\",\\\"s\\\":0,\\\"a\\\":0,\\\"t\\\":1,\\\"o\\\":0,\\\"u\\\":0,\\\"c\\\":[{\\\"i\\\":0,\\\"n\\\":\\\"desc\\\",\\\"o\\\":0,\\\"r\\\":[0],\\\"t\\\":0}],\\\"p\\\":[\\\"01234567\\\"]}\"}"
+            """{"type":"ch.threema.app.tasks.OutgoingPollSetupMessageTask.OutgoingPollSetupMessageData","messageModelId":0,""" +
+                """"recipientIdentities":["01234567","01234567"],"receiverType":0,"ballotId":[-58,11,102,-122,-119,-102,19,-10],""" +
+                """"ballotData":"{\"d\":\"description\",\"s\":0,\"a\":0,\"t\":1,\"o\":0,\"u\":0,""" +
+                """\"c\":[{\"i\":0,\"n\":\"desc\",\"o\":0,\"r\":[0],\"t\":0}],\"p\":[\"01234567\"]}"}""",
         )
         )
     }
     }
 
 
@@ -151,7 +172,9 @@ class PersistableTasksTest {
     fun testPollVoteContactMessageTask() {
     fun testPollVoteContactMessageTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingPollVoteContactMessageTask::class.java,
             OutgoingPollVoteContactMessageTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingPollVoteContactMessageTask.OutgoingPollVoteContactMessageData\",\"messageId\":\"0000000000000000\",\"ballotId\":[-127,-79,80,-109,-98,62,-3,81],\"ballotCreator\":\"01234567\",\"ballotVotes\":[{\"first\":0,\"second\":0}],\"toIdentity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.OutgoingPollVoteContactMessageTask.OutgoingPollVoteContactMessageData",""" +
+                """"messageId":"0000000000000000","ballotId":[-127,-79,80,-109,-98,62,-3,81],"ballotCreator":"01234567",""" +
+                """"ballotVotes":[{"first":0,"second":0}],"toIdentity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -159,7 +182,10 @@ class PersistableTasksTest {
     fun testPollVoteGroupMessageTask() {
     fun testPollVoteGroupMessageTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingPollVoteGroupMessageTask::class.java,
             OutgoingPollVoteGroupMessageTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingPollVoteGroupMessageTask.OutgoingPollVoteGroupMessageData\",\"messageId\":\"0000000000000000\",\"recipientIdentities\":[\"01234567\",\"01234567\"],\"ballotId\":[52,64,-6,18,2,-71,124,-19],\"ballotCreator\":\"01234567\",\"ballotVotes\":[{\"first\":0,\"second\":0}],\"ballotType\":\"INTERMEDIATE\",\"apiGroupId\":\"0000000000000000\",\"groupCreator\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.OutgoingPollVoteGroupMessageTask.OutgoingPollVoteGroupMessageData",""" +
+                """"messageId":"0000000000000000","recipientIdentities":["01234567","01234567"],"ballotId":[52,64,-6,18,2,-71,124,-19],""" +
+                """"ballotCreator":"01234567","ballotVotes":[{"first":0,"second":0}],"ballotType":"INTERMEDIATE",""" +
+                """"apiGroupId":"0000000000000000","groupCreator":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -167,7 +193,8 @@ class PersistableTasksTest {
     fun testTextMessageTask() {
     fun testTextMessageTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingTextMessageTask::class.java,
             OutgoingTextMessageTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingTextMessageTask.OutgoingTextMessageData\",\"messageModelId\":0,\"recipientIdentities\":[\"01234567\",\"01234567\"],\"receiverType\":0}"
+            """{"type":"ch.threema.app.tasks.OutgoingTextMessageTask.OutgoingTextMessageData","messageModelId":0,""" +
+                """"recipientIdentities":["01234567","01234567"],"receiverType":0}""",
         )
         )
     }
     }
 
 
@@ -175,7 +202,7 @@ class PersistableTasksTest {
     fun testSendProfilePictureTask() {
     fun testSendProfilePictureTask() {
         assertValidEncoding(
         assertValidEncoding(
             SendProfilePictureTask::class.java,
             SendProfilePictureTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.SendProfilePictureTask.SendProfilePictureData\",\"toIdentity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.SendProfilePictureTask.SendProfilePictureData","toIdentity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -183,7 +210,7 @@ class PersistableTasksTest {
     fun testSendPushTokenTask() {
     fun testSendPushTokenTask() {
         assertValidEncoding(
         assertValidEncoding(
             SendPushTokenTask::class.java,
             SendPushTokenTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.SendPushTokenTask.SendPushTokenData\",\"token\":\"token\",\"tokenType\":0}"
+            """{"type":"ch.threema.app.tasks.SendPushTokenTask.SendPushTokenData","token":"token","tokenType":0}""",
         )
         )
     }
     }
 
 
@@ -191,30 +218,35 @@ class PersistableTasksTest {
     fun testOutgoingContactRequestProfilePictureTask() {
     fun testOutgoingContactRequestProfilePictureTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingContactRequestProfilePictureTask::class.java,
             OutgoingContactRequestProfilePictureTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingContactRequestProfilePictureTask.OutgoingContactRequestProfilePictureData\",\"toIdentity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.OutgoingContactRequestProfilePictureTask.OutgoingContactRequestProfilePictureData",""" +
+                """"toIdentity":"01234567"}""",
         )
         )
     }
     }
 
 
     @Test
     @Test
     fun testDeleteAndTerminateFSSessionsTask() {
     fun testDeleteAndTerminateFSSessionsTask() {
         // Add the contact '01234567' so that creating the tasks works
         // Add the contact '01234567' so that creating the tasks works
-        addTestIdentity()
+        addTestData()
 
 
         assertValidEncoding(
         assertValidEncoding(
             DeleteAndTerminateFSSessionsTask::class.java,
             DeleteAndTerminateFSSessionsTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.DeleteAndTerminateFSSessionsTask.DeleteAndTerminateFSSessionsTaskData\",\"identity\":\"01234567\",\"cause\":\"RESET\"}"
+            """{"type":"ch.threema.app.tasks.DeleteAndTerminateFSSessionsTask.DeleteAndTerminateFSSessionsTaskData",""" +
+                """"identity":"01234567","cause":"RESET"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             DeleteAndTerminateFSSessionsTask::class.java,
             DeleteAndTerminateFSSessionsTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.DeleteAndTerminateFSSessionsTask.DeleteAndTerminateFSSessionsTaskData\",\"identity\":\"01234567\",\"cause\":\"UNKNOWN_SESSION\"}"
+            """{"type":"ch.threema.app.tasks.DeleteAndTerminateFSSessionsTask.DeleteAndTerminateFSSessionsTaskData",""" +
+                """"identity":"01234567","cause":"UNKNOWN_SESSION"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             DeleteAndTerminateFSSessionsTask::class.java,
             DeleteAndTerminateFSSessionsTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.DeleteAndTerminateFSSessionsTask.DeleteAndTerminateFSSessionsTaskData\",\"identity\":\"01234567\",\"cause\":\"DISABLED_BY_LOCAL\"}"
+            """{"type":"ch.threema.app.tasks.DeleteAndTerminateFSSessionsTask.DeleteAndTerminateFSSessionsTaskData",""" +
+                """"identity":"01234567","cause":"DISABLED_BY_LOCAL"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             DeleteAndTerminateFSSessionsTask::class.java,
             DeleteAndTerminateFSSessionsTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.DeleteAndTerminateFSSessionsTask.DeleteAndTerminateFSSessionsTaskData\",\"identity\":\"01234567\",\"cause\":\"DISABLED_BY_REMOTE\"}"
+            """{"type":"ch.threema.app.tasks.DeleteAndTerminateFSSessionsTask.DeleteAndTerminateFSSessionsTaskData",""" +
+                """"identity":"01234567","cause":"DISABLED_BY_REMOTE"}""",
         )
         )
     }
     }
 
 
@@ -222,7 +254,7 @@ class PersistableTasksTest {
     fun testApplicationUpdateStepsTask() {
     fun testApplicationUpdateStepsTask() {
         assertValidEncoding(
         assertValidEncoding(
             ApplicationUpdateStepsTask::class.java,
             ApplicationUpdateStepsTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ApplicationUpdateStepsTask.ApplicationUpdateStepsData\"}"
+            """{"type":"ch.threema.app.tasks.ApplicationUpdateStepsTask.ApplicationUpdateStepsData"}""",
         )
         )
     }
     }
 
 
@@ -230,7 +262,7 @@ class PersistableTasksTest {
     fun testFSRefreshStepsTask() {
     fun testFSRefreshStepsTask() {
         assertValidEncoding(
         assertValidEncoding(
             FSRefreshStepsTask::class.java,
             FSRefreshStepsTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.FSRefreshStepsTask.FSRefreshStepsTaskData\",\"contactIdentities\":[\"01234567\"]}"
+            """{"type":"ch.threema.app.tasks.FSRefreshStepsTask.FSRefreshStepsTaskData","contactIdentities":["01234567"]}""",
         )
         )
     }
     }
 
 
@@ -238,7 +270,8 @@ class PersistableTasksTest {
     fun testOutboundIncomingContactMessageUpdateReadTask() {
     fun testOutboundIncomingContactMessageUpdateReadTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutboundIncomingContactMessageUpdateReadTask::class.java,
             OutboundIncomingContactMessageUpdateReadTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutboundIncomingContactMessageUpdateReadTask.OutboundIncomingContactMessageUpdateReadData\",\"messageIds\":[[0,-1,2,3,4,5,6,7]],\"timestamp\":1704067200000,\"recipientIdentity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.OutboundIncomingContactMessageUpdateReadTask.OutboundIncomingContactMessageUpdateReadData",""" +
+                """"messageIds":[[0,-1,2,3,4,5,6,7]],"timestamp":1704067200000,"recipientIdentity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -246,7 +279,9 @@ class PersistableTasksTest {
     fun testOutboundIncomingGroupMessageUpdateReadTask() {
     fun testOutboundIncomingGroupMessageUpdateReadTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutboundIncomingGroupMessageUpdateReadTask::class.java,
             OutboundIncomingGroupMessageUpdateReadTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutboundIncomingGroupMessageUpdateReadTask.OutboundIncomingGroupMessageUpdateReadData\",\"messageIds\":[[0,-1,2,3,4,5,6,7]],\"timestamp\":1704067200000,\"groupId\":[0,0,0,0,0,0,0,0],\"creatorIdentity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.OutboundIncomingGroupMessageUpdateReadTask.OutboundIncomingGroupMessageUpdateReadData",""" +
+                """"messageIds":[[0,-1,2,3,4,5,6,7]],"timestamp":1704067200000,"groupId":[0,0,0,0,0,0,0,0],""" +
+                """"creatorIdentity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -254,7 +289,8 @@ class PersistableTasksTest {
     fun testOutgoingContactEditMessageTask() {
     fun testOutgoingContactEditMessageTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingContactEditMessageTask::class.java,
             OutgoingContactEditMessageTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingContactEditMessageTask.OutgoingContactEditMessageData\",\"toIdentity\":\"01234567\",\"messageModelId\":0, \"messageId\":[0,0,0,0,0,0,0,0], \"editedText\":\"test\", \"editedAt\":0}"
+            """{"type":"ch.threema.app.tasks.OutgoingContactEditMessageTask.OutgoingContactEditMessageData",""" +
+                """"toIdentity":"01234567","messageModelId":0, "messageId":[0,0,0,0,0,0,0,0], "editedText":"test", "editedAt":0}""",
         )
         )
     }
     }
 
 
@@ -262,7 +298,8 @@ class PersistableTasksTest {
     fun testOutgoingGroupEditMessageTask() {
     fun testOutgoingGroupEditMessageTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingGroupEditMessageTask::class.java,
             OutgoingGroupEditMessageTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupEditMessageTask.OutgoingGroupEditMessageData\",\"messageModelId\":0, \"messageId\":[0,0,0,0,0,0,0,0], \"editedText\":\"test\", \"editedAt\":0,\"recipientIdentities\":[\"01234567\",\"01234567\"]}"
+            """{"type":"ch.threema.app.tasks.OutgoingGroupEditMessageTask.OutgoingGroupEditMessageData","messageModelId":0, """ +
+                """"messageId":[0,0,0,0,0,0,0,0], "editedText":"test", "editedAt":0,"recipientIdentities":["01234567","01234567"]}""",
         )
         )
     }
     }
 
 
@@ -270,7 +307,8 @@ class PersistableTasksTest {
     fun testOutgoingContactDeleteMessageTask() {
     fun testOutgoingContactDeleteMessageTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingContactDeleteMessageTask::class.java,
             OutgoingContactDeleteMessageTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingContactDeleteMessageTask.OutgoingContactDeleteMessageData\",\"toIdentity\":\"01234567\",\"messageModelId\":0, \"messageId\":[0,0,0,0,0,0,0,0], \"deletedAt\":0}"
+            """{"type":"ch.threema.app.tasks.OutgoingContactDeleteMessageTask.OutgoingContactDeleteMessageData",""" +
+                """"toIdentity":"01234567","messageModelId":0, "messageId":[0,0,0,0,0,0,0,0], "deletedAt":0}""",
         )
         )
     }
     }
 
 
@@ -278,7 +316,8 @@ class PersistableTasksTest {
     fun testOutgoingGroupDeleteMessageTask() {
     fun testOutgoingGroupDeleteMessageTask() {
         assertValidEncoding(
         assertValidEncoding(
             OutgoingGroupDeleteMessageTask::class.java,
             OutgoingGroupDeleteMessageTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupDeleteMessageTask.OutgoingGroupDeleteMessageData\",\"messageModelId\":0,\"messageId\":[0,0,0,0,0,0,0,0],\"deletedAt\":0,\"recipientIdentities\":[\"01234567\",\"01234567\"]}"
+            """{"type":"ch.threema.app.tasks.OutgoingGroupDeleteMessageTask.OutgoingGroupDeleteMessageData",""" +
+                """"messageModelId":0,"messageId":[0,0,0,0,0,0,0,0],"deletedAt":0,"recipientIdentities":["01234567","01234567"]}""",
         )
         )
     }
     }
 
 
@@ -286,7 +325,8 @@ class PersistableTasksTest {
     fun testReflectUserProfileNicknameSyncTask() {
     fun testReflectUserProfileNicknameSyncTask() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectUserProfileNicknameSyncTask::class.java,
             ReflectUserProfileNicknameSyncTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectUserProfileNicknameSyncTask.ReflectUserProfileNicknameSyncTaskData\",\"newNickname\":\"nick\"}"
+            """{"type":"ch.threema.app.tasks.ReflectUserProfileNicknameSyncTask.ReflectUserProfileNicknameSyncTaskData",""" +
+                """"newNickname":"nick"}""",
         )
         )
     }
     }
 
 
@@ -294,7 +334,7 @@ class PersistableTasksTest {
     fun testReflectUserProfilePictureSyncTask() {
     fun testReflectUserProfilePictureSyncTask() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectUserProfilePictureSyncTask::class.java,
             ReflectUserProfilePictureSyncTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectUserProfilePictureSyncTask.ReflectUserProfilePictureSyncTaskData\"}"
+            """{"type":"ch.threema.app.tasks.ReflectUserProfilePictureSyncTask.ReflectUserProfilePictureSyncTaskData"}""",
         )
         )
     }
     }
 
 
@@ -302,7 +342,8 @@ class PersistableTasksTest {
     fun testReflectUserProfileShareWithPolicySyncTask() {
     fun testReflectUserProfileShareWithPolicySyncTask() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectUserProfileShareWithPolicySyncTask::class.java,
             ReflectUserProfileShareWithPolicySyncTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectUserProfileShareWithPolicySyncTask.ReflectUserProfileShareWithPolicySyncTaskData\",\"newPolicy\":\"NOBODY\"}"
+            """{"type":"ch.threema.app.tasks.ReflectUserProfileShareWithPolicySyncTask.ReflectUserProfileShareWithPolicySyncTaskData",""" +
+                """"newPolicy":"NOBODY"}""",
         )
         )
     }
     }
 
 
@@ -310,7 +351,17 @@ class PersistableTasksTest {
     fun testReflectUserProfileShareWithAllowListSyncTask() {
     fun testReflectUserProfileShareWithAllowListSyncTask() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectUserProfileShareWithAllowListSyncTask::class.java,
             ReflectUserProfileShareWithAllowListSyncTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectUserProfileShareWithAllowListSyncTask.ReflectUserProfileShareWithAllowListSyncTaskData\",\"allowedIdentities\":[\"01234567\", \"01234568\"]}"
+            """{"type":""" +
+                """"ch.threema.app.tasks.ReflectUserProfileShareWithAllowListSyncTask.ReflectUserProfileShareWithAllowListSyncTaskData",""" +
+                """"allowedIdentities":["01234567", "01234568"]}""",
+        )
+    }
+
+    @Test
+    fun testReflectUserProfileIdentityLinksTask() {
+        assertValidEncoding(
+            expectedTaskClass = ReflectUserProfileIdentityLinksTask::class.java,
+            encodedTask = """{"type":"ch.threema.app.tasks.ReflectUserProfileIdentityLinksTask.ReflectUserProfileIdentityLinksTaskData"}""",
         )
         )
     }
     }
 
 
@@ -318,15 +369,18 @@ class PersistableTasksTest {
     fun testReflectNameUpdate() {
     fun testReflectNameUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectNameUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectNameUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectNameUpdate.ReflectNameUpdateData\",\"firstName\":\"A\",\"lastName\":\"B\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectNameUpdate.ReflectNameUpdateData",""" +
+                """"firstName":"A","lastName":"B","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectNameUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectNameUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectNameUpdate.ReflectNameUpdateData\",\"firstName\":\"A\",\"lastName\":\"\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectNameUpdate.ReflectNameUpdateData",""" +
+                """"firstName":"A","lastName":"","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectNameUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectNameUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectNameUpdate.ReflectNameUpdateData\",\"firstName\":\"\",\"lastName\":\"B\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectNameUpdate.ReflectNameUpdateData",""" +
+                """"firstName":"","lastName":"B","identity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -334,15 +388,18 @@ class PersistableTasksTest {
     fun testReflectReadReceiptPolicyUpdate() {
     fun testReflectReadReceiptPolicyUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate.ReflectReadReceiptPolicyUpdateData\",\"readReceiptPolicy\":\"DEFAULT\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate.ReflectReadReceiptPolicyUpdateData",""" +
+                """"readReceiptPolicy":"DEFAULT","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate.ReflectReadReceiptPolicyUpdateData\",\"readReceiptPolicy\":\"SEND\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate.ReflectReadReceiptPolicyUpdateData",""" +
+                """"readReceiptPolicy":"SEND","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate.ReflectReadReceiptPolicyUpdateData\",\"readReceiptPolicy\":\"DONT_SEND\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectReadReceiptPolicyUpdate.ReflectReadReceiptPolicyUpdateData",""" +
+                """"readReceiptPolicy":"DONT_SEND","identity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -350,15 +407,18 @@ class PersistableTasksTest {
     fun testReflectTypingIndicatorPolicyUpdate() {
     fun testReflectTypingIndicatorPolicyUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate.ReflectTypingIndicatorPolicyUpdateData\",\"typingIndicatorPolicy\":\"DEFAULT\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate.""" +
+                """ReflectTypingIndicatorPolicyUpdateData","typingIndicatorPolicy":"DEFAULT","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate.ReflectTypingIndicatorPolicyUpdateData\",\"typingIndicatorPolicy\":\"SEND\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate.""" +
+                """ReflectTypingIndicatorPolicyUpdateData","typingIndicatorPolicy":"SEND","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate.ReflectTypingIndicatorPolicyUpdateData\",\"typingIndicatorPolicy\":\"DONT_SEND\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectTypingIndicatorPolicyUpdate.""" +
+                """ReflectTypingIndicatorPolicyUpdateData","typingIndicatorPolicy":"DONT_SEND","identity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -366,15 +426,18 @@ class PersistableTasksTest {
     fun testReflectActivityStateUpdate() {
     fun testReflectActivityStateUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectActivityStateUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectActivityStateUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectActivityStateUpdate.ReflectActivityStateUpdateData\",\"identityState\":\"ACTIVE\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectActivityStateUpdate.ReflectActivityStateUpdateData",""" +
+                """"identityState":"ACTIVE","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectActivityStateUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectActivityStateUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectActivityStateUpdate.ReflectActivityStateUpdateData\",\"identityState\":\"INACTIVE\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectActivityStateUpdate.ReflectActivityStateUpdateData",""" +
+                """"identityState":"INACTIVE","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectActivityStateUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectActivityStateUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectActivityStateUpdate.ReflectActivityStateUpdateData\",\"identityState\":\"INVALID\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectActivityStateUpdate.ReflectActivityStateUpdateData",""" +
+                """"identityState":"INVALID","identity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -382,7 +445,8 @@ class PersistableTasksTest {
     fun testReflectFeatureMaskUpdate() {
     fun testReflectFeatureMaskUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectFeatureMaskUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectFeatureMaskUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectFeatureMaskUpdate.ReflectFeatureMaskUpdateData\",\"featureMask\":12345,\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectFeatureMaskUpdate.ReflectFeatureMaskUpdateData",""" +
+                """"featureMask":12345,"identity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -390,15 +454,18 @@ class PersistableTasksTest {
     fun testVerificationLevelUpdate() {
     fun testVerificationLevelUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate.ReflectVerificationLevelUpdateData\",\"verificationLevel\":\"UNVERIFIED\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate.ReflectVerificationLevelUpdateData",""" +
+                """"verificationLevel":"UNVERIFIED","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate.ReflectVerificationLevelUpdateData\",\"verificationLevel\":\"SERVER_VERIFIED\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate.ReflectVerificationLevelUpdateData",""" +
+                """"verificationLevel":"SERVER_VERIFIED","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate.ReflectVerificationLevelUpdateData\",\"verificationLevel\":\"FULLY_VERIFIED\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectVerificationLevelUpdate.ReflectVerificationLevelUpdateData",""" +
+                """"verificationLevel":"FULLY_VERIFIED","identity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -406,11 +473,13 @@ class PersistableTasksTest {
     fun testWorkVerificationLevelUpdate() {
     fun testWorkVerificationLevelUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectWorkVerificationLevelUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectWorkVerificationLevelUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectWorkVerificationLevelUpdate.ReflectWorkVerificationLevelUpdateData\",\"workVerificationLevel\":\"NONE\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectWorkVerificationLevelUpdate.""" +
+                """ReflectWorkVerificationLevelUpdateData","workVerificationLevel":"NONE","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectWorkVerificationLevelUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectWorkVerificationLevelUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectWorkVerificationLevelUpdate.ReflectWorkVerificationLevelUpdateData\",\"workVerificationLevel\":\"WORK_SUBSCRIPTION_VERIFIED\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectWorkVerificationLevelUpdate.""" +
+                """ReflectWorkVerificationLevelUpdateData","workVerificationLevel":"WORK_SUBSCRIPTION_VERIFIED","identity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -418,11 +487,13 @@ class PersistableTasksTest {
     fun testIdentityTypeUpdate() {
     fun testIdentityTypeUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectIdentityTypeUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectIdentityTypeUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectIdentityTypeUpdate.ReflectIdentityTypeUpdateData\",\"identityType\":\"NORMAL\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectIdentityTypeUpdate.ReflectIdentityTypeUpdateData",""" +
+                """"identityType":"NORMAL","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectIdentityTypeUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectIdentityTypeUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectIdentityTypeUpdate.ReflectIdentityTypeUpdateData\",\"identityType\":\"WORK\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectIdentityTypeUpdate.ReflectIdentityTypeUpdateData",""" +
+                """"identityType":"WORK","identity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -430,11 +501,13 @@ class PersistableTasksTest {
     fun testAcquaintanceLevelUpdate() {
     fun testAcquaintanceLevelUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectAcquaintanceLevelUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectAcquaintanceLevelUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectAcquaintanceLevelUpdate.ReflectAcquaintanceLevelUpdateData\",\"acquaintanceLevel\":\"DIRECT\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectAcquaintanceLevelUpdate.ReflectAcquaintanceLevelUpdateData",""" +
+                """"acquaintanceLevel":"DIRECT","identity":"01234567"}""",
         )
         )
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectAcquaintanceLevelUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectAcquaintanceLevelUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectAcquaintanceLevelUpdate.ReflectAcquaintanceLevelUpdateData\",\"acquaintanceLevel\":\"GROUP\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectAcquaintanceLevelUpdate.ReflectAcquaintanceLevelUpdateData",""" +
+                """"acquaintanceLevel":"GROUP","identity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -442,18 +515,19 @@ class PersistableTasksTest {
     fun testUserDefinedProfilePictureUpdate() {
     fun testUserDefinedProfilePictureUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectUserDefinedProfilePictureUpdate::class.java,
             ReflectContactSyncUpdateTask.ReflectUserDefinedProfilePictureUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectUserDefinedProfilePictureUpdate.ReflectUserDefinedProfilePictureUpdateData\",\"identity\":\"0BZYE2H9\"}"
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectUserDefinedProfilePictureUpdate.""" +
+                """ReflectUserDefinedProfilePictureUpdateData","identity":"0BZYE2H9"}""",
         )
         )
     }
     }
 
 
     @Test
     @Test
     fun testOnFSFeatureMaskDowngradedTask() {
     fun testOnFSFeatureMaskDowngradedTask() {
         // Add the contact '01234567' so that creating the tasks works
         // Add the contact '01234567' so that creating the tasks works
-        addTestIdentity()
+        addTestData()
 
 
         assertValidEncoding(
         assertValidEncoding(
             OnFSFeatureMaskDowngradedTask::class.java,
             OnFSFeatureMaskDowngradedTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OnFSFeatureMaskDowngradedTask.OnFSFeatureMaskDowngradedData\",\"identity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.OnFSFeatureMaskDowngradedTask.OnFSFeatureMaskDowngradedData","identity":"01234567"}""",
         )
         )
     }
     }
 
 
@@ -461,7 +535,8 @@ class PersistableTasksTest {
     fun testReflectUnknownContactPolicyUpdate() {
     fun testReflectUnknownContactPolicyUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectSettingsSyncTask.ReflectUnknownContactPolicySyncUpdate::class.java,
             ReflectSettingsSyncTask.ReflectUnknownContactPolicySyncUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectSettingsSyncTask.ReflectUnknownContactPolicySyncUpdate.ReflectUnknownContactPolicySyncUpdateData\"}"
+            """{"type":"ch.threema.app.tasks.ReflectSettingsSyncTask.ReflectUnknownContactPolicySyncUpdate.""" +
+                """ReflectUnknownContactPolicySyncUpdateData"}""",
         )
         )
     }
     }
 
 
@@ -469,7 +544,7 @@ class PersistableTasksTest {
     fun testReflectReadReceiptPolicySyncUpdate() {
     fun testReflectReadReceiptPolicySyncUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectSettingsSyncTask.ReflectReadReceiptPolicySyncUpdate::class.java,
             ReflectSettingsSyncTask.ReflectReadReceiptPolicySyncUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectSettingsSyncTask.ReflectReadReceiptPolicySyncUpdate.ReadReceiptPolicySyncUpdateData\"}"
+            """{"type":"ch.threema.app.tasks.ReflectSettingsSyncTask.ReflectReadReceiptPolicySyncUpdate.ReadReceiptPolicySyncUpdateData"}""",
         )
         )
     }
     }
 
 
@@ -477,7 +552,8 @@ class PersistableTasksTest {
     fun testReflectTypingIndicatorPolicySyncUpdate() {
     fun testReflectTypingIndicatorPolicySyncUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectSettingsSyncTask.ReflectTypingIndicatorPolicySyncUpdate::class.java,
             ReflectSettingsSyncTask.ReflectTypingIndicatorPolicySyncUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectSettingsSyncTask.ReflectTypingIndicatorPolicySyncUpdate.ReflectTypingIndicatorPolicySyncUpdateData\"}"
+            """{"type":"ch.threema.app.tasks.ReflectSettingsSyncTask.ReflectTypingIndicatorPolicySyncUpdate.""" +
+                """ReflectTypingIndicatorPolicySyncUpdateData"}""",
         )
         )
     }
     }
 
 
@@ -485,17 +561,150 @@ class PersistableTasksTest {
     fun testReflectBlockedIdentitiesSyncUpdate() {
     fun testReflectBlockedIdentitiesSyncUpdate() {
         assertValidEncoding(
         assertValidEncoding(
             ReflectSettingsSyncTask.ReflectBlockedIdentitiesSyncUpdate::class.java,
             ReflectSettingsSyncTask.ReflectBlockedIdentitiesSyncUpdate::class.java,
-            "{\"type\":\"ch.threema.app.tasks.ReflectSettingsSyncTask.ReflectBlockedIdentitiesSyncUpdate.ReflectBlockedIdentitiesSyncUpdateData\"}"
+            """{"type":"ch.threema.app.tasks.ReflectSettingsSyncTask.ReflectBlockedIdentitiesSyncUpdate.ReflectBlockedIdentitiesSyncUpdateData"}""",
+        )
+    }
+
+    @Test
+    fun testGroupCreateTask() {
+        assertValidEncoding(
+            GroupCreateTask::class.java,
+            """{"type":"ch.threema.app.tasks.GroupCreateTask.GroupCreateTaskData","name":"Name","profilePictureChange":""" +
+                """{"type":"ch.threema.app.protocol.RemoveProfilePicture"},"members":["TESTTEST","01234567"],"groupIdentity":""" +
+                """{"creatorIdentity":"01234567","groupId":42},"predefinedMessageIds":{"messageIdBytes1":[-121,-57,86,-82,-126,8,80,89],""" +
+                """"messageIdBytes2":[54,-9,56,45,19,79,-33,80],"messageIdBytes3":[-62,57,-64,-73,-95,78,59,82],""" +
+                """"messageIdBytes4":[-59,-117,93,109,46,-10,-119,118]}}""",
+        )
+    }
+
+    @Test
+    fun testGroupUpdateTask() {
+        assertValidEncoding(
+            GroupUpdateTask::class.java,
+            """{"type":"ch.threema.app.tasks.GroupUpdateTask.GroupUpdateTaskData","name":"Name","profilePictureChange":""" +
+                """{"type":"ch.threema.app.protocol.RemoveProfilePicture"},"updatedMembers":["01234567"],"addedMembers":["TESTTEST",""" +
+                """"01234567"],"removedMembers":["01234567"],"groupIdentity":{"creatorIdentity":"01234567","groupId":42},""" +
+                """"predefinedMessageIds":{"messageIdBytes1":[5,-23,34,43,-15,49,22,-42],"messageIdBytes2":[-62,55,62,-15,-110,-56,58,-103],""" +
+                """"messageIdBytes3":[-128,28,-10,110,14,-39,105,-105],"messageIdBytes4":[-9,115,118,38,-118,-73,99,89]}}""",
+        )
+    }
+
+    @Test
+    fun testOutgoingGroupDisbandTask() {
+        assertValidEncoding(
+            OutgoingGroupDisbandTask::class.java,
+            """{"type":"ch.threema.app.tasks.OutgoingGroupDisbandTask.OutgoingGroupDisbandTaskData","groupIdentity":""" +
+                """{"creatorIdentity":"TESTTEST","groupId":42},"members":["01234567"],"messageId":[0,1,2,3,4,5,6,7]}""",
+        )
+    }
+
+    @Test
+    fun testContactNotificationTriggerPolicyOverrideUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate::class.java,
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate.""" +
+                """ReflectNotificationTriggerPolicyOverrideUpdateData","notificationTriggerPolicyOverride":1740396679447,""" +
+                """"contactIdentity":"01234567"}""",
+        )
+    }
+
+    @Test
+    fun testGroupNotificationTriggerPolicyOverrideUpdate() {
+        addTestData()
+        assertValidEncoding(
+            ReflectGroupSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate::class.java,
+            """{"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}}""",
+        )
+    }
+
+    @Test
+    fun testReflectContactConversationCategoryUpdate() {
+        assertValidEncoding(
+            expectedTaskClass = ReflectContactSyncUpdateTask.ReflectConversationCategoryUpdate::class.java,
+            encodedTask = "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectConversationCategoryUpdate" +
+                ".ReflectContactConversationCategoryUpdateData\",\"contactIdentity\":\"01234567\",\"isPrivateChat\":true}",
         )
         )
     }
     }
 
 
-    private fun addTestIdentity() = runBlocking {
+    @Test
+    fun testReflectGroupConversationCategoryUpdate() {
+        addTestData()
+        assertValidEncoding(
+            expectedTaskClass = ReflectGroupSyncUpdateTask.ReflectGroupConversationCategoryUpdateTask::class.java,
+            encodedTask = "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectGroupConversationCategoryUpdateTask" +
+                ".ReflectGroupConversationCategoryData\",\"groupIdentity\":{\"creatorIdentity\":\"01234567\",\"groupId\":6361180283070237492}" +
+                "\"isPrivateChat\":true}",
+        )
+    }
+
+    @Test
+    fun testReflectContactConversationVisibilityArchiveUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectConversationVisibilityArchiveUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectConversationVisibilityArchiveUpdate" +
+                ".ReflectConversationVisibilityArchiveUpdateData\",\"isArchived\":true,\"contactIdentity\":\"01234567\"}",
+        )
+    }
+
+    @Test
+    fun testReflectGroupConversationVisibilityArchiveUpdate() {
+        addTestData()
+        assertValidEncoding(
+            ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityArchiveUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityArchiveUpdate" +
+                ".ReflectGroupConversationVisibilityArchiveUpdateData\",\"isArchived\":true," +
+                "\"groupIdentity\":{\"creatorIdentity\":\"01234567\",\"groupId\":6361180283070237492}}",
+        )
+    }
+
+    @Test
+    fun testReflectContactConversationVisibilityPinnedUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectConversationVisibilityPinnedUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectConversationVisibilityPinnedUpdate" +
+                ".ReflectConversationVisibilityPinnedUpdateData\",\"isPinned\":true,\"contactIdentity\":\"01234567\"}",
+        )
+    }
+
+    @Test
+    fun testReflectGroupConversationVisibilityPinnedUpdate() {
+        addTestData()
+        assertValidEncoding(
+            ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityPinnedUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityPinnedUpdate" +
+                ".ReflectGroupConversationVisibilityPinnedUpdateData\",\"isPinned\":true," +
+                "\"groupIdentity\":{\"creatorIdentity\":\"01234567\",\"groupId\":6361180283070237492}}",
+        )
+    }
+
+    @Test
+    fun testDeactivateMultiDeviceTask() {
+        assertValidEncoding(
+            expectedTaskClass = DeactivateMultiDeviceTask::class.java,
+            encodedTask = """{"type":"ch.threema.app.tasks.DeactivateMultiDeviceTask.DeactivateMultiDeviceTaskData"}""",
+        )
+    }
+
+    @Test
+    fun testDeactivateMultiDeviceIfAloneTask() {
+        assertValidEncoding(
+            expectedTaskClass = DeactivateMultiDeviceIfAloneTask::class.java,
+            encodedTask = """{"type":"ch.threema.app.tasks.DeactivateMultiDeviceIfAloneTask.DeactivateMultiDeviceIfAloneTaskData"}""",
+        )
+    }
+
+    private fun addTestData() = runBlocking {
         val identity = "01234567"
         val identity = "01234567"
         if (serviceManager.modelRepositories.contacts.getByIdentity(identity) != null) {
         if (serviceManager.modelRepositories.contacts.getByIdentity(identity) != null) {
             // If the contact already exists, we do not add it again
             // If the contact already exists, we do not add it again
             return@runBlocking
             return@runBlocking
         }
         }
 
 
+        serviceManager.identityStore.storeIdentity(identity, "", byteArrayOf(), byteArrayOf())
+
         serviceManager.modelRepositories.contacts.createFromLocal(
         serviceManager.modelRepositories.contacts.createFromLocal(
             ContactModelData(
             ContactModelData(
                 identity = identity,
                 identity = identity,
@@ -513,13 +722,35 @@ class PersistableTasksTest {
                 featureMask = 0u,
                 featureMask = 0u,
                 typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
                 typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
                 readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
                 readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+                isArchived = false,
                 androidContactLookupKey = null,
                 androidContactLookupKey = null,
                 localAvatarExpires = null,
                 localAvatarExpires = null,
                 isRestored = false,
                 isRestored = false,
                 profilePictureBlobId = null,
                 profilePictureBlobId = null,
                 jobTitle = null,
                 jobTitle = null,
                 department = null,
                 department = null,
-            )
+                notificationTriggerPolicyOverride = null,
+            ),
+        )
+
+        serviceManager.modelRepositories.groups.persistNewGroup(
+            GroupModelData(
+                groupIdentity = GroupIdentity(
+                    creatorIdentity = identity,
+                    groupId = 6361180283070237492,
+                ),
+                name = null,
+                createdAt = Date(),
+                synchronizedAt = null,
+                lastUpdate = null,
+                isArchived = false,
+                precomputedColorIndex = null,
+                groupDescription = null,
+                groupDescriptionChangedAt = null,
+                otherMembers = setOf(identity),
+                userState = ch.threema.storage.models.GroupModel.UserState.MEMBER,
+                notificationTriggerPolicyOverride = null,
+            ),
         )
         )
     }
     }
 
 

+ 70 - 0
app/src/androidTest/java/ch/threema/app/testutils/AndroidTestUtils.kt

@@ -0,0 +1,70 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024-2025 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.testutils
+
+import ch.threema.app.managers.ListenerManager
+import ch.threema.app.managers.ServiceManager
+import ch.threema.data.models.GroupIdentity
+
+fun clearDatabaseAndCaches(serviceManager: ServiceManager) {
+    // First get all available contacts and groups
+    val contactIdentities =
+        serviceManager.databaseServiceNew.contactModelFactory.all.map { contact ->
+            contact.identity
+        }
+    val groupModelRepository = serviceManager.modelRepositories.groups
+    val groups = serviceManager.databaseServiceNew.groupModelFactory.all
+        .map { group ->
+            GroupIdentity(group.creatorIdentity, group.apiGroupId.toLong())
+        }.onEach {
+            // Because the column id is lazily loaded, we need to access it at least once
+            // before the group model is deleted below
+            groupModelRepository.getByGroupIdentity(it)?.getDatabaseId()
+        }
+
+    // Clear entire database
+    serviceManager.databaseServiceNew.writableDatabase.apply {
+        rawExecSQL("PRAGMA writable_schema = 1;")
+        rawExecSQL("DELETE FROM sqlite_master where type in ('table', 'index', 'trigger');")
+        rawExecSQL("PRAGMA writable_schema = 0;")
+        rawExecSQL("VACUUM;")
+        rawExecSQL("PRAGMA integrity_check;")
+        // Recreate the database
+        serviceManager.databaseServiceNew.onCreate(this)
+    }
+
+    // Clear caches in services and trigger listeners to refresh the new models from database
+    val contactService = serviceManager.contactService
+    val myIdentity = serviceManager.identityStore.identity
+    contactIdentities.forEach { identity ->
+        contactService.invalidateCache(identity)
+        ListenerManager.contactListeners.handle { it.onRemoved(identity) }
+        serviceManager.dhSessionStore.deleteAllDHSessions(myIdentity, identity)
+    }
+    val groupService = serviceManager.groupService
+    groupService.removeAll()
+    groups.forEach { groupIdentity ->
+        groupService.removeFromCache(groupIdentity)
+        groupModelRepository.persistRemovedGroup(groupIdentity)
+    }
+    serviceManager.conversationService.reset()
+}

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

@@ -31,8 +31,10 @@ import com.neilalexander.jnacl.NaCl;
 import java.io.BufferedReader;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.InputStreamReader;
+import java.util.Collection;
 import java.util.Date;
 import java.util.Date;
 import java.util.List;
 import java.util.List;
+import java.util.stream.Collectors;
 
 
 import androidx.annotation.NonNull;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.Nullable;
@@ -204,6 +206,13 @@ public class TestHelpers {
                 .setUserState(userState);
                 .setUserState(userState);
         }
         }
 
 
+        @NonNull
+        public Collection<TestContact> getMembersWithoutCreator() {
+            return members.stream()
+                .filter(member -> !member.identity.equals(groupCreator.identity))
+                .collect(Collectors.toList());
+        }
+
         public void setLocalGroupId(int localGroupId) {
         public void setLocalGroupId(int localGroupId) {
             this.localGroupId = localGroupId;
             this.localGroupId = localGroupId;
         }
         }
@@ -239,6 +248,22 @@ public class TestHelpers {
         return false;
         return false;
     }
     }
 
 
+    /**
+     * Set the provided identity if the current identity is not set or different.
+     */
+    public static void setIdentity(@NonNull ServiceManager serviceManager, TestContact user) throws Exception {
+        UserService userService = serviceManager.getUserService();
+        if (userService.hasIdentity() && userService.getIdentity().equals(user.identity)) {
+            return;
+        }
+
+        userService.restoreIdentity(
+            user.identity,
+            user.privateKey,
+            user.publicKey
+        );
+    }
+
     /**
     /**
      * Ensure that an identity is set up.
      * Ensure that an identity is set up.
      */
      */

+ 3 - 1
app/src/androidTest/java/ch/threema/app/utils/BackgroundErrorNotificationTest.java

@@ -24,6 +24,7 @@ package ch.threema.app.utils;
 import android.content.Context;
 import android.content.Context;
 
 
 import org.junit.Before;
 import org.junit.Before;
+import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.Test;
 import org.junit.rules.RuleChain;
 import org.junit.rules.RuleChain;
@@ -129,7 +130,8 @@ public class BackgroundErrorNotificationTest {
     /**
     /**
      * Ensure that a notification with "send to support" action works.
      * Ensure that a notification with "send to support" action works.
      */
      */
-    //@Test TODO danilo: Disabled until we have an empty test database
+    @Ignore("Disabled until we have an empty test database")
+    @Test
     public void testNotificationWithAction() {
     public void testNotificationWithAction() {
         // Go to home screen
         // Go to home screen
         mDevice.pressHome();
         mDevice.pressHome();

+ 5 - 6
app/src/androidTest/java/ch/threema/app/utils/BackgroundExecutorTest.kt

@@ -24,6 +24,7 @@ package ch.threema.app.utils
 import android.os.Looper
 import android.os.Looper
 import ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.app.utils.executor.BackgroundTask
 import ch.threema.app.utils.executor.BackgroundTask
+import kotlin.test.assertFailsWith
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.runBlocking
 import org.junit.Assert
 import org.junit.Assert
 import org.junit.Rule
 import org.junit.Rule
@@ -31,7 +32,6 @@ import org.junit.Test
 import org.junit.rules.Timeout
 import org.junit.rules.Timeout
 
 
 class BackgroundExecutorTest {
 class BackgroundExecutorTest {
-
     @Rule
     @Rule
     @JvmField
     @JvmField
     val timeout: Timeout = Timeout.seconds(10)
     val timeout: Timeout = Timeout.seconds(10)
@@ -98,7 +98,7 @@ class BackgroundExecutorTest {
 
 
         Assert.assertArrayEquals(
         Assert.assertArrayEquals(
             expected,
             expected,
-            methodExecutionList.toTypedArray()
+            methodExecutionList.toTypedArray(),
         )
         )
     }
     }
 
 
@@ -120,7 +120,7 @@ class BackgroundExecutorTest {
             }
             }
         })
         })
 
 
-        Assert.assertThrows(IllegalStateException::class.java) {
+        assertFailsWith<IllegalStateException> {
             runBlocking {
             runBlocking {
                 deferred.await()
                 deferred.await()
             }
             }
@@ -140,7 +140,7 @@ class BackgroundExecutorTest {
             }
             }
         })
         })
 
 
-        Assert.assertThrows(IllegalStateException::class.java) {
+        assertFailsWith<IllegalStateException> {
             runBlocking {
             runBlocking {
                 deferred.await()
                 deferred.await()
             }
             }
@@ -159,11 +159,10 @@ class BackgroundExecutorTest {
             }
             }
         })
         })
 
 
-        Assert.assertThrows(IllegalStateException::class.java) {
+        assertFailsWith<IllegalStateException> {
             runBlocking {
             runBlocking {
                 deferred.await()
                 deferred.await()
             }
             }
         }
         }
     }
     }
-
 }
 }

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

@@ -29,14 +29,13 @@ import ch.threema.domain.protocol.csp.messages.TextMessage
 import ch.threema.domain.protocol.csp.messages.fs.ForwardSecurityMode
 import ch.threema.domain.protocol.csp.messages.fs.ForwardSecurityMode
 import ch.threema.domain.taskmanager.ActiveTask
 import ch.threema.domain.taskmanager.ActiveTask
 import ch.threema.domain.taskmanager.ActiveTaskCodec
 import ch.threema.domain.taskmanager.ActiveTaskCodec
-import org.junit.Before
 import java.util.Date
 import java.util.Date
 import kotlin.test.Test
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertTrue
 import kotlin.test.assertTrue
+import org.junit.Before
 
 
 class BundledMessagesSendStepsTest : MessageProcessorProvider() {
 class BundledMessagesSendStepsTest : MessageProcessorProvider() {
-
     private lateinit var outgoingCspMessageServices: OutgoingCspMessageServices
     private lateinit var outgoingCspMessageServices: OutgoingCspMessageServices
 
 
     @Before
     @Before
@@ -199,14 +198,13 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
 
 
     private fun assertMessageHandleSent(
     private fun assertMessageHandleSent(
         messageHandle: OutgoingCspMessageHandle,
         messageHandle: OutgoingCspMessageHandle,
-        assertMessage: (AbstractMessage) -> Unit
+        assertMessage: (AbstractMessage) -> Unit,
     ) {
     ) {
         val expectedReceivers = messageHandle.receivers
         val expectedReceivers = messageHandle.receivers
             .map { it.identity }
             .map { it.identity }
             .filter { it != myContact.identity }
             .filter { it != myContact.identity }
             .sorted()
             .sorted()
 
 
-
         val actualReceivers = sentMessagesInsideTask
         val actualReceivers = sentMessagesInsideTask
             .asSequence()
             .asSequence()
             .take(expectedReceivers.size)
             .take(expectedReceivers.size)
@@ -215,7 +213,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
                 assertMessage(it)
                 assertMessage(it)
                 assertEquals(
                 assertEquals(
                     messageHandle.messageCreator.messageId.messageIdLong,
                     messageHandle.messageCreator.messageId.messageIdLong,
-                    it.messageId.messageIdLong
+                    it.messageId.messageIdLong,
                 )
                 )
                 assertEquals(messageHandle.messageCreator.createdAt.time, it.date.time)
                 assertEquals(messageHandle.messageCreator.createdAt.time, it.date.time)
             }
             }
@@ -235,5 +233,4 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
 
 
             override suspend fun invoke(handle: ActiveTaskCodec) = runnable(handle)
             override suspend fun invoke(handle: ActiveTaskCodec) = runnable(handle)
         })
         })
-
 }
 }

+ 1 - 3
app/src/androidTest/java/ch/threema/app/utils/GeoLocationUtilTest.kt

@@ -27,7 +27,6 @@ import org.junit.Assert.*
 import org.junit.Test
 import org.junit.Test
 
 
 class GeoLocationUtilTest {
 class GeoLocationUtilTest {
-
     private fun expectLocationData(expected: LocationDataModel?, uriStr: String) {
     private fun expectLocationData(expected: LocationDataModel?, uriStr: String) {
         val uri = Uri.parse(uriStr)
         val uri = Uri.parse(uriStr)
         val actual = GeoLocationUtil.getLocationDataFromGeoUri(uri)
         val actual = GeoLocationUtil.getLocationDataFromGeoUri(uri)
@@ -48,7 +47,7 @@ class GeoLocationUtilTest {
             latitude = 12.0,
             latitude = 12.0,
             longitude = 34.0,
             longitude = 34.0,
             accuracy = 0.0,
             accuracy = 0.0,
-            poi = null
+            poi = null,
         )
         )
         expectLocationData(latLong1234, "geo:12,34;abcd=efg")
         expectLocationData(latLong1234, "geo:12,34;abcd=efg")
         expectLocationData(latLong1234, "geo:12.0,34.00;a=b;c=d")
         expectLocationData(latLong1234, "geo:12.0,34.00;a=b;c=d")
@@ -58,5 +57,4 @@ class GeoLocationUtilTest {
         expectLocationData(latLong1234, "geo:12,34,56")
         expectLocationData(latLong1234, "geo:12,34,56")
         expectLocationData(latLong1234, "geo:12,34,56?z=12")
         expectLocationData(latLong1234, "geo:12,34,56?z=12")
     }
     }
-
 }
 }

+ 4 - 6
app/src/androidTest/java/ch/threema/app/utils/LinkifyUtilTest.kt

@@ -29,14 +29,13 @@ import org.junit.Assert.assertEquals
 import org.junit.Test
 import org.junit.Test
 
 
 class LinkifyUtilTest {
 class LinkifyUtilTest {
-
     /**
     /**
      * Get the spannable and a list of the URL spans as a pair. If there is no spannable, a pair
      * Get the spannable and a list of the URL spans as a pair. If there is no spannable, a pair
      * containing of null and an empty list is returned.
      * containing of null and an empty list is returned.
      */
      */
     private fun getSpanPair(
     private fun getSpanPair(
         text: String,
         text: String,
-        includePhoneNumbers: Boolean = true
+        includePhoneNumbers: Boolean = true,
     ): Pair<Spanned?, List<URLSpan>> {
     ): Pair<Spanned?, List<URLSpan>> {
         val textView = TextView(InstrumentationRegistry.getInstrumentation().context)
         val textView = TextView(InstrumentationRegistry.getInstrumentation().context)
         textView.text = text
         textView.text = text
@@ -45,7 +44,7 @@ class LinkifyUtilTest {
         }
         }
         val spannableText = textView.text
         val spannableText = textView.text
         if (spannableText !is Spanned) {
         if (spannableText !is Spanned) {
-            return null to listOf()
+            return null to emptyList()
         }
         }
         val spans = spannableText.getSpans(0, text.length + 1, URLSpan::class.java).toList()
         val spans = spannableText.getSpans(0, text.length + 1, URLSpan::class.java).toList()
         return spannableText to spans
         return spannableText to spans
@@ -57,7 +56,7 @@ class LinkifyUtilTest {
     private fun assertSpans(
     private fun assertSpans(
         text: String,
         text: String,
         spanPoints: Set<Pair<Int, Int>>,
         spanPoints: Set<Pair<Int, Int>>,
-        includePhoneNumbers: Boolean = true
+        includePhoneNumbers: Boolean = true,
     ) {
     ) {
         val (spannable, spans) = getSpanPair(text, includePhoneNumbers)
         val (spannable, spans) = getSpanPair(text, includePhoneNumbers)
         assert(spannable != null || spans.isEmpty())
         assert(spannable != null || spans.isEmpty())
@@ -85,7 +84,7 @@ class LinkifyUtilTest {
      * Expects that there are no spans in the given string.
      * Expects that there are no spans in the given string.
      */
      */
     private fun assertNoSpan(text: String, includePhoneNumbers: Boolean = true) {
     private fun assertNoSpan(text: String, includePhoneNumbers: Boolean = true) {
-        assertSpans(text, setOf(), includePhoneNumbers)
+        assertSpans(text, emptySet(), includePhoneNumbers)
     }
     }
 
 
     @Test
     @Test
@@ -168,5 +167,4 @@ class LinkifyUtilTest {
         assertSpans("geo:0,0?z=3.1", setOf(0 to 11))
         assertSpans("geo:0,0?z=3.1", setOf(0 to 11))
         assertSpans("geo:0,0?z=12(Label+not+allowed+here)", setOf(0 to 12))
         assertSpans("geo:0,0?z=12(Label+not+allowed+here)", setOf(0 to 12))
     }
     }
-
 }
 }

+ 2 - 2
app/src/androidTest/java/ch/threema/app/voip/SdpTest.java

@@ -433,8 +433,8 @@ public class SdpTest {
             }
             }
             // TODO(SE-63): Ehh, dirty hack... it should create a transceiver instead
             // TODO(SE-63): Ehh, dirty hack... it should create a transceiver instead
             matches.add("^a=recvonly");
             matches.add("^a=recvonly");
-//			expectedMatchesPart1.add("^a=sendrecv");
-//			expectedMatchesPart1.add("^a=msid:3MACALL 3MACALLv0");
+//            expectedMatchesPart1.add("^a=sendrecv");
+//            expectedMatchesPart1.add("^a=msid:3MACALL 3MACALLv0");
             matches.add("^a=rtcp-mux$");
             matches.add("^a=rtcp-mux$");
             matches.add("^a=rtcp-rsize$");
             matches.add("^a=rtcp-rsize$");
 
 

+ 19 - 35
app/src/androidTest/java/ch/threema/app/webclient/activities/SessionsActivityTest.java

@@ -21,7 +21,6 @@
 
 
 package ch.threema.app.webclient.activities;
 package ch.threema.app.webclient.activities;
 
 
-
 import android.app.Activity;
 import android.app.Activity;
 import android.app.Instrumentation;
 import android.app.Instrumentation;
 import android.content.Context;
 import android.content.Context;
@@ -36,7 +35,9 @@ import org.junit.rules.RuleChain;
 import org.junit.runner.RunWith;
 import org.junit.runner.RunWith;
 
 
 import java.util.Date;
 import java.util.Date;
+import java.util.Objects;
 
 
+import androidx.annotation.NonNull;
 import androidx.preference.PreferenceManager;
 import androidx.preference.PreferenceManager;
 import androidx.test.InstrumentationRegistry;
 import androidx.test.InstrumentationRegistry;
 import androidx.test.filters.LargeTest;
 import androidx.test.filters.LargeTest;
@@ -114,14 +115,11 @@ public class SessionsActivityTest {
         String label,
         String label,
         WebClientSessionModel.State state,
         WebClientSessionModel.State state,
         boolean persistent,
         boolean persistent,
-        Date created,
-        Date lastConnection,
-        Browser browser
+        @NonNull Date created,
+        @NonNull Date lastConnection,
+        @NonNull Browser browser
     ) {
     ) {
-        final DatabaseServiceNew databaseService = ThreemaApplication
-            .getServiceManager()
-            .getDatabaseServiceNew();
-
+        final DatabaseServiceNew databaseService = Objects.requireNonNull(ThreemaApplication.getServiceManager()).getDatabaseServiceNew();
         final WebClientSessionModel model = new WebClientSessionModel();
         final WebClientSessionModel model = new WebClientSessionModel();
 
 
         model.setLabel(label);
         model.setLabel(label);
@@ -190,11 +188,11 @@ public class SessionsActivityTest {
     @Test
     @Test
     public void testSessionList() {
     public void testSessionList() {
         // Create two sessions
         // Create two sessions
-        createSession("Feuerfuchs", WebClientSessionModel.State.AUTHORIZED,
-            true, new Date(), new Date(), Browser.FIREFOX);
+        createSession("Feuerfuchs", WebClientSessionModel.State.AUTHORIZED, true, new Date(), new Date(), Browser.FIREFOX);
         createSession("Googlebrowser", WebClientSessionModel.State.ERROR,
         createSession("Googlebrowser", WebClientSessionModel.State.ERROR,
             true, new Date(System.currentTimeMillis() - 3600),
             true, new Date(System.currentTimeMillis() - 3600),
-            new Date(System.currentTimeMillis() - 3500), Browser.CHROME);
+            new Date(System.currentTimeMillis() - 3500), Browser.CHROME
+        );
 
 
         // Start activty
         // Start activty
         activityTestRule.launchActivity(null);
         activityTestRule.launchActivity(null);
@@ -230,33 +228,19 @@ public class SessionsActivityTest {
         final Date hours23ago = new Date(System.currentTimeMillis() - hours * 23);
         final Date hours23ago = new Date(System.currentTimeMillis() - hours * 23);
         final Date hours25ago = new Date(System.currentTimeMillis() - hours * 25);
         final Date hours25ago = new Date(System.currentTimeMillis() - hours * 25);
 
 
-        createSession("Persistent now", WebClientSessionModel.State.AUTHORIZED,
-            true, now, now, Browser.FIREFOX);
-        createSession("Persistent old", WebClientSessionModel.State.AUTHORIZED,
-            true, hours25ago, hours25ago, Browser.CHROME);
-        createSession("Disposable now", WebClientSessionModel.State.AUTHORIZED,
-            false, now, now, Browser.SAFARI);
-        createSession("Disposable fresh", WebClientSessionModel.State.AUTHORIZED,
-            false, now, null, Browser.SAFARI);
-        createSession("Disposable still valid", WebClientSessionModel.State.AUTHORIZED,
-            false, hours23ago, hours23ago, Browser.OPERA);
-        createSession("Disposable expired", WebClientSessionModel.State.AUTHORIZED,
-            false, hours25ago, hours25ago, Browser.EDGE);
+        createSession("Persistent now", WebClientSessionModel.State.AUTHORIZED, true, now, now, Browser.FIREFOX);
+        createSession("Persistent old", WebClientSessionModel.State.AUTHORIZED, true, hours25ago, hours25ago, Browser.CHROME);
+        createSession("Disposable now", WebClientSessionModel.State.AUTHORIZED, false, now, now, Browser.SAFARI);
+        createSession("Disposable still valid", WebClientSessionModel.State.AUTHORIZED, false, hours23ago, hours23ago, Browser.OPERA);
+        createSession("Disposable expired", WebClientSessionModel.State.AUTHORIZED, false, hours25ago, hours25ago, Browser.EDGE);
 
 
         activityTestRule.launchActivity(null);
         activityTestRule.launchActivity(null);
 
 
-        onView(withText("Persistent now"))
-            .check(matches(isDisplayed()));
-        onView(withText("Persistent old"))
-            .check(matches(isDisplayed()));
-        onView(withText("Disposable now"))
-            .check(matches(isDisplayed()));
-        onView(withText("Disposable fresh"))
-            .check(matches(isDisplayed()));
-        onView(withText("Disposable still valid"))
-            .check(matches(isDisplayed()));
-        onView(withText("Disposable expired"))
-            .check(doesNotExist());
+        onView(withText("Persistent now")).check(matches(isDisplayed()));
+        onView(withText("Persistent old")).check(matches(isDisplayed()));
+        onView(withText("Disposable now")).check(matches(isDisplayed()));
+        onView(withText("Disposable still valid")).check(matches(isDisplayed()));
+        onView(withText("Disposable expired")).check(doesNotExist());
     }
     }
 
 
 }
 }

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

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

+ 1 - 1
app/src/androidTest/java/ch/threema/data/TestDatabaseService.kt

@@ -32,5 +32,5 @@ class TestDatabaseService : DatabaseServiceNew(
     ApplicationProvider.getApplicationContext(),
     ApplicationProvider.getApplicationContext(),
     null,
     null,
     "test-database-key",
     "test-database-key",
-    UpdateSystemServiceImpl()
+    UpdateSystemServiceImpl(),
 )
 )

+ 27 - 16
app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt

@@ -25,6 +25,7 @@ import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestMultiDeviceManager
 import ch.threema.app.TestMultiDeviceManager
 import ch.threema.app.TestTaskManager
 import ch.threema.app.TestTaskManager
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.testutils.TestHelpers
 import ch.threema.data.TestDatabaseService
 import ch.threema.data.TestDatabaseService
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.ContactModelData.Companion.getIdColorIndex
 import ch.threema.data.models.ContactModelData.Companion.getIdColorIndex
@@ -42,19 +43,19 @@ import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.testhelpers.nonSecureRandomArray
 import ch.threema.testhelpers.nonSecureRandomArray
 import ch.threema.testhelpers.randomIdentity
 import ch.threema.testhelpers.randomIdentity
 import com.neilalexander.jnacl.NaCl
 import com.neilalexander.jnacl.NaCl
-import junit.framework.TestCase.assertNotNull
-import kotlinx.coroutines.runBlocking
-import org.junit.Assert.assertThrows
-import org.junit.Before
-import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
 import java.util.Date
 import java.util.Date
+import junit.framework.TestCase.assertNotNull
 import kotlin.test.Test
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
 import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
 import kotlin.test.assertNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
 import kotlin.test.assertTrue
 import kotlin.test.fail
 import kotlin.test.fail
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
 
 
 @RunWith(value = Parameterized::class)
 @RunWith(value = Parameterized::class)
 class ContactModelRepositoryTest(private val contactModelData: ContactModelData) {
 class ContactModelRepositoryTest(private val contactModelData: ContactModelData) {
@@ -126,24 +127,31 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
             featureMask = featureMask,
             featureMask = featureMask,
             readReceiptPolicy = readReceiptPolicy,
             readReceiptPolicy = readReceiptPolicy,
             typingIndicatorPolicy = typingIndicatorPolicy,
             typingIndicatorPolicy = typingIndicatorPolicy,
+            isArchived = false,
             androidContactLookupKey = androidContactLookupKey,
             androidContactLookupKey = androidContactLookupKey,
             localAvatarExpires = localAvatarExpires,
             localAvatarExpires = localAvatarExpires,
             isRestored = isRestored,
             isRestored = isRestored,
             profilePictureBlobId = profilePictureBlobId,
             profilePictureBlobId = profilePictureBlobId,
             jobTitle = jobTitle,
             jobTitle = jobTitle,
             department = department,
             department = department,
+            notificationTriggerPolicyOverride = null,
         )
         )
     }
     }
 
 
     @Before
     @Before
     fun before() {
     fun before() {
+        TestHelpers.setIdentity(
+            ThreemaApplication.requireServiceManager(),
+            TestHelpers.TEST_CONTACT,
+        )
+
         // Instantiate services where MD is disabled
         // Instantiate services where MD is disabled
         this.databaseService = TestDatabaseService()
         this.databaseService = TestDatabaseService()
         this.coreServiceManager = TestCoreServiceManager(
         this.coreServiceManager = TestCoreServiceManager(
             version = ThreemaApplication.getAppVersion(),
             version = ThreemaApplication.getAppVersion(),
             databaseService = databaseService,
             databaseService = databaseService,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
-            taskManager = TestTaskManager(UnusedTaskCodec())
+            taskManager = TestTaskManager(UnusedTaskCodec()),
         )
         )
         this.contactModelRepository = ModelRepositories(coreServiceManager).contacts
         this.contactModelRepository = ModelRepositories(coreServiceManager).contacts
 
 
@@ -158,7 +166,7 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
                 isMultiDeviceActive = true,
                 isMultiDeviceActive = true,
                 isMdDisabledOrSupportsFs = false,
                 isMdDisabledOrSupportsFs = false,
             ),
             ),
-            taskManager = TestTaskManager(taskCodecMd)
+            taskManager = TestTaskManager(taskCodecMd),
         )
         )
         this.contactModelRepositoryMd = ModelRepositories(coreServiceManagerMd).contacts
         this.contactModelRepositoryMd = ModelRepositories(coreServiceManagerMd).contacts
     }
     }
@@ -322,7 +330,6 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
                         )
                         )
                     }
                     }
             }
             }
-
         }
         }
 
 
         // Insert it for the first time
         // Insert it for the first time
@@ -344,8 +351,8 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         assertContentEquals(contactModelData, newModelMd.data.value)
         assertContentEquals(contactModelData, newModelMd.data.value)
 
 
         // Insert for the second time and assert that an exception is thrown
         // Insert for the second time and assert that an exception is thrown
-        assertThrows(ContactStoreException::class.java) { runBlocking { runCreation() } }
-        assertThrows(ContactReflectException::class.java) { runBlocking { runCreationMd() } }
+        assertFailsWith<ContactStoreException> { runBlocking { runCreation() } }
+        assertFailsWith<ContactReflectException> { runBlocking { runCreationMd() } }
 
 
         // Assert that there is still only one transaction and therefore the transaction has not
         // Assert that there is still only one transaction and therefore the transaction has not
         // been executed again (due to precondition failure)
         // been executed again (due to precondition failure)
@@ -399,7 +406,7 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         assertContentEquals(contactModelData, addedData)
         assertContentEquals(contactModelData, addedData)
 
 
         // Assert that the contact data cannot be inserted again (as it already exists)
         // Assert that the contact data cannot be inserted again (as it already exists)
-        assertThrows(ContactStoreException::class.java) {
+        assertFailsWith<ContactStoreException> {
             contactModelRepositoryMd.createFromSync(contactModelData)
             contactModelRepositoryMd.createFromSync(contactModelData)
         }
         }
 
 
@@ -415,8 +422,8 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         databaseService.contactModelFactory.createOrUpdate(
         databaseService.contactModelFactory.createOrUpdate(
             ContactModel(
             ContactModel(
                 identity,
                 identity,
-                nonSecureRandomArray(32)
-            )
+                nonSecureRandomArray(32),
+            ),
         )
         )
 
 
         // Fetch model
         // Fetch model
@@ -457,10 +464,14 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         assertEquals(expected.featureMask, actual.featureMask)
         assertEquals(expected.featureMask, actual.featureMask)
         assertEquals(expected.readReceiptPolicy, actual.readReceiptPolicy)
         assertEquals(expected.readReceiptPolicy, actual.readReceiptPolicy)
         assertEquals(expected.typingIndicatorPolicy, actual.typingIndicatorPolicy)
         assertEquals(expected.typingIndicatorPolicy, actual.typingIndicatorPolicy)
-        // TODO(ANDR-2998): Assert that notification trigger and sound policy override are set
-        //  correctly
+        assertEquals(
+            expected.notificationTriggerPolicyOverride,
+            actual.notificationTriggerPolicyOverride,
+        )
         assertEquals(expected.androidContactLookupKey, actual.androidContactLookupKey)
         assertEquals(expected.androidContactLookupKey, actual.androidContactLookupKey)
 
 
+        // TODO(ANDR-2998): Assert that notification sound policy override are set correctly
+
         // Just in case there are new fields added that are not explicitly compared here
         // Just in case there are new fields added that are not explicitly compared here
         assertEquals(expected, actual)
         assertEquals(expected, actual)
     }
     }

+ 13 - 7
app/src/androidTest/java/ch/threema/data/repositories/EditHistoryRepositoryTest.kt

@@ -24,6 +24,7 @@ package ch.threema.data.repositories
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestTaskManager
 import ch.threema.app.TestTaskManager
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.testutils.TestHelpers
 import ch.threema.data.TestDatabaseService
 import ch.threema.data.TestDatabaseService
 import ch.threema.data.storage.EditHistoryDao
 import ch.threema.data.storage.EditHistoryDao
 import ch.threema.data.storage.EditHistoryDaoImpl
 import ch.threema.data.storage.EditHistoryDaoImpl
@@ -32,10 +33,11 @@ import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageType
 import ch.threema.storage.models.MessageType
+import java.util.UUID
+import kotlin.test.assertFailsWith
 import org.junit.Assert
 import org.junit.Assert
 import org.junit.Before
 import org.junit.Before
 import org.junit.Test
 import org.junit.Test
-import java.util.UUID
 
 
 class EditHistoryRepositoryTest {
 class EditHistoryRepositoryTest {
     private lateinit var databaseService: TestDatabaseService
     private lateinit var databaseService: TestDatabaseService
@@ -44,12 +46,17 @@ class EditHistoryRepositoryTest {
 
 
     @Before
     @Before
     fun before() {
     fun before() {
+        TestHelpers.setIdentity(
+            ThreemaApplication.requireServiceManager(),
+            TestHelpers.TEST_CONTACT,
+        )
+
         databaseService = TestDatabaseService()
         databaseService = TestDatabaseService()
         val testCoreServiceManager = TestCoreServiceManager(
         val testCoreServiceManager = TestCoreServiceManager(
             version = ThreemaApplication.getAppVersion(),
             version = ThreemaApplication.getAppVersion(),
             databaseService = databaseService,
             databaseService = databaseService,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
-            taskManager = TestTaskManager(UnusedTaskCodec())
+            taskManager = TestTaskManager(UnusedTaskCodec()),
         )
         )
         editHistoryRepository = ModelRepositories(testCoreServiceManager).editHistory
         editHistoryRepository = ModelRepositories(testCoreServiceManager).editHistory
         editHistoryDao = EditHistoryDaoImpl(databaseService)
         editHistoryDao = EditHistoryDaoImpl(databaseService)
@@ -59,7 +66,7 @@ class EditHistoryRepositoryTest {
     fun testContactMessageHistoryForeignKeyConstraint() {
     fun testContactMessageHistoryForeignKeyConstraint() {
         val contactMessage = MessageModel().enrich()
         val contactMessage = MessageModel().enrich()
 
 
-        Assert.assertThrows(EditHistoryEntryCreateException::class.java) {
+        assertFailsWith<EditHistoryEntryCreateException> {
             editHistoryRepository.createEntry(contactMessage)
             editHistoryRepository.createEntry(contactMessage)
         }
         }
 
 
@@ -83,7 +90,7 @@ class EditHistoryRepositoryTest {
     fun testGroupMessageHistoryForeignKeyConstraint() {
     fun testGroupMessageHistoryForeignKeyConstraint() {
         val groupMessage = GroupMessageModel().enrich()
         val groupMessage = GroupMessageModel().enrich()
 
 
-        Assert.assertThrows(EditHistoryEntryCreateException::class.java) {
+        assertFailsWith<EditHistoryEntryCreateException> {
             editHistoryRepository.createEntry(groupMessage)
             editHistoryRepository.createEntry(groupMessage)
         }
         }
 
 
@@ -111,15 +118,14 @@ class EditHistoryRepositoryTest {
      * This is not a problem, because the message history cannot be displayed when the message was deleted.
      * This is not a problem, because the message history cannot be displayed when the message was deleted.
      */
      */
     private fun AbstractMessageModel.assertEditHistorySize(expectedSize: Int) {
     private fun AbstractMessageModel.assertEditHistorySize(expectedSize: Int) {
-        val actualSize = editHistoryDao.findAllByMessageUid(uid).size
+        val actualSize = editHistoryDao.findAllByMessageUid(uid!!).size
 
 
         Assert.assertEquals(expectedSize, actualSize)
         Assert.assertEquals(expectedSize, actualSize)
     }
     }
 
 
-    private fun <T : AbstractMessageModel> T.enrich(text: String = "Text"): T {
+    private fun <T : AbstractMessageModel> T.enrich(text: String = "Text"): T = apply {
         type = MessageType.TEXT
         type = MessageType.TEXT
         uid = UUID.randomUUID().toString()
         uid = UUID.randomUUID().toString()
         body = text
         body = text
-        return this
     }
     }
 }
 }

+ 16 - 17
app/src/androidTest/java/ch/threema/data/repositories/EmojiReactionsRepositoryTest.kt

@@ -37,15 +37,16 @@ import ch.threema.storage.models.DistributionListMessageModel
 import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageType
 import ch.threema.storage.models.MessageType
-import org.junit.Assert
-import org.junit.Before
-import org.junit.Test
 import java.util.Date
 import java.util.Date
 import java.util.UUID
 import java.util.UUID
 import kotlin.test.assertContentEquals
 import kotlin.test.assertContentEquals
+import kotlin.test.assertFailsWith
 import kotlin.test.assertNotNull
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.assertNull
 import kotlin.test.fail
 import kotlin.test.fail
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
 
 
 class EmojiReactionsRepositoryTest {
 class EmojiReactionsRepositoryTest {
     private lateinit var testCoreServiceManager: TestCoreServiceManager
     private lateinit var testCoreServiceManager: TestCoreServiceManager
@@ -60,7 +61,7 @@ class EmojiReactionsRepositoryTest {
             version = ThreemaApplication.getAppVersion(),
             version = ThreemaApplication.getAppVersion(),
             databaseService = databaseService,
             databaseService = databaseService,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
-            taskManager = TestTaskManager(UnusedTaskCodec())
+            taskManager = TestTaskManager(UnusedTaskCodec()),
         )
         )
 
 
         emojiReactionsRepository = ModelRepositories(testCoreServiceManager).emojiReaction
         emojiReactionsRepository = ModelRepositories(testCoreServiceManager).emojiReaction
@@ -71,7 +72,7 @@ class EmojiReactionsRepositoryTest {
     fun testEmojiReactionForeignKeyConstraint() {
     fun testEmojiReactionForeignKeyConstraint() {
         val contactMessage = MessageModel().enrich()
         val contactMessage = MessageModel().enrich()
 
 
-        Assert.assertThrows(EmojiReactionEntryCreateException::class.java) {
+        assertFailsWith<EmojiReactionEntryCreateException> {
             emojiReactionsRepository.createEntry(contactMessage, "ABCDEFGH", "\uD83C\uDFC8")
             emojiReactionsRepository.createEntry(contactMessage, "ABCDEFGH", "\uD83C\uDFC8")
         }
         }
 
 
@@ -95,7 +96,7 @@ class EmojiReactionsRepositoryTest {
     fun testGroupEmojiReactionForeignKeyConstraint() {
     fun testGroupEmojiReactionForeignKeyConstraint() {
         val groupMessage = GroupMessageModel().enrich()
         val groupMessage = GroupMessageModel().enrich()
 
 
-        Assert.assertThrows(EmojiReactionEntryCreateException::class.java) {
+        assertFailsWith<EmojiReactionEntryCreateException> {
             emojiReactionsRepository.createEntry(groupMessage, "ABCDEFGH", "⚾")
             emojiReactionsRepository.createEntry(groupMessage, "ABCDEFGH", "⚾")
         }
         }
 
 
@@ -128,7 +129,7 @@ class EmojiReactionsRepositoryTest {
 
 
         message.assertEmojiReactionSize(1)
         message.assertEmojiReactionSize(1)
 
 
-        Assert.assertThrows(EmojiReactionEntryCreateException::class.java) {
+        assertFailsWith<EmojiReactionEntryCreateException> {
             emojiReactionsRepository.createEntry(message, "ABCDEFGH", "⚽")
             emojiReactionsRepository.createEntry(message, "ABCDEFGH", "⚽")
         }
         }
 
 
@@ -215,7 +216,6 @@ class EmojiReactionsRepositoryTest {
 
 
     @Test
     @Test
     fun testEmojiReactionsModelCaching() {
     fun testEmojiReactionsModelCaching() {
-
         val testEmojiCache = ModelTypeCache<ReactionMessageIdentifier, EmojiReactionsModel>()
         val testEmojiCache = ModelTypeCache<ReactionMessageIdentifier, EmojiReactionsModel>()
 
 
         val contactMessage = MessageModel().enrich()
         val contactMessage = MessageModel().enrich()
@@ -227,7 +227,7 @@ class EmojiReactionsRepositoryTest {
 
 
         // Test unsuccessful creation of reaction-message-identifier
         // Test unsuccessful creation of reaction-message-identifier
         val reactionMessageIdentifierNull = ReactionMessageIdentifier.fromMessageModel(
         val reactionMessageIdentifierNull = ReactionMessageIdentifier.fromMessageModel(
-            messageModel = DistributionListMessageModel()
+            messageModel = DistributionListMessageModel(),
         )
         )
         assertNull(reactionMessageIdentifierNull)
         assertNull(reactionMessageIdentifierNull)
 
 
@@ -240,11 +240,11 @@ class EmojiReactionsRepositoryTest {
             contactMessage.id,
             contactMessage.id,
             senderIdentity = "ABCD1234",
             senderIdentity = "ABCD1234",
             emojiSequence = "⛵",
             emojiSequence = "⛵",
-            reactedAt = Date()
+            reactedAt = Date(),
         )
         )
         val emojiReactionsModel = EmojiReactionsModel(
         val emojiReactionsModel = EmojiReactionsModel(
             data = listOf(emojiReactionData),
             data = listOf(emojiReactionData),
-            coreServiceManager = testCoreServiceManager
+            coreServiceManager = testCoreServiceManager,
         )
         )
         cachedEntry = testEmojiCache.getOrCreate(reactionMessageIdentifier) { emojiReactionsModel }
         cachedEntry = testEmojiCache.getOrCreate(reactionMessageIdentifier) { emojiReactionsModel }
         assertContentEquals(listOf(emojiReactionData), cachedEntry!!.data.value)
         assertContentEquals(listOf(emojiReactionData), cachedEntry!!.data.value)
@@ -263,29 +263,28 @@ class EmojiReactionsRepositoryTest {
 
 
     @Test
     @Test
     fun testCacheCollision() {
     fun testCacheCollision() {
-
         // arrange
         // arrange
         val testEmojiCache = ModelTypeCache<ReactionMessageIdentifier, EmojiReactionsModel>()
         val testEmojiCache = ModelTypeCache<ReactionMessageIdentifier, EmojiReactionsModel>()
         val contactMessageId = 1
         val contactMessageId = 1
         val groupMessageId = 1
         val groupMessageId = 1
         val reactionMessageIdentifierContact = ReactionMessageIdentifier(
         val reactionMessageIdentifierContact = ReactionMessageIdentifier(
             messageId = contactMessageId,
             messageId = contactMessageId,
-            messageType = ReactionMessageIdentifier.TargetMessageType.ONE_TO_ONE
+            messageType = ReactionMessageIdentifier.TargetMessageType.ONE_TO_ONE,
         )
         )
         val reactionMessageIdentifierGroup = ReactionMessageIdentifier(
         val reactionMessageIdentifierGroup = ReactionMessageIdentifier(
             messageId = groupMessageId,
             messageId = groupMessageId,
-            messageType = ReactionMessageIdentifier.TargetMessageType.GROUP
+            messageType = ReactionMessageIdentifier.TargetMessageType.GROUP,
         )
         )
         // Add only the emoji reaction of the 1:1 message to the cache
         // Add only the emoji reaction of the 1:1 message to the cache
         val emojiReactionDataForContactMessage = EmojiReactionData(
         val emojiReactionDataForContactMessage = EmojiReactionData(
             messageId = contactMessageId,
             messageId = contactMessageId,
             senderIdentity = "ABCD1234",
             senderIdentity = "ABCD1234",
             emojiSequence = "⛵",
             emojiSequence = "⛵",
-            reactedAt = Date()
+            reactedAt = Date(),
         )
         )
         val emojiReactionsModelContact = EmojiReactionsModel(
         val emojiReactionsModelContact = EmojiReactionsModel(
             data = listOf(emojiReactionDataForContactMessage),
             data = listOf(emojiReactionDataForContactMessage),
-            coreServiceManager = testCoreServiceManager
+            coreServiceManager = testCoreServiceManager,
         )
         )
 
 
         val cachedEntryContact =
         val cachedEntryContact =
@@ -293,7 +292,7 @@ class EmojiReactionsRepositoryTest {
 
 
         assertContentEquals(
         assertContentEquals(
             listOf(emojiReactionDataForContactMessage),
             listOf(emojiReactionDataForContactMessage),
-            cachedEntryContact!!.data.value
+            cachedEntryContact!!.data.value,
         )
         )
         assertNull(testEmojiCache.get(reactionMessageIdentifierGroup))
         assertNull(testEmojiCache.get(reactionMessageIdentifierGroup))
 
 

+ 30 - 25
app/src/androidTest/java/ch/threema/data/repositories/GroupModelRepositoryTest.kt

@@ -21,10 +21,11 @@
 
 
 package ch.threema.data.repositories
 package ch.threema.data.repositories
 
 
-import ch.threema.data.TestDatabaseService
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestTaskManager
 import ch.threema.app.TestTaskManager
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.testutils.TestHelpers
+import ch.threema.data.TestDatabaseService
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModelDataFactory
 import ch.threema.data.models.GroupModelDataFactory
 import ch.threema.data.storage.DatabaseBackend
 import ch.threema.data.storage.DatabaseBackend
@@ -33,13 +34,12 @@ import ch.threema.data.storage.SqliteDatabaseBackend
 import ch.threema.domain.helpers.UnusedTaskCodec
 import ch.threema.domain.helpers.UnusedTaskCodec
 import ch.threema.domain.models.GroupId
 import ch.threema.domain.models.GroupId
 import ch.threema.storage.models.GroupModel
 import ch.threema.storage.models.GroupModel
-import org.junit.Assert
-import org.junit.Before
-import org.junit.Test
 import java.util.Date
 import java.util.Date
 import kotlin.test.assertEquals
 import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
 import kotlin.test.assertNull
 import kotlin.test.assertNull
-import kotlin.test.assertTrue
+import org.junit.Before
+import org.junit.Test
 
 
 class GroupModelRepositoryTest {
 class GroupModelRepositoryTest {
     private lateinit var databaseService: TestDatabaseService
     private lateinit var databaseService: TestDatabaseService
@@ -49,31 +49,36 @@ class GroupModelRepositoryTest {
 
 
     private fun createTestDbGroup(groupIdentity: GroupIdentity): DbGroup {
     private fun createTestDbGroup(groupIdentity: GroupIdentity): DbGroup {
         return DbGroup(
         return DbGroup(
-            groupIdentity.creatorIdentity,
-            groupIdentity.groupIdHexString,
-            "Group",
-            Date(),
-            Date(),
-            null,
-            deleted = false,
+            creatorIdentity = groupIdentity.creatorIdentity,
+            groupId = groupIdentity.groupIdHexString,
+            name = "Group",
+            createdAt = Date(),
+            synchronizedAt = Date(),
+            lastUpdate = null,
             isArchived = false,
             isArchived = false,
-            0.toUByte(),
-            "Description",
-            Date(),
-            setOf("AAAAAAAA", "BBBBBBBB"),
-            GroupModel.UserState.MEMBER,
+            colorIndex = 0.toUByte(),
+            groupDescription = "Description",
+            groupDescriptionChangedAt = Date(),
+            members = setOf("AAAAAAAA", "BBBBBBBB"),
+            userState = GroupModel.UserState.MEMBER,
+            notificationTriggerPolicyOverride = null,
         )
         )
     }
     }
 
 
     @Before
     @Before
     fun before() {
     fun before() {
+        TestHelpers.setIdentity(
+            ThreemaApplication.requireServiceManager(),
+            TestHelpers.TEST_CONTACT,
+        )
+
         this.databaseService = TestDatabaseService()
         this.databaseService = TestDatabaseService()
         this.databaseBackend = SqliteDatabaseBackend(databaseService)
         this.databaseBackend = SqliteDatabaseBackend(databaseService)
         this.coreServiceManager = TestCoreServiceManager(
         this.coreServiceManager = TestCoreServiceManager(
             version = ThreemaApplication.getAppVersion(),
             version = ThreemaApplication.getAppVersion(),
             databaseService = databaseService,
             databaseService = databaseService,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
-            taskManager = TestTaskManager(UnusedTaskCodec())
+            taskManager = TestTaskManager(UnusedTaskCodec()),
         )
         )
         this.groupModelRepository = ModelRepositories(coreServiceManager).groups
         this.groupModelRepository = ModelRepositories(coreServiceManager).groups
     }
     }
@@ -100,12 +105,12 @@ class GroupModelRepositoryTest {
             GroupModel()
             GroupModel()
                 .setCreatorIdentity(groupIdentity.creatorIdentity)
                 .setCreatorIdentity(groupIdentity.creatorIdentity)
                 .setApiGroupId(GroupId(groupIdentity.groupId))
                 .setApiGroupId(GroupId(groupIdentity.groupId))
-                .setCreatedAt(Date())
+                .setCreatedAt(Date()),
         )
         )
 
 
         // Fetch group using the "new" model
         // Fetch group using the "new" model
         val model = groupModelRepository.getByGroupIdentity(groupIdentity)!!
         val model = groupModelRepository.getByGroupIdentity(groupIdentity)!!
-        assertTrue { model.groupIdentity == groupIdentity }
+        assertEquals(groupIdentity, model.groupIdentity)
     }
     }
 
 
     @Test
     @Test
@@ -118,13 +123,13 @@ class GroupModelRepositoryTest {
             GroupModel()
             GroupModel()
                 .setCreatorIdentity(creatorIdentity)
                 .setCreatorIdentity(creatorIdentity)
                 .setApiGroupId(groupId)
                 .setApiGroupId(groupId)
-                .setCreatedAt(Date())
+                .setCreatedAt(Date()),
         )
         )
 
 
         // Fetch group using the "new" model
         // Fetch group using the "new" model
         val model = groupModelRepository.getByCreatorIdentityAndId(creatorIdentity, groupId)!!
         val model = groupModelRepository.getByCreatorIdentityAndId(creatorIdentity, groupId)!!
         val groupIdentity = GroupIdentity(creatorIdentity, groupId.toLong())
         val groupIdentity = GroupIdentity(creatorIdentity, groupId.toLong())
-        assertTrue { model.groupIdentity == groupIdentity }
+        assertEquals(groupIdentity, model.groupIdentity)
     }
     }
 
 
     @Test
     @Test
@@ -166,7 +171,7 @@ class GroupModelRepositoryTest {
         val datesNullGroup = createTestDbGroup(groupIdentityDatesNull).copy(
         val datesNullGroup = createTestDbGroup(groupIdentityDatesNull).copy(
             synchronizedAt = null,
             synchronizedAt = null,
             lastUpdate = null,
             lastUpdate = null,
-            groupDescriptionChangedAt = null
+            groupDescriptionChangedAt = null,
         )
         )
         testInsertAndGet(groupIdentityDatesNull, datesNullGroup)
         testInsertAndGet(groupIdentityDatesNull, datesNullGroup)
     }
     }
@@ -178,12 +183,12 @@ class GroupModelRepositoryTest {
         testInsertAndGet(groupIdentity, defaultGroup)
         testInsertAndGet(groupIdentity, defaultGroup)
 
 
         val testData = groupModelRepository.getByGroupIdentity(groupIdentity)!!.data.value!!
         val testData = groupModelRepository.getByGroupIdentity(groupIdentity)!!.data.value!!
-        Assert.assertThrows(UnsupportedOperationException::class.java) {
+        assertFailsWith<UnsupportedOperationException> {
             // Casting the set to a mutable set will work, but adding a new member to the set should
             // Casting the set to a mutable set will work, but adding a new member to the set should
             // result in a runtime exception. Note that this is mainly in java code a problem, as
             // result in a runtime exception. Note that this is mainly in java code a problem, as
             // there is no cast needed to add a new member. Of course, it will result in a runtime
             // there is no cast needed to add a new member. Of course, it will result in a runtime
             // exception as well.
             // exception as well.
-            (testData.members as MutableSet).add("01234567")
+            (testData.otherMembers as MutableSet).add("01234567")
         }
         }
     }
     }
 
 

+ 7 - 10
app/src/androidTest/java/ch/threema/storage/DatabaseNonceStoreTest.kt

@@ -28,12 +28,12 @@ import ch.threema.base.crypto.Nonce
 import ch.threema.base.crypto.NonceScope
 import ch.threema.base.crypto.NonceScope
 import ch.threema.base.crypto.NonceStore
 import ch.threema.base.crypto.NonceStore
 import ch.threema.domain.stores.IdentityStoreInterface
 import ch.threema.domain.stores.IdentityStoreInterface
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
 import org.junit.After
 import org.junit.After
 import org.junit.Assert.*
 import org.junit.Assert.*
 import org.junit.Before
 import org.junit.Before
 import org.junit.Test
 import org.junit.Test
-import javax.crypto.Mac
-import javax.crypto.spec.SecretKeySpec
 
 
 class DatabaseNonceStoreTest {
 class DatabaseNonceStoreTest {
     private lateinit var tempDbFileName: String
     private lateinit var tempDbFileName: String
@@ -44,13 +44,12 @@ class DatabaseNonceStoreTest {
 
 
     @Before
     @Before
     fun setup() {
     fun setup() {
-
         tempDbFileName = "threema-nonce-test-${System.currentTimeMillis()}.db"
         tempDbFileName = "threema-nonce-test-${System.currentTimeMillis()}.db"
         val identityStore = TestIdentityStore()
         val identityStore = TestIdentityStore()
         _store = DatabaseNonceStore(
         _store = DatabaseNonceStore(
             ApplicationProvider.getApplicationContext(),
             ApplicationProvider.getApplicationContext(),
             identityStore,
             identityStore,
-            tempDbFileName
+            tempDbFileName,
         )
         )
     }
     }
 
 
@@ -102,7 +101,6 @@ class DatabaseNonceStoreTest {
             assertTrue(store.store(NonceScope.D2D, it))
             assertTrue(store.store(NonceScope.D2D, it))
         }
         }
 
 
-
         // Assert the nonces exist after the insert
         // Assert the nonces exist after the insert
         nonces.forEach {
         nonces.forEach {
             assertTrue(store.exists(NonceScope.CSP, it))
             assertTrue(store.exists(NonceScope.CSP, it))
@@ -194,7 +192,7 @@ class DatabaseNonceStoreTest {
 
 
     private fun assertSameHashedNonces(
     private fun assertSameHashedNonces(
         expected: Collection<HashedNonce>,
         expected: Collection<HashedNonce>,
-        actual: Collection<HashedNonce>
+        actual: Collection<HashedNonce>,
     ) {
     ) {
         assertEquals(expected.size, actual.size)
         assertEquals(expected.size, actual.size)
         expected.forEach { expectedNonce ->
         expected.forEach { expectedNonce ->
@@ -233,13 +231,13 @@ private class TestIdentityStore : IdentityStoreInterface {
     override fun encryptData(
     override fun encryptData(
         plaintext: ByteArray,
         plaintext: ByteArray,
         nonce: ByteArray,
         nonce: ByteArray,
-        receiverPublicKey: ByteArray
+        receiverPublicKey: ByteArray,
     ): ByteArray = throw UnsupportedOperationException()
     ): ByteArray = throw UnsupportedOperationException()
 
 
     override fun decryptData(
     override fun decryptData(
         ciphertext: ByteArray,
         ciphertext: ByteArray,
         nonce: ByteArray,
         nonce: ByteArray,
-        senderPublicKey: ByteArray
+        senderPublicKey: ByteArray,
     ): ByteArray = throw UnsupportedOperationException()
     ): ByteArray = throw UnsupportedOperationException()
 
 
     override fun calcSharedSecret(publicKey: ByteArray): ByteArray =
     override fun calcSharedSecret(publicKey: ByteArray): ByteArray =
@@ -257,7 +255,6 @@ private class TestIdentityStore : IdentityStoreInterface {
         identity: String,
         identity: String,
         serverGroup: String,
         serverGroup: String,
         publicKey: ByteArray,
         publicKey: ByteArray,
-        privateKey: ByteArray
+        privateKey: ByteArray,
     ) = throw UnsupportedOperationException()
     ) = throw UnsupportedOperationException()
-
 }
 }

+ 5 - 1
app/src/blue/java/ch/threema/app/compose/theme/color/ColorsDark.kt

@@ -24,7 +24,6 @@ package ch.threema.app.compose.theme.color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 
 
 object ColorsDark : ComposeColorPaletteDark() {
 object ColorsDark : ComposeColorPaletteDark() {
-
     override val primary = Color(0xFFA4C8FF)
     override val primary = Color(0xFFA4C8FF)
     override val onPrimary = Color(0xFF00315E)
     override val onPrimary = Color(0xFF00315E)
     override val primaryContainer = Color(0xFF004784)
     override val primaryContainer = Color(0xFF004784)
@@ -47,6 +46,11 @@ object ColorsDark : ComposeColorPaletteDark() {
     override val onSurface = Color(0xFFE3E2E6)
     override val onSurface = Color(0xFFE3E2E6)
     override val surfaceVariant = Color(0xFF282E35)
     override val surfaceVariant = Color(0xFF282E35)
     override val onSurfaceVariant = Color(0xFFC3C6CF)
     override val onSurfaceVariant = Color(0xFFC3C6CF)
+    override val surfaceContainerLowest = Color(0xFF212429)
+    override val surfaceContainerLow = Color(0xFF212429)
+    override val surfaceContainer = Color(0xFF252A30)
+    override val surfaceContainerHigh = Color(0xFF282E35)
+    override val surfaceContainerHighest = Color(0xFF2C333C)
     override val outline = Color(0xFF8D9199)
     override val outline = Color(0xFF8D9199)
     override val outlineVariant = Color(0xFF43474E)
     override val outlineVariant = Color(0xFF43474E)
     override val scrim = Color(0xFF000000)
     override val scrim = Color(0xFF000000)

+ 5 - 0
app/src/blue/java/ch/threema/app/compose/theme/color/ColorsLight.kt

@@ -46,6 +46,11 @@ object ColorsLight : ComposeColorPaletteLight() {
     override val onSurface = Color(0xFF1A1C1E)
     override val onSurface = Color(0xFF1A1C1E)
     override val surfaceVariant = Color(0xFFE2EBF6)
     override val surfaceVariant = Color(0xFFE2EBF6)
     override val onSurfaceVariant = Color(0xFF43474E)
     override val onSurfaceVariant = Color(0xFF43474E)
+    override val surfaceContainerLowest = Color(0xFFF0F3FA)
+    override val surfaceContainerLow = Color(0xFFF0F3FA)
+    override val surfaceContainer = Color(0xFFE8EFF8)
+    override val surfaceContainerHigh = Color(0xFFE2EBF6)
+    override val surfaceContainerHighest = Color(0xFFDAE6F3)
     override val outline = Color(0xFF73777F)
     override val outline = Color(0xFF73777F)
     override val outlineVariant = Color(0xFFC3C6CF)
     override val outlineVariant = Color(0xFFC3C6CF)
     override val scrim = Color(0xFF000000)
     override val scrim = Color(0xFF000000)

+ 1 - 1
app/src/blue/java/ch/threema/app/compose/theme/color/CustomColorsDark.kt

@@ -24,5 +24,5 @@ package ch.threema.app.compose.theme.color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 
 
 val CustomColorDark = CustomColor(
 val CustomColorDark = CustomColor(
-    messageBubbleContainerReceive = Color(0xFF666666)
+    messageBubbleContainerReceive = Color(0xFF666666),
 )
 )

+ 1 - 1
app/src/blue/java/ch/threema/app/compose/theme/color/CustomColorsLight.kt

@@ -24,5 +24,5 @@ package ch.threema.app.compose.theme.color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 
 
 val CustomColorLight = CustomColor(
 val CustomColorLight = CustomColor(
-    messageBubbleContainerReceive = Color(0xFFF2F2F2)
+    messageBubbleContainerReceive = Color(0xFFF2F2F2),
 )
 )

+ 98 - 35
app/src/blue/res/drawable-v24/ic_launcher_foreground.xml

@@ -1,4 +1,5 @@
 <vector xmlns:android="http://schemas.android.com/apk/res/android"
 <vector xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:aapt="http://schemas.android.com/aapt"
     android:width="108dp"
     android:width="108dp"
     android:height="108dp"
     android:height="108dp"
     android:viewportWidth="1500"
     android:viewportWidth="1500"
@@ -6,48 +7,110 @@
     <group
     <group
         android:translateX="238"
         android:translateX="238"
         android:translateY="238">
         android:translateY="238">
-        <!--    <group>-->
-        <!-- background color of icon -->
         <path
         <path
-            android:fillColor="#FFF"
+            android:fillColor="#FAFAFA"
             android:fillType="evenOdd"
             android:fillType="evenOdd"
             android:pathData="M0,0h1024v1024h-1024z"
             android:pathData="M0,0h1024v1024h-1024z"
+            android:strokeWidth="1"
             android:strokeColor="#00000000" />
             android:strokeColor="#00000000" />
-        <!-- sky color -->
         <path
         <path
-            android:fillColor="@color/ic_launcher_sky"
-            android:fillType="evenOdd"
-            android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8ZZ"
+            android:fillColor="#035EA5"
+            android:fillType="nonZero"
+            android:pathData="M567.8,838.9C567.8,869.9 542.8,894.9 511.8,894.9C480.9,894.9 455.9,869.9 455.9,838.9C455.9,808 480.9,782.9 511.8,782.9C542.8,782.9 567.8,808 567.8,838.9ZM365.9,838.9C365.9,869.9 340.9,894.9 309.9,894.9C278.9,894.9 253.9,869.9 253.9,838.9C253.9,808 279,782.9 309.9,782.9C340.8,782.9 365.9,808 365.9,838.9ZM769.8,838.9C769.8,869.9 744.8,894.9 713.8,894.9C682.8,894.9 657.8,869.9 657.8,838.9C657.8,808 682.9,782.9 713.8,782.9C744.7,782.9 769.8,808 769.8,838.9Z"
+            android:strokeWidth="1"
             android:strokeColor="#00000000" />
             android:strokeColor="#00000000" />
-        <!-- shadow of the dune -->
-        <group>
-            <clip-path android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8Z" />
-            <path
-                android:fillColor="@color/ic_launcher_shadow"
-                android:fillType="evenOdd"
-                android:pathData="M134.7,434.5C286.6,434.5 403.7,243 512,243C618.4,245.6 774.8,438.4 882,434.5L882,730L134.7,730L134.7,434.5ZZ"
-                android:strokeColor="#00000000" />
-        </group>
-        <!-- right side of the dune -->
-        <group>
-            <clip-path android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8Z" />
-            <path
-                android:fillColor="@color/ic_launcher_dune"
-                android:fillType="evenOdd"
-                android:pathData="M479.9,248.7C544.4,229 572.5,307.3 459.2,389.2C346,471 182.8,668 479.9,730L882,730L882,434.5C769.9,434.5 594.9,204.7 479.9,248.7ZZ"
-                android:strokeColor="#00000000" />
-        </group>
-        <!-- pad lock symbol -->
         <path
         <path
-            android:fillColor="#FFFFFF"
-            android:fillType="evenOdd"
-            android:pathData="M512,274C563.6,274 605.3,315.8 605.3,367.2L605.3,404.5L609,404.5C617.3,404.5 624,411.2 624,419.5L624,551C624,559.3 617.3,566 609,566L415,566C406.7,566 400,559.3 400,551L400,419.5C400,411.2 406.7,404.5 415,404.5L418.7,404.5L418.7,367.2C418.7,315.8 460.4,274 512,274ZZM512,311.3C481.1,311.3 456,336.3 456,367.2L456,404.5L568,404.5L568,367.2C568,336.3 542.9,311.3 512,311.3ZZ"
-            android:strokeColor="#00000000" />
-        <!-- threema dots -->
+            android:fillType="nonZero"
+            android:pathData="M512,301.4C481,301.4 456,326.4 456,357.3L456,394.6L568,394.6L568,357.3C568,326.4 542.9,301.4 512,301.4L512,301.4Z"
+            android:strokeWidth="1"
+            android:strokeColor="#00000000">
+            <aapt:attr name="android:fillColor">
+                <gradient
+                    android:endX="582.1"
+                    android:endY="392.4"
+                    android:startX="466.1"
+                    android:startY="315.7"
+                    android:type="linear">
+                    <item
+                        android:color="#FF88C6F9"
+                        android:offset="0" />
+                    <item
+                        android:color="#FF5FB1F6"
+                        android:offset="1" />
+                </gradient>
+            </aapt:attr>
+        </path>
         <path
         <path
-            android:fillColor="@color/ic_launcher_dots"
-            android:fillType="evenOdd"
-            android:pathData="M568,848C568,878.9 542.9,904 511.9,904C481,904 456,878.9 456,848C456,817.1 481,792 511.9,792C542.9,792 568,817.1 568,848ZZM366,848C366,878.9 341,904 310,904C279.1,904 254,878.9 254,848C254,817.1 279.1,792 310,792C341,792 366,817.1 366,848ZZM769.9,848C769.9,878.9 744.9,904 713.9,904C683,904 658,878.9 658,848C658,817.1 683,792 713.9,792C744.9,792 769.9,817.1 769.9,848ZZ"
-            android:strokeColor="#00000000" />
+            android:fillType="nonZero"
+            android:pathData="M830.9,347.1C790.3,337 747.7,321 702.2,301.4C589.2,252.6 515.1,222.7 444.9,236.1C414,241.9 392.6,251.6 383.5,256.9C374.4,262.2 355.4,285.2 355.4,308.3C355.4,341.4 367.4,371 405.8,397.7C408.4,395.7 411.5,394.6 415,394.6L418.7,394.6L418.7,357.3C418.7,305.9 460.4,264.1 512,264.1C563.5,264.1 605.3,305.9 605.3,357.3L605.3,394.6L609,394.6C617.3,394.6 624,401.3 624,409.6L624,498.9C677.2,531.3 721.2,574.7 728.3,636.7C797.4,583.6 841,505.6 841,418.6C841,393.9 837.5,369.9 830.9,347.1L830.9,347.1Z"
+            android:strokeWidth="1"
+            android:strokeColor="#00000000">
+            <aapt:attr name="android:fillColor">
+                <gradient
+                    android:endX="734.7"
+                    android:endY="587.3"
+                    android:startX="386.9"
+                    android:startY="305.6"
+                    android:type="linear">
+                    <item
+                        android:color="#FF92CBFA"
+                        android:offset="0" />
+                    <item
+                        android:color="#FF389EF3"
+                        android:offset="1" />
+                </gradient>
+            </aapt:attr>
+        </path>
+        <path
+            android:fillType="nonZero"
+            android:pathData="M624,541.1C624,549.4 617.3,556.1 609,556.1L415,556.1C406.7,556.1 400,549.4 400,541.1L400,409.6C400,404.7 402.3,400.5 405.8,397.7C369,369 356.5,341.4 356.5,308.3C356.5,285.2 373.8,263.6 383.5,256.9C323,292.2 271,344.3 186.4,376.9C184.2,390.5 183,404.4 183,418.6C183,478 203.3,533.2 238.2,579.2L203,720.1L367.8,678.9C411.4,697.6 460.3,708.1 512,708.1C594.8,708.1 670.6,681.1 728.4,636.6C722,573.7 678.7,529.5 624,498.9L624,541.1Z"
+            android:strokeWidth="1"
+            android:strokeColor="#00000000">
+            <aapt:attr name="android:fillColor">
+                <gradient
+                    android:endX="498.8"
+                    android:endY="639.9"
+                    android:startX="183"
+                    android:startY="409.8"
+                    android:type="linear">
+                    <item
+                        android:color="#FF3DA7FF"
+                        android:offset="0" />
+                    <item
+                        android:color="#FF1968A8"
+                        android:offset="0.5" />
+                    <item
+                        android:color="#FF105891"
+                        android:offset="0.7" />
+                    <item
+                        android:color="#FF003C6B"
+                        android:offset="1" />
+                </gradient>
+            </aapt:attr>
+        </path>
+        <path
+            android:fillType="nonZero"
+            android:pathData="M512,129.1C346.4,129.1 209.3,236.8 186.4,376.9C271,344.3 323,293.2 383.5,257.9C392.6,252.6 414,242.9 444.9,237C515.1,223.7 589.2,255.6 702.2,304.4C747.7,323.9 790.3,337 830.9,347C794.7,221.8 665.6,129.1 512,129.1Z"
+            android:strokeWidth="1"
+            android:strokeColor="#00000000">
+            <aapt:attr name="android:fillColor">
+                <gradient
+                    android:endX="281"
+                    android:endY="215.7"
+                    android:startX="627.6"
+                    android:startY="296.8"
+                    android:type="linear">
+                    <item
+                        android:color="#FFE0F2FF"
+                        android:offset="0" />
+                    <item
+                        android:color="#FFB9DFFD"
+                        android:offset="1" />
+                    <item
+                        android:color="#FFB9DFFD"
+                        android:offset="1" />
+                </gradient>
+            </aapt:attr>
+        </path>
     </group>
     </group>
 </vector>
 </vector>

+ 4 - 4
app/src/blue/res/drawable-v24/ic_launcher_monochrome.xml

@@ -9,11 +9,11 @@
         android:translateX="14.04"
         android:translateX="14.04"
         android:translateY="14.04">
         android:translateY="14.04">
         <path
         <path
-            android:pathData="m58.72,80.98c1.56,-0.53 2.64,-1.78 2.64,-3.7 0,-2.88 -2.23,-4.39 -5.14,-4.39h-8.11v17.04h8.57c3.19,0 5.28,-1.8 5.28,-4.8 0,-2.18 -1.22,-3.53 -3.24,-4.15zM51.47,75.48h3.96c1.75,0 2.66,0.7 2.66,2.06 0,1.37 -0.94,2.21 -2.69,2.21h-3.94zM55.48,87.34h-4.01v-4.97h4.01c2.06,0 3.12,0.98 3.12,2.5 0,1.54 -1.03,2.47 -3.12,2.47z"
-            android:fillColor="#333333" />
+            android:fillColor="#333333"
+            android:pathData="m58.72,80.98c1.56,-0.53 2.64,-1.78 2.64,-3.7 0,-2.88 -2.23,-4.39 -5.14,-4.39h-8.11v17.04h8.57c3.19,0 5.28,-1.8 5.28,-4.8 0,-2.18 -1.22,-3.53 -3.24,-4.15zM51.47,75.48h3.96c1.75,0 2.66,0.7 2.66,2.06 0,1.37 -0.94,2.21 -2.69,2.21h-3.94zM55.48,87.34h-4.01v-4.97h4.01c2.06,0 3.12,0.98 3.12,2.5 0,1.54 -1.03,2.47 -3.12,2.47z" />
         <path
         <path
-            android:pathData="M40.84,67.66l-14.89,3.72 3.18,-12.73c-3.15,-4.15 -4.99,-9.14 -4.99,-14.51 0,-14.45 13.31,-26.16 29.73,-26.16s29.73,11.71 29.73,26.16 -13.31,26.16 -29.73,26.16c-4.67,0 -9.09,-0.95 -13.03,-2.64h0ZM45.43,41.97h-0.33c-0.75,0 -1.36,0.61 -1.36,1.36v11.88c0,0.75 0.61,1.36 1.36,1.36h17.53c0.75,0 1.36,-0.61 1.36,-1.36v-11.88c0,-0.75 -0.61,-1.36 -1.36,-1.36h-0.33v-3.37c0,-4.65 -3.77,-8.42 -8.44,-8.42s-8.43,3.77 -8.43,8.42v3.37ZM58.92,41.97h-10.12v-3.37c0,-2.79 2.26,-5.05 5.06,-5.05s5.06,2.27 5.06,5.05v3.37Z"
             android:fillColor="#333"
             android:fillColor="#333"
-            android:fillType="evenOdd" />
+            android:fillType="evenOdd"
+            android:pathData="M40.84,67.66l-14.89,3.72 3.18,-12.73c-3.15,-4.15 -4.99,-9.14 -4.99,-14.51 0,-14.45 13.31,-26.16 29.73,-26.16s29.73,11.71 29.73,26.16 -13.31,26.16 -29.73,26.16c-4.67,0 -9.09,-0.95 -13.03,-2.64h0ZM45.43,41.97h-0.33c-0.75,0 -1.36,0.61 -1.36,1.36v11.88c0,0.75 0.61,1.36 1.36,1.36h17.53c0.75,0 1.36,-0.61 1.36,-1.36v-11.88c0,-0.75 -0.61,-1.36 -1.36,-1.36h-0.33v-3.37c0,-4.65 -3.77,-8.42 -8.44,-8.42s-8.43,3.77 -8.43,8.42v3.37ZM58.92,41.97h-10.12v-3.37c0,-2.79 2.26,-5.05 5.06,-5.05s5.06,2.27 5.06,5.05v3.37Z" />
     </group>
     </group>
 </vector>
 </vector>

+ 0 - 25
app/src/blue/res/drawable/ic_finger_with_circles.xml

@@ -1,25 +0,0 @@
-<vector android:height="187dp"
-    android:viewportHeight="374.962"
-    android:viewportWidth="330.142"
-    android:width="164.64749dp"
-    xmlns:android="http://schemas.android.com/apk/res/android">
-    <path
-        android:fillColor="#0096ff"
-        android:pathData="M165.124,80.018a85.07,85.07 0,1 1,-22.053 2.917,85.032 85.032,0 0,1 22.053,-2.917m0,-10v0a95.012,95.012 0,1 0,91.707 70.433,95.233 95.233,0 0,0 -91.707,-70.433Z" />
-    <path
-        android:fillAlpha="0.4"
-        android:fillColor="#0096ff"
-        android:pathData="M165.146,45.009A120.1,120.1 0,1 1,134.015 49.128a120.046,120.046 0,0 1,31.133 -4.119m0.006,-10v10l0,-10A130.026,130.026 0,1 0,290.641 131.388,130.372 130.372,0 0,0 165.15,35.009Z"
-        android:strokeAlpha="0.4" />
-    <path
-        android:fillAlpha="0.1"
-        android:fillColor="#0096ff"
-        android:pathData="M165.168,10a155.132,155.132 0,1 1,-40.214 5.32,155.06 155.06,0 0,1 40.214,-5.32m0.007,-10v10l0,-10a165.329,165.329 0,1 0,99.548 33.519,165.526 165.526,0 0,0 -99.548,-33.519Z"
-        android:strokeAlpha="0.1" />
-    <path
-        android:fillColor="#fff"
-        android:pathData="M151.989,374.963L290.651,374.963L229.758,147.706a66.969,66.969 0,0 0,-129.374 34.666Z" />
-    <path
-        android:fillColor="#d8d8d8"
-        android:pathData="M151.282,113.576a53.278,53.278 0,0 0,-38.626 41.88l13.882,51.81a48.149,48.149 0,0 0,93.017 -24.924l-13.882,-51.809A53.278,53.278 0,0 0,151.282 113.576Z" />
-</vector>

+ 7 - 9
app/src/blue/res/layout/activity_enter_serial.xml

@@ -50,7 +50,7 @@
                 android:layout_centerHorizontal="true"
                 android:layout_centerHorizontal="true"
                 android:linksClickable="true"
                 android:linksClickable="true"
                 android:autoLink="web"
                 android:autoLink="web"
-                android:text="@string/enter_serial_body"
+                android:text="@string/flavored__enter_serial_body"
                 android:layout_marginBottom="5dp" />
                 android:layout_marginBottom="5dp" />
 
 
             <LinearLayout
             <LinearLayout
@@ -121,9 +121,8 @@
                         android:layout_height="@dimen/wizard_default_view_height"
                         android:layout_height="@dimen/wizard_default_view_height"
                         android:hint="@string/password_hint"
                         android:hint="@string/password_hint"
                         android:id="@+id/password"
                         android:id="@+id/password"
-                        android:nextFocusRight="@+id/unlock_button"
                         android:inputType="textNoSuggestions|textPassword"
                         android:inputType="textNoSuggestions|textPassword"
-                        android:imeOptions="actionGo"
+                        android:imeOptions="actionDone"
                         android:singleLine="true" />
                         android:singleLine="true" />
 
 
                 </com.google.android.material.textfield.TextInputLayout>
                 </com.google.android.material.textfield.TextInputLayout>
@@ -141,21 +140,20 @@
                 android:textSize="@dimen/wizard_text_medium"
                 android:textSize="@dimen/wizard_text_medium"
                 android:textColor="@color/material_red" />
                 android:textColor="@color/material_red" />
 
 
-            <androidx.appcompat.widget.AppCompatButton
-                style="@style/WizardButtonRegular"
-                android:id="@+id/unlock_button_work"
-                android:layout_below="@id/unlock_state"
+            <ch.threema.app.activities.wizard.components.WizardButtonXml
+                android:id="@+id/unlock_button_work_compose"
                 android:layout_width="wrap_content"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:layout_height="wrap_content"
                 android:layout_alignParentRight="true"
                 android:layout_alignParentRight="true"
-                android:text="@string/next" />
+                android:layout_below="@id/unlock_state"
+                app:wizardButton_text="@string/next"/>
 
 
             <TextView
             <TextView
                 style="@style/WizardMediumText"
                 style="@style/WizardMediumText"
                 android:id="@+id/work_lost_credential_help"
                 android:id="@+id/work_lost_credential_help"
                 android:layout_width="fill_parent"
                 android:layout_width="fill_parent"
                 android:layout_height="wrap_content"
                 android:layout_height="wrap_content"
-                android:layout_below="@id/unlock_button_work"
+                android:layout_below="@id/unlock_button_work_compose"
                 android:gravity="center_horizontal"
                 android:gravity="center_horizontal"
                 android:layout_marginTop="32dp" />
                 android:layout_marginTop="32dp" />
 
 

BIN
app/src/blue/res/mipmap-hdpi/ic_launcher.png


BIN
app/src/blue/res/mipmap-mdpi/ic_launcher.png


BIN
app/src/blue/res/mipmap-xhdpi/ic_launcher.png


BIN
app/src/blue/res/mipmap-xxhdpi/ic_launcher.png


BIN
app/src/blue/res/mipmap-xxxhdpi/ic_launcher.png


+ 0 - 32
app/src/blue/res/values-de/strings.xml

@@ -1,32 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<resources>
-    <string name="about_title">Threema Blue für Android</string>
-    <string name="enter_serial_body">Geben Sie Ihre Threema Work-Zugangsdaten ein, die Sie von Ihrem Unternehmen erhalten haben.</string>
-    <string name="serial_required_want_exit">Die Lizenz ist ungültig. Möchten Sie es nochmals versuchen oder Threema verlassen?</string>
-    <string name="checking_serial">Zugangsberechtigung wird überprüft</string>
-    <string name="username_hint">Benutzername</string>
-    <string name="new_wizard_works_like_phone_number">Ihre Threema-ID funktioniert wie eine Telefonnummer. Ihre Kontakte können Sie über diese ID erreichen.</string>
-    <string name="new_wizard_nickname_explain">Der Nickname wird bei Ihren Kontakten angezeigt, wenn sie eine Nachricht empfangen.</string>
-    <string name="new_wizard_help_your_friends_find_you">Damit Sie einfacher gefunden werden</string>
-    <string name="new_wizard_find_friends">Finden Sie Ihre Kontakte auf Threema</string>
-    <string name="new_wizard_sync_contacts_explain">Einschalten, um zu sehen, welche Ihrer Kontakte Threema oder Threema Work nutzen.</string>
-    <string name="new_wizard_anonymous_confirm">Sie haben weder eine Handynummer noch eine E-Mail-Adresse zur Verknüpfung angegeben. Sie werden deshalb nicht automatisch in Kontaktlisten aufgeführt. Möchten Sie Threema Blue wirklich komplett anonym nutzen?</string>
-    <string name="new_wizard_setup_threema">Threema Blue einrichten</string>
-    <string name="new_wizard_welcome">Willkommen bei Threema Blue</string>
-    <string name="new_wizard_info_fingerprint">Durch die Bewegung des Fingers erzeugen Sie Zufallsdaten (sogenannte Entropie),
-		die für die Erzeugung des Schlüsselpaars genutzt werden. Dieses Schlüsselpaar wird mit Ihrer neuen Threema-ID verbunden. Es besteht aus einem
-		<b>öffentlichen Schlüssel</b>, der an Ihre Kontakte verteilt wird und einem <b>privaten Schlüssel</b>, der auf Ihrem Gerät verbleibt. Bei Ihren
-		Kontakten wird der öffentliche Schlüssel genutzt, um Nachrichten an Sie zu verschlüsseln. Nur der Inhaber des privaten Schlüssels und niemand
-		anderes kann die Nachrichten wieder entschlüsseln.
-	</string>
-    <string name="new_wizard_info_link">Wenn Sie Ihre eigene Handynummer und/oder E-Mail-Adresse angeben, kann Threema Blue Ihren Kontakten helfen, Sie
-		automatisch zu finden, wenn Sie in deren Adressbuch eingetragen sind. Die Angaben werden dazu in einwegverschlüsselter (gehashter) Form
-		auf unserem Server gespeichert. Sie können diesen Schritt auch einfach übespringen, wenn Sie Threema Blue anonym benutzen möchten.
-	</string>
-    <string name="threema_contact">Threema Work-Kontakt</string>
-    <string name="menu_about">Über Threema Blue</string>
-    <string name="directory_search">Im Verzeichnis suchen</string>
-    <string name="directory_title">Unternehmensverzeichnis</string>
-    <string name="directory_empty_view_text">Bitte geben Sie mindestens 3 Zeichen eines Namens ein, um mit der Suche im Unternehmensverzeichnis zu beginnen oder wählen Sie eine Kategorie, indem Sie auf das Filter-Symbol tippen.</string>
-</resources>
-

+ 2 - 5
app/src/blue/res/values/colors.xml

@@ -1,11 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
 <resources>
     <!-- wizard -->
     <!-- wizard -->
-    <color name="wizard_color_text_on_primary">@android:color/white</color>
-    <color name="wizard_alpha_background">#33ffffff</color>
-    <color name="wizard_color_accent">#0096ff</color>
-    <color name="wizard_color_accent_dark">#323232</color>
-    <color name="wizard_color_button_disabled">#660096ff</color>
+    <color name="color_wizard_primary">#0096ff</color>
+    <color name="color_wizard_on_primary">#FFFFFF</color>
 
 
     <!-- chat bubbles -->
     <!-- chat bubbles -->
     <color name="bubble_receive">#F2F2F2</color>
     <color name="bubble_receive">#F2F2F2</color>

+ 0 - 1
app/src/blue/res/values/firebase_messaging.xml

@@ -2,7 +2,6 @@
   ~ Copyright (c) 2020 Threema GmbH
   ~ Copyright (c) 2020 Threema GmbH
   ~ All rights reserved.
   ~ All rights reserved.
   -->
   -->
-<!-- TODO: move to build.gradle -->
 <resources>
 <resources>
     <string name="google_app_id" translatable="false">1:480681303521:android:6ec12987090e0e4f9fc6a0</string>
     <string name="google_app_id" translatable="false">1:480681303521:android:6ec12987090e0e4f9fc6a0</string>
     <string name="gcm_defaultSenderId" translatable="false">480681303521</string>
     <string name="gcm_defaultSenderId" translatable="false">480681303521</string>

+ 7 - 0
app/src/blue/res/values/flavor_specific_strings.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!-- Overrides for Work -->
+    <string name="flavored__new_wizard_setup_threema" translatable="false">@string/work_new_wizard_setup_threema</string>
+    <string name="flavored__enter_serial_body" translatable="false">@string/work_enter_serial_body</string>
+    <string name="flavored__checking_serial" translatable="false">@string/work_checking_credentials</string>
+</resources>

+ 0 - 4
app/src/blue/res/values/ic_launcher_colors.xml

@@ -1,8 +1,4 @@
 <?xml version="1.0" encoding="utf-8"?>
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
 <resources>
     <color name="ic_launcher_background">#ffffff</color>
     <color name="ic_launcher_background">#ffffff</color>
-    <color name="ic_launcher_dune">#004A7F</color>
-    <color name="ic_launcher_dots">#004A7F</color>
-    <color name="ic_launcher_shadow">#333333</color>
-    <color name="ic_launcher_sky">#A0D8FF</color>
 </resources>
 </resources>

+ 0 - 31
app/src/blue/res/values/strings.xml

@@ -1,31 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<resources>
-    <string name="about_title">Threema Blue for Android</string>
-    <string name="enter_serial_body">Please enter the Threema Work credentials provided by your company.</string>
-    <string name="serial_required_want_exit">License is invalid. Would you like to try again or quit Threema?</string>
-    <string name="checking_serial">Checking credentials</string>
-    <string name="username_hint">Username</string>
-    <string name="new_wizard_works_like_phone_number">Your Threema ID works just like a phone number.\nYour contacts can reach you through this ID.</string>
-    <string name="new_wizard_nickname_explain">Your contacts will see your nickname in their notifications.</string>
-    <string name="new_wizard_help_your_friends_find_you">Help your contacts find you!</string>
-    <string name="new_wizard_find_friends">Find your contacts on Threema Blue</string>
-    <string name="new_wizard_sync_contacts_explain">Switch on to see which of your contacts is already using Threema or Threema Work.</string>
-    <string name="new_wizard_anonymous_confirm">You have entered neither a mobile number nor an email address to link to your Threema ID. You will not appear on contact lists. Do you really want to use Threema Blue anonymously?</string>
-    <string name="new_wizard_setup_threema">Set up Threema Blue</string>
-    <string name="new_wizard_welcome">Welcome to Threema Blue!</string>
-    <string name="new_wizard_info_fingerprint">By moving your finger, you create random data (called entropy) that is used to generate
-		the keypair associated with your new unique Threema ID. The keypair consists of a <b>public key</b> that is distributed to
-		your contacts and a <b>private key</b> that is safely stored on your phone. Your contacts will encrypt messages to you with
-		your public key. Only the owner of the private key and nobody else is able to decrypt these messages.
-	</string>
-    <string name="new_wizard_info_link">By providing your phone number and email address, Threema Blue can help your contacts
-		find you automatically if they have you in their phone’s address book. The data will be stored in a
-		one-way encrypted (hashed) form on our server. You can simply skip this step, if you would like to use Threema Blue
-		anonymously.
-	</string>
-    <string name="threema_contact">Threema Work Contact</string>
-    <string name="menu_about">About Threema Blue</string>
-    <string name="directory_search">Search in the directory</string>
-    <string name="directory_title">Company directory</string>
-    <string name="directory_empty_view_text">Please enter at least 3 characters of a name to begin searching in your company\'s directory or select a category by tapping on the filter icon.</string>
-</resources>

+ 7 - 0
app/src/blue/res/xml/app_restrictions.xml

@@ -222,6 +222,13 @@
         android:restrictionType="bool"
         android:restrictionType="bool"
         android:title="@string/restriction_disable_web" />
         android:title="@string/restriction_disable_web" />
 
 
+    <restriction
+        android:defaultValue="false"
+        android:description="@string/restriction_disable_multidevice_desc"
+        android:key="th_disable_multidevice"
+        android:restrictionType="bool"
+        android:title="@string/restriction_disable_multidevice" />
+
     <restriction
     <restriction
         android:defaultValue="false"
         android:defaultValue="false"
         android:description="@string/restriction_disable_id_export_desc"
         android:description="@string/restriction_disable_id_export_desc"

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

@@ -269,14 +269,14 @@ POSSIBILITY OF SUCH DAMAGE.</p>
 
 
 <h2>saltyrtc-client-java</h2>
 <h2>saltyrtc-client-java</h2>
 
 
-<p>Copyright (c) 2016-2024 Threema GmbH</p>
+<p>Copyright (c) 2016-2025 Threema GmbH</p>
 
 
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 
 
 <h2>saltyrtc-task-webrtc-java</h2>
 <h2>saltyrtc-task-webrtc-java</h2>
 
 
-<p>Copyright (c) 2016-2024 Threema GmbH</p>
+<p>Copyright (c) 2016-2025 Threema GmbH</p>
 
 
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 

+ 1 - 0
app/src/google_services_based/java/ch/threema/app/push/PushRegistrationWorker.java

@@ -81,6 +81,7 @@ public class PushRegistrationWorker extends Worker {
 
 
                     String token = task.getResult();
                     String token = task.getResult();
                     logger.info("Received FCM registration token");
                     logger.info("Received FCM registration token");
+                    logger.debug("FCM push token: {}", token);
                     String error = null;
                     String error = null;
                     try {
                     try {
                         PushUtil.sendTokenToServer(token, ProtocolDefines.PUSHTOKEN_TYPE_FCM);
                         PushUtil.sendTokenToServer(token, ProtocolDefines.PUSHTOKEN_TYPE_FCM);

+ 1 - 0
app/src/google_services_based/java/ch/threema/app/push/PushService.java

@@ -55,6 +55,7 @@ public class PushService extends FirebaseMessagingService {
     @Override
     @Override
     public void onNewToken(@NonNull String token) {
     public void onNewToken(@NonNull String token) {
         logger.info("New FCM token received");
         logger.info("New FCM token received");
+        logger.debug("FCM push token: {}", token);
         try {
         try {
             PushUtil.sendTokenToServer(token, ProtocolDefines.PUSHTOKEN_TYPE_FCM);
             PushUtil.sendTokenToServer(token, ProtocolDefines.PUSHTOKEN_TYPE_FCM);
         } catch (ThreemaException e) {
         } catch (ThreemaException e) {

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

@@ -192,7 +192,7 @@ public class VoiceActionService extends SearchActionVerificationClientService {
         }
         }
     }
     }
 
 
-    /*	@Override
+    /*    @Override
         public boolean isTestingMode() {
         public boolean isTestingMode() {
             return true;
             return true;
         }
         }

+ 5 - 1
app/src/green/java/ch/threema/app/compose/theme/color/ColorsDark.kt

@@ -24,7 +24,6 @@ package ch.threema.app.compose.theme.color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 
 
 object ColorsDark : ComposeColorPaletteDark() {
 object ColorsDark : ComposeColorPaletteDark() {
-
     override val primary = Color(0xFF34C955)
     override val primary = Color(0xFF34C955)
     override val onPrimary = Color(0xFF003910)
     override val onPrimary = Color(0xFF003910)
     override val primaryContainer = Color(0xFF00531B)
     override val primaryContainer = Color(0xFF00531B)
@@ -47,6 +46,11 @@ object ColorsDark : ComposeColorPaletteDark() {
     override val onSurface = Color(0xFFE2E3DD)
     override val onSurface = Color(0xFFE2E3DD)
     override val surfaceVariant = Color(0xFF203022)
     override val surfaceVariant = Color(0xFF203022)
     override val onSurfaceVariant = Color(0xFFC2C9BD)
     override val onSurfaceVariant = Color(0xFFC2C9BD)
+    override val surfaceContainerLowest = Color(0xFF1B241C)
+    override val surfaceContainerLow = Color(0xFF1B241C)
+    override val surfaceContainer = Color(0xFF1C2A1D)
+    override val surfaceContainerHigh = Color(0xFF1C2E1F)
+    override val surfaceContainerHighest = Color(0xFF1D3321)
     override val outline = Color(0xFF8C9389)
     override val outline = Color(0xFF8C9389)
     override val outlineVariant = Color(0xFF424940)
     override val outlineVariant = Color(0xFF424940)
     override val scrim = Color(0xFF000000)
     override val scrim = Color(0xFF000000)

+ 5 - 0
app/src/green/java/ch/threema/app/compose/theme/color/ColorsLight.kt

@@ -46,6 +46,11 @@ object ColorsLight : ComposeColorPaletteLight() {
     override val onSurface = Color(0xFF1A1C19)
     override val onSurface = Color(0xFF1A1C19)
     override val surfaceVariant = Color(0xFFE0E4DD)
     override val surfaceVariant = Color(0xFFE0E4DD)
     override val onSurfaceVariant = Color(0xFF424940)
     override val onSurfaceVariant = Color(0xFF424940)
+    override val surfaceContainerLowest = Color(0xFFF1FAF4)
+    override val surfaceContainerLow = Color(0xFFF1FAF4)
+    override val surfaceContainer = Color(0xFFE9F7EE)
+    override val surfaceContainerHigh = Color(0xFFE3F5E9)
+    override val surfaceContainerHighest = Color(0xFFDBF2E3)
     override val outline = Color(0xFF72796F)
     override val outline = Color(0xFF72796F)
     override val outlineVariant = Color(0xFFC4C8C1)
     override val outlineVariant = Color(0xFFC4C8C1)
     override val scrim = Color(0xFF000000)
     override val scrim = Color(0xFF000000)

+ 1 - 1
app/src/green/java/ch/threema/app/compose/theme/color/CustomColorsDark.kt

@@ -24,5 +24,5 @@ package ch.threema.app.compose.theme.color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 
 
 val CustomColorDark = CustomColor(
 val CustomColorDark = CustomColor(
-    messageBubbleContainerReceive = Color(0xFF666666)
+    messageBubbleContainerReceive = Color(0xFF666666),
 )
 )

+ 1 - 1
app/src/green/java/ch/threema/app/compose/theme/color/CustomColorsLight.kt

@@ -24,5 +24,5 @@ package ch.threema.app.compose.theme.color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 
 
 val CustomColorLight = CustomColor(
 val CustomColorLight = CustomColor(
-    messageBubbleContainerReceive = Color(0xFFF2F4F1)
+    messageBubbleContainerReceive = Color(0xFFF2F4F1),
 )
 )

+ 98 - 35
app/src/green/res/drawable-v24/ic_launcher_foreground.xml

@@ -1,4 +1,5 @@
 <vector xmlns:android="http://schemas.android.com/apk/res/android"
 <vector xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:aapt="http://schemas.android.com/aapt"
     android:width="108dp"
     android:width="108dp"
     android:height="108dp"
     android:height="108dp"
     android:viewportWidth="1500"
     android:viewportWidth="1500"
@@ -6,48 +7,110 @@
     <group
     <group
         android:translateX="238"
         android:translateX="238"
         android:translateY="238">
         android:translateY="238">
-        <!--    <group>-->
-        <!-- background color of icon -->
         <path
         <path
-            android:fillColor="#FFF"
+            android:fillColor="#FAFAFA"
             android:fillType="evenOdd"
             android:fillType="evenOdd"
             android:pathData="M0,0h1024v1024h-1024z"
             android:pathData="M0,0h1024v1024h-1024z"
+            android:strokeWidth="1"
             android:strokeColor="#00000000" />
             android:strokeColor="#00000000" />
-        <!-- sky color -->
         <path
         <path
-            android:fillColor="@color/ic_launcher_sky"
-            android:fillType="evenOdd"
-            android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8ZZ"
+            android:fillColor="#027A22"
+            android:fillType="nonZero"
+            android:pathData="M567.8,838.9C567.8,869.9 542.8,894.9 511.8,894.9C480.9,894.9 455.9,869.9 455.9,838.9C455.9,808 480.9,782.9 511.8,782.9C542.8,782.9 567.8,808 567.8,838.9ZM365.9,838.9C365.9,869.9 340.9,894.9 309.9,894.9C278.9,894.9 253.9,869.9 253.9,838.9C253.9,808 279,782.9 309.9,782.9C340.8,782.9 365.9,808 365.9,838.9ZM769.8,838.9C769.8,869.9 744.8,894.9 713.8,894.9C682.8,894.9 657.8,869.9 657.8,838.9C657.8,808 682.9,782.9 713.8,782.9C744.7,782.9 769.8,808 769.8,838.9Z"
+            android:strokeWidth="1"
             android:strokeColor="#00000000" />
             android:strokeColor="#00000000" />
-        <!-- shadow of the dune -->
-        <group>
-            <clip-path android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8Z" />
-            <path
-                android:fillColor="@color/ic_launcher_shadow"
-                android:fillType="evenOdd"
-                android:pathData="M134.7,434.5C286.6,434.5 403.7,243 512,243C618.4,245.6 774.8,438.4 882,434.5L882,730L134.7,730L134.7,434.5ZZ"
-                android:strokeColor="#00000000" />
-        </group>
-        <!-- right side of the dune -->
-        <group>
-            <clip-path android:pathData="M367.8,688.8L203,730L238.2,589.1C203.3,543.1 183,487.9 183,428.5C183,268.6 330.3,139 512,139C693.7,139 841,268.6 841,428.5C841,588.4 693.7,718 512,718C460.3,718 411.4,707.5 367.8,688.8Z" />
-            <path
-                android:fillColor="@color/ic_launcher_dune"
-                android:fillType="evenOdd"
-                android:pathData="M479.9,248.7C544.4,229 572.5,307.3 459.2,389.2C346,471 182.8,668 479.9,730L882,730L882,434.5C769.9,434.5 594.9,204.7 479.9,248.7ZZ"
-                android:strokeColor="#00000000" />
-        </group>
-        <!-- pad lock symbol -->
         <path
         <path
-            android:fillColor="#FFFFFF"
-            android:fillType="evenOdd"
-            android:pathData="M512,274C563.6,274 605.3,315.8 605.3,367.2L605.3,404.5L609,404.5C617.3,404.5 624,411.2 624,419.5L624,551C624,559.3 617.3,566 609,566L415,566C406.7,566 400,559.3 400,551L400,419.5C400,411.2 406.7,404.5 415,404.5L418.7,404.5L418.7,367.2C418.7,315.8 460.4,274 512,274ZZM512,311.3C481.1,311.3 456,336.3 456,367.2L456,404.5L568,404.5L568,367.2C568,336.3 542.9,311.3 512,311.3ZZ"
-            android:strokeColor="#00000000" />
-        <!-- threema dots -->
+            android:fillType="nonZero"
+            android:pathData="M512,301.4C481,301.4 456,326.4 456,357.3L456,394.6L568,394.6L568,357.3C568,326.4 542.9,301.4 512,301.4L512,301.4Z"
+            android:strokeWidth="1"
+            android:strokeColor="#00000000">
+            <aapt:attr name="android:fillColor">
+                <gradient
+                    android:endX="576.3"
+                    android:endY="387.2"
+                    android:startX="390.1"
+                    android:startY="282.6"
+                    android:type="linear">
+                    <item
+                        android:color="#FF8BD4A5"
+                        android:offset="0" />
+                    <item
+                        android:color="#FF61BF80"
+                        android:offset="0.5" />
+                    <item
+                        android:color="#FF46B269"
+                        android:offset="1" />
+                </gradient>
+            </aapt:attr>
+        </path>
         <path
         <path
-            android:fillColor="@color/ic_launcher_dots"
-            android:fillType="evenOdd"
-            android:pathData="M568,848C568,878.9 542.9,904 511.9,904C481,904 456,878.9 456,848C456,817.1 481,792 511.9,792C542.9,792 568,817.1 568,848ZZM366,848C366,878.9 341,904 310,904C279.1,904 254,878.9 254,848C254,817.1 279.1,792 310,792C341,792 366,817.1 366,848ZZM769.9,848C769.9,878.9 744.9,904 713.9,904C683,904 658,878.9 658,848C658,817.1 683,792 713.9,792C744.9,792 769.9,817.1 769.9,848ZZ"
-            android:strokeColor="#00000000" />
+            android:fillType="nonZero"
+            android:pathData="M830.9,347.1C790.3,337 747.7,321 702.2,301.4C589.2,252.6 515.1,222.7 444.9,236.1C414,241.9 392.6,251.6 383.5,256.9C374.4,262.2 355.4,285.2 355.4,308.3C355.4,341.4 367.4,371 405.8,397.7C408.4,395.7 411.5,394.6 415,394.6L418.7,394.6L418.7,357.3C418.7,305.9 460.4,264.1 512,264.1C563.5,264.1 605.3,305.9 605.3,357.3L605.3,394.6L609,394.6C617.3,394.6 624,401.3 624,409.6L624,498.9C677.2,531.3 721.2,574.7 728.3,636.7C797.4,583.6 841,505.6 841,418.6C841,393.9 837.5,369.9 830.9,347.1L830.9,347.1Z"
+            android:strokeWidth="1"
+            android:strokeColor="#00000000">
+            <aapt:attr name="android:fillColor">
+                <gradient
+                    android:endX="750.2"
+                    android:endY="572.8"
+                    android:startX="394.6"
+                    android:startY="301"
+                    android:type="linear">
+                    <item
+                        android:color="#FF6CCB8F"
+                        android:offset="0" />
+                    <item
+                        android:color="#FF0B8632"
+                        android:offset="1" />
+                </gradient>
+            </aapt:attr>
+        </path>
+        <path
+            android:fillType="nonZero"
+            android:pathData="M624,541.1C624,549.4 617.3,556.1 609,556.1L415,556.1C406.7,556.1 400,549.4 400,541.1L400,409.6C400,404.7 402.3,400.5 405.8,397.7C369,369 356.5,341.4 356.5,308.3C356.5,285.2 373.8,263.6 383.5,256.9C323,292.2 271,344.3 186.4,376.9C184.2,390.5 183,404.4 183,418.6C183,478 203.3,533.2 238.2,579.2L203,720.1L367.8,678.9C411.4,697.6 460.3,708.1 512,708.1C594.8,708.1 670.6,681.1 728.4,636.6C722,573.7 678.7,529.5 624,498.9L624,541.1Z"
+            android:strokeWidth="1"
+            android:strokeColor="#00000000">
+            <aapt:attr name="android:fillColor">
+                <gradient
+                    android:endX="510.9"
+                    android:endY="645.8"
+                    android:startX="183"
+                    android:startY="408.5"
+                    android:type="linear">
+                    <item
+                        android:color="#FF00B441"
+                        android:offset="0" />
+                    <item
+                        android:color="#FF006D1E"
+                        android:offset="0.4" />
+                    <item
+                        android:color="#FF005D16"
+                        android:offset="0.7" />
+                    <item
+                        android:color="#FF004F0F"
+                        android:offset="1" />
+                </gradient>
+            </aapt:attr>
+        </path>
+        <path
+            android:fillType="nonZero"
+            android:pathData="M512,129.1C346.4,129.1 209.3,236.8 186.4,376.9C271,344.3 323,293.2 383.5,257.9C392.6,252.6 414,242.9 444.9,237C515.1,223.7 589.2,255.6 702.2,304.4C747.7,323.9 790.3,337 830.9,347C794.7,221.8 665.6,129.1 512,129.1Z"
+            android:strokeWidth="1"
+            android:strokeColor="#00000000">
+            <aapt:attr name="android:fillColor">
+                <gradient
+                    android:endX="626.5"
+                    android:endY="294.5"
+                    android:startX="259.5"
+                    android:startY="215.7"
+                    android:type="linear">
+                    <item
+                        android:color="#FF98DBB0"
+                        android:offset="0" />
+                    <item
+                        android:color="#FFE1F4E7"
+                        android:offset="1" />
+                </gradient>
+            </aapt:attr>
+        </path>
     </group>
     </group>
 </vector>
 </vector>

BIN
app/src/green/res/mipmap-hdpi/ic_launcher.png


BIN
app/src/green/res/mipmap-mdpi/ic_launcher.png


BIN
app/src/green/res/mipmap-xhdpi/ic_launcher.png


BIN
app/src/green/res/mipmap-xxhdpi/ic_launcher.png


BIN
app/src/green/res/mipmap-xxxhdpi/ic_launcher.png


+ 1 - 2
app/src/green/res/values/firebase_messaging.xml

@@ -1,8 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?><!--
 <?xml version="1.0" encoding="utf-8"?><!--
-  ~ Copyright (c) 2019-2024 Threema GmbH
+  ~ Copyright (c) 2019-2025 Threema GmbH
   ~ All rights reserved.
   ~ All rights reserved.
   -->
   -->
-<!-- TODO: move to build.gradle -->
 <resources>
 <resources>
     <string name="google_app_id" translatable="false">1:480681303521:android:6ec12987090e0e4f9fc6a0</string>
     <string name="google_app_id" translatable="false">1:480681303521:android:6ec12987090e0e4f9fc6a0</string>
     <string name="gcm_defaultSenderId" translatable="false">480681303521</string>
     <string name="gcm_defaultSenderId" translatable="false">480681303521</string>

+ 0 - 4
app/src/green/res/values/ic_launcher_colors.xml

@@ -1,8 +1,4 @@
 <?xml version="1.0" encoding="utf-8"?>
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
 <resources>
     <color name="ic_launcher_background">#ffffff</color>
     <color name="ic_launcher_background">#ffffff</color>
-    <color name="ic_launcher_dune">#05A63F</color>
-    <color name="ic_launcher_dots">#05A63F</color>
-    <color name="ic_launcher_shadow">#024219</color>
-    <color name="ic_launcher_sky">#333333</color>
 </resources>
 </resources>

+ 5 - 1
app/src/hms/java/ch/threema/app/compose/theme/color/ColorsDark.kt

@@ -24,7 +24,6 @@ package ch.threema.app.compose.theme.color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 
 
 object ColorsDark : ComposeColorPaletteDark() {
 object ColorsDark : ComposeColorPaletteDark() {
-
     override val primary = Color(0xFF34C955)
     override val primary = Color(0xFF34C955)
     override val onPrimary = Color(0xFF003910)
     override val onPrimary = Color(0xFF003910)
     override val primaryContainer = Color(0xFF00531B)
     override val primaryContainer = Color(0xFF00531B)
@@ -47,6 +46,11 @@ object ColorsDark : ComposeColorPaletteDark() {
     override val onSurface = Color(0xFFE2E3DD)
     override val onSurface = Color(0xFFE2E3DD)
     override val surfaceVariant = Color(0xFF203022)
     override val surfaceVariant = Color(0xFF203022)
     override val onSurfaceVariant = Color(0xFFC2C9BD)
     override val onSurfaceVariant = Color(0xFFC2C9BD)
+    override val surfaceContainerLowest = Color(0xFF1B241C)
+    override val surfaceContainerLow = Color(0xFF1B241C)
+    override val surfaceContainer = Color(0xFF1C2A1D)
+    override val surfaceContainerHigh = Color(0xFF1C2E1F)
+    override val surfaceContainerHighest = Color(0xFF1D3321)
     override val outline = Color(0xFF8C9389)
     override val outline = Color(0xFF8C9389)
     override val outlineVariant = Color(0xFF424940)
     override val outlineVariant = Color(0xFF424940)
     override val scrim = Color(0xFF000000)
     override val scrim = Color(0xFF000000)

+ 5 - 0
app/src/hms/java/ch/threema/app/compose/theme/color/ColorsLight.kt

@@ -46,6 +46,11 @@ object ColorsLight : ComposeColorPaletteLight() {
     override val onSurface = Color(0xFF1A1C19)
     override val onSurface = Color(0xFF1A1C19)
     override val surfaceVariant = Color(0xFFE0E4DD)
     override val surfaceVariant = Color(0xFFE0E4DD)
     override val onSurfaceVariant = Color(0xFF424940)
     override val onSurfaceVariant = Color(0xFF424940)
+    override val surfaceContainerLowest = Color(0xFFF1FAF4)
+    override val surfaceContainerLow = Color(0xFFF1FAF4)
+    override val surfaceContainer = Color(0xFFE9F7EE)
+    override val surfaceContainerHigh = Color(0xFFE3F5E9)
+    override val surfaceContainerHighest = Color(0xFFDBF2E3)
     override val outline = Color(0xFF72796F)
     override val outline = Color(0xFF72796F)
     override val outlineVariant = Color(0xFFC4C8C1)
     override val outlineVariant = Color(0xFFC4C8C1)
     override val scrim = Color(0xFF000000)
     override val scrim = Color(0xFF000000)

+ 1 - 1
app/src/hms/java/ch/threema/app/compose/theme/color/CustomColorsDark.kt

@@ -24,5 +24,5 @@ package ch.threema.app.compose.theme.color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 
 
 val CustomColorDark = CustomColor(
 val CustomColorDark = CustomColor(
-    messageBubbleContainerReceive = Color(0xFF666666)
+    messageBubbleContainerReceive = Color(0xFF666666),
 )
 )

+ 1 - 1
app/src/hms/java/ch/threema/app/compose/theme/color/CustomColorsLight.kt

@@ -24,5 +24,5 @@ package ch.threema.app.compose.theme.color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 
 
 val CustomColorLight = CustomColor(
 val CustomColorLight = CustomColor(
-    messageBubbleContainerReceive = Color(0xFFF2F4F1)
+    messageBubbleContainerReceive = Color(0xFFF2F4F1),
 )
 )

+ 1 - 5
app/src/hms_services_based/java/ch/threema/app/push/HmsTokenUtil.kt

@@ -29,7 +29,6 @@ import com.huawei.agconnect.AGConnectOptionsBuilder
 private val logger = LoggingUtil.getThreemaLogger("HmsTokenUtil")
 private val logger = LoggingUtil.getThreemaLogger("HmsTokenUtil")
 
 
 object HmsTokenUtil {
 object HmsTokenUtil {
-
     const val TOKEN_SCOPE = "HCM"
     const val TOKEN_SCOPE = "HCM"
 
 
     private const val APP_ID_CONFIG_FIELD = "client/app_id"
     private const val APP_ID_CONFIG_FIELD = "client/app_id"
@@ -56,10 +55,7 @@ object HmsTokenUtil {
                 .getString(APP_ID_CONFIG_FIELD)
                 .getString(APP_ID_CONFIG_FIELD)
                 ?: appIdHardcoded
                 ?: appIdHardcoded
         } catch (e: Exception) {
         } catch (e: Exception) {
-            logger.error(
-                "Could not obtain HMS-App-ID from config file. Fallback to hardcoded ID.",
-                e
-            )
+            logger.error("Could not obtain HMS-App-ID from config file. Fallback to hardcoded ID.", e)
             appIdHardcoded
             appIdHardcoded
         }
         }
     }
     }

+ 5 - 1
app/src/hms_work/java/ch/threema/app/compose/theme/color/ColorsDark.kt

@@ -24,7 +24,6 @@ package ch.threema.app.compose.theme.color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 
 
 object ColorsDark : ComposeColorPaletteDark() {
 object ColorsDark : ComposeColorPaletteDark() {
-
     override val primary = Color(0xFFA4C8FF)
     override val primary = Color(0xFFA4C8FF)
     override val onPrimary = Color(0xFF00315E)
     override val onPrimary = Color(0xFF00315E)
     override val primaryContainer = Color(0xFF004784)
     override val primaryContainer = Color(0xFF004784)
@@ -47,6 +46,11 @@ object ColorsDark : ComposeColorPaletteDark() {
     override val onSurface = Color(0xFFE3E2E6)
     override val onSurface = Color(0xFFE3E2E6)
     override val surfaceVariant = Color(0xFF282E35)
     override val surfaceVariant = Color(0xFF282E35)
     override val onSurfaceVariant = Color(0xFFC3C6CF)
     override val onSurfaceVariant = Color(0xFFC3C6CF)
+    override val surfaceContainerLowest = Color(0xFF212429)
+    override val surfaceContainerLow = Color(0xFF212429)
+    override val surfaceContainer = Color(0xFF252A30)
+    override val surfaceContainerHigh = Color(0xFF282E35)
+    override val surfaceContainerHighest = Color(0xFF2C333C)
     override val outline = Color(0xFF8D9199)
     override val outline = Color(0xFF8D9199)
     override val outlineVariant = Color(0xFF43474E)
     override val outlineVariant = Color(0xFF43474E)
     override val scrim = Color(0xFF000000)
     override val scrim = Color(0xFF000000)

+ 5 - 0
app/src/hms_work/java/ch/threema/app/compose/theme/color/ColorsLight.kt

@@ -46,6 +46,11 @@ object ColorsLight : ComposeColorPaletteLight() {
     override val onSurface = Color(0xFF1A1C1E)
     override val onSurface = Color(0xFF1A1C1E)
     override val surfaceVariant = Color(0xFFE2EBF6)
     override val surfaceVariant = Color(0xFFE2EBF6)
     override val onSurfaceVariant = Color(0xFF43474E)
     override val onSurfaceVariant = Color(0xFF43474E)
+    override val surfaceContainerLowest = Color(0xFFF0F3FA)
+    override val surfaceContainerLow = Color(0xFFF0F3FA)
+    override val surfaceContainer = Color(0xFFE8EFF8)
+    override val surfaceContainerHigh = Color(0xFFE2EBF6)
+    override val surfaceContainerHighest = Color(0xFFDAE6F3)
     override val outline = Color(0xFF73777F)
     override val outline = Color(0xFF73777F)
     override val outlineVariant = Color(0xFFC3C6CF)
     override val outlineVariant = Color(0xFFC3C6CF)
     override val scrim = Color(0xFF000000)
     override val scrim = Color(0xFF000000)

+ 1 - 1
app/src/hms_work/java/ch/threema/app/compose/theme/color/CustomColorsDark.kt

@@ -24,5 +24,5 @@ package ch.threema.app.compose.theme.color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 
 
 val CustomColorDark = CustomColor(
 val CustomColorDark = CustomColor(
-    messageBubbleContainerReceive = Color(0xFF666666)
+    messageBubbleContainerReceive = Color(0xFF666666),
 )
 )

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