Threema 6 месяцев назад
Родитель
Сommit
f5d06389d0
100 измененных файлов с 5214 добавлено и 2376 удалено
  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
 indent_style = space
 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/
 .cxx/
 **/.DS_Store
+.kotlin

+ 5 - 1
README.md

@@ -167,7 +167,7 @@ Prerequisites:
 - Android SDK
 - Android NDK
 - bash shell
-- protobuf compiler version 21.12
+- protobuf compiler
 - Rust compiler and cargo (including the target architectures)
 
 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
     ./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
 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>
 
-<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>
 
 
 <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>
 

+ 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 {
         GrantPermissionRule.grant(
             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) {
         GrantPermissionRule.grant(
             Manifest.permission.READ_MEDIA_IMAGES,
-            Manifest.permission.READ_MEDIA_VIDEO
+            Manifest.permission.READ_MEDIA_VIDEO,
         )
     } else {
         GrantPermissionRule.grant(
             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
         } else {
             Manifest.permission.BLUETOOTH
-        }
+        },
     )
 }
 

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

@@ -21,9 +21,14 @@
 
 package ch.threema.app
 
+import androidx.annotation.AnyThread
 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.PersistedMultiDeviceProperties
 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.UserService
 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.domain.helpers.TransactionAckTaskCodec
 import ch.threema.domain.models.AppVersion
-import ch.threema.domain.protocol.connection.csp.DeviceCookieManager
 import ch.threema.domain.protocol.D2mProtocolDefines
+import ch.threema.domain.protocol.connection.csp.DeviceCookieManager
 import ch.threema.domain.protocol.connection.d2m.MultiDevicePropertyProvider
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
-import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
 import ch.threema.domain.protocol.connection.data.D2dMessage
 import ch.threema.domain.protocol.connection.data.D2mProtocolVersion
 import ch.threema.domain.protocol.connection.data.DeviceId
@@ -54,13 +58,14 @@ import ch.threema.domain.taskmanager.TaskArchiver
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.storage.DatabaseServiceNew
+import ch.threema.testhelpers.MUST_NOT_BE_CALLED
+import kotlin.time.Duration
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
 
@@ -113,7 +118,7 @@ class TestDeviceCookieManager : DeviceCookieManager {
 }
 
 class TestTaskManager(
-    val taskCodec: TaskCodec
+    private val taskCodec: TaskCodec,
 ) : TaskManager {
     private val taskQueue = Channel<QueueElement<Any>>()
 
@@ -160,43 +165,52 @@ class TestMultiDeviceManager(
     override val isMultiDeviceActive: Boolean = false,
     override val propertiesProvider: MultiDevicePropertyProvider = TestMultiDevicePropertyProvider,
     override val socketCloseListener: D2mSocketCloseListener = D2mSocketCloseListener { },
-    override val latestSocketCloseReason: Flow<D2mSocketCloseReason?> = flowOf(),
 ) : MultiDeviceManager {
-    override suspend fun activate(
-        deviceLabel: String,
-        contactService: ContactService,
-        userService: UserService,
-        fsMessageProcessor: ForwardSecurityMessageProcessor,
-        taskCreator: TaskCreator,
-    ) {
-        throw AssertionError("Not supported")
-    }
-
-    override suspend fun deactivate(
-        userService: UserService,
-        fsMessageProcessor: ForwardSecurityMessageProcessor,
-        taskCreator: TaskCreator,
-    ) {
-        throw AssertionError("Not supported")
+    @AnyThread
+    override suspend fun deactivate(serviceManager: ServiceManager, handle: ActiveTaskCodec) {
+        MUST_NOT_BE_CALLED()
     }
 
     override suspend fun setDeviceLabel(deviceLabel: String) {
-        throw AssertionError("Not supported")
+        MUST_NOT_BE_CALLED()
     }
 
     override suspend fun linkDevice(
+        serviceManager: ServiceManager,
         deviceJoinOfferUri: String,
         taskCreator: TaskCreator,
     ): 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
-
 }
 
 object TestMultiDevicePropertyProvider : MultiDevicePropertyProvider {
@@ -233,8 +246,8 @@ object TestMultiDevicePropertyProvider : MultiDevicePropertyProvider {
                 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.getConversationService(),
                 serviceManager.getRingtoneService(),
-                serviceManager.getMutedChatsListService(),
-                serviceManager.getHiddenChatsListService(),
+                serviceManager.getConversationCategoryService(),
                 serviceManager.getProfilePicRecipientsService(),
                 serviceManager.getWallpaperService(),
                 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.VerificationLevel
 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.FetchIdentityResult
 import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import com.neilalexander.jnacl.NaCl
-import kotlinx.coroutines.runBlocking
-import org.junit.Assert.assertArrayEquals
-import org.junit.Before
 import java.net.HttpURLConnection
+import kotlin.test.BeforeTest
 import kotlin.test.Test
+import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertFalse
 import kotlin.test.assertNotEquals
 import kotlin.test.assertTrue
 import kotlin.test.fail
+import kotlinx.coroutines.runBlocking
 
 class AddOrUpdateContactBackgroundTaskTest {
-
     private val backgroundExecutor = BackgroundExecutor()
     private lateinit var databaseService: TestDatabaseService
     private lateinit var coreServiceManager: CoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
 
-    @Before
+    @BeforeTest
     fun before() {
         databaseService = TestDatabaseService()
         val serviceManager = ThreemaApplication.requireServiceManager()
@@ -99,13 +99,13 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data.value!!
                 assertEquals(newIdentity, data.identity)
-                assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
+                assertContentEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
                 assertEquals(12u, data.featureMask)
                 assertEquals(IdentityType.NORMAL, data.identityType)
                 assertEquals(IdentityState.ACTIVE, data.activityState)
                 assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
                 assertEquals(AcquaintanceLevel.DIRECT, data.acquaintanceLevel)
-            }
+            },
         )
     }
 
@@ -129,13 +129,13 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data.value!!
                 assertEquals(newIdentity, data.identity)
-                assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
+                assertContentEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
                 assertEquals(12u, data.featureMask)
                 assertEquals(IdentityType.NORMAL, data.identityType)
                 assertEquals(IdentityState.ACTIVE, data.activityState)
                 assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
                 assertEquals(AcquaintanceLevel.GROUP, data.acquaintanceLevel)
-            }
+            },
         )
     }
 
@@ -158,7 +158,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertEquals(newIdentity, it.contactModel.identity)
                 val data = it.contactModel.data.value!!
                 assertEquals(newIdentity, data.identity)
-                assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
+                assertContentEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
                 assertEquals(127u, data.featureMask)
                 assertEquals(IdentityType.WORK, data.identityType)
                 assertEquals(IdentityState.INACTIVE, data.activityState)
@@ -205,7 +205,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             {
                 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)
-            }
+            },
         )
     }
 
@@ -238,7 +238,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             apiConnectorResult,
             {
                 assertTrue(it is ContactCreated)
-            }
+            },
         )
 
         // The second time adding the contact should fail
@@ -246,7 +246,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             apiConnectorResult,
             {
                 assertTrue(it is ContactExists)
-            }
+            },
         )
     }
 
@@ -279,7 +279,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             {
                 assertTrue(it is AlreadyVerified)
             },
-            publicKey = publicKey
+            publicKey = publicKey,
         )
     }
 
@@ -303,7 +303,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             {
                 assertTrue(it is ContactCreated)
             },
-            newIdentity = newIdentity
+            newIdentity = newIdentity,
         )
 
         val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
@@ -323,7 +323,7 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertFalse(it.verificationLevelChanged)
                 assertEquals(AcquaintanceLevel.DIRECT, contactModel.data.value!!.acquaintanceLevel)
             },
-            newIdentity = newIdentity
+            newIdentity = newIdentity,
         )
     }
 
@@ -347,7 +347,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             {
                 assertTrue(it is ContactCreated)
             },
-            newIdentity = newIdentity
+            newIdentity = newIdentity,
         )
 
         val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
@@ -364,11 +364,11 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertFalse(it.acquaintanceLevelChanged)
                 assertEquals(
                     VerificationLevel.FULLY_VERIFIED,
-                    contactModel.data.value!!.verificationLevel
+                    contactModel.data.value!!.verificationLevel,
                 )
             },
             newIdentity = newIdentity,
-            publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+            publicKey = ByteArray(NaCl.PUBLICKEYBYTES),
         )
     }
 
@@ -392,7 +392,7 @@ class AddOrUpdateContactBackgroundTaskTest {
             {
                 assertTrue(it is ContactCreated)
             },
-            newIdentity = newIdentity
+            newIdentity = newIdentity,
         )
 
         val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
@@ -414,11 +414,11 @@ class AddOrUpdateContactBackgroundTaskTest {
                 assertEquals(AcquaintanceLevel.DIRECT, contactModel.data.value!!.acquaintanceLevel)
                 assertEquals(
                     VerificationLevel.FULLY_VERIFIED,
-                    contactModel.data.value!!.verificationLevel
+                    contactModel.data.value!!.verificationLevel,
                 )
             },
             newIdentity = newIdentity,
-            publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+            publicKey = ByteArray(NaCl.PUBLICKEYBYTES),
         )
     }
 
@@ -524,9 +524,8 @@ class AddOrUpdateContactBackgroundTaskTest {
             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)
         }
     }
-
 }

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

@@ -21,17 +21,22 @@
 
 package ch.threema.app.contacts
 
+import androidx.annotation.AnyThread
 import ch.threema.app.DangerousTest
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestMultiDevicePropertyProvider
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.asynctasks.AndroidContactLinkPolicy
 import ch.threema.app.asynctasks.ContactSyncPolicy
-import ch.threema.app.asynctasks.MarkContactAsDeletedBackgroundTask
 import ch.threema.app.asynctasks.DeleteContactServices
+import ch.threema.app.asynctasks.MarkContactAsDeletedBackgroundTask
 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.PersistedMultiDeviceProperties
 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.UserService
 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.WorkVerificationLevel
 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.taskmanager.ActiveTaskCodec
 import ch.threema.domain.taskmanager.QueueSendCompleteListener
 import ch.threema.domain.taskmanager.Task
 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.testhelpers.MUST_NOT_BE_CALLED
 import com.neilalexander.jnacl.NaCl
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.test.runTest
-import org.junit.Before
 import java.util.Date
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertIs
 import kotlin.test.assertNotNull
 import kotlin.test.assertTrue
+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
 class MarkContactAsDeletedBackgroundTaskTest {
-
     private val backgroundExecutor = BackgroundExecutor()
     private lateinit var testTaskCodec: TransactionAckTaskCodec
     private val testTaskManager = object : TaskManager {
@@ -96,7 +101,6 @@ class MarkContactAsDeletedBackgroundTaskTest {
         override fun removeQueueSendCompleteListener(listener: QueueSendCompleteListener) {
             // Nothing to do
         }
-
     }
     private lateinit var databaseService: TestDatabaseService
     private val multiDeviceManager = object : MultiDeviceManager {
@@ -108,23 +112,9 @@ class MarkContactAsDeletedBackgroundTaskTest {
             get() = multiDeviceEnabled
         override val propertiesProvider = TestMultiDevicePropertyProvider
         override val socketCloseListener: D2mSocketCloseListener = D2mSocketCloseListener { }
-        override val latestSocketCloseReason: Flow<D2mSocketCloseReason?> = flowOf()
 
-        override suspend fun activate(
-            deviceLabel: String,
-            contactService: ContactService,
-            userService: UserService,
-            fsMessageProcessor: ForwardSecurityMessageProcessor,
-            taskCreator: TaskCreator,
-        ) {
-            MUST_NOT_BE_CALLED()
-        }
-
-        override suspend fun deactivate(
-            userService: UserService,
-            fsMessageProcessor: ForwardSecurityMessageProcessor,
-            taskCreator: TaskCreator,
-        ) {
+        @AnyThread
+        override suspend fun deactivate(serviceManager: ServiceManager, handle: ActiveTaskCodec) {
             MUST_NOT_BE_CALLED()
         }
 
@@ -133,21 +123,44 @@ class MarkContactAsDeletedBackgroundTaskTest {
         }
 
         override suspend fun linkDevice(
+            serviceManager: ServiceManager,
             deviceJoinOfferUri: String,
             taskCreator: TaskCreator,
         ): Flow<DeviceLinkingStatus> {
             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()
         }
 
-        override suspend fun loadLinkedDevicesInfo(taskCreator: TaskCreator): List<String> {
+        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()
+        }
     }
+
     private lateinit var coreServiceManager: CoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
     private lateinit var deleteContactServices: DeleteContactServices
@@ -167,12 +180,14 @@ class MarkContactAsDeletedBackgroundTaskTest {
         featureMask = 0u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
         androidContactLookupKey = null,
         localAvatarExpires = null,
         isRestored = false,
         profilePictureBlobId = null,
         jobTitle = null,
         department = null,
+        notificationTriggerPolicyOverride = null,
     )
 
     @Before
@@ -192,8 +207,7 @@ class MarkContactAsDeletedBackgroundTaskTest {
             serviceManager.contactService,
             serviceManager.conversationService,
             serviceManager.ringtoneService,
-            serviceManager.mutedChatsListService,
-            serviceManager.hiddenChatsListService,
+            serviceManager.conversationCategoryService,
             serviceManager.profilePicRecipientsService,
             serviceManager.wallpaperService,
             serviceManager.fileService,
@@ -223,7 +237,7 @@ class MarkContactAsDeletedBackgroundTaskTest {
                 deleteContactServices,
                 ContactSyncPolicy.INCLUDE,
                 AndroidContactLinkPolicy.REMOVE_LINK,
-            )
+            ),
         ).await()
 
         // Assert that the contact's acquaintance level is "group" now
@@ -248,7 +262,7 @@ class MarkContactAsDeletedBackgroundTaskTest {
                 deleteContactServices,
                 ContactSyncPolicy.INCLUDE,
                 AndroidContactLinkPolicy.REMOVE_LINK,
-            )
+            ),
         ).await()
 
         // Assert that the there was no task scheduled
@@ -273,7 +287,7 @@ class MarkContactAsDeletedBackgroundTaskTest {
                 deleteContactServices,
                 ContactSyncPolicy.INCLUDE,
                 AndroidContactLinkPolicy.REMOVE_LINK,
-            )
+            ),
         ).await()
 
         // 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 com.google.protobuf.kotlin.toByteString
 import com.neilalexander.jnacl.NaCl
-import kotlinx.coroutines.runBlocking
-import org.junit.Before
-import org.junit.runner.RunWith
 import java.util.Date
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
@@ -66,10 +63,12 @@ import kotlin.test.assertEquals
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
 import kotlin.test.fail
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.runner.RunWith
 
 @RunWith(AndroidJUnit4::class)
 class ReflectedContactSyncTaskTest {
-
     private lateinit var databaseService: TestDatabaseService
     private lateinit var taskCodec: TransactionAckTaskCodec
     private lateinit var coreServiceManager: TestCoreServiceManager
@@ -92,12 +91,14 @@ class ReflectedContactSyncTaskTest {
         featureMask = 511u,
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
+        isArchived = false,
         androidContactLookupKey = null,
         localAvatarExpires = null,
         isRestored = false,
         profilePictureBlobId = null,
         jobTitle = null,
         department = null,
+        notificationTriggerPolicyOverride = null,
     )
 
     @Before
@@ -166,7 +167,7 @@ class ReflectedContactSyncTaskTest {
             assertEquals(contact.readReceiptPolicyOverride.convert(), data.readReceiptPolicy)
             assertEquals(
                 contact.typingIndicatorPolicyOverride.convert(),
-                data.typingIndicatorPolicy
+                data.typingIndicatorPolicy,
             )
         }
     }
@@ -178,7 +179,7 @@ class ReflectedContactSyncTaskTest {
             contact {
                 identity = "01234567"
                 nickname = newNickname
-            }
+            },
         ) { contactModel ->
             assertEquals(newNickname, contactModel.data.value?.nickname)
         }
@@ -240,9 +241,12 @@ class ReflectedContactSyncTaskTest {
     private fun assertAndClearOneTransactionCount() {
         assertEquals(1, taskCodec.transactionBeginCount)
         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.transactionCommitCount = 0
+        taskCodec.outboundMessages.clear()
     }
 
     private fun assertZeroTransactionCount() {
@@ -333,5 +337,4 @@ class ReflectedContactSyncTaskTest {
             TypingIndicatorPolicyOverride.OverrideCase.OVERRIDE_NOT_SET -> fail("Typing indicator policy override not set")
             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.EmptyOrDeleteConversationsAsyncTask
 import ch.threema.app.asynctasks.MarkContactAsDeletedBackgroundTask
+import ch.threema.app.groupflows.GroupLeaveIntent
 import ch.threema.app.processors.MessageProcessorProvider
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.GroupService
 import ch.threema.app.services.MessageService
 import ch.threema.app.utils.executor.BackgroundExecutor
+import ch.threema.data.models.GroupIdentity
 import ch.threema.data.repositories.ContactModelRepository
 import ch.threema.data.storage.EditHistoryDao
 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.GroupMessageModel
 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.test.runTest
 import org.junit.Test
 import org.junit.runner.RunWith
-import java.util.Date
-import kotlin.test.assertEquals
 
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @DangerousTest
 class EditHistoryTest : MessageProcessorProvider() {
-
     private val messageService: MessageService by lazy { serviceManager.messageService }
     private val contactService: ContactService by lazy { serviceManager.contactService }
     private val groupService: GroupService by lazy { serviceManager.groupService }
@@ -80,8 +82,7 @@ class EditHistoryTest : MessageProcessorProvider() {
             contactService,
             serviceManager.conversationService,
             serviceManager.ringtoneService,
-            serviceManager.mutedChatsListService,
-            serviceManager.hiddenChatsListService,
+            serviceManager.conversationCategoryService,
             serviceManager.profilePicRecipientsService,
             serviceManager.wallpaperService,
             serviceManager.fileService,
@@ -276,12 +277,12 @@ class EditHistoryTest : MessageProcessorProvider() {
 
         BackgroundExecutor().executeDeferred(
             MarkContactAsDeletedBackgroundTask(
-                setOf(messageModel.identity),
+                setOf(messageModel.identity!!),
                 contactModelRepository,
                 deleteContactServices,
                 ContactSyncPolicy.INCLUDE,
                 AndroidContactLinkPolicy.KEEP,
-            )
+            ),
         ).await()
 
         messageModel.assertHistorySize(0)
@@ -299,7 +300,18 @@ class EditHistoryTest : MessageProcessorProvider() {
 
         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)
     }
@@ -312,9 +324,12 @@ class EditHistoryTest : MessageProcessorProvider() {
             mode,
             arrayOf(receiver),
             serviceManager.conversationService,
-            serviceManager.groupService,
             serviceManager.distributionListService,
-            null, null
+            serviceManager.modelRepositories.groups,
+            serviceManager.groupFlowDispatcher,
+            myContact.identity,
+            null,
+            null,
         ) { deferred.complete(Unit) }.execute()
         deferred.await()
     }
@@ -331,7 +346,7 @@ class EditHistoryTest : MessageProcessorProvider() {
 
         return messageModelFactory.getByApiMessageIdAndIdentity(
             message.messageId,
-            message.fromIdentity
+            message.fromIdentity,
         )!!
     }
 
@@ -339,8 +354,8 @@ class EditHistoryTest : MessageProcessorProvider() {
         val editMessage = EditMessage(
             EditMessageData(
                 MessageId.fromString(apiMessageId).messageIdLong,
-                "$body Edited"
-            )
+                "$body Edited",
+            ),
         ).apply {
             fromIdentity = identity
             toIdentity = myContact.identity
@@ -350,7 +365,7 @@ class EditHistoryTest : MessageProcessorProvider() {
 
     private suspend fun MessageModel.receiveDelete() {
         val deleteMessage = DeleteMessage(
-            DeleteMessageData(MessageId.fromString(apiMessageId).messageIdLong)
+            DeleteMessageData(MessageId.fromString(apiMessageId).messageIdLong),
         ).apply {
             fromIdentity = identity
             toIdentity = myContact.identity
@@ -372,7 +387,7 @@ class EditHistoryTest : MessageProcessorProvider() {
 
         return groupMessageModelFactory.getByApiMessageIdAndIdentity(
             message.messageId,
-            message.fromIdentity
+            message.fromIdentity,
         )!!
     }
 
@@ -380,8 +395,8 @@ class EditHistoryTest : MessageProcessorProvider() {
         val editMessage = GroupEditMessage(
             EditMessageData(
                 MessageId.fromString(apiMessageId).messageIdLong,
-                "$body Edited"
-            )
+                "$body Edited",
+            ),
         ).apply {
             apiGroupId = groupA.apiGroupId
             groupCreator = groupA.groupCreator.identity
@@ -393,7 +408,7 @@ class EditHistoryTest : MessageProcessorProvider() {
 
     private suspend fun GroupMessageModel.receiveDelete() {
         val deleteMessage = GroupDeleteMessage(
-            DeleteMessageData(MessageId.fromString(apiMessageId).messageIdLong)
+            DeleteMessageData(MessageId.fromString(apiMessageId).messageIdLong),
         ).apply {
             apiGroupId = groupA.apiGroupId
             groupCreator = groupA.groupCreator.identity
@@ -429,7 +444,7 @@ class EditHistoryTest : MessageProcessorProvider() {
     private fun <T : AbstractMessageModel> T.assertHistorySize(size: Int) {
         assertEquals(
             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.testutils.TestHelpers.TestGroup
 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.GroupSetupMessage
 import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
 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 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
  * group receive steps should not be executed for a certain message type, the common group step
  * receive methods should be overridden.
  */
-@ExperimentalCoroutinesApi
 abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProvider() {
-
     /**
      * 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.
@@ -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
-    open fun testCommonGroupReceiveStep2_1() = runTest {
+    open fun testCommonGroupReceiveStepUnknownGroupUserCreator() = runTest {
         val (message, identityStore) = getMyUnknownGroupMessage()
         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
-    open fun testCommonGroupReceiveStep2_2() = runTest {
+    open fun testCommonGroupReceiveStepUnknownGroupUserNotCreator() = runTest {
         val (message, identityStore) = getUnknownGroupMessage()
         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
-    open fun testCommonGroupReceiveStep3_1() = runTest {
+    open fun testCommonGroupReceiveStepLeftGroupUserCreator() = runTest {
         val (message, identityStore) = getMyLeftGroupMessage()
         setupAndProcessMessage(message, identityStore)
 
@@ -127,18 +124,18 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
         assertEquals(myContact.identity, firstMessage.fromIdentity)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
-        assertArrayEquals(emptyArray<String>(), firstMessage.members)
+        assertContentEquals(emptyArray<String>(), firstMessage.members)
 
         assertTrue(sentMessagesInsideTask.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
-    open fun testCommonGroupReceiveStep3_2() = runTest {
+    open fun testCommonGroupReceiveStepLeftGroupUserNotCreator() = runTest {
         // First, test the common group receive steps for a message from the group creator
         val (firstIncomingMessage, firstIdentityStore) = getLeftGroupMessageFromCreator()
         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
-    open fun testCommonGroupReceiveStep4_1() = runTest {
+    open fun testCommonGroupReceiveStepSenderNotMemberUserCreator() = runTest {
         val (message, identityStore) = getSenderNotMemberOfMyGroupMessage()
         setupAndProcessMessage(message, identityStore)
 
@@ -183,18 +180,18 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
         assertEquals(myContact.identity, firstMessage.fromIdentity)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
-        assertArrayEquals(emptyArray<String>(), firstMessage.members)
+        assertContentEquals(emptyArray<String>(), firstMessage.members)
 
         assertTrue(sentMessagesInsideTask.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
-    open fun testCommonGroupReceiveStep4_2() = runTest {
+    open fun testCommonGroupReceiveStepSenderNotMemberUserNotCreator() = runTest {
         val (message, identityStore) = getSenderNotMemberMessage()
         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.
  */
 abstract class GroupConversationListTest<T : AbstractGroupMessage> : GroupControlTest<T>() {
-
     /**
      * Assert that in the given scenario the expected groups are listed.
      */
     protected fun assertGroupConversations(
         scenario: ActivityScenario<HomeActivity>,
-        expectedGroups: List<TestGroup>
+        expectedGroups: List<TestGroup>,
+        errorMessage: String = "",
     ) {
         Thread.sleep(500)
 
         scenario.onActivity { activity ->
             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
      */
-    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 actualGroupNames = (0 until adapter.itemCount)
@@ -61,7 +65,6 @@ abstract class GroupConversationListTest<T : AbstractGroupMessage> : GroupContro
             .map { it.receiver.displayName }
             .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.testutils.TestHelpers.TestContact
 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.GroupSyncRequestMessage
-import ch.threema.storage.models.GroupModel
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertFalse
 import junit.framework.TestCase.assertTrue
 import junit.framework.TestCase.fail
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-
 import kotlinx.coroutines.test.runTest
 import org.junit.After
-import org.junit.Assert.assertArrayEquals
 import org.junit.Test
 import org.junit.runner.RunWith
 
 /**
  * Tests that incoming group leave messages are handled correctly.
  */
-@ExperimentalCoroutinesApi
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @DangerousTest
 class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
-
     /**
      * Test that contact A leaving my group works as expected.
      */
@@ -131,27 +126,27 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
     override fun createMessageForGroup() = GroupLeaveMessage()
 
-    override fun testCommonGroupReceiveStep2_1() {
+    override fun testCommonGroupReceiveStepUnknownGroupUserCreator() {
         // 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
     }
 
-    override fun testCommonGroupReceiveStep3_1() {
+    override fun testCommonGroupReceiveStepLeftGroupUserCreator() {
         // 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
     }
 
-    override fun testCommonGroupReceiveStep4_1() {
+    override fun testCommonGroupReceiveStepSenderNotMemberUserCreator() {
         // 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
     }
 
@@ -166,7 +161,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
         assertEquals(
             group.members.map { it.identity },
-            serviceManager.groupService.getGroupIdentities(group.groupModel).toList()
+            serviceManager.groupService.getGroupMemberIdentities(group.groupModel).toList(),
         )
 
         val leaveTracker = GroupLeaveTracker(group, contact.identity, expectStateChange)
@@ -183,11 +178,11 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
         assertEquals(
             group.members.size - 1,
-            serviceManager.groupService.countMembers(group.groupModel)
+            serviceManager.groupService.countMembers(group.groupModel),
         )
         assertEquals(
             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
@@ -247,7 +242,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
     private fun assertGroupIdentities(expectedMemberList: List<String>, group: TestGroup) {
         if (serviceManager.groupService.getByApiGroupIdAndCreator(
-                group.apiGroupId, group.groupCreator.identity
+                group.apiGroupId, group.groupCreator.identity,
             ) != null
         ) {
             // 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.
             assertEquals(
                 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) {
         if (serviceManager.groupService.getByApiGroupIdAndCreator(
                 group.apiGroupId,
-                group.groupCreator.identity
+                group.groupCreator.identity,
             ) != null
         ) {
             // We only check the expected members if the group is available in the database.
@@ -271,7 +266,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             // model.
             assertEquals(
                 expectedMemberCount,
-                serviceManager.groupService.countMembers(group.groupModel)
+                serviceManager.groupService.countMembers(group.groupModel),
             )
         }
     }
@@ -284,43 +279,43 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
         private var memberHasLeft = false
 
         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(
-                group: GroupModel?,
-                newIdentity: String?,
+                groupIdentity: GroupIdentity,
+                identityNew: String,
             ) = fail()
 
             override fun onMemberLeave(
-                groupModel: GroupModel?,
-                identity: String?,
+                groupIdentity: GroupIdentity,
+                identityLeft: String,
             ) {
                 assertFalse(memberHasLeft)
                 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
             }
 
             override fun onMemberKicked(
-                group: GroupModel?,
-                identity: String?,
+                groupIdentity: GroupIdentity,
+                identityKicked: String,
             ) = 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(
-                groupModel: GroupModel?,
+                groupIdentity: GroupIdentity,
                 oldState: Int,
                 newState: Int,
             ) {
@@ -359,5 +354,4 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             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.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.data.models.GroupIdentity
 import ch.threema.domain.models.GroupId
 import ch.threema.domain.protocol.csp.messages.GroupNameMessage
-import ch.threema.storage.models.GroupModel
 import junit.framework.TestCase.*
-import kotlinx.coroutines.*
 import kotlinx.coroutines.test.runTest
 import org.junit.After
-import org.junit.Assert.assertArrayEquals
 import org.junit.Test
 import org.junit.runner.RunWith
-import java.util.*
 
 /**
  * Tests that incoming group name messages are handled correctly.
  */
-@ExperimentalCoroutinesApi
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @DangerousTest
 class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
-
     override fun createMessageForGroup(): GroupNameMessage {
         return GroupNameMessage()
             .apply { groupName = "New Group Name" }
@@ -72,7 +67,7 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
                 groupA.groupCreator,
                 groupA.members,
                 "GroupARenamed",
-                myContact.identity
+                myContact.identity,
             )
 
         val renameTracker = GroupRenameTracker(groupARenamed).apply { start() }
@@ -81,7 +76,7 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
             groupARenamed.groupName,
             groupARenamed.groupCreator.identity,
             groupARenamed.apiGroupId,
-            groupARenamed.groupCreator
+            groupARenamed.groupCreator,
         )
 
         // Process the group rename message
@@ -114,16 +109,18 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
                 groupA.groupCreator,
                 groupA.members,
                 "GroupARenamed",
-                myContact.identity
+                myContact.identity,
             )
 
         val renameTracker = GroupRenameTracker(null).apply { start() }
 
         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
@@ -135,36 +132,36 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
         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
         // 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
         // and if the sender of the message is the creator of a group owned by this user, then the
         // message comes from this user itself - which is impossible.
     }
 
-    override fun 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
         // 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.
     }
 
-    override fun testCommonGroupReceiveStep4_2() {
+    override fun testCommonGroupReceiveStepSenderNotMemberUserNotCreator() {
         // 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
-        // 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.
     }
 
@@ -202,61 +199,60 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
         private var hasBeenRenamed = false
 
         private val groupListener = object : GroupListener {
-            override fun onCreate(newGroupModel: GroupModel?) {
+            override fun onCreate(groupIdentity: GroupIdentity) {
                 fail()
             }
 
-            override fun onRename(groupModel: GroupModel?) {
+            override fun onRename(groupIdentity: GroupIdentity) {
                 assertFalse(hasBeenRenamed)
                 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
             }
 
-            override fun onUpdatePhoto(groupModel: GroupModel?) {
+            override fun onUpdatePhoto(groupIdentity: GroupIdentity) {
                 fail()
             }
 
-            override fun onRemove(groupModel: GroupModel?) {
+            override fun onRemove(groupDbId: Long) {
                 fail()
             }
 
             override fun onNewMember(
-                group: GroupModel?,
-                newIdentity: String?,
+                groupIdentity: GroupIdentity,
+                identityNew: String?,
             ) {
                 fail()
             }
 
             override fun onMemberLeave(
-                group: GroupModel?,
-                identity: String?,
+                groupIdentity: GroupIdentity,
+                identityLeft: String,
             ) {
                 fail()
             }
 
             override fun onMemberKicked(
-                group: GroupModel?,
-                identity: String?,
+                groupIdentity: GroupIdentity,
+                identityKicked: String?,
             ) {
                 fail()
             }
 
-            override fun onUpdate(groupModel: GroupModel?) {
+            override fun onUpdate(groupIdentity: GroupIdentity) {
                 fail()
             }
 
-            override fun onLeave(groupModel: GroupModel?) {
+            override fun onLeave(groupIdentity: GroupIdentity) {
                 fail()
             }
 
             override fun onGroupStateChanged(
-                groupModel: GroupModel?,
+                groupIdentity: GroupIdentity,
                 oldState: Int,
-                newState: Int
+                newState: Int,
             ) {
                 fail()
             }
@@ -291,5 +287,4 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
             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.testutils.TestHelpers.TestContact
 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.GroupLeaveMessage
+import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.GroupModel
+import com.neilalexander.jnacl.NaCl
+import java.util.Date
 import junit.framework.TestCase
-import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
 import kotlinx.coroutines.test.runTest
 import org.junit.After
-import org.junit.Assert
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
-import org.junit.Assert.assertNotNull
 import org.junit.Assert.assertTrue
 import org.junit.Assert.fail
 import org.junit.Test
@@ -48,11 +58,12 @@ import org.junit.runner.RunWith
  * Runs different tests that verify that incoming group setup messages are handled according to the
  * protocol.
  */
-@ExperimentalCoroutinesApi
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @DangerousTest
 class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
+    private val groupService by lazy { serviceManager.groupService }
+    private val groupModelRepository by lazy { serviceManager.modelRepositories.groups }
 
     override fun createMessageForGroup() = GroupSetupMessage()
 
@@ -64,7 +75,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         val scenario = startScenario()
 
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "initial groups")
 
         val setupTracker = GroupSetupTracker(
             groupAUnknown,
@@ -84,7 +95,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, groupAUnknown.groupCreator.identityStore)
 
         // 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
         assertEquals(0, sentMessagesInsideTask.size)
@@ -104,7 +115,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         val scenario = startScenario()
 
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "epect initial group")
 
         val setupTracker = GroupSetupTracker(
             groupAUnknown,
@@ -124,7 +135,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, groupAUnknown.groupCreator.identityStore)
 
         // 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
         assertEquals(0, sentMessagesInsideTask.size)
@@ -149,46 +160,17 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         serviceManager.blockedIdentitiesService.blockIdentity(contactA.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,
-            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()
 
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "initial groups")
 
         // 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)
-        assertEquals(GroupModel.UserState.MEMBER, beforeKicked!!.userState)
-        assertTrue(serviceManager.groupService.isGroupMember(beforeKicked))
+        assertEquals(GroupModel.UserState.MEMBER, beforeKicked.userState)
+        assertTrue(groupService.isGroupMember(beforeKicked))
 
         val setupTracker = GroupSetupTracker(
             groupAB,
@@ -225,13 +207,14 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, groupAB.groupCreator.identityStore)
 
         // 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)
-        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
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "no changes")
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
@@ -272,7 +255,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, groupAB.groupCreator.identityStore)
 
         // 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
         assertEquals(0, sentMessagesInsideTask.size)
@@ -289,82 +272,17 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
      */
     @Test
     fun testNewGroup() = runTest {
-        val scenario = startScenario()
-
-        // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
-
         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
             // that is not followed by a group rename
-            "Me, 12345678, ABCDEFGH",
+            "Me, ABCDEFGH",
             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()
 
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(scenario, initialGroups, "initial groups")
 
         val setupTracker = GroupSetupTracker(
             groupAB,
@@ -426,7 +344,8 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         val newGroup = TestGroup(
             newAGroup.apiGroupId,
             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
             // that is not followed by a group rename
             "Me, 12345678, ABCDEFGH",
@@ -450,7 +369,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, newGroup.groupCreator.identityStore)
 
         // Assert that the new group appears in the list
-        assertGroupConversations(scenario, initialGroups + newGroup)
+        assertGroupConversations(scenario, listOf(newGroup) + initialGroups)
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
@@ -462,6 +381,141 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         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()
         .apply {
             apiGroupId = testGroup.apiGroupId
@@ -488,64 +542,64 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         private var kickedMembersRemoved = mutableListOf<String>()
 
         private val groupListener = object : GroupListener {
-            override fun onCreate(newGroupModel: GroupModel?) {
+            override fun onCreate(groupIdentity: GroupIdentity) {
                 assertTrue(expectCreate)
                 assertFalse(hasBeenCreated)
                 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
             }
 
-            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(
-                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(
-                group: GroupModel?,
-                identity: String?,
+                groupIdentity: GroupIdentity,
+                identityLeft: String,
             ) = fail()
 
             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)
                     assertFalse(hasBeenKicked)
                     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
                 // added or kicked)
                 assertTrue(newMembers.isNotEmpty() || kickedMembers.isNotEmpty())
             }
 
-            override fun onLeave(groupModel: GroupModel?) = fail()
+            override fun onLeave(groupIdentity: GroupIdentity) = fail()
 
             override fun onGroupStateChanged(
-                groupModel: GroupModel?,
+                groupIdentity: GroupIdentity,
                 oldState: Int,
-                newState: Int
+                newState: Int,
             ) {
             }
         }
@@ -590,27 +644,53 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         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
     }
 
-    override fun testCommonGroupReceiveStep2_2() {
+    override fun testCommonGroupReceiveStepUnknownGroupUserNotCreator() {
         // 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
     }
 
-    override fun testCommonGroupReceiveStep3_2() {
+    override fun testCommonGroupReceiveStepLeftGroupUserNotCreator() {
         // 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
     }
 
-    override fun testCommonGroupReceiveStep4_2() {
+    override fun testCommonGroupReceiveStepSenderNotMemberUserNotCreator() {
         // 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.testutils.TestHelpers.TestContact
 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.GroupNameMessage
+import ch.threema.domain.protocol.csp.messages.GroupSetupMessage
 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 org.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
 import org.junit.runner.RunWith
 
 /**
  * Tests that incoming group sync request messages are handled correctly.
  */
-@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @DangerousTest
 class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>() {
-
     override fun createMessageForGroup() = GroupSyncRequestMessage()
 
     @Test
@@ -79,27 +77,27 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
         assertLeftGroupSyncRequest(myLeftGroup, contactA)
     }
 
-    override fun testCommonGroupReceiveStep2_1() {
+    override fun testCommonGroupReceiveStepUnknownGroupUserCreator() {
         // 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
     }
 
-    override fun testCommonGroupReceiveStep3_1() {
+    override fun testCommonGroupReceiveStepLeftGroupUserCreator() {
         // 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
     }
 
-    override fun testCommonGroupReceiveStep4_1() {
+    override fun testCommonGroupReceiveStepSenderNotMemberUserCreator() {
         // 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
     }
 
@@ -120,7 +118,10 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
 
         // Check that the first sent message (setup) is correct
         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(contact.identity, setupMessage.toIdentity)
         assertEquals(group.groupCreator.identity, setupMessage.groupCreator)
@@ -134,7 +135,7 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
         assertEquals(group.groupCreator.identity, renameMessage.groupCreator)
         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
         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
         assertEquals(1, sentMessagesInsideTask.size)
         val setupMessage = sentMessagesInsideTask.first() as GroupSetupMessage
-        assertArrayEquals(emptyArray(), setupMessage.members)
+        assertContentEquals(emptyArray(), setupMessage.members)
         assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
         assertEquals(contact.identity, setupMessage.toIdentity)
         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 ch.threema.app.DangerousTest
 import ch.threema.domain.protocol.csp.messages.GroupTextMessage
-import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.runBlocking
 import org.junit.Assert
 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.
  */
-@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @DangerousTest
 class IncomingGroupTextTest : GroupControlTest<GroupTextMessage>() {
-
     @Test
     fun testForwardSecureTextMessages() = runBlocking {
         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.messages.AbstractMessage
 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.TypingIndicatorMessage
 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.PollSetupMessage
 import ch.threema.domain.protocol.csp.messages.ballot.PollVoteMessage
+import ch.threema.domain.protocol.csp.messages.location.LocationMessage
 import ch.threema.domain.protocol.csp.messages.location.LocationMessageData
+import java.util.Date
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertTrue
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.fail
 import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertArrayEquals
-import org.junit.Assert.fail
-import org.junit.Test
-import java.util.Date
 
 @DangerousTest
 class IncomingMessageProcessorTest : MessageProcessorProvider() {
-
     @Test
     fun testIncomingTextMessage() = runTest {
         assertSuccessfulMessageProcessing(
             TextMessage().also { it.text = "Hello!" }.enrich(),
-            contactA
+            contactA,
         )
     }
 
@@ -67,11 +66,11 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
             latitude = 0.0,
             longitude = 0.0,
             accuracy = null,
-            poi = null
+            poi = null,
         )
         assertSuccessfulMessageProcessing(
             message = LocationMessage(locationMessageData = locationMessageData).enrich(),
-            fromContact = contactA
+            fromContact = contactA,
         )
     }
 
@@ -107,9 +106,11 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
         val pollVoteMessage = PollVoteMessage().also { voteMessage ->
             voteMessage.ballotId = ballotId
             voteMessage.ballotCreatorIdentity = ballotCreator
-            voteMessage.votes.addAll(List(5) { index ->
-                BallotVote(index, 0)
-            })
+            voteMessage.votes.addAll(
+                List(5) { index ->
+                    BallotVote(index, 0)
+                },
+            )
         }.enrich()
 
         assertSuccessfulMessageProcessing(pollVoteMessage, contactA)
@@ -125,7 +126,8 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
                 it.receiptType = DELIVERYRECEIPT_MSGRECEIVED
                 it.receiptMessageIds = arrayOf(messageId)
                 it.messageId = MessageId(0)
-            }.enrich(), contactA
+            }.enrich(),
+            contactA,
         )
 
         // Test 'read'
@@ -133,7 +135,8 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
             DeliveryReceiptMessage().also {
                 it.receiptType = DELIVERYRECEIPT_MSGREAD
                 it.receiptMessageIds = arrayOf(messageId)
-            }.enrich(), contactA
+            }.enrich(),
+            contactA,
         )
 
         // Test 'userack'
@@ -141,7 +144,8 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
             DeliveryReceiptMessage().also {
                 it.receiptType = DELIVERYRECEIPT_MSGUSERACK
                 it.receiptMessageIds = arrayOf(messageId)
-            }.enrich(), contactA
+            }.enrich(),
+            contactA,
         )
 
         // Test 'userdec'
@@ -149,7 +153,8 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
             DeliveryReceiptMessage().also {
                 it.receiptType = DELIVERYRECEIPT_MSGUSERDEC
                 it.receiptMessageIds = arrayOf(messageId)
-            }.enrich(), contactA
+            }.enrich(),
+            contactA,
         )
 
         // Test 'received' with two times the same message id
@@ -158,7 +163,8 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
                 it.receiptType = DELIVERYRECEIPT_MSGRECEIVED
                 it.receiptMessageIds = arrayOf(messageId, messageId)
                 it.messageId = MessageId(0)
-            }.enrich(), contactA
+            }.enrich(),
+            contactA,
         )
 
         // Test 'received' with many message ids
@@ -167,7 +173,8 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
                 it.receiptType = DELIVERYRECEIPT_MSGRECEIVED
                 it.receiptMessageIds = Array(100) { MessageId() }
                 it.messageId = MessageId(0)
-            }.enrich(), contactA
+            }.enrich(),
+            contactA,
         )
     }
 
@@ -175,11 +182,11 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
     fun testIncomingTypingIndicator() = runTest {
         assertSuccessfulMessageProcessing(
             TypingIndicatorMessage().also { it.isTyping = true }.enrich(),
-            contactA
+            contactA,
         )
         assertSuccessfulMessageProcessing(
             TypingIndicatorMessage().also { it.isTyping = false }.enrich(),
-            contactA
+            contactA,
         )
     }
 
@@ -222,17 +229,17 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
         val messageId = message.messageId
         processMessage(
             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) {
             val deliveryReceiptMessage = sentMessagesInsideTask.poll()
             if (deliveryReceiptMessage is DeliveryReceiptMessage) {
-                assertArrayEquals(
+                assertContentEquals(
                     messageId.messageId,
-                    deliveryReceiptMessage.receiptMessageIds[0].messageId
+                    deliveryReceiptMessage.receiptMessageIds[0].messageId,
                 )
                 assertEquals(DELIVERYRECEIPT_MSGRECEIVED, deliveryReceiptMessage.receiptType)
             } else {
@@ -250,7 +257,7 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
     ) {
         processMessage(
             message.also { it.fromIdentity = fromContact.identity },
-            fromContact.identityStore
+            fromContact.identityStore,
         )
 
         assertTrue(sentMessagesInsideTask.isEmpty())
@@ -263,5 +270,4 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
         messageId = MessageId()
         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.content.Intent
 import android.os.Build
-import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.GrantPermissionRule
 import ch.threema.app.TestCoreServiceManager
 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.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.app.testutils.clearDatabaseAndCaches
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.ForwardSecurityStatusSender
 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.NonceScope
 import ch.threema.base.crypto.NonceStore
+import ch.threema.data.models.GroupIdentity
 import ch.threema.domain.fs.DHSession
 import ch.threema.domain.helpers.DecryptTaskCodec
 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.IdentityType
 import ch.threema.domain.protocol.ThreemaFeature
+import ch.threema.domain.protocol.Version
 import ch.threema.domain.protocol.api.APIConnector
-import ch.threema.domain.protocol.connection.ConnectionState
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.domain.protocol.csp.coders.MessageBox
 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.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.GroupMemberModel
+import java.util.Queue
+import java.util.concurrent.ConcurrentLinkedQueue
 import junit.framework.TestCase.assertEquals
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Deferred
@@ -84,12 +87,8 @@ import org.junit.After
 import org.junit.Before
 import org.junit.Rule
 import org.junit.rules.Timeout
-import java.io.File
-import java.util.Queue
-import java.util.concurrent.ConcurrentLinkedQueue
 
 open class MessageProcessorProvider {
-
     protected val myContact: TestContact = TestHelpers.TEST_CONTACT
     protected val contactA = TestContact("12345678")
     protected val contactB = TestContact("ABCDEFGH")
@@ -100,7 +99,7 @@ open class MessageProcessorProvider {
         myContact,
         listOf(myContact, contactA, contactB),
         "MyGroup",
-        myContact.identity
+        myContact.identity,
     )
     protected val myGroupWithProfilePicture =
         TestGroup(
@@ -109,7 +108,7 @@ open class MessageProcessorProvider {
             listOf(myContact, contactA),
             "MyGroupWithPicture",
             byteArrayOf(0, 1, 2, 3),
-            myContact.identity
+            myContact.identity,
         )
     protected val groupA =
         TestGroup(GroupId(2), contactA, listOf(myContact, contactA), "GroupA", myContact.identity)
@@ -121,7 +120,7 @@ open class MessageProcessorProvider {
             contactA,
             listOf(myContact, contactA, contactB),
             "GroupAB",
-            myContact.identity
+            myContact.identity,
         )
     protected val groupAUnknown =
         TestGroup(
@@ -129,7 +128,7 @@ open class MessageProcessorProvider {
             contactA,
             listOf(myContact, contactA, contactB),
             "GroupAUnknown",
-            myContact.identity
+            myContact.identity,
         )
     protected val groupALeft =
         TestGroup(
@@ -137,7 +136,7 @@ open class MessageProcessorProvider {
             contactA,
             listOf(contactA, contactB),
             "GroupALeft",
-            myContact.identity
+            myContact.identity,
         )
     protected val myUnknownGroup =
         TestGroup(
@@ -145,7 +144,7 @@ open class MessageProcessorProvider {
             myContact,
             listOf(myContact, contactA),
             "MyUnknownGroup",
-            myContact.identity
+            myContact.identity,
         )
     protected val myLeftGroup =
         TestGroup(GroupId(8), myContact, listOf(contactA), "MyLeftGroup", myContact.identity)
@@ -155,7 +154,15 @@ open class MessageProcessorProvider {
             contactA,
             listOf(myContact, contactA, contactB),
             "NewAGroup",
-            myContact.identity
+            myContact.identity,
+        )
+    protected val newBGroup =
+        TestGroup(
+            GroupId(10),
+            contactB,
+            listOf(myContact, contactB),
+            "NewBGroup",
+            myContact.identity,
         )
 
     protected val serviceManager: ServiceManager = ThreemaApplication.requireServiceManager()
@@ -177,10 +184,19 @@ open class MessageProcessorProvider {
         serviceManager.contactService,
         serviceManager.messageService,
         APIConnector(
+            /* ipv6 = */
             false,
+            /* serverAddressProvider = */
             null,
-            false
-        ) { host -> ConfigUtils.getSSLSocketFactory(host) },
+            /* isWork = */
+            false,
+            /* sslSocketFactoryFactory = */
+            ConfigUtils::getSSLSocketFactory,
+            /* version = */
+            Version(),
+            /* language = */
+            null,
+        ),
         serviceManager.userService,
         serviceManager.modelRepositories.contacts,
     ) {
@@ -200,20 +216,20 @@ open class MessageProcessorProvider {
             contactStore,
             contactA.identityStore,
             NonceFactory(InMemoryNonceStore()),
-            forwardSecurityStatusListener
+            forwardSecurityStatusListener,
         ),
         contactB.identity to ForwardSecurityMessageProcessor(
             InMemoryDHSessionStore(),
             contactStore,
             contactB.identityStore, NonceFactory(InMemoryNonceStore()),
-            forwardSecurityStatusListener
+            forwardSecurityStatusListener,
         ),
         contactC.identity to ForwardSecurityMessageProcessor(
             InMemoryDHSessionStore(),
             contactStore,
             contactC.identityStore,
             NonceFactory(InMemoryNonceStore()),
-            forwardSecurityStatusListener
+            forwardSecurityStatusListener,
         ),
     ).toMap()
 
@@ -263,7 +279,7 @@ open class MessageProcessorProvider {
 
     @Rule
     @JvmField
-    val timeout: Timeout = Timeout.seconds(300)
+    val timeout: Timeout = Timeout.seconds(150)
 
     @JvmField
     @Rule
@@ -279,14 +295,14 @@ open class MessageProcessorProvider {
      */
     @Before
     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
         serviceManager.databaseServiceNew.taskArchiveFactory.deleteAll()
 
-        // Then stop connection
-        serviceManager.connection.stop()
-
         // Replace original task manager (save a copy of it)
         originalTaskManager = serviceManager.taskManager
 
@@ -350,13 +366,13 @@ open class MessageProcessorProvider {
             val initMessageBox = MessageBox.parseBinary(initCspMessage.toOutgoingMessageData().data)
             val init = MessageCoder(
                 contactStore,
-                it.identityStore
+                it.identityStore,
             ).decode(initMessageBox) as ForwardSecurityEnvelopeMessage
             runBlocking {
                 forwardSecurityMessageProcessorMap[it.identity]!!.processInit(
                     myContact.contact,
                     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
     fun cleanup() {
-        clearData()
-
         if (this::originalTaskManager.isInitialized) {
             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() {
-        // 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
         initialContacts.forEach {
@@ -430,12 +402,6 @@ open class MessageProcessorProvider {
 
         // Remove files
         serviceManager.fileService.removeAllAvatars()
-        serviceManager.fileService.remove(
-            File(
-                InstrumentationRegistry.getInstrumentation().context.filesDir,
-                "taskArchive"
-            ), true
-        )
 
         // Unblock contacts
         val blockedIdentitiesService = serviceManager.blockedIdentitiesService
@@ -466,19 +432,22 @@ open class MessageProcessorProvider {
     private fun disableLifetimeService() {
         val field = ServiceManager::class.java.getDeclaredField("lifetimeService")
         field.isAccessible = true
-        field.set(ThreemaApplication.getServiceManager(), object : LifetimeService {
-            override fun acquireConnection(sourceTag: String, unpauseable: Boolean) = Unit
-            override fun acquireConnection(source: String) = Unit
-            override fun acquireUnpauseableConnection(source: String) = Unit
-            override fun releaseConnection(sourceTag: String) = Unit
-            override fun releaseConnectionLinger(sourceTag: String, timeoutMs: Long) = Unit
-            override fun ensureConnection() = Unit
-            override fun alarm(intent: Intent?) = Unit
-            override fun isActive(): Boolean = true
-            override fun pause() = Unit
-            override fun unpause() = Unit
-            override fun addListener(listener: LifetimeService.LifetimeServiceListener?) = Unit
-        })
+        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,
                 databaseService,
                 contactStore,
-                AcquaintanceLevel.GROUP
+                AcquaintanceLevel.GROUP,
             )
         }
 
@@ -510,7 +479,7 @@ open class MessageProcessorProvider {
     ) {
         databaseService.contactModelFactory.createOrUpdate(
             testContact.contactModel.setAcquaintanceLevel(acquaintanceLevel)
-                .setFeatureMask(ThreemaFeature.FORWARD_SECURITY)
+                .setFeatureMask(ThreemaFeature.FORWARD_SECURITY),
         )
 
         contactStore.addCachedContact(testContact.toBasicContact())
@@ -536,6 +505,13 @@ open class MessageProcessorProvider {
         if (testGroup.profilePicture != null) {
             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(
             message,
             identityStore,
-            forwardSecurityMessageProcessorMap[message.fromIdentity]!!
+            forwardSecurityMessageProcessorMap[message.fromIdentity]!!,
         )
 
         // Process the group message
@@ -559,7 +535,7 @@ open class MessageProcessorProvider {
         // Assert that this message has been acked towards the server
         assertEquals(
             message.hasFlags(ProtocolDefines.MESSAGE_FLAG_NO_SERVER_ACK),
-            !localTaskCodec.ackedIncomingMessages.contains(message.messageId)
+            !localTaskCodec.ackedIncomingMessages.contains(message.messageId),
         )
 
         while (globalTaskQueue.isNotEmpty()) {
@@ -591,7 +567,7 @@ open class MessageProcessorProvider {
                 scope: NonceScope,
                 chunkSize: Int,
                 offset: Int,
-                nonces: MutableList<HashedNonce>
+                nonces: MutableList<HashedNonce>,
             ) {
             }
 
@@ -600,12 +576,12 @@ open class MessageProcessorProvider {
 
         val encapsulated = forwardSecurityMessageProcessor.runFsEncapsulationSteps(
             contactStore.getContactForIdentityIncludingCache(
-                msg.toIdentity
+                msg.toIdentity,
             )!!.enhanceToBasicContact(),
             msg,
             nonceFactory.next(NonceScope.CSP),
             nonceFactory,
-            globalTaskCodec
+            globalTaskCodec,
         ).outgoingMessages.last().first
 
         val messageCoder = MessageCoder(contactStore, identityStore)
@@ -630,5 +606,4 @@ open class MessageProcessorProvider {
         IdentityState.ACTIVE,
         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.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.app.testutils.clearDatabaseAndCaches
 import ch.threema.data.TestDatabaseService
 import ch.threema.data.models.ContactModelData
 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.GroupMemberModel
 import ch.threema.storage.models.GroupModel
+import java.util.Date
+import kotlin.test.assertEquals
 import kotlinx.coroutines.runBlocking
 import org.junit.Before
 import org.junit.Test
-import java.util.Date
-import kotlin.test.assertEquals
 
 @DangerousTest
 class IdentityBlockedStepsTest {
-
     private lateinit var contactModelRepository: ContactModelRepository
     private lateinit var contactStore: ContactStore
     private lateinit var groupService: GroupService
@@ -76,15 +76,18 @@ class IdentityBlockedStepsTest {
 
     @Before
     fun setup() {
-        assert(myContact.identity == TestHelpers.ensureIdentity(ThreemaApplication.requireServiceManager()))
-
         val serviceManager = ThreemaApplication.requireServiceManager()
+
+        clearDatabaseAndCaches(serviceManager)
+
+        assert(myContact.identity == TestHelpers.ensureIdentity(serviceManager))
+
         val databaseService = TestDatabaseService()
         val coreServiceManager = TestCoreServiceManager(
             version = ThreemaApplication.getAppVersion(),
             databaseService = databaseService,
             preferenceStore = serviceManager.preferenceStore,
-            taskManager = TestTaskManager(UnusedTaskCodec())
+            taskManager = TestTaskManager(UnusedTaskCodec()),
         )
         contactModelRepository = ModelRepositories(coreServiceManager).contacts
         contactStore = serviceManager.contactStore
@@ -124,14 +127,14 @@ class IdentityBlockedStepsTest {
     fun testExplicitlyBlockedContact() {
         assertEquals(
             BlockState.EXPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(explicitlyBlockedContact.identity, noBlockPreferenceService)
+            runIdentityBlockedSteps(explicitlyBlockedContact.identity, noBlockPreferenceService),
         )
         assertEquals(
             BlockState.EXPLICITLY_BLOCKED,
             runIdentityBlockedSteps(
                 explicitlyBlockedContact.identity,
-                blockUnknownPreferenceService
-            )
+                blockUnknownPreferenceService,
+            ),
         )
     }
 
@@ -139,7 +142,7 @@ class IdentityBlockedStepsTest {
     fun testImplicitlyBlockedContact() {
         assertEquals(
             BlockState.IMPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(unknownContact.identity, blockUnknownPreferenceService)
+            runIdentityBlockedSteps(unknownContact.identity, blockUnknownPreferenceService),
         )
     }
 
@@ -147,7 +150,7 @@ class IdentityBlockedStepsTest {
     fun testImplicitlyBlockedSpecialContact() {
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(specialContact.identity, blockUnknownPreferenceService)
+            runIdentityBlockedSteps(specialContact.identity, blockUnknownPreferenceService),
         )
     }
 
@@ -155,7 +158,7 @@ class IdentityBlockedStepsTest {
     fun testGroupContactWithGroup() {
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(inGroup.identity, blockUnknownPreferenceService)
+            runIdentityBlockedSteps(inGroup.identity, blockUnknownPreferenceService),
         )
     }
 
@@ -163,7 +166,7 @@ class IdentityBlockedStepsTest {
     fun testGroupContactWithoutGroup() {
         assertEquals(
             BlockState.IMPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(inNoGroup.identity, blockUnknownPreferenceService)
+            runIdentityBlockedSteps(inNoGroup.identity, blockUnknownPreferenceService),
         )
     }
 
@@ -171,7 +174,7 @@ class IdentityBlockedStepsTest {
     fun testGroupContactWithLeftGroup() {
         assertEquals(
             BlockState.IMPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(inLeftGroup.identity, blockUnknownPreferenceService)
+            runIdentityBlockedSteps(inLeftGroup.identity, blockUnknownPreferenceService),
         )
     }
 
@@ -199,17 +202,16 @@ class IdentityBlockedStepsTest {
         )
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(inGroup.identity, noBlockPreferenceService)
+            runIdentityBlockedSteps(inGroup.identity, noBlockPreferenceService),
         )
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(inNoGroup.identity, noBlockPreferenceService)
+            runIdentityBlockedSteps(inNoGroup.identity, noBlockPreferenceService),
         )
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(inLeftGroup.identity, noBlockPreferenceService)
+            runIdentityBlockedSteps(inLeftGroup.identity, noBlockPreferenceService),
         )
-
     }
 
     private fun runIdentityBlockedSteps(
@@ -227,107 +229,115 @@ class IdentityBlockedStepsTest {
     private fun addKnownContacts() = runBlocking {
         contactModelRepository.createFromLocal(
             ContactModelData(
-                knownContact.identity,
-                knownContact.publicKey,
-                Date(),
-                "",
-                "",
-                "",
-                0u,
-                VerificationLevel.UNVERIFIED,
-                WorkVerificationLevel.NONE,
-                IdentityType.NORMAL,
-                ContactModel.AcquaintanceLevel.DIRECT,
-                IdentityState.ACTIVE,
-                ContactSyncState.INITIAL,
-                0u,
-                ReadReceiptPolicy.DEFAULT,
-                TypingIndicatorPolicy.DEFAULT,
-                null,
-                null,
-                false,
-                null,
-                null,
-                null,
-            )
+                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(
             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(
             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(
             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))
                     .setCreatorIdentity(myContact.identity)
                     .setUserState(GroupModel.UserState.MEMBER)
-                    .setCreatedAt(Date())
+                    .setCreatedAt(Date()),
             )
             create(
                 GroupModel()
                     .setApiGroupId(GroupId(1))
                     .setCreatorIdentity(myContact.identity)
                     .setUserState(GroupModel.UserState.LEFT)
-                    .setCreatedAt(Date())
+                    .setCreatedAt(Date()),
             )
         }
         val memberGroup = databaseService.groupModelFactory.getByApiGroupIdAndCreator(
             GroupId(0).toString(),
-            myContact.identity
+            myContact.identity,
         )
         val leftGroup = databaseService.groupModelFactory.getByApiGroupIdAndCreator(
             GroupId(1).toString(),
-            myContact.identity
+            myContact.identity,
         )
         databaseService.groupMemberModelFactory.apply {
             create(
                 GroupMemberModel()
                     .setGroupId(memberGroup.id)
-                    .setIdentity(inGroup.identity)
+                    .setIdentity(inGroup.identity),
             )
             create(
                 GroupMemberModel()
                     .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
-            public void linkWithEmail(String email) throws Exception {
+            public void linkWithEmail(String email, @NonNull TriggerSource triggerSource) throws Exception {
 
             }
 
             @Override
-            public void unlinkEmail() throws Exception {
+            public void unlinkEmail(@NonNull TriggerSource triggerSource) throws Exception {
 
             }
 
@@ -219,12 +219,12 @@ public class GroupInviteServiceTest {
             }
 
             @Override
-            public void checkEmailLinkState() {
+            public void checkEmailLinkState(@NonNull TriggerSource triggerSource) {
 
             }
 
             @Override
-            public Date linkWithMobileNumber(String number) throws Exception {
+            public Date linkWithMobileNumber(String number, @NonNull TriggerSource triggerSource) throws Exception {
                 return null;
             }
 
@@ -234,12 +234,12 @@ public class GroupInviteServiceTest {
             }
 
             @Override
-            public void unlinkMobileNumber() throws Exception {
+            public void unlinkMobileNumber(@NonNull TriggerSource triggerSource) throws Exception {
 
             }
 
             @Override
-            public boolean verifyMobileNumber(String code) throws Exception {
+            public boolean verifyMobileNumber(String code, @NonNull TriggerSource triggerSource) throws Exception {
                 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 kotlin.test.BeforeTest
 import kotlin.test.Test
-import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertFalse
 import kotlin.test.assertTrue
 
 class BlockedIdentitiesServiceTest {
-
     private val multiDeviceManager = TestMultiDeviceManager(
-        isMultiDeviceActive = false
+        isMultiDeviceActive = false,
     )
 
     private val taskManager = TestTaskManager(ServerAckTaskCodec())
@@ -49,7 +47,7 @@ class BlockedIdentitiesServiceTest {
         ThreemaApplication.getAppContext(),
         PreferenceStore(
             ThreemaApplication.getAppContext(),
-            ThreemaApplication.getMasterKey()
+            ThreemaApplication.getMasterKey(),
         ),
         taskManager,
         multiDeviceManager,
@@ -112,14 +110,14 @@ class BlockedIdentitiesServiceTest {
 
         assertEquals(
             setOf("ABCDEFGH", "12345678"),
-            setOf(onModified.removeFirst(), onModified.removeFirst())
+            setOf(onModified.removeFirst(), onModified.removeFirst()),
         )
 
         blockedIdentitiesService.persistBlockedIdentities(setOf("ABCDEFGH", "TESTTEST"))
 
         assertEquals(
             setOf("12345678", "TESTTEST"),
-            setOf(onModified.removeFirst(), onModified.removeFirst())
+            setOf(onModified.removeFirst(), onModified.removeFirst()),
         )
         assertTrue { onModified.isEmpty() }
     }
@@ -138,5 +136,4 @@ class BlockedIdentitiesServiceTest {
 
         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
 
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
 import net.zetetic.database.sqlcipher.SQLiteDatabase
 import org.junit.Before
 import org.junit.Test
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
 
 class SystemUpdateHelpersTest {
     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.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.IdentityState
 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.storage.models.ContactModel
 import com.neilalexander.jnacl.NaCl
+import java.util.Date
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertNotNull
 import junit.framework.TestCase.fail
 import kotlinx.coroutines.runBlocking
 import kotlinx.serialization.json.Json
 import org.junit.Test
-import java.util.Date
 
 /**
  * These tests are useful to detect when a task cannot be created out of a persisted representation
@@ -55,7 +57,8 @@ class PersistableTasksTest {
     fun testContactDeliveryReceiptMessageTask() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             OutgoingContactRequestProfilePictureTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutgoingContactRequestProfilePictureTask.OutgoingContactRequestProfilePictureData\",\"toIdentity\":\"01234567\"}"
+            """{"type":"ch.threema.app.tasks.OutgoingContactRequestProfilePictureTask.OutgoingContactRequestProfilePictureData",""" +
+                """"toIdentity":"01234567"}""",
         )
     }
 
     @Test
     fun testDeleteAndTerminateFSSessionsTask() {
         // Add the contact '01234567' so that creating the tasks works
-        addTestIdentity()
+        addTestData()
 
         assertValidEncoding(
             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(
             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(
             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(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             OutboundIncomingGroupMessageUpdateReadTask::class.java,
-            "{\"type\":\"ch.threema.app.tasks.OutboundIncomingGroupMessageUpdateReadTask.OutboundIncomingGroupMessageUpdateReadData\",\"messageIds\":[[0,-1,2,3,4,5,6,7]],\"timestamp\":1704067200000,\"groupId\":[0,0,0,0,0,0,0,0],\"creatorIdentity\":\"01234567\"}"
+            """{"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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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(
             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(
             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() {
         assertValidEncoding(
             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(
             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(
             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() {
         assertValidEncoding(
             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(
             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(
             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() {
         assertValidEncoding(
             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(
             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(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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(
             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(
             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() {
         assertValidEncoding(
             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(
             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() {
         assertValidEncoding(
             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(
             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() {
         assertValidEncoding(
             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(
             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() {
         assertValidEncoding(
             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
     fun testOnFSFeatureMaskDowngradedTask() {
         // Add the contact '01234567' so that creating the tasks works
-        addTestIdentity()
+        addTestData()
 
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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() {
         assertValidEncoding(
             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"
         if (serviceManager.modelRepositories.contacts.getByIdentity(identity) != null) {
             // If the contact already exists, we do not add it again
             return@runBlocking
         }
 
+        serviceManager.identityStore.storeIdentity(identity, "", byteArrayOf(), byteArrayOf())
+
         serviceManager.modelRepositories.contacts.createFromLocal(
             ContactModelData(
                 identity = identity,
@@ -513,13 +722,35 @@ class PersistableTasksTest {
                 featureMask = 0u,
                 typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
                 readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
+                isArchived = false,
                 androidContactLookupKey = null,
                 localAvatarExpires = null,
                 isRestored = false,
                 profilePictureBlobId = null,
                 jobTitle = 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.IOException;
 import java.io.InputStreamReader;
+import java.util.Collection;
 import java.util.Date;
 import java.util.List;
+import java.util.stream.Collectors;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -204,6 +206,13 @@ public class TestHelpers {
                 .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) {
             this.localGroupId = localGroupId;
         }
@@ -239,6 +248,22 @@ public class TestHelpers {
         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.
      */

+ 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 org.junit.Before;
+import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.RuleChain;
@@ -129,7 +130,8 @@ public class BackgroundErrorNotificationTest {
     /**
      * 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() {
         // Go to home screen
         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 ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.app.utils.executor.BackgroundTask
+import kotlin.test.assertFailsWith
 import kotlinx.coroutines.runBlocking
 import org.junit.Assert
 import org.junit.Rule
@@ -31,7 +32,6 @@ import org.junit.Test
 import org.junit.rules.Timeout
 
 class BackgroundExecutorTest {
-
     @Rule
     @JvmField
     val timeout: Timeout = Timeout.seconds(10)
@@ -98,7 +98,7 @@ class BackgroundExecutorTest {
 
         Assert.assertArrayEquals(
             expected,
-            methodExecutionList.toTypedArray()
+            methodExecutionList.toTypedArray(),
         )
     }
 
@@ -120,7 +120,7 @@ class BackgroundExecutorTest {
             }
         })
 
-        Assert.assertThrows(IllegalStateException::class.java) {
+        assertFailsWith<IllegalStateException> {
             runBlocking {
                 deferred.await()
             }
@@ -140,7 +140,7 @@ class BackgroundExecutorTest {
             }
         })
 
-        Assert.assertThrows(IllegalStateException::class.java) {
+        assertFailsWith<IllegalStateException> {
             runBlocking {
                 deferred.await()
             }
@@ -159,11 +159,10 @@ class BackgroundExecutorTest {
             }
         })
 
-        Assert.assertThrows(IllegalStateException::class.java) {
+        assertFailsWith<IllegalStateException> {
             runBlocking {
                 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.taskmanager.ActiveTask
 import ch.threema.domain.taskmanager.ActiveTaskCodec
-import org.junit.Before
 import java.util.Date
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertTrue
+import org.junit.Before
 
 class BundledMessagesSendStepsTest : MessageProcessorProvider() {
-
     private lateinit var outgoingCspMessageServices: OutgoingCspMessageServices
 
     @Before
@@ -199,14 +198,13 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
 
     private fun assertMessageHandleSent(
         messageHandle: OutgoingCspMessageHandle,
-        assertMessage: (AbstractMessage) -> Unit
+        assertMessage: (AbstractMessage) -> Unit,
     ) {
         val expectedReceivers = messageHandle.receivers
             .map { it.identity }
             .filter { it != myContact.identity }
             .sorted()
 
-
         val actualReceivers = sentMessagesInsideTask
             .asSequence()
             .take(expectedReceivers.size)
@@ -215,7 +213,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
                 assertMessage(it)
                 assertEquals(
                     messageHandle.messageCreator.messageId.messageIdLong,
-                    it.messageId.messageIdLong
+                    it.messageId.messageIdLong,
                 )
                 assertEquals(messageHandle.messageCreator.createdAt.time, it.date.time)
             }
@@ -235,5 +233,4 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
 
             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
 
 class GeoLocationUtilTest {
-
     private fun expectLocationData(expected: LocationDataModel?, uriStr: String) {
         val uri = Uri.parse(uriStr)
         val actual = GeoLocationUtil.getLocationDataFromGeoUri(uri)
@@ -48,7 +47,7 @@ class GeoLocationUtilTest {
             latitude = 12.0,
             longitude = 34.0,
             accuracy = 0.0,
-            poi = null
+            poi = null,
         )
         expectLocationData(latLong1234, "geo:12,34;abcd=efg")
         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?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
 
 class LinkifyUtilTest {
-
     /**
      * 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.
      */
     private fun getSpanPair(
         text: String,
-        includePhoneNumbers: Boolean = true
+        includePhoneNumbers: Boolean = true,
     ): Pair<Spanned?, List<URLSpan>> {
         val textView = TextView(InstrumentationRegistry.getInstrumentation().context)
         textView.text = text
@@ -45,7 +44,7 @@ class LinkifyUtilTest {
         }
         val spannableText = textView.text
         if (spannableText !is Spanned) {
-            return null to listOf()
+            return null to emptyList()
         }
         val spans = spannableText.getSpans(0, text.length + 1, URLSpan::class.java).toList()
         return spannableText to spans
@@ -57,7 +56,7 @@ class LinkifyUtilTest {
     private fun assertSpans(
         text: String,
         spanPoints: Set<Pair<Int, Int>>,
-        includePhoneNumbers: Boolean = true
+        includePhoneNumbers: Boolean = true,
     ) {
         val (spannable, spans) = getSpanPair(text, includePhoneNumbers)
         assert(spannable != null || spans.isEmpty())
@@ -85,7 +84,7 @@ class LinkifyUtilTest {
      * Expects that there are no spans in the given string.
      */
     private fun assertNoSpan(text: String, includePhoneNumbers: Boolean = true) {
-        assertSpans(text, setOf(), includePhoneNumbers)
+        assertSpans(text, emptySet(), includePhoneNumbers)
     }
 
     @Test
@@ -168,5 +167,4 @@ class LinkifyUtilTest {
         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))
     }
-
 }

+ 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
             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-rsize$");
 

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

@@ -21,7 +21,6 @@
 
 package ch.threema.app.webclient.activities;
 
-
 import android.app.Activity;
 import android.app.Instrumentation;
 import android.content.Context;
@@ -36,7 +35,9 @@ import org.junit.rules.RuleChain;
 import org.junit.runner.RunWith;
 
 import java.util.Date;
+import java.util.Objects;
 
+import androidx.annotation.NonNull;
 import androidx.preference.PreferenceManager;
 import androidx.test.InstrumentationRegistry;
 import androidx.test.filters.LargeTest;
@@ -114,14 +115,11 @@ public class SessionsActivityTest {
         String label,
         WebClientSessionModel.State state,
         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();
 
         model.setLabel(label);
@@ -190,11 +188,11 @@ public class SessionsActivityTest {
     @Test
     public void testSessionList() {
         // Create two sessions
-        createSession("Feuerfuchs", WebClientSessionModel.State.AUTHORIZED,
-            true, new Date(), new Date(), Browser.FIREFOX);
+        createSession("Feuerfuchs", WebClientSessionModel.State.AUTHORIZED, true, new Date(), new Date(), Browser.FIREFOX);
         createSession("Googlebrowser", WebClientSessionModel.State.ERROR,
             true, new Date(System.currentTimeMillis() - 3600),
-            new Date(System.currentTimeMillis() - 3500), Browser.CHROME);
+            new Date(System.currentTimeMillis() - 3500), Browser.CHROME
+        );
 
         // Start activty
         activityTestRule.launchActivity(null);
@@ -230,33 +228,19 @@ public class SessionsActivityTest {
         final Date hours23ago = new Date(System.currentTimeMillis() - hours * 23);
         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);
 
-        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<>()
         );
         final AbstractMessageModel messageModel = new MessageModel();
-        messageModel.setFileDataModel(fileDataModel);
+        messageModel.setFileData(fileDataModel);
         messageModel.setCreatedAt(createdAt);
         messageModel.setApiMessageId(messageId);
         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(),
     null,
     "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.TestTaskManager
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.testutils.TestHelpers
 import ch.threema.data.TestDatabaseService
 import ch.threema.data.models.ContactModelData
 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.randomIdentity
 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 junit.framework.TestCase.assertNotNull
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
 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)
 class ContactModelRepositoryTest(private val contactModelData: ContactModelData) {
@@ -126,24 +127,31 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
             featureMask = featureMask,
             readReceiptPolicy = readReceiptPolicy,
             typingIndicatorPolicy = typingIndicatorPolicy,
+            isArchived = false,
             androidContactLookupKey = androidContactLookupKey,
             localAvatarExpires = localAvatarExpires,
             isRestored = isRestored,
             profilePictureBlobId = profilePictureBlobId,
             jobTitle = jobTitle,
             department = department,
+            notificationTriggerPolicyOverride = null,
         )
     }
 
     @Before
     fun before() {
+        TestHelpers.setIdentity(
+            ThreemaApplication.requireServiceManager(),
+            TestHelpers.TEST_CONTACT,
+        )
+
         // Instantiate services where MD is disabled
         this.databaseService = TestDatabaseService()
         this.coreServiceManager = TestCoreServiceManager(
             version = ThreemaApplication.getAppVersion(),
             databaseService = databaseService,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
-            taskManager = TestTaskManager(UnusedTaskCodec())
+            taskManager = TestTaskManager(UnusedTaskCodec()),
         )
         this.contactModelRepository = ModelRepositories(coreServiceManager).contacts
 
@@ -158,7 +166,7 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
                 isMultiDeviceActive = true,
                 isMdDisabledOrSupportsFs = false,
             ),
-            taskManager = TestTaskManager(taskCodecMd)
+            taskManager = TestTaskManager(taskCodecMd),
         )
         this.contactModelRepositoryMd = ModelRepositories(coreServiceManagerMd).contacts
     }
@@ -322,7 +330,6 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
                         )
                     }
             }
-
         }
 
         // Insert it for the first time
@@ -344,8 +351,8 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         assertContentEquals(contactModelData, newModelMd.data.value)
 
         // 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
         // been executed again (due to precondition failure)
@@ -399,7 +406,7 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         assertContentEquals(contactModelData, addedData)
 
         // Assert that the contact data cannot be inserted again (as it already exists)
-        assertThrows(ContactStoreException::class.java) {
+        assertFailsWith<ContactStoreException> {
             contactModelRepositoryMd.createFromSync(contactModelData)
         }
 
@@ -415,8 +422,8 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         databaseService.contactModelFactory.createOrUpdate(
             ContactModel(
                 identity,
-                nonSecureRandomArray(32)
-            )
+                nonSecureRandomArray(32),
+            ),
         )
 
         // Fetch model
@@ -457,10 +464,14 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         assertEquals(expected.featureMask, actual.featureMask)
         assertEquals(expected.readReceiptPolicy, actual.readReceiptPolicy)
         assertEquals(expected.typingIndicatorPolicy, actual.typingIndicatorPolicy)
-        // TODO(ANDR-2998): Assert that notification trigger and sound policy override are set
-        //  correctly
+        assertEquals(
+            expected.notificationTriggerPolicyOverride,
+            actual.notificationTriggerPolicyOverride,
+        )
         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
         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.TestTaskManager
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.testutils.TestHelpers
 import ch.threema.data.TestDatabaseService
 import ch.threema.data.storage.EditHistoryDao
 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.MessageModel
 import ch.threema.storage.models.MessageType
+import java.util.UUID
+import kotlin.test.assertFailsWith
 import org.junit.Assert
 import org.junit.Before
 import org.junit.Test
-import java.util.UUID
 
 class EditHistoryRepositoryTest {
     private lateinit var databaseService: TestDatabaseService
@@ -44,12 +46,17 @@ class EditHistoryRepositoryTest {
 
     @Before
     fun before() {
+        TestHelpers.setIdentity(
+            ThreemaApplication.requireServiceManager(),
+            TestHelpers.TEST_CONTACT,
+        )
+
         databaseService = TestDatabaseService()
         val testCoreServiceManager = TestCoreServiceManager(
             version = ThreemaApplication.getAppVersion(),
             databaseService = databaseService,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
-            taskManager = TestTaskManager(UnusedTaskCodec())
+            taskManager = TestTaskManager(UnusedTaskCodec()),
         )
         editHistoryRepository = ModelRepositories(testCoreServiceManager).editHistory
         editHistoryDao = EditHistoryDaoImpl(databaseService)
@@ -59,7 +66,7 @@ class EditHistoryRepositoryTest {
     fun testContactMessageHistoryForeignKeyConstraint() {
         val contactMessage = MessageModel().enrich()
 
-        Assert.assertThrows(EditHistoryEntryCreateException::class.java) {
+        assertFailsWith<EditHistoryEntryCreateException> {
             editHistoryRepository.createEntry(contactMessage)
         }
 
@@ -83,7 +90,7 @@ class EditHistoryRepositoryTest {
     fun testGroupMessageHistoryForeignKeyConstraint() {
         val groupMessage = GroupMessageModel().enrich()
 
-        Assert.assertThrows(EditHistoryEntryCreateException::class.java) {
+        assertFailsWith<EditHistoryEntryCreateException> {
             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.
      */
     private fun AbstractMessageModel.assertEditHistorySize(expectedSize: Int) {
-        val actualSize = editHistoryDao.findAllByMessageUid(uid).size
+        val actualSize = editHistoryDao.findAllByMessageUid(uid!!).size
 
         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
         uid = UUID.randomUUID().toString()
         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.MessageModel
 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.UUID
 import kotlin.test.assertContentEquals
+import kotlin.test.assertFailsWith
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.fail
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
 
 class EmojiReactionsRepositoryTest {
     private lateinit var testCoreServiceManager: TestCoreServiceManager
@@ -60,7 +61,7 @@ class EmojiReactionsRepositoryTest {
             version = ThreemaApplication.getAppVersion(),
             databaseService = databaseService,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
-            taskManager = TestTaskManager(UnusedTaskCodec())
+            taskManager = TestTaskManager(UnusedTaskCodec()),
         )
 
         emojiReactionsRepository = ModelRepositories(testCoreServiceManager).emojiReaction
@@ -71,7 +72,7 @@ class EmojiReactionsRepositoryTest {
     fun testEmojiReactionForeignKeyConstraint() {
         val contactMessage = MessageModel().enrich()
 
-        Assert.assertThrows(EmojiReactionEntryCreateException::class.java) {
+        assertFailsWith<EmojiReactionEntryCreateException> {
             emojiReactionsRepository.createEntry(contactMessage, "ABCDEFGH", "\uD83C\uDFC8")
         }
 
@@ -95,7 +96,7 @@ class EmojiReactionsRepositoryTest {
     fun testGroupEmojiReactionForeignKeyConstraint() {
         val groupMessage = GroupMessageModel().enrich()
 
-        Assert.assertThrows(EmojiReactionEntryCreateException::class.java) {
+        assertFailsWith<EmojiReactionEntryCreateException> {
             emojiReactionsRepository.createEntry(groupMessage, "ABCDEFGH", "⚾")
         }
 
@@ -128,7 +129,7 @@ class EmojiReactionsRepositoryTest {
 
         message.assertEmojiReactionSize(1)
 
-        Assert.assertThrows(EmojiReactionEntryCreateException::class.java) {
+        assertFailsWith<EmojiReactionEntryCreateException> {
             emojiReactionsRepository.createEntry(message, "ABCDEFGH", "⚽")
         }
 
@@ -215,7 +216,6 @@ class EmojiReactionsRepositoryTest {
 
     @Test
     fun testEmojiReactionsModelCaching() {
-
         val testEmojiCache = ModelTypeCache<ReactionMessageIdentifier, EmojiReactionsModel>()
 
         val contactMessage = MessageModel().enrich()
@@ -227,7 +227,7 @@ class EmojiReactionsRepositoryTest {
 
         // Test unsuccessful creation of reaction-message-identifier
         val reactionMessageIdentifierNull = ReactionMessageIdentifier.fromMessageModel(
-            messageModel = DistributionListMessageModel()
+            messageModel = DistributionListMessageModel(),
         )
         assertNull(reactionMessageIdentifierNull)
 
@@ -240,11 +240,11 @@ class EmojiReactionsRepositoryTest {
             contactMessage.id,
             senderIdentity = "ABCD1234",
             emojiSequence = "⛵",
-            reactedAt = Date()
+            reactedAt = Date(),
         )
         val emojiReactionsModel = EmojiReactionsModel(
             data = listOf(emojiReactionData),
-            coreServiceManager = testCoreServiceManager
+            coreServiceManager = testCoreServiceManager,
         )
         cachedEntry = testEmojiCache.getOrCreate(reactionMessageIdentifier) { emojiReactionsModel }
         assertContentEquals(listOf(emojiReactionData), cachedEntry!!.data.value)
@@ -263,29 +263,28 @@ class EmojiReactionsRepositoryTest {
 
     @Test
     fun testCacheCollision() {
-
         // arrange
         val testEmojiCache = ModelTypeCache<ReactionMessageIdentifier, EmojiReactionsModel>()
         val contactMessageId = 1
         val groupMessageId = 1
         val reactionMessageIdentifierContact = ReactionMessageIdentifier(
             messageId = contactMessageId,
-            messageType = ReactionMessageIdentifier.TargetMessageType.ONE_TO_ONE
+            messageType = ReactionMessageIdentifier.TargetMessageType.ONE_TO_ONE,
         )
         val reactionMessageIdentifierGroup = ReactionMessageIdentifier(
             messageId = groupMessageId,
-            messageType = ReactionMessageIdentifier.TargetMessageType.GROUP
+            messageType = ReactionMessageIdentifier.TargetMessageType.GROUP,
         )
         // Add only the emoji reaction of the 1:1 message to the cache
         val emojiReactionDataForContactMessage = EmojiReactionData(
             messageId = contactMessageId,
             senderIdentity = "ABCD1234",
             emojiSequence = "⛵",
-            reactedAt = Date()
+            reactedAt = Date(),
         )
         val emojiReactionsModelContact = EmojiReactionsModel(
             data = listOf(emojiReactionDataForContactMessage),
-            coreServiceManager = testCoreServiceManager
+            coreServiceManager = testCoreServiceManager,
         )
 
         val cachedEntryContact =
@@ -293,7 +292,7 @@ class EmojiReactionsRepositoryTest {
 
         assertContentEquals(
             listOf(emojiReactionDataForContactMessage),
-            cachedEntryContact!!.data.value
+            cachedEntryContact!!.data.value,
         )
         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
 
-import ch.threema.data.TestDatabaseService
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestTaskManager
 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.GroupModelDataFactory
 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.models.GroupId
 import ch.threema.storage.models.GroupModel
-import org.junit.Assert
-import org.junit.Before
-import org.junit.Test
 import java.util.Date
 import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
 import kotlin.test.assertNull
-import kotlin.test.assertTrue
+import org.junit.Before
+import org.junit.Test
 
 class GroupModelRepositoryTest {
     private lateinit var databaseService: TestDatabaseService
@@ -49,31 +49,36 @@ class GroupModelRepositoryTest {
 
     private fun createTestDbGroup(groupIdentity: GroupIdentity): 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,
-            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
     fun before() {
+        TestHelpers.setIdentity(
+            ThreemaApplication.requireServiceManager(),
+            TestHelpers.TEST_CONTACT,
+        )
+
         this.databaseService = TestDatabaseService()
         this.databaseBackend = SqliteDatabaseBackend(databaseService)
         this.coreServiceManager = TestCoreServiceManager(
             version = ThreemaApplication.getAppVersion(),
             databaseService = databaseService,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
-            taskManager = TestTaskManager(UnusedTaskCodec())
+            taskManager = TestTaskManager(UnusedTaskCodec()),
         )
         this.groupModelRepository = ModelRepositories(coreServiceManager).groups
     }
@@ -100,12 +105,12 @@ class GroupModelRepositoryTest {
             GroupModel()
                 .setCreatorIdentity(groupIdentity.creatorIdentity)
                 .setApiGroupId(GroupId(groupIdentity.groupId))
-                .setCreatedAt(Date())
+                .setCreatedAt(Date()),
         )
 
         // Fetch group using the "new" model
         val model = groupModelRepository.getByGroupIdentity(groupIdentity)!!
-        assertTrue { model.groupIdentity == groupIdentity }
+        assertEquals(groupIdentity, model.groupIdentity)
     }
 
     @Test
@@ -118,13 +123,13 @@ class GroupModelRepositoryTest {
             GroupModel()
                 .setCreatorIdentity(creatorIdentity)
                 .setApiGroupId(groupId)
-                .setCreatedAt(Date())
+                .setCreatedAt(Date()),
         )
 
         // Fetch group using the "new" model
         val model = groupModelRepository.getByCreatorIdentityAndId(creatorIdentity, groupId)!!
         val groupIdentity = GroupIdentity(creatorIdentity, groupId.toLong())
-        assertTrue { model.groupIdentity == groupIdentity }
+        assertEquals(groupIdentity, model.groupIdentity)
     }
 
     @Test
@@ -166,7 +171,7 @@ class GroupModelRepositoryTest {
         val datesNullGroup = createTestDbGroup(groupIdentityDatesNull).copy(
             synchronizedAt = null,
             lastUpdate = null,
-            groupDescriptionChangedAt = null
+            groupDescriptionChangedAt = null,
         )
         testInsertAndGet(groupIdentityDatesNull, datesNullGroup)
     }
@@ -178,12 +183,12 @@ class GroupModelRepositoryTest {
         testInsertAndGet(groupIdentity, defaultGroup)
 
         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
             // 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
             // 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.NonceStore
 import ch.threema.domain.stores.IdentityStoreInterface
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
 import org.junit.After
 import org.junit.Assert.*
 import org.junit.Before
 import org.junit.Test
-import javax.crypto.Mac
-import javax.crypto.spec.SecretKeySpec
 
 class DatabaseNonceStoreTest {
     private lateinit var tempDbFileName: String
@@ -44,13 +44,12 @@ class DatabaseNonceStoreTest {
 
     @Before
     fun setup() {
-
         tempDbFileName = "threema-nonce-test-${System.currentTimeMillis()}.db"
         val identityStore = TestIdentityStore()
         _store = DatabaseNonceStore(
             ApplicationProvider.getApplicationContext(),
             identityStore,
-            tempDbFileName
+            tempDbFileName,
         )
     }
 
@@ -102,7 +101,6 @@ class DatabaseNonceStoreTest {
             assertTrue(store.store(NonceScope.D2D, it))
         }
 
-
         // Assert the nonces exist after the insert
         nonces.forEach {
             assertTrue(store.exists(NonceScope.CSP, it))
@@ -194,7 +192,7 @@ class DatabaseNonceStoreTest {
 
     private fun assertSameHashedNonces(
         expected: Collection<HashedNonce>,
-        actual: Collection<HashedNonce>
+        actual: Collection<HashedNonce>,
     ) {
         assertEquals(expected.size, actual.size)
         expected.forEach { expectedNonce ->
@@ -233,13 +231,13 @@ private class TestIdentityStore : IdentityStoreInterface {
     override fun encryptData(
         plaintext: ByteArray,
         nonce: ByteArray,
-        receiverPublicKey: ByteArray
+        receiverPublicKey: ByteArray,
     ): ByteArray = throw UnsupportedOperationException()
 
     override fun decryptData(
         ciphertext: ByteArray,
         nonce: ByteArray,
-        senderPublicKey: ByteArray
+        senderPublicKey: ByteArray,
     ): ByteArray = throw UnsupportedOperationException()
 
     override fun calcSharedSecret(publicKey: ByteArray): ByteArray =
@@ -257,7 +255,6 @@ private class TestIdentityStore : IdentityStoreInterface {
         identity: String,
         serverGroup: String,
         publicKey: ByteArray,
-        privateKey: ByteArray
+        privateKey: ByteArray,
     ) = 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
 
 object ColorsDark : ComposeColorPaletteDark() {
-
     override val primary = Color(0xFFA4C8FF)
     override val onPrimary = Color(0xFF00315E)
     override val primaryContainer = Color(0xFF004784)
@@ -47,6 +46,11 @@ object ColorsDark : ComposeColorPaletteDark() {
     override val onSurface = Color(0xFFE3E2E6)
     override val surfaceVariant = Color(0xFF282E35)
     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 outlineVariant = Color(0xFF43474E)
     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 surfaceVariant = Color(0xFFE2EBF6)
     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 outlineVariant = Color(0xFFC3C6CF)
     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
 
 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
 
 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"
+    xmlns:aapt="http://schemas.android.com/aapt"
     android:width="108dp"
     android:height="108dp"
     android:viewportWidth="1500"
@@ -6,48 +7,110 @@
     <group
         android:translateX="238"
         android:translateY="238">
-        <!--    <group>-->
-        <!-- background color of icon -->
         <path
-            android:fillColor="#FFF"
+            android:fillColor="#FAFAFA"
             android:fillType="evenOdd"
             android:pathData="M0,0h1024v1024h-1024z"
+            android:strokeWidth="1"
             android:strokeColor="#00000000" />
-        <!-- sky color -->
         <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" />
-        <!-- 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
-            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
-            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>
 </vector>

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

@@ -9,11 +9,11 @@
         android:translateX="14.04"
         android:translateY="14.04">
         <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
-            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: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>
 </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:linksClickable="true"
                 android:autoLink="web"
-                android:text="@string/enter_serial_body"
+                android:text="@string/flavored__enter_serial_body"
                 android:layout_marginBottom="5dp" />
 
             <LinearLayout
@@ -121,9 +121,8 @@
                         android:layout_height="@dimen/wizard_default_view_height"
                         android:hint="@string/password_hint"
                         android:id="@+id/password"
-                        android:nextFocusRight="@+id/unlock_button"
                         android:inputType="textNoSuggestions|textPassword"
-                        android:imeOptions="actionGo"
+                        android:imeOptions="actionDone"
                         android:singleLine="true" />
 
                 </com.google.android.material.textfield.TextInputLayout>
@@ -141,21 +140,20 @@
                 android:textSize="@dimen/wizard_text_medium"
                 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_height="wrap_content"
                 android:layout_alignParentRight="true"
-                android:text="@string/next" />
+                android:layout_below="@id/unlock_state"
+                app:wizardButton_text="@string/next"/>
 
             <TextView
                 style="@style/WizardMediumText"
                 android:id="@+id/work_lost_credential_help"
                 android:layout_width="fill_parent"
                 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: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"?>
 <resources>
     <!-- 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 -->
     <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
   ~ All rights reserved.
   -->
-<!-- TODO: move to build.gradle -->
 <resources>
     <string name="google_app_id" translatable="false">1:480681303521:android:6ec12987090e0e4f9fc6a0</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"?>
 <resources>
     <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>

+ 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: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
         android:defaultValue="false"
         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>
 
-<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>
 
 
 <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>
 

+ 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();
                     logger.info("Received FCM registration token");
+                    logger.debug("FCM push token: {}", token);
                     String error = null;
                     try {
                         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
     public void onNewToken(@NonNull String token) {
         logger.info("New FCM token received");
+        logger.debug("FCM push token: {}", token);
         try {
             PushUtil.sendTokenToServer(token, ProtocolDefines.PUSHTOKEN_TYPE_FCM);
         } 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() {
             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
 
 object ColorsDark : ComposeColorPaletteDark() {
-
     override val primary = Color(0xFF34C955)
     override val onPrimary = Color(0xFF003910)
     override val primaryContainer = Color(0xFF00531B)
@@ -47,6 +46,11 @@ object ColorsDark : ComposeColorPaletteDark() {
     override val onSurface = Color(0xFFE2E3DD)
     override val surfaceVariant = Color(0xFF203022)
     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 outlineVariant = Color(0xFF424940)
     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 surfaceVariant = Color(0xFFE0E4DD)
     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 outlineVariant = Color(0xFFC4C8C1)
     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
 
 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
 
 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"
+    xmlns:aapt="http://schemas.android.com/aapt"
     android:width="108dp"
     android:height="108dp"
     android:viewportWidth="1500"
@@ -6,48 +7,110 @@
     <group
         android:translateX="238"
         android:translateY="238">
-        <!--    <group>-->
-        <!-- background color of icon -->
         <path
-            android:fillColor="#FFF"
+            android:fillColor="#FAFAFA"
             android:fillType="evenOdd"
             android:pathData="M0,0h1024v1024h-1024z"
+            android:strokeWidth="1"
             android:strokeColor="#00000000" />
-        <!-- sky color -->
         <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" />
-        <!-- 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
-            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
-            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>
 </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"?><!--
-  ~ Copyright (c) 2019-2024 Threema GmbH
+  ~ Copyright (c) 2019-2025 Threema GmbH
   ~ All rights reserved.
   -->
-<!-- TODO: move to build.gradle -->
 <resources>
     <string name="google_app_id" translatable="false">1:480681303521:android:6ec12987090e0e4f9fc6a0</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"?>
 <resources>
     <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>

+ 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
 
 object ColorsDark : ComposeColorPaletteDark() {
-
     override val primary = Color(0xFF34C955)
     override val onPrimary = Color(0xFF003910)
     override val primaryContainer = Color(0xFF00531B)
@@ -47,6 +46,11 @@ object ColorsDark : ComposeColorPaletteDark() {
     override val onSurface = Color(0xFFE2E3DD)
     override val surfaceVariant = Color(0xFF203022)
     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 outlineVariant = Color(0xFF424940)
     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 surfaceVariant = Color(0xFFE0E4DD)
     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 outlineVariant = Color(0xFFC4C8C1)
     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
 
 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
 
 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")
 
 object HmsTokenUtil {
-
     const val TOKEN_SCOPE = "HCM"
 
     private const val APP_ID_CONFIG_FIELD = "client/app_id"
@@ -56,10 +55,7 @@ object HmsTokenUtil {
                 .getString(APP_ID_CONFIG_FIELD)
                 ?: appIdHardcoded
         } 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
         }
     }

+ 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
 
 object ColorsDark : ComposeColorPaletteDark() {
-
     override val primary = Color(0xFFA4C8FF)
     override val onPrimary = Color(0xFF00315E)
     override val primaryContainer = Color(0xFF004784)
@@ -47,6 +46,11 @@ object ColorsDark : ComposeColorPaletteDark() {
     override val onSurface = Color(0xFFE3E2E6)
     override val surfaceVariant = Color(0xFF282E35)
     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 outlineVariant = Color(0xFF43474E)
     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 surfaceVariant = Color(0xFFE2EBF6)
     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 outlineVariant = Color(0xFFC3C6CF)
     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
 
 val CustomColorDark = CustomColor(
-    messageBubbleContainerReceive = Color(0xFF666666)
+    messageBubbleContainerReceive = Color(0xFF666666),
 )

Некоторые файлы не были показаны из-за большого количества измененных файлов