Threema il y a 1 an
Parent
commit
66da899e56
100 fichiers modifiés avec 1848 ajouts et 787 suppressions
  1. 7 7
      README.md
  2. 2 2
      app/assets/license.html
  3. 162 111
      app/build.gradle
  4. 2 2
      app/jni/Application.mk
  5. 1 2
      app/proguard-project.txt
  6. 54 3
      app/src/androidTest/java/ch/threema/app/PermissionRuleUtils.kt
  7. 0 2
      app/src/androidTest/java/ch/threema/app/ThreemaTestRunner.java
  8. 41 250
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupControlTest.kt
  9. 18 15
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt
  10. 12 8
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt
  11. 27 23
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt
  12. 32 31
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSyncRequestTest.kt
  13. 23 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupTextTest.kt
  14. 273 0
      app/src/androidTest/java/ch/threema/app/processors/IncomingMessageProcessorTest.kt
  15. 0 74
      app/src/androidTest/java/ch/threema/app/processors/MessageAckProcessorTest.java
  16. 516 0
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt
  17. 0 240
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorTest.java
  18. 6 1
      app/src/androidTest/java/ch/threema/app/service/GroupInviteServiceTest.java
  19. 57 0
      app/src/androidTest/java/ch/threema/app/services/systemupdate/SystemUpdateHelpersTest.kt
  20. 249 0
      app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt
  21. 169 0
      app/src/androidTest/java/ch/threema/app/utils/BackgroundExecutorTest.kt
  22. 3 4
      app/src/androidTest/java/ch/threema/app/utils/LinkifyUtilTest.kt
  23. 52 12
      app/src/androidTest/java/ch/threema/storage/SQLDHSessionStoreTest.java
  24. 101 0
      app/src/androidTest/java/ch/threema/storage/TaskArchiveFactoryTest.kt
  25. 0 0
      app/src/blue/AndroidManifest.xml
  26. 0 0
      app/src/blue/ic_launcher-web.png
  27. 0 0
      app/src/blue/java/ch/threema/app/activities/DownloadApkActivity.java
  28. 0 0
      app/src/blue/java/ch/threema/app/utils/DownloadUtil.java
  29. 0 0
      app/src/blue/res/drawable-hdpi/ic_notification_multi.png
  30. 0 0
      app/src/blue/res/drawable-hdpi/ic_notification_small.png
  31. 0 0
      app/src/blue/res/drawable-hdpi/logo_main_white.png
  32. 0 0
      app/src/blue/res/drawable-mdpi/ic_notification_multi.png
  33. 0 0
      app/src/blue/res/drawable-mdpi/ic_notification_small.png
  34. 0 0
      app/src/blue/res/drawable-mdpi/logo_main_white.png
  35. 41 0
      app/src/blue/res/drawable-v24/ic_launcher_foreground.xml
  36. 0 0
      app/src/blue/res/drawable-v24/ic_launcher_monochrome.xml
  37. 0 0
      app/src/blue/res/drawable-xhdpi/anim_01_40ms.png
  38. 0 0
      app/src/blue/res/drawable-xhdpi/anim_02_40ms.png
  39. 0 0
      app/src/blue/res/drawable-xhdpi/anim_03_40ms.png
  40. 0 0
      app/src/blue/res/drawable-xhdpi/anim_04_40ms.png
  41. 0 0
      app/src/blue/res/drawable-xhdpi/anim_05_40ms.png
  42. 0 0
      app/src/blue/res/drawable-xhdpi/anim_06_40ms.png
  43. 0 0
      app/src/blue/res/drawable-xhdpi/anim_07_40ms.png
  44. 0 0
      app/src/blue/res/drawable-xhdpi/anim_08_40ms.png
  45. 0 0
      app/src/blue/res/drawable-xhdpi/anim_09_40ms.png
  46. 0 0
      app/src/blue/res/drawable-xhdpi/anim_10_40ms.png
  47. 0 0
      app/src/blue/res/drawable-xhdpi/anim_11_40ms.png
  48. 0 0
      app/src/blue/res/drawable-xhdpi/anim_12_40ms.png
  49. 0 0
      app/src/blue/res/drawable-xhdpi/anim_13_40ms.png
  50. 0 0
      app/src/blue/res/drawable-xhdpi/anim_14_40ms.png
  51. 0 0
      app/src/blue/res/drawable-xhdpi/anim_15_40ms.png
  52. 0 0
      app/src/blue/res/drawable-xhdpi/anim_16_40ms.png
  53. 0 0
      app/src/blue/res/drawable-xhdpi/anim_17_40ms.png
  54. 0 0
      app/src/blue/res/drawable-xhdpi/anim_18_40ms.png
  55. 0 0
      app/src/blue/res/drawable-xhdpi/anim_19_40ms.png
  56. 0 0
      app/src/blue/res/drawable-xhdpi/anim_20_40ms.png
  57. 0 0
      app/src/blue/res/drawable-xhdpi/anim_21_40ms.png
  58. 0 0
      app/src/blue/res/drawable-xhdpi/anim_22_40ms.png
  59. 0 0
      app/src/blue/res/drawable-xhdpi/anim_23_40ms.png
  60. 0 0
      app/src/blue/res/drawable-xhdpi/anim_24_40ms.png
  61. 0 0
      app/src/blue/res/drawable-xhdpi/anim_25_40ms.png
  62. 0 0
      app/src/blue/res/drawable-xhdpi/anim_26_40ms.png
  63. 0 0
      app/src/blue/res/drawable-xhdpi/anim_27_40ms.png
  64. 0 0
      app/src/blue/res/drawable-xhdpi/anim_28_40ms.png
  65. 0 0
      app/src/blue/res/drawable-xhdpi/anim_29_40ms.png
  66. 0 0
      app/src/blue/res/drawable-xhdpi/anim_30_40ms.png
  67. 0 0
      app/src/blue/res/drawable-xhdpi/anim_31_40ms.png
  68. 0 0
      app/src/blue/res/drawable-xhdpi/anim_32_400ms.png
  69. 0 0
      app/src/blue/res/drawable-xhdpi/anim_33_40ms.png
  70. 0 0
      app/src/blue/res/drawable-xhdpi/anim_34_40ms.png
  71. 0 0
      app/src/blue/res/drawable-xhdpi/anim_35_40ms.png
  72. 0 0
      app/src/blue/res/drawable-xhdpi/anim_36_40ms.png
  73. 0 0
      app/src/blue/res/drawable-xhdpi/anim_37_40ms.png
  74. 0 0
      app/src/blue/res/drawable-xhdpi/anim_38_40ms.png
  75. 0 0
      app/src/blue/res/drawable-xhdpi/anim_39_40ms.png
  76. 0 0
      app/src/blue/res/drawable-xhdpi/anim_40_40ms.png
  77. 0 0
      app/src/blue/res/drawable-xhdpi/anim_41_40ms.png
  78. 0 0
      app/src/blue/res/drawable-xhdpi/anim_42_40ms.png
  79. 0 0
      app/src/blue/res/drawable-xhdpi/anim_43_40ms.png
  80. 0 0
      app/src/blue/res/drawable-xhdpi/anim_44_40ms.png
  81. 0 0
      app/src/blue/res/drawable-xhdpi/anim_45_40ms.png
  82. 0 0
      app/src/blue/res/drawable-xhdpi/anim_46_40ms.png
  83. 0 0
      app/src/blue/res/drawable-xhdpi/anim_47_40ms.png
  84. 0 0
      app/src/blue/res/drawable-xhdpi/anim_48_40ms.png
  85. 0 0
      app/src/blue/res/drawable-xhdpi/anim_49_40ms.png
  86. 0 0
      app/src/blue/res/drawable-xhdpi/anim_50_40ms.png
  87. 0 0
      app/src/blue/res/drawable-xhdpi/anim_51_40ms.png
  88. 0 0
      app/src/blue/res/drawable-xhdpi/anim_52_40ms.png
  89. 0 0
      app/src/blue/res/drawable-xhdpi/anim_53_40ms.png
  90. 0 0
      app/src/blue/res/drawable-xhdpi/anim_54_40ms.png
  91. 0 0
      app/src/blue/res/drawable-xhdpi/anim_55_40ms.png
  92. 0 0
      app/src/blue/res/drawable-xhdpi/anim_56_40ms.png
  93. 0 0
      app/src/blue/res/drawable-xhdpi/anim_70_40ms.png
  94. 0 0
      app/src/blue/res/drawable-xhdpi/anim_71_40ms.png
  95. 0 0
      app/src/blue/res/drawable-xhdpi/anim_72_1200ms.png
  96. 0 0
      app/src/blue/res/drawable-xhdpi/anim_73_40ms.png
  97. 0 0
      app/src/blue/res/drawable-xhdpi/anim_74_40ms.png
  98. 0 0
      app/src/blue/res/drawable-xhdpi/anim_75_40ms.png
  99. 0 0
      app/src/blue/res/drawable-xhdpi/anim_76_40ms.png
  100. 0 0
      app/src/blue/res/drawable-xhdpi/anim_77_40ms.png

+ 7 - 7
README.md

@@ -142,12 +142,12 @@ Threema OnPrem customers:
 
 
 The following variants are only used for development and testing within Threema:
 The following variants are only used for development and testing within Threema:
 
 
-| Flavor               | Description                                    | License Checks |
-| -------------------- | ---------------------------------------------- | -------------- |
-| `none`               | Used for development                           | Allowlist      |
-| `sandbox`            | Uses sandbox test environment¹                 | Allowlist      |
-| `sandbox_work`       | Uses sandbox test environment¹                 | Threema Work   |
-| `red`                | Uses sandbox test environment¹                 | Threema Work   |
+| Flavor         | Description                                    | License Checks |
+|----------------| ---------------------------------------------- | -------------- |
+| `none`         | Used for development                           | Allowlist      |
+| `green`        | Uses sandbox test environment¹                 | Allowlist      |
+| `sandbox_work` | Uses sandbox test environment¹                 | Threema Work   |
+| `blue`         | Uses sandbox test environment¹                 | Threema Work   |
 
 
 ¹ *The “sandbox” is a backend test environment that is used for internal testing
 ¹ *The “sandbox” is a backend test environment that is used for internal testing
   at Threema. The sandbox backend can currently not be accessed from the public
   at Threema. The sandbox backend can currently not be accessed from the public
@@ -251,7 +251,7 @@ language, please sign up at <https://threema.oneskyapp.com/collaboration/>.
 
 
 Threema for Android is licensed under the GNU Affero General Public License v3.
 Threema for Android is licensed under the GNU Affero General Public License v3.
 
 
-    Copyright (c) 2013-2023 Threema GmbH
+    Copyright (c) 2013-2024 Threema GmbH
 
 
     This program is free software: you can redistribute it and/or modify
     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,
     it under the terms of the GNU Affero General Public License, version 3,

+ 2 - 2
app/assets/license.html

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

+ 162 - 111
app/build.gradle

@@ -1,10 +1,8 @@
-import com.android.tools.profgen.ArtProfileKt
-import com.android.tools.profgen.ArtProfileSerializer
-import com.android.tools.profgen.DexFile
 import org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs
 import org.jetbrains.kotlin.gradle.tasks.KaptGenerateStubs
 
 
 plugins {
 plugins {
     id 'org.sonarqube'
     id 'org.sonarqube'
+    id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
 }
 }
 
 
 apply plugin: 'com.android.application'
 apply plugin: 'com.android.application'
@@ -18,9 +16,16 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 }
 }
 
 
 // version codes
 // version codes
-def app_version = "5.2.4"
-def beta_suffix = "" // with leading dash
-def defaultVersionCode = 943
+
+// Only use the scheme "<major>.<minor>.<patch>" for the app_version
+def app_version = "5.3"
+
+// 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 = 955
 
 
 /**
 /**
  * Return the git hash, if git is installed.
  * Return the git hash, if git is installed.
@@ -80,7 +85,7 @@ def keystores = [
     release: findKeystore("threema"),
     release: findKeystore("threema"),
     hms_release: findKeystore("threema_hms"),
     hms_release: findKeystore("threema_hms"),
     onprem_release: findKeystore("onprem"),
     onprem_release: findKeystore("onprem"),
-    red_release: findKeystore("red"),
+    blue_release: findKeystore("red"),
 ]
 ]
 
 
 android {
 android {
@@ -89,7 +94,7 @@ android {
     //       `.gitlab-ci.yml` as well!
     //       `.gitlab-ci.yml` as well!
     compileSdk 34
     compileSdk 34
     buildToolsVersion = '34.0.0'
     buildToolsVersion = '34.0.0'
-    ndkVersion '26.0.10792818'
+    ndkVersion '25.2.9519653'
 
 
     defaultConfig {
     defaultConfig {
         // https://developer.android.com/training/testing/espresso/setup#analytics
         // https://developer.android.com/training/testing/espresso/setup#analytics
@@ -124,6 +129,7 @@ android {
         buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-apip.threema.ch/\""
         buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-apip.threema.ch/\""
         buildConfigField "String", "WORK_SERVER_URL", "null"
         buildConfigField "String", "WORK_SERVER_URL", "null"
         buildConfigField "String", "WORK_SERVER_IPV6_URL", "null"
         buildConfigField "String", "WORK_SERVER_IPV6_URL", "null"
+        buildConfigField "String", "MEDIATOR_SERVER_URL", "\"wss://mediator-{deviceGroupIdPrefix4}.threema.ch/{deviceGroupIdPrefix8}\""
         buildConfigField "String", "BLOB_SERVER_DOWNLOAD_URL", "\"https://blobp-{blobIdPrefix}.threema.ch/{blobId}\""
         buildConfigField "String", "BLOB_SERVER_DOWNLOAD_URL", "\"https://blobp-{blobIdPrefix}.threema.ch/{blobId}\""
         buildConfigField "String", "BLOB_SERVER_DOWNLOAD_IPV6_URL", "\"https://ds-blobp-{blobIdPrefix}.threema.ch/{blobId}\""
         buildConfigField "String", "BLOB_SERVER_DOWNLOAD_IPV6_URL", "\"https://ds-blobp-{blobIdPrefix}.threema.ch/{blobId}\""
         buildConfigField "String", "BLOB_SERVER_DONE_URL", "\"https://blobp-{blobIdPrefix}.threema.ch/{blobId}/done\""
         buildConfigField "String", "BLOB_SERVER_DONE_URL", "\"https://blobp-{blobIdPrefix}.threema.ch/{blobId}/done\""
@@ -131,8 +137,9 @@ android {
         buildConfigField "String", "BLOB_SERVER_UPLOAD_URL", "\"https://blobp-upload.threema.ch/upload\""
         buildConfigField "String", "BLOB_SERVER_UPLOAD_URL", "\"https://blobp-upload.threema.ch/upload\""
         buildConfigField "String", "BLOB_SERVER_UPLOAD_IPV6_URL", "\"https://ds-blobp-upload.threema.ch/upload\""
         buildConfigField "String", "BLOB_SERVER_UPLOAD_IPV6_URL", "\"https://ds-blobp-upload.threema.ch/upload\""
         buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.threema.ch/\""
         buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.threema.ch/\""
-        buildConfigField "String", "SAFE_SERVER_URL", "\"https://safe-%h.threema.ch/\""
+        buildConfigField "String", "SAFE_SERVER_URL", "\"https://safe-{backupIdPrefix8}.threema.ch/\""
         buildConfigField "String", "WEB_SERVER_URL", "\"https://web.threema.ch/\""
         buildConfigField "String", "WEB_SERVER_URL", "\"https://web.threema.ch/\""
+        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 "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", "ONPREM_ID_PREFIX", "\"O\""
         buildConfigField "String", "LOG_TAG", "\"3ma\""
         buildConfigField "String", "LOG_TAG", "\"3ma\""
@@ -140,7 +147,7 @@ android {
 
 
         buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "null"
         buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "null"
         buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "false"
         buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "false"
-        buildConfigField "boolean", "FORWARD_SECURITY", "true"
+        buildConfigField "boolean", "MD_ENABLED", "false"
 
 
         // config fields for action URLs / deep links
         // config fields for action URLs / deep links
         buildConfigField "String", "uriScheme", "\"threema\""
         buildConfigField "String", "uriScheme", "\"threema\""
@@ -157,10 +164,6 @@ android {
             callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.call",
             callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.call",
         ]
         ]
 
 
-        ndk {
-            abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
-        }
-
         testInstrumentationRunner 'ch.threema.app.ThreemaTestRunner'
         testInstrumentationRunner 'ch.threema.app.ThreemaTestRunner'
 
 
         // Only include language resources for those languages
         // Only include language resources for those languages
@@ -172,6 +175,7 @@ android {
             "de",
             "de",
             "es",
             "es",
             "fr",
             "fr",
+            "gsw",
             "hu",
             "hu",
             "it",
             "it",
             "ja",
             "ja",
@@ -195,7 +199,7 @@ android {
             reset()
             reset()
             include 'armeabi-v7a', 'x86', 'arm64-v8a', 'x86_64'
             include 'armeabi-v7a', 'x86', 'arm64-v8a', 'x86_64'
             exclude 'armeabi', 'mips', 'mips64'
             exclude 'armeabi', 'mips', 'mips64'
-            universalApk true
+            universalApk project.hasProperty("buildUniversalApk")
         }
         }
     }
     }
 
 
@@ -230,6 +234,7 @@ android {
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaWork\""
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaWork\""
             buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-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", "LOG_TAG", "\"3mawrk\""
             buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
             buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
 
 
@@ -243,20 +248,24 @@ android {
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.work.call",
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.work.call",
             ]
             ]
         }
         }
-        sandbox {
+        green {
+            // The app was previously named `sandbox`. The app id remains unchanged to still be able to install updates.
             applicationId "ch.threema.app.sandbox"
             applicationId "ch.threema.app.sandbox"
             testApplicationId 'ch.threema.app.sandbox.test'
             testApplicationId 'ch.threema.app.sandbox.test'
-            resValue "string", "app_name", "Threema Sandbox"
+            resValue "string", "app_name", "Threema Green"
             resValue "string", "package_name", applicationId
             resValue "string", "package_name", applicationId
             resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.sandbox.profile"
             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"
             resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.sandbox.call"
-            buildConfigField "String", "MEDIA_PATH", "\"ThreemaSandbox\""
+            buildConfigField "String", "MEDIA_PATH", "\"ThreemaGreen\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
             buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
             buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
             buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-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", "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"
         }
         }
         sandbox_work {
         sandbox_work {
             versionName "${app_version}k${beta_suffix}"
             versionName "${app_version}k${beta_suffix}"
@@ -277,14 +286,19 @@ android {
             buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-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_URL", "\"https://apip-work.test.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-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", "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", "LOG_TAG", "\"3mawrk\""
             buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
             buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
 
 
+
             // config fields for action URLs / deep links
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemawork\""
             buildConfigField "String", "uriScheme", "\"threemawork\""
             buildConfigField "String", "actionUrl", "\"work.test.threema.ch\""
             buildConfigField "String", "actionUrl", "\"work.test.threema.ch\""
 
 
+            buildConfigField "boolean", "MD_ENABLED", "true"
+
             manifestPlaceholders = [
             manifestPlaceholders = [
                 uriScheme       : "threemawork",
                 uriScheme       : "threemawork",
                 actionUrl       : "work.test.threema.ch",
                 actionUrl       : "work.test.threema.ch",
@@ -328,39 +342,79 @@ android {
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.onprem.call",
                 callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.onprem.call",
             ]
             ]
         }
         }
+        onprem_internal {
+            versionName "${app_version}o${beta_suffix}"
+            applicationId "ch.threema.app.onprem.internal"
+            testApplicationId 'ch.threema.app.onprem.internal.test'
+            resValue "string", "app_name", "Threema OnPrem Internal"
+            resValue "string", "package_name", applicationId
+            resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.onprem.internal.profile"
+            resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.onprem.internal.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", "\"ThreemaOnPremInt\""
+            buildConfigField "boolean", "CHAT_SERVER_GROUPS", "false"
 
 
-        red { // Essentially like sandbox work, but with a different icon and accent color, used for internal testing
+            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_DOWNLOAD_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_DOWNLOAD_IPV6_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_DONE_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_DONE_IPV6_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_UPLOAD_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_UPLOAD_IPV6_URL", "null"
+            buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "new String[] {\"ek1qBp4DyRmLL9J5sCmsKSfwbsiGNB4veDAODjkwe/k=\", \"Hrk8aCjwKkXySubI7CZ3y9Sx+oToEHjNkGw98WSRneU=\", \"5pEn1T/5bhecNWrp9NgUQweRfgVtu/I8gRb3VxGP7k4=\"}"
+            buildConfigField "String", "LOG_TAG", "\"3maoi\""
+
+            // config fields for action URLs / deep links
+            buildConfigField "String", "uriScheme", "\"threemaonprem\""
+            buildConfigField "String", "actionUrl", "\"onprem.threema.ch\""
+
+            manifestPlaceholders = [
+                uriScheme: "threemaonprem",
+                actionUrl: "onprem.threema.ch",
+                callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.onprem.internal.call",
+            ]
+        }
+        blue { // Essentially like sandbox work, but with a different icon and accent color, used for internal testing
             versionName "${app_version}r${beta_suffix}"
             versionName "${app_version}r${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"
             applicationId "ch.threema.app.red"
-            testApplicationId 'ch.threema.app.red.test'
-            resValue "string", "app_name", "Threema Red"
+            testApplicationId 'ch.threema.app.blue.test'
+            resValue "string", "app_name", "Threema Blue"
             resValue "string", "package_name", applicationId
             resValue "string", "package_name", applicationId
-            resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.red.profile"
-            resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.red.call"
+            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_PREFIX", "\"w-\""
             buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
             buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
-            buildConfigField "String", "MEDIA_PATH", "\"ThreemaRed\""
+            buildConfigField "String", "MEDIA_PATH", "\"ThreemaBlue\""
             buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
             buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
             buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-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_URL", "\"https://apip-work.test.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-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", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
-            buildConfigField "String", "LOG_TAG", "\"3mared\""
+            buildConfigField "String", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android-work/{rating}\""
+            buildConfigField "String", "LOG_TAG", "\"3mablue\""
 
 
             buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "true"
             buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "true"
 
 
             // config fields for action URLs / deep links
             // config fields for action URLs / deep links
-            buildConfigField "String", "uriScheme", "\"threemared\""
-            buildConfigField "String", "actionUrl", "\"red.threema.ch\""
+            buildConfigField "String", "uriScheme", "\"threemablue\""
+            buildConfigField "String", "actionUrl", "\"blue.threema.ch\""
 
 
             manifestPlaceholders = [
             manifestPlaceholders = [
-                uriScheme: "threemared",
-                actionUrl: "red.threema.ch",
-                callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.red.call",
+                uriScheme: "threemablue",
+                actionUrl: "blue.threema.ch",
+                callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.blue.call",
             ]
             ]
         }
         }
         hms {
         hms {
@@ -379,6 +433,7 @@ android {
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaWork\""
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaWork\""
             buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-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", "LOG_TAG", "\"3mawrk\""
             buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
             buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
 
 
@@ -396,6 +451,7 @@ android {
             versionName "${app_version}l${beta_suffix}"
             versionName "${app_version}l${beta_suffix}"
             applicationId "ch.threema.app.libre"
             applicationId "ch.threema.app.libre"
             testApplicationId 'ch.threema.app.libre.test'
             testApplicationId 'ch.threema.app.libre.test'
+            resValue "string", "package_name",  applicationId
             resValue "string", "app_name", "Threema Libre"
             resValue "string", "app_name", "Threema Libre"
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaLibre\""
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaLibre\""
         }
         }
@@ -447,16 +503,16 @@ android {
             logger.warn("No onprem keystore found. Falling back to locally generated keystore.")
             logger.warn("No onprem keystore found. Falling back to locally generated keystore.")
         }
         }
 
 
-        // Red release config
-        if (keystores.red_release != null) {
-            red_release {
-                storeFile file(keystores.red_release.storeFile)
-                storePassword keystores.red_release.storePassword
-                keyAlias keystores.red_release.keyAlias
-                keyPassword keystores.red_release.keyPassword
+        // 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 {
         } else {
-            logger.warn("No red keystore found. Falling back to locally generated keystore.")
+            logger.warn("No blue keystore found. Falling back to locally generated keystore.")
         }
         }
 
 
         // Note: Libre release is signed with HSM, no config here
         // Note: Libre release is signed with HSM, no config here
@@ -488,7 +544,10 @@ android {
         onprem {
         onprem {
             java.srcDir 'src/google_services_based/java'
             java.srcDir 'src/google_services_based/java'
         }
         }
-        sandbox {
+        onprem_internal {
+            java.srcDir 'src/google_services_based/java'
+        }
+        green {
             java.srcDir 'src/google_services_based/java'
             java.srcDir 'src/google_services_based/java'
             manifest.srcFile 'src/store_google/AndroidManifest.xml'
             manifest.srcFile 'src/store_google/AndroidManifest.xml'
         }
         }
@@ -497,9 +556,9 @@ android {
             res.srcDir 'src/store_google_work/res'
             res.srcDir 'src/store_google_work/res'
             manifest.srcFile 'src/store_google_work/AndroidManifest.xml'
             manifest.srcFile 'src/store_google_work/AndroidManifest.xml'
         }
         }
-        red {
+        blue {
             java.srcDir 'src/google_services_based/java'
             java.srcDir 'src/google_services_based/java'
-            res.srcDir 'src/red/res'
+            res.srcDir 'src/blue/res'
         }
         }
 
 
         // Based on Huawei services
         // Based on Huawei services
@@ -522,8 +581,6 @@ android {
         debug {
         debug {
             debuggable true
             debuggable true
             jniDebuggable false
             jniDebuggable false
-            multiDexEnabled true
-            multiDexKeepProguard file('multidex-keep.pro')
             testCoverageEnabled false
             testCoverageEnabled false
             ndk {
             ndk {
                 debugSymbolLevel 'FULL'
                 debugSymbolLevel 'FULL'
@@ -538,9 +595,6 @@ android {
             jniDebuggable false
             jniDebuggable false
             minifyEnabled true
             minifyEnabled true
             shrinkResources false // Caused inconsistencies between local and CI builds
             shrinkResources false // Caused inconsistencies between local and CI builds
-            zipAlignEnabled true
-            multiDexEnabled true
-            multiDexKeepProguard file('multidex-keep.pro')
             proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-project.txt'
             proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-project.txt'
             ndk {
             ndk {
                 debugSymbolLevel 'FULL' // 'SYMBOL_TABLE'
                 debugSymbolLevel 'FULL' // 'SYMBOL_TABLE'
@@ -550,7 +604,7 @@ android {
                 productFlavors.store_google.signingConfig signingConfigs.release
                 productFlavors.store_google.signingConfig signingConfigs.release
                 productFlavors.store_google_work.signingConfig signingConfigs.release
                 productFlavors.store_google_work.signingConfig signingConfigs.release
                 productFlavors.store_threema.signingConfig signingConfigs.release
                 productFlavors.store_threema.signingConfig signingConfigs.release
-                productFlavors.sandbox.signingConfig signingConfigs.release
+                productFlavors.green.signingConfig signingConfigs.release
                 productFlavors.sandbox_work.signingConfig signingConfigs.release
                 productFlavors.sandbox_work.signingConfig signingConfigs.release
                 productFlavors.none.signingConfig signingConfigs.release
                 productFlavors.none.signingConfig signingConfigs.release
             }
             }
@@ -564,8 +618,8 @@ android {
                 productFlavors.onprem.signingConfig signingConfigs.onprem_release
                 productFlavors.onprem.signingConfig signingConfigs.onprem_release
             }
             }
 
 
-            if (keystores['red_release'] != null) {
-                productFlavors.red.signingConfig signingConfigs.red_release
+            if (keystores['blue_release'] != null) {
+                productFlavors.blue.signingConfig signingConfigs.blue_release
             }
             }
 
 
             // Note: Libre release is signed with HSM, no config here
             // Note: Libre release is signed with HSM, no config here
@@ -578,7 +632,7 @@ android {
 
 
         if (
         if (
             variant.buildType.name == "release" && (
             variant.buildType.name == "release" && (
-                names.contains("sandbox") || names.contains("sandbox_work")
+                names.contains("green") || names.contains("sandbox_work")
             )
             )
         ) {
         ) {
             setIgnore(true)
             setIgnore(true)
@@ -593,13 +647,24 @@ android {
 
 
     packagingOptions {
     packagingOptions {
         jniLibs {
         jniLibs {
-            // fix https://stackoverflow.com/questions/42739916/aarch64-linux-android-strip-file-missing
-            keepDebugSymbols += ['*/mips/*.so', '*/mips64/*.so', '*/armeabi/*.so']
             // replacement for extractNativeLibs in AndroidManifest
             // replacement for extractNativeLibs in AndroidManifest
-            useLegacyPackaging = true
+            useLegacyPackaging = false
         }
         }
         resources {
         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']
+            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'
+            ]
         }
         }
     }
     }
 
 
@@ -648,33 +713,6 @@ android {
         noCompress 'png'
         noCompress 'png'
     }
     }
 
 
-    // Fix non-producible `baseline.profm` in release builds due to unstable ordering.
-    // See https://issuetracker.google.com/issues/231837768
-    project.afterEvaluate {
-        applicationVariants.all { variant ->
-            if (variant.name.endsWith("Release")) {
-                tasks["compile${variant.name.capitalize()}ArtProfile"].doLast {
-                    outputs.files.each { file ->
-                        if (file.toString().endsWith(".profm")) {
-                            println("Sorting ${file} ...")
-                            def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
-                            def profile = ArtProfileKt.ArtProfile(file)
-                            def keys = new ArrayList(profile.profileData.keySet())
-                            def sortedData = new LinkedHashMap()
-                            Collections.sort keys, new DexFile.Companion()
-                            keys.each { key -> sortedData[key] = profile.profileData[key] }
-                            new FileOutputStream(file).with {
-                                write(version.magicBytes$profgen)
-                                write(version.versionBytes$profgen)
-                                version.write$profgen(it, sortedData, "")
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    }
-
     lint {
     lint {
         // if true, stop the gradle build if errors are found
         // if true, stop the gradle build if errors are found
         abortOnError true
         abortOnError true
@@ -723,7 +761,6 @@ dependencies {
     coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
     coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
 
 
     implementation project(':domain')
     implementation project(':domain')
-    implementation project(path: ':task-manager')
 
 
     implementation 'net.zetetic:sqlcipher-android:4.5.5@aar'
     implementation 'net.zetetic:sqlcipher-android:4.5.5@aar'
 
 
@@ -751,32 +788,32 @@ dependencies {
     implementation 'androidx.appcompat:appcompat:1.6.1'
     implementation 'androidx.appcompat:appcompat:1.6.1'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
     implementation 'androidx.biometric:biometric:1.1.0'
     implementation 'androidx.biometric:biometric:1.1.0'
-    implementation 'androidx.work:work-runtime-ktx:2.8.1'
+    implementation 'androidx.work:work-runtime-ktx:2.9.0'
     implementation 'androidx.fragment:fragment-ktx:1.6.2'
     implementation 'androidx.fragment:fragment-ktx:1.6.2'
     implementation 'androidx.activity:activity-ktx:1.8.2'
     implementation 'androidx.activity:activity-ktx:1.8.2'
     implementation 'androidx.sqlite:sqlite:2.2.2'
     implementation 'androidx.sqlite:sqlite:2.2.2'
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
-    implementation "androidx.camera:camera-camera2:1.3.1"
-    implementation "androidx.camera:camera-lifecycle:1.3.1"
-    implementation "androidx.camera:camera-view:1.3.1"
-    implementation 'androidx.camera:camera-video:1.3.1'
+    implementation "androidx.camera:camera-camera2:1.3.2"
+    implementation "androidx.camera:camera-lifecycle:1.3.2"
+    implementation "androidx.camera:camera-view:1.3.2"
+    implementation 'androidx.camera:camera-video:1.3.2'
     implementation "androidx.media:media:1.7.0"
     implementation "androidx.media:media:1.7.0"
-    implementation 'androidx.media3:media3-exoplayer:1.2.1'
-    implementation 'androidx.media3:media3-ui:1.2.1'
-    implementation "androidx.media3:media3-session:1.2.1"
-    implementation 'androidx.multidex:multidex:2.0.1'
-    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2"
-    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.2"
-    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
-    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2"
-    implementation "androidx.lifecycle:lifecycle-service:2.6.2"
-    implementation "androidx.lifecycle:lifecycle-process:2.6.2"
-    implementation "androidx.lifecycle:lifecycle-common-java8:2.6.2"
+    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.7.0"
+    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0"
+    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0"
+    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0"
+    implementation "androidx.lifecycle:lifecycle-service:2.7.0"
+    implementation "androidx.lifecycle:lifecycle-process:2.7.0"
+    implementation "androidx.lifecycle:lifecycle-common-java8:2.7.0"
     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
     implementation "androidx.paging:paging-runtime-ktx:3.2.1"
     implementation "androidx.paging:paging-runtime-ktx:3.2.1"
     implementation "androidx.sharetarget:sharetarget:1.2.0"
     implementation "androidx.sharetarget:sharetarget:1.2.0"
-    implementation 'androidx.room:room-runtime:2.5.2'
-    kapt 'androidx.room:room-compiler:2.5.2'
+    implementation 'androidx.room:room-runtime:2.6.1'
+    implementation 'androidx.window:window:1.2.0'
+    kapt 'androidx.room:room-compiler:2.6.1'
 
 
     implementation 'com.google.android.material:material:1.10.0' // last version before switch to tonal system: https://github.com/material-components/material-components-android/releases/tag/1.11.0
     implementation 'com.google.android.material:material:1.10.0' // last version before switch to tonal system: https://github.com/material-components/material-components-android/releases/tag/1.11.0
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on API < 24
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on API < 24
@@ -795,7 +832,7 @@ dependencies {
     }
     }
 
 
     implementation 'org.saltyrtc:chunked-dc:1.0.1'
     implementation 'org.saltyrtc:chunked-dc:1.0.1'
-    implementation 'ch.threema:webrtc-android:120.0.0'
+    implementation 'ch.threema:webrtc-android:123.0.0'
     implementation('org.saltyrtc:saltyrtc-task-webrtc:0.18.1') {
     implementation('org.saltyrtc:saltyrtc-task-webrtc:0.18.1') {
         exclude module: 'saltyrtc-client'
         exclude module: 'saltyrtc-client'
     }
     }
@@ -806,13 +843,23 @@ dependencies {
     kapt 'com.github.bumptech.glide:compiler:4.16.0'
     kapt 'com.github.bumptech.glide:compiler:4.16.0'
     annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
     annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
 
 
-    // kotlin
+    // Kotlin
     implementation 'androidx.core:core-ktx:1.10.1'
     implementation 'androidx.core:core-ktx:1.10.1'
     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
     implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
     implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
-
-    // use leak canary in debug builds
-//    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
+    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 dev builds
+    if (!project.hasProperty("noLeakCanary")) {
+        def leakCanaryDependency = 'com.squareup.leakcanary:leakcanary-android:2.13'
+        blueImplementation(leakCanaryDependency)
+        greenImplementation(leakCanaryDependency)
+        sandbox_workImplementation(leakCanaryDependency)
+        // Uncomment the following line to use leak canary in *any* debug build
+        // debugImplementation(leakCanaryDependency)
+    }
 
 
     // test dependencies
     // test dependencies
     testImplementation "junit:junit:$junit_version"
     testImplementation "junit:junit:$junit_version"
@@ -857,6 +904,7 @@ dependencies {
     androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
     androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
     androidTestImplementation 'androidx.test:core-ktx:1.5.0'
     androidTestImplementation 'androidx.test:core-ktx:1.5.0'
     androidTestImplementation "org.mockito:mockito-core:4.8.1"
     androidTestImplementation "org.mockito:mockito-core:4.8.1"
+    androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlin_coroutines_version"
 
 
     // Google Play Services and related libraries
     // Google Play Services and related libraries
     def googleDependencies = [
     def googleDependencies = [
@@ -878,9 +926,10 @@ dependencies {
         store_google_workImplementation(dependency) { excludes.each { exclude it } }
         store_google_workImplementation(dependency) { excludes.each { exclude it } }
         store_threemaImplementation(dependency) { excludes.each { exclude it } }
         store_threemaImplementation(dependency) { excludes.each { exclude it } }
         onpremImplementation(dependency) { excludes.each { exclude it } }
         onpremImplementation(dependency) { excludes.each { exclude it } }
-        sandboxImplementation(dependency) { excludes.each { exclude it } }
+        onprem_internalImplementation(dependency) { excludes.each { exclude it } }
+        greenImplementation(dependency) { excludes.each { exclude it } }
         sandbox_workImplementation(dependency) { excludes.each { exclude it } }
         sandbox_workImplementation(dependency) { excludes.each { exclude it } }
-        redImplementation(dependency) { excludes.each { exclude it } }
+        blueImplementation(dependency) { excludes.each { exclude it } }
     }
     }
 
 
     // Google Assistant Voice Action verification library
     // Google Assistant Voice Action verification library
@@ -888,10 +937,11 @@ dependencies {
     store_googleImplementation(name: 'libgsaverification-client', ext: 'aar')
     store_googleImplementation(name: 'libgsaverification-client', ext: 'aar')
     store_google_workImplementation(name: 'libgsaverification-client', ext: 'aar')
     store_google_workImplementation(name: 'libgsaverification-client', ext: 'aar')
     onpremImplementation(name: 'libgsaverification-client', ext: 'aar')
     onpremImplementation(name: 'libgsaverification-client', ext: 'aar')
+    onprem_internalImplementation(name: 'libgsaverification-client', ext: 'aar')
     store_threemaImplementation(name: 'libgsaverification-client', ext: 'aar')
     store_threemaImplementation(name: 'libgsaverification-client', ext: 'aar')
-    sandboxImplementation(name: 'libgsaverification-client', ext: 'aar')
+    greenImplementation(name: 'libgsaverification-client', ext: 'aar')
     sandbox_workImplementation(name: 'libgsaverification-client', ext: 'aar')
     sandbox_workImplementation(name: 'libgsaverification-client', ext: 'aar')
-    redImplementation(name: 'libgsaverification-client', ext: 'aar')
+    blueImplementation(name: 'libgsaverification-client', ext: 'aar')
 
 
     // Maplibre (may have transitive dependencies on Google location services)
     // Maplibre (may have transitive dependencies on Google location services)
     def maplibreDependency = 'org.maplibre.gl:android-sdk:10.3.0'
     def maplibreDependency = 'org.maplibre.gl:android-sdk:10.3.0'
@@ -901,9 +951,10 @@ dependencies {
     store_threemaImplementation maplibreDependency
     store_threemaImplementation maplibreDependency
     libreImplementation maplibreDependency, { exclude group: 'com.google.android.gms' }
     libreImplementation maplibreDependency, { exclude group: 'com.google.android.gms' }
     onpremImplementation maplibreDependency
     onpremImplementation maplibreDependency
-    sandboxImplementation maplibreDependency
+    onprem_internalImplementation maplibreDependency
+    greenImplementation maplibreDependency
     sandbox_workImplementation maplibreDependency
     sandbox_workImplementation maplibreDependency
-    redImplementation maplibreDependency
+    blueImplementation maplibreDependency
     hmsImplementation maplibreDependency
     hmsImplementation maplibreDependency
     hms_workImplementation maplibreDependency
     hms_workImplementation maplibreDependency
 
 

+ 2 - 2
app/jni/Application.mk

@@ -1,4 +1,4 @@
 APP_ABI := armeabi-v7a x86 arm64-v8a x86_64
 APP_ABI := armeabi-v7a x86 arm64-v8a x86_64
 APP_PLATFORM := android-21
 APP_PLATFORM := android-21
-APP_DEBUG := false
-APP_OPTIM := release
+APP_CPPFLAGS += -fexceptions
+APP_OPTIM := debug

+ 1 - 2
app/proguard-project.txt

@@ -118,8 +118,6 @@ public static <fields>;
 # "Warning: org.webrtc.SoftwareVideoDecoderFactory: can't find referenced class org.webrtc.LibvpxVp8Decoder"
 # "Warning: org.webrtc.SoftwareVideoDecoderFactory: can't find referenced class org.webrtc.LibvpxVp8Decoder"
 -dontwarn org.webrtc.**
 -dontwarn org.webrtc.**
 
 
-
-
 # hms requirements
 # hms requirements
 -ignorewarnings
 -ignorewarnings
 -keep class com.huawei.updatesdk.**{*;}
 -keep class com.huawei.updatesdk.**{*;}
@@ -230,3 +228,4 @@ public static <fields>;
 -dontwarn org.conscrypt.**
 -dontwarn org.conscrypt.**
 -dontwarn org.bouncycastle.**
 -dontwarn org.bouncycastle.**
 -dontwarn org.openjsse.**
 -dontwarn org.openjsse.**
+

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

@@ -21,6 +21,7 @@
 
 
 package ch.threema.app
 package ch.threema.app
 
 
+import android.Manifest
 import android.os.Build
 import android.os.Build
 import androidx.test.rule.GrantPermissionRule
 import androidx.test.rule.GrantPermissionRule
 
 
@@ -30,7 +31,7 @@ import androidx.test.rule.GrantPermissionRule
  */
  */
 fun getNotificationPermissionRule(): GrantPermissionRule {
 fun getNotificationPermissionRule(): GrantPermissionRule {
     return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
     return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-        GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS)
+        GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
     } else {
     } else {
         GrantPermissionRule.grant()
         GrantPermissionRule.grant()
     }
     }
@@ -46,8 +47,58 @@ fun getReadWriteExternalStoragePermissionRule(): GrantPermissionRule {
         GrantPermissionRule.grant()
         GrantPermissionRule.grant()
     } else {
     } else {
         GrantPermissionRule.grant(
         GrantPermissionRule.grant(
-            android.Manifest.permission.READ_EXTERNAL_STORAGE,
-            android.Manifest.permission.WRITE_EXTERNAL_STORAGE
+            Manifest.permission.READ_EXTERNAL_STORAGE,
+            Manifest.permission.WRITE_EXTERNAL_STORAGE
         )
         )
     }
     }
 }
 }
+
+/**
+ * Get the permission to read images and videos from android 13, and read/write external storage on
+ * older android versions.
+ */
+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
+        )
+    } else {
+        GrantPermissionRule.grant(
+            Manifest.permission.READ_EXTERNAL_STORAGE,
+            Manifest.permission.WRITE_EXTERNAL_STORAGE
+        )
+    }
+}
+
+/**
+ * Get the microphone permission rule.
+ */
+fun getMicrophonePermissionRule(): GrantPermissionRule =
+    GrantPermissionRule.grant(Manifest.permission.RECORD_AUDIO)
+
+/**
+ * Get [Manifest.permission.BLUETOOTH] or [Manifest.permission.BLUETOOTH_CONNECT] permission rule
+ * depending on the android version.
+ */
+fun getBluetoothPermissionRule(): GrantPermissionRule {
+    return GrantPermissionRule.grant(
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            Manifest.permission.BLUETOOTH_CONNECT
+        } else {
+            Manifest.permission.BLUETOOTH
+        }
+    )
+}
+
+/**
+ * Get the [Manifest.permission.READ_PHONE_STATE] permission rule on android 12 or higher and an
+ * empty permission rule on older versions.
+ */
+fun getReadPhoneStatePermissionRule(): GrantPermissionRule {
+    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+        GrantPermissionRule.grant(Manifest.permission.READ_PHONE_STATE)
+    } else {
+        GrantPermissionRule.grant()
+    }
+}

+ 0 - 2
app/src/androidTest/java/ch/threema/app/ThreemaTestRunner.java

@@ -25,13 +25,11 @@ import android.app.Application;
 import android.content.Context;
 import android.content.Context;
 import android.os.Bundle;
 import android.os.Bundle;
 
 
-import androidx.multidex.MultiDex;
 import androidx.test.runner.AndroidJUnitRunner;
 import androidx.test.runner.AndroidJUnitRunner;
 
 
 public class ThreemaTestRunner extends AndroidJUnitRunner {
 public class ThreemaTestRunner extends AndroidJUnitRunner {
 	@Override
 	@Override
 	public void onCreate(Bundle arguments) {
 	public void onCreate(Bundle arguments) {
-		MultiDex.install(getTargetContext());
 		super.onCreate(arguments);
 		super.onCreate(arguments);
 	}
 	}
 
 

+ 41 - 250
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupControlTest.kt

@@ -21,8 +21,6 @@
 
 
 package ch.threema.app.groupmanagement
 package ch.threema.app.groupmanagement
 
 
-import android.Manifest
-import android.os.Build
 import androidx.test.core.app.ActivityScenario
 import androidx.test.core.app.ActivityScenario
 import androidx.test.core.app.launchActivity
 import androidx.test.core.app.launchActivity
 import androidx.test.espresso.Espresso
 import androidx.test.espresso.Espresso
@@ -30,161 +28,29 @@ import androidx.test.espresso.NoMatchingViewException
 import androidx.test.espresso.action.ViewActions
 import androidx.test.espresso.action.ViewActions
 import androidx.test.espresso.intent.Intents
 import androidx.test.espresso.intent.Intents
 import androidx.test.espresso.matcher.ViewMatchers
 import androidx.test.espresso.matcher.ViewMatchers
-import androidx.test.rule.GrantPermissionRule
 import ch.threema.app.R
 import ch.threema.app.R
-import ch.threema.app.ThreemaApplication
 import ch.threema.app.activities.HomeActivity
 import ch.threema.app.activities.HomeActivity
-import ch.threema.app.managers.ServiceManager
-import ch.threema.app.services.FileService
-import ch.threema.app.testutils.TestHelpers
-import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.app.processors.MessageProcessorProvider
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.app.testutils.TestHelpers.TestGroup
-import ch.threema.base.crypto.NonceFactory
-import ch.threema.base.crypto.NonceStoreInterface
-import ch.threema.domain.helpers.InMemoryContactStore
-import ch.threema.domain.models.GroupId
-import ch.threema.domain.protocol.csp.coders.MessageBox
-import ch.threema.domain.protocol.csp.coders.MessageCoder
-import ch.threema.domain.protocol.csp.connection.MessageQueue
 import ch.threema.domain.protocol.csp.messages.AbstractGroupMessage
 import ch.threema.domain.protocol.csp.messages.AbstractGroupMessage
-import ch.threema.domain.protocol.csp.messages.AbstractMessage
-import ch.threema.domain.protocol.csp.messages.GroupCreateMessage
+import ch.threema.domain.protocol.csp.messages.GroupSetupMessage
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
-import ch.threema.domain.protocol.csp.messages.GroupRequestSyncMessage
-import ch.threema.domain.stores.ContactStore
+import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
 import ch.threema.domain.stores.IdentityStoreInterface
 import ch.threema.domain.stores.IdentityStoreInterface
-import ch.threema.storage.DatabaseServiceNew
-import ch.threema.storage.models.GroupMemberModel
-import org.junit.After
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
 import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Rule
 import org.junit.Test
 import org.junit.Test
-import java.lang.reflect.Field
 
 
 /**
 /**
  * A collection of basic data and utility functions to test group control messages. If the common
  * A collection of basic data and utility functions to test group control messages. If the common
  * group receive steps should not be executed for a certain message type, the common group step
  * group receive steps should not be executed for a certain message type, the common group step
  * receive methods should be overridden.
  * receive methods should be overridden.
  */
  */
-abstract class GroupControlTest<T : AbstractGroupMessage> {
-
-    protected val myContact: TestContact = TestHelpers.TEST_CONTACT
-    protected val contactA = TestContact("12345678")
-    protected val contactB = TestContact("ABCDEFGH")
-    protected val contactC = TestContact("SX96PM5A")
-
-    protected val myGroup = TestGroup(GroupId(0), myContact, listOf(myContact, contactA), "MyGroup")
-    protected val myGroupWithProfilePicture = TestGroup(GroupId(1), myContact, listOf(myContact, contactA), "MyGroupWithPicture", byteArrayOf(0, 1, 2, 3))
-    protected val groupA = TestGroup(GroupId(2), contactA, listOf(myContact, contactA), "GroupA")
-    protected val groupB = TestGroup(GroupId(3), contactB, listOf(myContact, contactB), "GroupB")
-    protected val groupAB = TestGroup(GroupId(4), contactA, listOf(myContact, contactA, contactB), "GroupAB")
-    protected val groupAUnknown = TestGroup(GroupId(5), contactA, listOf(myContact, contactA, contactB), "GroupAUnknown")
-    protected val groupALeft = TestGroup(GroupId(6), contactA, listOf(contactA, contactB), "GroupALeft")
-    protected val myUnknownGroup = TestGroup(GroupId(7), myContact, listOf(myContact, contactA), "MyUnknownGroup")
-    protected val myLeftGroup = TestGroup(GroupId(8), myContact, listOf(contactA), "MyLeftGroup")
-    protected val newAGroup = TestGroup(GroupId(9), contactA, listOf(myContact, contactA, contactB), "NewAGroup")
-
-    protected val serviceManager: ServiceManager = ThreemaApplication.requireServiceManager()
-    private val contactStore: ContactStore = InMemoryContactStore().apply {
-        addContact(myContact.contact, true)
-        addContact(contactA.contact, true)
-        addContact(contactB.contact, true)
-        addContact(contactC.contact, true)
-    }
-
-    private val mutableSentMessages: MutableList<AbstractMessage> = mutableListOf()
-    protected val sentMessages: List<AbstractMessage> = mutableSentMessages
-
-    protected val initialContacts = listOf(myContact, contactA, contactB, contactC)
-
-    protected val initialGroups =
-        listOf(myGroup, myGroupWithProfilePicture, groupA, groupB, groupAB, groupALeft, myLeftGroup)
-
-    @JvmField
-    @Rule
-    val grantPermissionRule: GrantPermissionRule =
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-            GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
-        } else {
-            GrantPermissionRule.grant()
-        }
-
-    /**
-     * Asserts that the correct identity is set up and fills the database with the initial data.
-     */
-    @Before
-    fun setup() {
-        assert(myContact.identity == TestHelpers.ensureIdentity(ThreemaApplication.requireServiceManager()))
-
-        setMessageQueue()
-
-        serviceManager.connection.stop()
-
-        cleanup()
-
-        fillDatabase()
-    }
-
-    /**
-     * Clean the data after the tests. This includes the deletion of the database entries, the
-     * avatar files, and the blocked contacts.
-     */
-    @After
-    fun cleanup() {
-        // Clear conversations
-        serviceManager.conversationService.getAll(true).forEach {
-            serviceManager.conversationService.clear(it)
-        }
-
-        // Delete database
-        serviceManager.databaseServiceNew.apply {
-            contactModelFactory.deleteAll()
-            messageModelFactory.deleteAll()
-            groupCallModelFactory.deleteAll()
-            groupInviteModelFactory.deleteAll()
-            groupBallotModelFactory.deleteAll()
-            groupMessagePendingMessageIdModelFactory.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()
-            groupRequestSyncLogModelFactory.deleteAll()
-            ballotModelFactory.deleteAll()
-            ballotChoiceModelFactory.deleteAll()
-            ballotVoteModelFactory.deleteAll()
-            identityBallotModelFactory.deleteAll()
-            webClientSessionModelFactory.deleteAll()
-            conversationTagFactory.deleteAll()
-            outgoingGroupJoinRequestModelFactory.deleteAll()
-            incomingGroupJoinRequestModelFactory.deleteAll()
-            serverMessageModelFactory.deleteAll()
-        }
-
-        // Remove files
-        serviceManager.fileService.removeAllAvatars()
-
-        // Unblock contacts
-        serviceManager.blackListService.removeAll()
-    }
-
-    /**
-     * Fills basic data into the database. This is executed before each test. Override this if other
-     * database entries are needed.
-     */
-    open fun fillDatabase() {
-        val databaseService = serviceManager.databaseServiceNew
-        val fileService = serviceManager.fileService
-
-        initialContacts.forEach { addContactToDatabase(it, databaseService, true) }
-
-        initialGroups.forEach { addGroupToDatabase(it, databaseService, fileService) }
-    }
+@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
      * Create a message of the tested group message type. This is used to create a message that will
@@ -197,8 +63,6 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
 
 
         val scenario = launchActivity<HomeActivity>()
         val scenario = launchActivity<HomeActivity>()
 
 
-        Thread.sleep(200)
-
         do {
         do {
             var switchedToMessages = false
             var switchedToMessages = false
             try {
             try {
@@ -211,36 +75,21 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
 
 
         Intents.release()
         Intents.release()
 
 
-        Thread.sleep(200)
-
         return scenario
         return scenario
     }
     }
 
 
-    /**
-     * Send a message from a user with the provided identity store.
-     */
-    protected fun processMessage(message: AbstractMessage, identityStore: IdentityStoreInterface) {
-        val messageBox = createMessageBox(message, identityStore)
-
-        // Process the group message
-        val processingResult = serviceManager.messageProcessor.processIncomingMessage(messageBox)
-        assertTrue(processingResult.wasProcessed())
-
-        // Give the listeners enough time to fire the event
-        Thread.sleep(200)
-    }
-
     /**
     /**
      * Check step 2.1 of the common group receive steps: The group could not be found and the user
      * 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.
      * is the creator of the group (as alleged by the message). The message should be discarded.
      */
      */
     @Test
     @Test
-    open fun testCommonGroupReceiveStep2_1() {
+    open fun testCommonGroupReceiveStep2_1() = runTest {
         val (message, identityStore) = getMyUnknownGroupMessage()
         val (message, identityStore) = getMyUnknownGroupMessage()
         setupAndProcessMessage(message, identityStore)
         setupAndProcessMessage(message, identityStore)
 
 
         // Nothing is expected to be sent
         // Nothing is expected to be sent
-        assertEquals(0, sentMessages.size)
+        assertTrue(sentMessagesInsideTask.isEmpty())
+        assertTrue(sentMessagesNewTask.isEmpty())
     }
     }
 
 
     /**
     /**
@@ -248,16 +97,18 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
      * is not the creator. In this case, a group sync request should be sent.
      * is not the creator. In this case, a group sync request should be sent.
      */
      */
     @Test
     @Test
-    open fun testCommonGroupReceiveStep2_2() {
+    open fun testCommonGroupReceiveStep2_2() = runTest {
         val (message, identityStore) = getUnknownGroupMessage()
         val (message, identityStore) = getUnknownGroupMessage()
         setupAndProcessMessage(message, identityStore)
         setupAndProcessMessage(message, identityStore)
 
 
-        assertEquals(1, sentMessages.size)
-        val firstMessage = sentMessages.first() as GroupRequestSyncMessage
+        val firstMessage = sentMessagesInsideTask.poll() as GroupSyncRequestMessage
         assertEquals(message.groupCreator, firstMessage.toIdentity)
         assertEquals(message.groupCreator, firstMessage.toIdentity)
         assertEquals(message.toIdentity, firstMessage.fromIdentity)
         assertEquals(message.toIdentity, firstMessage.fromIdentity)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
+
+        assertTrue(sentMessagesInsideTask.isEmpty())
+        assertTrue(sentMessagesNewTask.isEmpty())
     }
     }
 
 
     /**
     /**
@@ -266,18 +117,20 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
      * sent back to the sender.
      * sent back to the sender.
      */
      */
     @Test
     @Test
-    open fun testCommonGroupReceiveStep3_1() {
+    open fun testCommonGroupReceiveStep3_1() = runTest {
         val (message, identityStore) = getMyLeftGroupMessage()
         val (message, identityStore) = getMyLeftGroupMessage()
         setupAndProcessMessage(message, identityStore)
         setupAndProcessMessage(message, identityStore)
 
 
         // Check that empty sync is sent.
         // Check that empty sync is sent.
-        assertEquals(1, sentMessages.size)
-        val firstMessage = sentMessages.first() as GroupCreateMessage
+        val firstMessage = sentMessagesInsideTask.poll() as GroupSetupMessage
         assertEquals(message.fromIdentity, firstMessage.toIdentity)
         assertEquals(message.fromIdentity, firstMessage.toIdentity)
         assertEquals(myContact.identity, firstMessage.fromIdentity)
         assertEquals(myContact.identity, firstMessage.fromIdentity)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
         assertArrayEquals(emptyArray<String>(), firstMessage.members)
         assertArrayEquals(emptyArray<String>(), firstMessage.members)
+
+        assertTrue(sentMessagesInsideTask.isEmpty())
+        assertTrue(sentMessagesNewTask.isEmpty())
     }
     }
 
 
     /**
     /**
@@ -285,30 +138,33 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
      * not the creator of the group. In this case, a group leave should be sent back to the sender.
      * not the creator of the group. In this case, a group leave should be sent back to the sender.
      */
      */
     @Test
     @Test
-    open fun testCommonGroupReceiveStep3_2() {
+    open fun testCommonGroupReceiveStep3_2() = runTest {
         // First, test the common group receive steps for a message from the group creator
         // First, test the common group receive steps for a message from the group creator
         val (firstIncomingMessage, firstIdentityStore) = getLeftGroupMessageFromCreator()
         val (firstIncomingMessage, firstIdentityStore) = getLeftGroupMessageFromCreator()
         setupAndProcessMessage(firstIncomingMessage, firstIdentityStore)
         setupAndProcessMessage(firstIncomingMessage, firstIdentityStore)
 
 
         // Check that a group leave is sent back to the sender
         // Check that a group leave is sent back to the sender
-        assertEquals(1, sentMessages.size)
-        val firstSentMessage = sentMessages.first() as GroupLeaveMessage
+        val firstSentMessage = sentMessagesInsideTask.poll() as GroupLeaveMessage
         assertEquals(firstIncomingMessage.fromIdentity, firstSentMessage.toIdentity)
         assertEquals(firstIncomingMessage.fromIdentity, firstSentMessage.toIdentity)
         assertEquals(myContact.identity, firstSentMessage.fromIdentity)
         assertEquals(myContact.identity, firstSentMessage.fromIdentity)
         assertEquals(firstIncomingMessage.apiGroupId, firstSentMessage.apiGroupId)
         assertEquals(firstIncomingMessage.apiGroupId, firstSentMessage.apiGroupId)
         assertEquals(firstIncomingMessage.groupCreator, firstSentMessage.groupCreator)
         assertEquals(firstIncomingMessage.groupCreator, firstSentMessage.groupCreator)
 
 
+        assertTrue(sentMessagesInsideTask.isEmpty())
+
         // Second, test the common group receive steps for a message from a group member
         // Second, test the common group receive steps for a message from a group member
         val (secondIncomingMessage, secondIdentityStore) = getLeftGroupMessage()
         val (secondIncomingMessage, secondIdentityStore) = getLeftGroupMessage()
         setupAndProcessMessage(secondIncomingMessage, secondIdentityStore)
         setupAndProcessMessage(secondIncomingMessage, secondIdentityStore)
 
 
         // Check that a group leave is sent back to the sender
         // Check that a group leave is sent back to the sender
-        assertEquals(2, sentMessages.size)
-        val secondSentMessage = sentMessages[1] as GroupLeaveMessage
+        val secondSentMessage = sentMessagesInsideTask.poll() as GroupLeaveMessage
         assertEquals(secondIncomingMessage.fromIdentity, secondSentMessage.toIdentity)
         assertEquals(secondIncomingMessage.fromIdentity, secondSentMessage.toIdentity)
         assertEquals(myContact.identity, secondSentMessage.fromIdentity)
         assertEquals(myContact.identity, secondSentMessage.fromIdentity)
         assertEquals(secondIncomingMessage.apiGroupId, secondSentMessage.apiGroupId)
         assertEquals(secondIncomingMessage.apiGroupId, secondSentMessage.apiGroupId)
         assertEquals(secondIncomingMessage.groupCreator, secondSentMessage.groupCreator)
         assertEquals(secondIncomingMessage.groupCreator, secondSentMessage.groupCreator)
+
+        assertTrue(sentMessagesInsideTask.isEmpty())
+        assertTrue(sentMessagesNewTask.isEmpty())
     }
     }
 
 
     /**
     /**
@@ -317,18 +173,20 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
      * should be sent back to the sender.
      * should be sent back to the sender.
      */
      */
     @Test
     @Test
-    open fun testCommonGroupReceiveStep4_1() {
+    open fun testCommonGroupReceiveStep4_1() = runTest {
         val (message, identityStore) = getSenderNotMemberOfMyGroupMessage()
         val (message, identityStore) = getSenderNotMemberOfMyGroupMessage()
         setupAndProcessMessage(message, identityStore)
         setupAndProcessMessage(message, identityStore)
 
 
         // Check that a group setup with empty member list is sent back to the sender
         // Check that a group setup with empty member list is sent back to the sender
-        assertEquals(1, sentMessages.size)
-        val firstMessage = sentMessages.first() as GroupCreateMessage
+        val firstMessage = sentMessagesInsideTask.poll() as GroupSetupMessage
         assertEquals(message.fromIdentity, firstMessage.toIdentity)
         assertEquals(message.fromIdentity, firstMessage.toIdentity)
         assertEquals(myContact.identity, firstMessage.fromIdentity)
         assertEquals(myContact.identity, firstMessage.fromIdentity)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
         assertArrayEquals(emptyArray<String>(), firstMessage.members)
         assertArrayEquals(emptyArray<String>(), firstMessage.members)
+
+        assertTrue(sentMessagesInsideTask.isEmpty())
+        assertTrue(sentMessagesNewTask.isEmpty())
     }
     }
 
 
     /**
     /**
@@ -336,46 +194,18 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
      * the user is not the creator of the group. The message should be discarded.
      * the user is not the creator of the group. The message should be discarded.
      */
      */
     @Test
     @Test
-    open fun testCommonGroupReceiveStep4_2() {
+    open fun testCommonGroupReceiveStep4_2() = runTest {
         val (message, identityStore) = getSenderNotMemberMessage()
         val (message, identityStore) = getSenderNotMemberMessage()
         setupAndProcessMessage(message, identityStore)
         setupAndProcessMessage(message, identityStore)
 
 
         // Check that no message is sent
         // Check that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertTrue(sentMessagesInsideTask.isEmpty())
+        assertTrue(sentMessagesNewTask.isEmpty())
     }
     }
 
 
-    private fun addContactToDatabase(
-        testContact: TestContact,
-        databaseService: DatabaseServiceNew,
-        addHidden: Boolean = false,
-    ) {
-        databaseService.contactModelFactory.createOrUpdate(
-            testContact.contactModel.setIsHidden(addHidden)
-        )
-    }
-
-    private fun addGroupToDatabase(
-        testGroup: TestGroup,
-        databaseService: DatabaseServiceNew,
-        fileService: FileService,
-    ) {
-        val groupModel = testGroup.groupModel
-        databaseService.groupModelFactory.createOrUpdate(groupModel)
-        testGroup.setLocalGroupId(groupModel.id)
-        testGroup.members.forEach { member ->
-            val memberModel = GroupMemberModel()
-                .setGroupId(groupModel.id)
-                .setIdentity(member.identity)
-            databaseService.groupMemberModelFactory.createOrUpdate(memberModel)
-        }
-        if (testGroup.profilePicture != null) {
-            fileService.writeGroupAvatar(groupModel, testGroup.profilePicture)
-        }
-    }
-
-    private fun setupAndProcessMessage(
+    private suspend fun setupAndProcessMessage(
         message: AbstractGroupMessage,
         message: AbstractGroupMessage,
-        identityStore: IdentityStoreInterface
+        identityStore: IdentityStoreInterface,
     ) {
     ) {
         // Start home activity and navigate to chat section
         // Start home activity and navigate to chat section
         launchActivity<HomeActivity>()
         launchActivity<HomeActivity>()
@@ -391,6 +221,9 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
      */
      */
     private fun getMyUnknownGroupMessage() = createMessageForGroup().apply {
     private fun getMyUnknownGroupMessage() = createMessageForGroup().apply {
         enrich(myUnknownGroup)
         enrich(myUnknownGroup)
+        // Set from identity to a different identity than myself. Note that this may have an effect
+        // on the group creator for messages that are wrapped in a group-creator-container.
+        fromIdentity = contactA.identity
     } to myUnknownGroup.groupCreator.identityStore
     } to myUnknownGroup.groupCreator.identityStore
 
 
     /**
     /**
@@ -452,48 +285,6 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
         toIdentity = myContact.identity
         toIdentity = myContact.identity
     }
     }
 
 
-    /**
-     * Create a message box from a user with the given identity store.
-     */
-    private fun createMessageBox(
-        msg: AbstractMessage,
-        identityStore: IdentityStoreInterface,
-    ): MessageBox {
-        val nonceFactory = NonceFactory(object : NonceStoreInterface {
-            override fun exists(nonce: ByteArray) = false
-            override fun store(nonce: ByteArray) = true
-        })
-
-        val messageCoder = MessageCoder(contactStore, identityStore)
-        return messageCoder.encode(msg, nonceFactory)
-    }
-
-    private fun setMessageQueue() {
-        val messageQueue = object :
-            MessageQueue(contactStore, myContact.identityStore, serviceManager.connection) {
-            override fun enqueue(message: AbstractMessage): MessageBox {
-                mutableSentMessages.add(message)
-                return MessageBox()
-            }
-        }
-
-        replaceMessageQueue(messageQueue, serviceManager)
-        replaceMessageQueue(messageQueue, serviceManager.contactService)
-        replaceMessageQueue(messageQueue, serviceManager.messageService)
-        replaceMessageQueue(messageQueue, serviceManager.groupJoinResponseService)
-        replaceMessageQueue(messageQueue, serviceManager.outgoingGroupJoinRequestService)
-        replaceMessageQueue(messageQueue, serviceManager.groupMessagingService)
-        serviceManager.forwardSecurityMessageProcessor?.let {
-            replaceMessageQueue(messageQueue, it)
-        }
-    }
-
-    private fun replaceMessageQueue(messageQueue: MessageQueue, c: Any) {
-        val messageQueueField: Field = c.javaClass.getDeclaredField("messageQueue")
-        messageQueueField.isAccessible = true
-        messageQueueField.set(c, messageQueue)
-    }
-
     protected fun <T> Iterable<T>.replace(original: T, new: T) =
     protected fun <T> Iterable<T>.replace(original: T, new: T) =
         map { if (it == original) new else it }
         map { if (it == original) new else it }
 }
 }

+ 18 - 15
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt

@@ -25,18 +25,20 @@ import androidx.test.core.app.launchActivity
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.filters.LargeTest
 import ch.threema.app.DangerousTest
 import ch.threema.app.DangerousTest
+import kotlinx.coroutines.test.runTest
 import ch.threema.app.activities.HomeActivity
 import ch.threema.app.activities.HomeActivity
 import ch.threema.app.listeners.GroupListener
 import ch.threema.app.listeners.GroupListener
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
-import ch.threema.domain.protocol.csp.messages.GroupRequestSyncMessage
+import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
 import ch.threema.storage.models.GroupModel
 import ch.threema.storage.models.GroupModel
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertFalse
 import junit.framework.TestCase.assertFalse
 import junit.framework.TestCase.assertTrue
 import junit.framework.TestCase.assertTrue
 import junit.framework.TestCase.fail
 import junit.framework.TestCase.fail
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import org.junit.After
 import org.junit.After
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertArrayEquals
 import org.junit.Ignore
 import org.junit.Ignore
@@ -46,6 +48,7 @@ import org.junit.runner.RunWith
 /**
 /**
  * Tests that incoming group leave messages are handled correctly.
  * Tests that incoming group leave messages are handled correctly.
  */
  */
+@ExperimentalCoroutinesApi
 @RunWith(AndroidJUnit4::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @LargeTest
 @DangerousTest
 @DangerousTest
@@ -55,7 +58,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * Test that contact A leaving my group works as expected.
      * Test that contact A leaving my group works as expected.
      */
      */
     @Test
     @Test
-    fun testValidLeaveInMyGroup() {
+    fun testValidLeaveInMyGroup() = runTest {
         assertSuccessfulLeave(myGroup, contactA, true)
         assertSuccessfulLeave(myGroup, contactA, true)
     }
     }
 
 
@@ -63,7 +66,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * Test that contact B leaving groupAB works as expected.
      * Test that contact B leaving groupAB works as expected.
      */
      */
     @Test
     @Test
-    fun testValidLeave() {
+    fun testValidLeave() = runTest {
         assertSuccessfulLeave(groupAB, contactB)
         assertSuccessfulLeave(groupAB, contactB)
     }
     }
 
 
@@ -72,7 +75,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      */
      */
     @Test
     @Test
     @Ignore("TODO(ANDR-2385): ignore group leave messages from group creators")
     @Ignore("TODO(ANDR-2385): ignore group leave messages from group creators")
-    fun testLeaveFromSender() {
+    fun testLeaveFromSender() = runTest {
         assertUnsuccessfulLeave(groupA, contactA)
         assertUnsuccessfulLeave(groupA, contactA)
         assertUnsuccessfulLeave(groupB, contactB)
         assertUnsuccessfulLeave(groupB, contactB)
     }
     }
@@ -82,7 +85,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * not change anything).
      * not change anything).
      */
      */
     @Test
     @Test
-    fun testLeaveOfMyNonExistingGroup() {
+    fun testLeaveOfMyNonExistingGroup() = runTest {
         assertUnsuccessfulLeave(myUnknownGroup, contactA, emptyList())
         assertUnsuccessfulLeave(myUnknownGroup, contactA, emptyList())
     }
     }
 
 
@@ -91,7 +94,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * no effect.
      * no effect.
      */
      */
     @Test
     @Test
-    fun testLeaveOfNonExistingGroup() {
+    fun testLeaveOfNonExistingGroup() = runTest {
         assertUnsuccessfulLeave(groupAUnknown, contactB, emptyList(), true)
         assertUnsuccessfulLeave(groupAUnknown, contactB, emptyList(), true)
     }
     }
 
 
@@ -100,7 +103,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * does not change anything).
      * does not change anything).
      */
      */
     @Test
     @Test
-    fun testLeaveOfLeftGroup() {
+    fun testLeaveOfLeftGroup() = runTest {
         assertUnsuccessfulLeave(groupALeft, contactB, null, true)
         assertUnsuccessfulLeave(groupALeft, contactB, null, true)
     }
     }
 
 
@@ -109,7 +112,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * changed).
      * changed).
      */
      */
     @Test
     @Test
-    fun testLeaveOfMyLeftGroup() {
+    fun testLeaveOfMyLeftGroup() = runTest {
         assertUnsuccessfulLeave(myLeftGroup, contactA)
         assertUnsuccessfulLeave(myLeftGroup, contactA)
     }
     }
 
 
@@ -118,7 +121,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * effect.
      * effect.
      */
      */
     @Test
     @Test
-    fun testLeaveOfNonMember() {
+    fun testLeaveOfNonMember() = runTest {
         assertUnsuccessfulLeave(groupA, contactB)
         assertUnsuccessfulLeave(groupA, contactB)
     }
     }
 
 
@@ -153,7 +156,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
         // The common group receive steps are not executed for group leave messages
         // The common group receive steps are not executed for group leave messages
     }
     }
 
 
-    private fun assertSuccessfulLeave(group: TestGroup, contact: TestContact, expectStateChange: Boolean = false) {
+    private suspend fun assertSuccessfulLeave(group: TestGroup, contact: TestContact, expectStateChange: Boolean = false) {
         launchActivity<HomeActivity>()
         launchActivity<HomeActivity>()
 
 
         serviceManager.groupService.resetCache(group.groupModel.id)
         serviceManager.groupService.resetCache(group.groupModel.id)
@@ -183,10 +186,10 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             serviceManager.groupService.getGroupMemberModels(group.groupModel).map { it.identity })
             serviceManager.groupService.getGroupMemberModels(group.groupModel).map { it.identity })
 
 
         // Assert that no message has been sent as a response to a group leave
         // Assert that no message has been sent as a response to a group leave
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
     }
     }
 
 
-    private fun assertUnsuccessfulLeave(
+    private suspend fun assertUnsuccessfulLeave(
         group: TestGroup,
         group: TestGroup,
         contact: TestContact,
         contact: TestContact,
         expectedMembers: List<String>? = null,
         expectedMembers: List<String>? = null,
@@ -223,15 +226,15 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
 
         if (shouldSendSyncRequest) {
         if (shouldSendSyncRequest) {
             // Should send sync request to the group creator
             // Should send sync request to the group creator
-            assertEquals(1, sentMessages.size)
-            val sentMessage = sentMessages.first() as GroupRequestSyncMessage
+            assertEquals(1, sentMessagesInsideTask.size)
+            val sentMessage = sentMessagesInsideTask.first() as GroupSyncRequestMessage
             assertEquals(myContact.identity, sentMessage.fromIdentity)
             assertEquals(myContact.identity, sentMessage.fromIdentity)
             assertEquals(group.groupCreator.identity, sentMessage.toIdentity)
             assertEquals(group.groupCreator.identity, sentMessage.toIdentity)
             assertEquals(group.apiGroupId, sentMessage.apiGroupId)
             assertEquals(group.apiGroupId, sentMessage.apiGroupId)
             assertEquals(group.groupCreator.identity, sentMessage.groupCreator)
             assertEquals(group.groupCreator.identity, sentMessage.groupCreator)
         } else {
         } else {
             // Assert that no message has been sent as a response to the leave message
             // Assert that no message has been sent as a response to the leave message
-            assertEquals(0, sentMessages.size)
+            assertEquals(0, sentMessagesInsideTask.size)
         }
         }
     }
     }
 
 

+ 12 - 8
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt

@@ -29,10 +29,11 @@ import ch.threema.app.managers.ListenerManager
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.domain.models.GroupId
 import ch.threema.domain.models.GroupId
-import ch.threema.domain.protocol.csp.messages.GroupRenameMessage
+import ch.threema.domain.protocol.csp.messages.GroupNameMessage
 import ch.threema.storage.models.GroupModel
 import ch.threema.storage.models.GroupModel
 import junit.framework.TestCase.*
 import junit.framework.TestCase.*
 import kotlinx.coroutines.*
 import kotlinx.coroutines.*
+import kotlinx.coroutines.test.runTest
 import org.junit.After
 import org.junit.After
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertArrayEquals
 import org.junit.Test
 import org.junit.Test
@@ -42,20 +43,22 @@ import java.util.*
 /**
 /**
  * Tests that incoming group name messages are handled correctly.
  * Tests that incoming group name messages are handled correctly.
  */
  */
+@ExperimentalCoroutinesApi
 @RunWith(AndroidJUnit4::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @LargeTest
 @DangerousTest
 @DangerousTest
-class IncomingGroupNameTest : GroupConversationListTest<GroupRenameMessage>() {
+class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
 
 
-    override fun createMessageForGroup(): GroupRenameMessage {
-        return GroupRenameMessage().apply { groupName = "New Group Name" }
+    override fun createMessageForGroup(): GroupNameMessage {
+        return GroupNameMessage()
+            .apply { groupName = "New Group Name" }
     }
     }
 
 
     /**
     /**
      * Tests that a (valid) group rename message really changes the group name.
      * Tests that a (valid) group rename message really changes the group name.
      */
      */
     @Test
     @Test
-    fun testValidGroupRename() {
+    fun testValidGroupRename() = runTest {
         // Start home activity and navigate to chat section
         // Start home activity and navigate to chat section
         val activityScenario = startScenario()
         val activityScenario = startScenario()
 
 
@@ -91,7 +94,7 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupRenameMessage>() {
      * does not lead to a group name change.
      * does not lead to a group name change.
      */
      */
     @Test
     @Test
-    fun testInvalidGroupRenameSender() {
+    fun testInvalidGroupRenameSender() = runTest {
         // Start home activity and navigate to chat section
         // Start home activity and navigate to chat section
         val activityScenario = startScenario()
         val activityScenario = startScenario()
 
 
@@ -121,7 +124,8 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupRenameMessage>() {
     }
     }
 
 
     override fun testCommonGroupReceiveStep2_1() {
     override fun testCommonGroupReceiveStep2_1() {
-        runWithoutGroupRename { super.testCommonGroupReceiveStep2_1() }
+        // 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() {
     override fun testCommonGroupReceiveStep2_2() {
@@ -162,7 +166,7 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupRenameMessage>() {
         groupCreatorIdentity: String,
         groupCreatorIdentity: String,
         apiGroupId: GroupId,
         apiGroupId: GroupId,
         fromContact: TestContact,
         fromContact: TestContact,
-    ) = GroupRenameMessage().apply {
+    ) = GroupNameMessage().apply {
         groupName = newGroupName
         groupName = newGroupName
         groupCreator = groupCreatorIdentity
         groupCreator = groupCreatorIdentity
         fromIdentity = fromContact.identity
         fromIdentity = fromContact.identity

+ 27 - 23
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt

@@ -28,10 +28,12 @@ import ch.threema.app.listeners.GroupListener
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.app.testutils.TestHelpers.TestGroup
-import ch.threema.domain.protocol.csp.messages.GroupCreateMessage
+import ch.threema.domain.protocol.csp.messages.GroupSetupMessage
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
 import ch.threema.storage.models.GroupModel
 import ch.threema.storage.models.GroupModel
 import junit.framework.TestCase
 import junit.framework.TestCase
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
 import org.junit.After
 import org.junit.After
 import org.junit.Assert
 import org.junit.Assert
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
@@ -45,18 +47,19 @@ import org.junit.runner.RunWith
  * Runs different tests that verify that incoming group setup messages are handled according to the
  * Runs different tests that verify that incoming group setup messages are handled according to the
  * protocol.
  * protocol.
  */
  */
+@ExperimentalCoroutinesApi
 @RunWith(AndroidJUnit4::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @LargeTest
 @DangerousTest
 @DangerousTest
-class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
+class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
 
 
-    override fun createMessageForGroup() = GroupCreateMessage()
+    override fun createMessageForGroup() = GroupSetupMessage()
 
 
     /**
     /**
      * Test a group setup message of an unknown group where the user is not not a member.
      * Test a group setup message of an unknown group where the user is not not a member.
      */
      */
     @Test
     @Test
-    fun testUnknownGroupNotMember() {
+    fun testUnknownGroupNotMember() = runTest {
         val scenario = startScenario()
         val scenario = startScenario()
 
 
         // Assert initial group conversations
         // Assert initial group conversations
@@ -83,7 +86,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         assertGroupConversations(scenario, initialGroups)
         assertGroupConversations(scenario, initialGroups)
 
 
         // Assert that no message is sent
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
 
         // Assert that no action has been triggered
         // Assert that no action has been triggered
         setupTracker.assertAllNewMembersAdded()
         setupTracker.assertAllNewMembersAdded()
@@ -96,7 +99,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
      * Test a group setup message of an unknown group that has no members.
      * Test a group setup message of an unknown group that has no members.
      */
      */
     @Test
     @Test
-    fun testUnknownEmptyGroup() {
+    fun testUnknownEmptyGroup() = runTest {
         val scenario = startScenario()
         val scenario = startScenario()
 
 
         // Assert initial group conversations
         // Assert initial group conversations
@@ -123,7 +126,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         assertGroupConversations(scenario, initialGroups)
         assertGroupConversations(scenario, initialGroups)
 
 
         // Assert that no message is sent
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
 
         // Assert that no action has been triggered
         // Assert that no action has been triggered
         setupTracker.assertAllNewMembersAdded()
         setupTracker.assertAllNewMembersAdded()
@@ -136,7 +139,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
      * Test a group setup message of a blocked contact.
      * Test a group setup message of a blocked contact.
      */
      */
     @Test
     @Test
-    fun testBlocked() {
+    fun testBlocked() = runTest {
         val scenario = startScenario()
         val scenario = startScenario()
 
 
         // Assert initial group conversations
         // Assert initial group conversations
@@ -165,12 +168,12 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
 
 
         // Assert that a group leave message is sent to the created and all provided members
         // Assert that a group leave message is sent to the created and all provided members
         // including those that are blocked
         // including those that are blocked
-        assertEquals(2, sentMessages.size)
-        val first = sentMessages.first() as GroupLeaveMessage
+        assertEquals(2, sentMessagesInsideTask.size)
+        val first = sentMessagesInsideTask.first() as GroupLeaveMessage
         assertEquals(myContact.identity, first.fromIdentity)
         assertEquals(myContact.identity, first.fromIdentity)
         assertEquals(newAGroup.apiGroupId, first.apiGroupId)
         assertEquals(newAGroup.apiGroupId, first.apiGroupId)
         assertEquals(newAGroup.groupCreator.identity, first.groupCreator)
         assertEquals(newAGroup.groupCreator.identity, first.groupCreator)
-        val second = sentMessages.last() as GroupLeaveMessage
+        val second = sentMessagesInsideTask.last() as GroupLeaveMessage
         assertEquals(myContact.identity, second.fromIdentity)
         assertEquals(myContact.identity, second.fromIdentity)
         assertEquals(newAGroup.apiGroupId, second.apiGroupId)
         assertEquals(newAGroup.apiGroupId, second.apiGroupId)
         assertEquals(newAGroup.groupCreator.identity, second.groupCreator)
         assertEquals(newAGroup.groupCreator.identity, second.groupCreator)
@@ -191,7 +194,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
      * Test a group setup message of a group where the user is not a member anymore.
      * Test a group setup message of a group where the user is not a member anymore.
      */
      */
     @Test
     @Test
-    fun testKicked() {
+    fun testKicked() = runTest {
         val scenario = startScenario()
         val scenario = startScenario()
 
 
         // Assert initial group conversations
         // Assert initial group conversations
@@ -218,7 +221,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         assertGroupConversations(scenario, initialGroups)
         assertGroupConversations(scenario, initialGroups)
 
 
         // Assert that no message is sent
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
 
         // Assert that the user has been kicked and the members are updated
         // Assert that the user has been kicked and the members are updated
         setupTracker.assertAllNewMembersAdded()
         setupTracker.assertAllNewMembersAdded()
@@ -231,7 +234,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
      * Test a group setup message of a group where the members changed.
      * Test a group setup message of a group where the members changed.
      */
      */
     @Test
     @Test
-    fun testMembersChanged() {
+    fun testMembersChanged() = runTest {
         val scenario = startScenario()
         val scenario = startScenario()
 
 
         // Assert initial group conversations
         // Assert initial group conversations
@@ -259,7 +262,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         assertGroupConversations(scenario, initialGroups)
         assertGroupConversations(scenario, initialGroups)
 
 
         // Assert that no message is sent
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
 
         // Assert that the members have changed
         // Assert that the members have changed
         setupTracker.assertAllNewMembersAdded()
         setupTracker.assertAllNewMembersAdded()
@@ -272,7 +275,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
      * Test a group setup message of a newly created group.
      * Test a group setup message of a newly created group.
      */
      */
     @Test
     @Test
-    fun testNewGroup() {
+    fun testNewGroup() = runTest {
         val scenario = startScenario()
         val scenario = startScenario()
 
 
         // Assert initial group conversations
         // Assert initial group conversations
@@ -306,7 +309,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         assertGroupConversations(scenario, initialGroups + newGroup)
         assertGroupConversations(scenario, initialGroups + newGroup)
 
 
         // Assert that no message is sent
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
 
         // Assert that the group has been created and the new members are set correctly
         // Assert that the group has been created and the new members are set correctly
         setupTracker.assertAllNewMembersAdded()
         setupTracker.assertAllNewMembersAdded()
@@ -319,7 +322,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
      * Test two group setup messages that remove and then add the user.
      * Test two group setup messages that remove and then add the user.
      */
      */
     @Test
     @Test
-    fun testRemoveJoin() {
+    fun testRemoveJoin() = runTest {
         val scenario = startScenario()
         val scenario = startScenario()
 
 
         // Assert initial group conversations
         // Assert initial group conversations
@@ -343,7 +346,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         processMessage(removeMessage, groupAB.groupCreator.identityStore)
         processMessage(removeMessage, groupAB.groupCreator.identityStore)
 
 
         // Assert that no message is sent
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
 
         // Create the group setup message (now again with this user)
         // Create the group setup message (now again with this user)
         val addMessage = createGroupSetupMessage(groupAB)
         val addMessage = createGroupSetupMessage(groupAB)
@@ -353,7 +356,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         processMessage(addMessage, groupAB.groupCreator.identityStore)
         processMessage(addMessage, groupAB.groupCreator.identityStore)
 
 
         // Assert that no message is sent
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
 
         // Assert that the user has been kicked and added again
         // Assert that the user has been kicked and added again
         setupTracker.assertAllNewMembersAdded()
         setupTracker.assertAllNewMembersAdded()
@@ -363,7 +366,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
     }
     }
 
 
     @Test
     @Test
-    fun testGroupContainingInvalidIDs() {
+    fun testGroupContainingInvalidIDs() = runTest {
         val scenario = startScenario()
         val scenario = startScenario()
 
 
         // Assert initial group conversations
         // Assert initial group conversations
@@ -400,7 +403,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         assertGroupConversations(scenario, initialGroups + newGroup)
         assertGroupConversations(scenario, initialGroups + newGroup)
 
 
         // Assert that no message is sent
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
 
         // Assert that the group has been created and the new members are set correctly
         // Assert that the group has been created and the new members are set correctly
         setupTracker.assertAllNewMembersAdded()
         setupTracker.assertAllNewMembersAdded()
@@ -409,7 +412,8 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         setupTracker.stop()
         setupTracker.stop()
     }
     }
 
 
-    private fun createGroupSetupMessage(testGroup: TestGroup) = GroupCreateMessage().apply {
+    private fun createGroupSetupMessage(testGroup: TestGroup) = GroupSetupMessage()
+        .apply {
         apiGroupId = testGroup.apiGroupId
         apiGroupId = testGroup.apiGroupId
         groupCreator = testGroup.groupCreator.identity
         groupCreator = testGroup.groupCreator.identity
         fromIdentity = testGroup.groupCreator.identity
         fromIdentity = testGroup.groupCreator.identity

+ 32 - 31
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSyncRequestTest.kt

@@ -28,10 +28,12 @@ import ch.threema.app.DangerousTest
 import ch.threema.app.activities.HomeActivity
 import ch.threema.app.activities.HomeActivity
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.app.testutils.TestHelpers.TestGroup
-import ch.threema.domain.protocol.csp.messages.GroupCreateMessage
-import ch.threema.domain.protocol.csp.messages.GroupDeletePhotoMessage
-import ch.threema.domain.protocol.csp.messages.GroupRenameMessage
-import ch.threema.domain.protocol.csp.messages.GroupRequestSyncMessage
+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.GroupSyncRequestMessage
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
 import org.junit.Assert.assertTrue
@@ -41,12 +43,13 @@ import org.junit.runner.RunWith
 /**
 /**
  * Tests that incoming group sync request messages are handled correctly.
  * Tests that incoming group sync request messages are handled correctly.
  */
  */
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @LargeTest
 @DangerousTest
 @DangerousTest
-class IncomingGroupSyncRequestTest : GroupControlTest<GroupRequestSyncMessage>() {
+class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>() {
 
 
-    override fun createMessageForGroup() = GroupRequestSyncMessage()
+    override fun createMessageForGroup() = GroupSyncRequestMessage()
 
 
     @Test
     @Test
     fun testValidSyncRequest() {
     fun testValidSyncRequest() {
@@ -54,22 +57,17 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupRequestSyncMessage>()
     }
     }
 
 
     @Test
     @Test
-    fun testSyncRequestToMember() {
+    fun testSyncRequestToMember() = runTest {
         assertIgnoredGroupSyncRequest(groupAB, contactB)
         assertIgnoredGroupSyncRequest(groupAB, contactB)
     }
     }
 
 
     @Test
     @Test
-    fun testSyncRequestFromNonMember() {
+    fun testSyncRequestFromNonMember() = runTest {
         assertLeftGroupSyncRequest(myGroup, contactB)
         assertLeftGroupSyncRequest(myGroup, contactB)
     }
     }
 
 
     @Test
     @Test
-    fun testSyncRequestFromMyself() {
-        assertIgnoredGroupSyncRequest(myGroup, myContact)
-    }
-
-    @Test
-    fun testSyncRequestToLeftGroup() {
+    fun testSyncRequestToLeftGroup() = runTest {
         assertLeftGroupSyncRequest(myLeftGroup, contactA)
         assertLeftGroupSyncRequest(myLeftGroup, contactA)
     }
     }
 
 
@@ -97,11 +95,12 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupRequestSyncMessage>()
         // Common group receive steps are not executed for group sync request messages
         // Common group receive steps are not executed for group sync request messages
     }
     }
 
 
-    private fun assertValidGroupSyncRequest(group: TestGroup, contact: TestContact) {
+    private fun assertValidGroupSyncRequest(group: TestGroup, contact: TestContact) = runTest {
         launchActivity<HomeActivity>()
         launchActivity<HomeActivity>()
 
 
         // Create group sync request message
         // Create group sync request message
-        val groupRequestSyncMessage = GroupRequestSyncMessage().apply {
+        val groupSyncRequestMessage = GroupSyncRequestMessage()
+            .apply {
             fromIdentity = contact.identity
             fromIdentity = contact.identity
             toIdentity = myContact.identity
             toIdentity = myContact.identity
             apiGroupId = group.apiGroupId
             apiGroupId = group.apiGroupId
@@ -109,12 +108,10 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupRequestSyncMessage>()
         }
         }
 
 
         // Process sync request message
         // Process sync request message
-        processMessage(groupRequestSyncMessage, contact.identityStore)
-
-        assertEquals(3, sentMessages.size)
+        processMessage(groupSyncRequestMessage, contact.identityStore)
 
 
         // Check that the first sent message (setup) is correct
         // Check that the first sent message (setup) is correct
-        val setupMessage = sentMessages[0] as GroupCreateMessage
+        val setupMessage = sentMessagesInsideTask.poll() as GroupSetupMessage
         assertArrayEquals(group.members.map { it.identity }.toTypedArray(), setupMessage.members)
         assertArrayEquals(group.members.map { it.identity }.toTypedArray(), setupMessage.members)
         assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
         assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
         assertEquals(contact.identity, setupMessage.toIdentity)
         assertEquals(contact.identity, setupMessage.toIdentity)
@@ -122,7 +119,7 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupRequestSyncMessage>()
         assertEquals(group.apiGroupId, setupMessage.apiGroupId)
         assertEquals(group.apiGroupId, setupMessage.apiGroupId)
 
 
         // Check that the second sent message (rename) is correct
         // Check that the second sent message (rename) is correct
-        val renameMessage = sentMessages[1] as GroupRenameMessage
+        val renameMessage = sentMessagesInsideTask.poll() as GroupNameMessage
         assertEquals(group.groupName, renameMessage.groupName)
         assertEquals(group.groupName, renameMessage.groupName)
         assertEquals(myContact.identity, renameMessage.fromIdentity)
         assertEquals(myContact.identity, renameMessage.fromIdentity)
         assertEquals(contact.identity, renameMessage.toIdentity)
         assertEquals(contact.identity, renameMessage.toIdentity)
@@ -132,45 +129,49 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupRequestSyncMessage>()
         assertTrue("Groups with photo are not supported for testing", group.profilePicture == null)
         assertTrue("Groups with photo are not supported for testing", group.profilePicture == null)
 
 
         // Check that the third sent message (set/delete photo) is correct
         // Check that the third sent message (set/delete photo) is correct
-        val deletePhotoMessage = sentMessages[2] as GroupDeletePhotoMessage
+        val deletePhotoMessage = sentMessagesInsideTask.poll() as GroupDeleteProfilePictureMessage
         assertEquals(myContact.identity, deletePhotoMessage.fromIdentity)
         assertEquals(myContact.identity, deletePhotoMessage.fromIdentity)
         assertEquals(contact.identity, deletePhotoMessage.toIdentity)
         assertEquals(contact.identity, deletePhotoMessage.toIdentity)
         assertEquals(group.groupCreator.identity, deletePhotoMessage.groupCreator)
         assertEquals(group.groupCreator.identity, deletePhotoMessage.groupCreator)
         assertEquals(group.apiGroupId, deletePhotoMessage.apiGroupId)
         assertEquals(group.apiGroupId, deletePhotoMessage.apiGroupId)
+
+        assertTrue(sentMessagesInsideTask.isEmpty())
     }
     }
 
 
-    private fun assertIgnoredGroupSyncRequest(group: TestGroup, contact: TestContact) {
+    private suspend fun assertIgnoredGroupSyncRequest(group: TestGroup, contact: TestContact) {
         launchActivity<HomeActivity>()
         launchActivity<HomeActivity>()
 
 
         // Create group sync request message
         // Create group sync request message
-        val groupRequestSyncMessage = GroupRequestSyncMessage().apply {
+        val groupSyncRequestMessage = GroupSyncRequestMessage()
+            .apply {
             fromIdentity = contact.identity
             fromIdentity = contact.identity
             toIdentity = myContact.identity
             toIdentity = myContact.identity
             apiGroupId = group.apiGroupId
             apiGroupId = group.apiGroupId
             groupCreator = group.groupCreator.identity
             groupCreator = group.groupCreator.identity
         }
         }
 
 
-        processMessage(groupRequestSyncMessage, contact.identityStore)
+        processMessage(groupSyncRequestMessage, contact.identityStore)
 
 
-        assertEquals(0, sentMessages.size)
+        assertTrue(sentMessagesInsideTask.isEmpty())
     }
     }
 
 
-    private fun assertLeftGroupSyncRequest(group: TestGroup, contact: TestContact) {
+    private suspend fun assertLeftGroupSyncRequest(group: TestGroup, contact: TestContact) {
         launchActivity<HomeActivity>()
         launchActivity<HomeActivity>()
 
 
         // Create group sync request message
         // Create group sync request message
-        val groupRequestSyncMessage = GroupRequestSyncMessage().apply {
+        val groupSyncRequestMessage = GroupSyncRequestMessage()
+            .apply {
             fromIdentity = contact.identity
             fromIdentity = contact.identity
             toIdentity = myContact.identity
             toIdentity = myContact.identity
             apiGroupId = group.apiGroupId
             apiGroupId = group.apiGroupId
             groupCreator = group.groupCreator.identity
             groupCreator = group.groupCreator.identity
         }
         }
 
 
-        processMessage(groupRequestSyncMessage, contact.identityStore)
+        processMessage(groupSyncRequestMessage, contact.identityStore)
 
 
         // Check that a setup message has been sent with empty members list
         // Check that a setup message has been sent with empty members list
-        assertEquals(1, sentMessages.size)
-        val setupMessage = sentMessages.first() as GroupCreateMessage
+        assertEquals(1, sentMessagesInsideTask.size)
+        val setupMessage = sentMessagesInsideTask.first() as GroupSetupMessage
         assertArrayEquals(emptyArray(), setupMessage.members)
         assertArrayEquals(emptyArray(), setupMessage.members)
         assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
         assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
         assertEquals(contact.identity, setupMessage.toIdentity)
         assertEquals(contact.identity, setupMessage.toIdentity)

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

@@ -25,15 +25,38 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.filters.LargeTest
 import ch.threema.app.DangerousTest
 import ch.threema.app.DangerousTest
 import ch.threema.domain.protocol.csp.messages.GroupTextMessage
 import ch.threema.domain.protocol.csp.messages.GroupTextMessage
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert
+import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runner.RunWith
 
 
 /**
 /**
  * Tests that the common group receive steps are executed for a group text message.
  * Tests that the common group receive steps are executed for a group text message.
  */
  */
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @LargeTest
 @DangerousTest
 @DangerousTest
 class IncomingGroupTextTest : GroupControlTest<GroupTextMessage>() {
 class IncomingGroupTextTest : GroupControlTest<GroupTextMessage>() {
+
+    @Test
+    fun testForwardSecureTextMessages() = runBlocking {
+        val firstMessage = GroupTextMessage()
+        firstMessage.fromIdentity = contactA.identity
+        firstMessage.toIdentity = myContact.identity
+        firstMessage.text = "First"
+        firstMessage.groupCreator = groupA.groupCreator.identity
+        firstMessage.apiGroupId = groupA.apiGroupId
+
+        // We enforce forward secure messages in the TestTaskCodec and forward security status
+        // listener. Therefore it is sufficient to test that processing a message succeeds.
+        processMessage(firstMessage, contactA.identityStore)
+
+        Assert.assertTrue(sentMessagesInsideTask.isEmpty())
+        Assert.assertTrue(sentMessagesNewTask.isEmpty())
+    }
+
     override fun createMessageForGroup(): GroupTextMessage {
     override fun createMessageForGroup(): GroupTextMessage {
         return GroupTextMessage().apply { text = "Group text message" }
         return GroupTextMessage().apply { text = "Group text message" }
     }
     }

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

@@ -0,0 +1,273 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2023-2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.processors
+
+import ch.threema.app.DangerousTest
+import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.domain.models.MessageId
+import ch.threema.domain.protocol.csp.ProtocolDefines
+import ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGCONSUMED
+import ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGREAD
+import ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGRECEIVED
+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.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
+import ch.threema.domain.protocol.csp.messages.ballot.BallotDataChoice
+import ch.threema.domain.protocol.csp.messages.ballot.BallotDataChoiceBuilder
+import ch.threema.domain.protocol.csp.messages.ballot.BallotId
+import ch.threema.domain.protocol.csp.messages.ballot.BallotVote
+import ch.threema.domain.protocol.csp.messages.ballot.PollSetupMessage
+import ch.threema.domain.protocol.csp.messages.ballot.PollVoteMessage
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertTrue
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.fail
+import org.junit.Test
+import java.util.Date
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@DangerousTest
+class IncomingMessageProcessorTest : MessageProcessorProvider() {
+
+    @Test
+    fun testIncomingTextMessage() = runTest {
+        assertSuccessfulMessageProcessing(
+            TextMessage().also { it.text = "Hello!" }.enrich(),
+            contactA
+        )
+    }
+
+    @Test
+    fun testIncomingLocationMessage() = runTest {
+        assertSuccessfulMessageProcessing(LocationMessage().also {
+            it.longitude = 0.0
+            it.longitude = 0.0
+        }.enrich(), contactA)
+
+        assertSuccessfulMessageProcessing(LocationMessage().enrich(), contactA)
+    }
+
+    @Test
+    fun testIncomingPoll() = runTest {
+        val ballotId = BallotId()
+        val ballotCreator = contactA.identity
+
+        val ballotData = BallotData().also { data ->
+            data.description = "This describes the ballot!"
+            data.assessmentType = BallotData.AssessmentType.SINGLE
+            data.type = BallotData.Type.INTERMEDIATE
+            List<BallotDataChoice>(10) { index ->
+                BallotDataChoiceBuilder()
+                    .setId(index)
+                    .setDescription("This is choice $index!")
+                    .setSortKey(index)
+                    .build()
+            }.forEach { data.addChoice(it) }
+            data.displayType = BallotData.DisplayType.LIST_MODE
+            data.state = BallotData.State.OPEN
+        }
+
+        val pollSetupMessage = PollSetupMessage().also {
+            it.ballotCreator = ballotCreator
+            it.ballotId = ballotId
+            it.data = ballotData
+        }.enrich()
+
+        // Test a valid ballot setup message that opens a poll
+        assertSuccessfulMessageProcessing(pollSetupMessage, contactA)
+
+        val pollVoteMessage = PollVoteMessage().also { voteMessage ->
+            voteMessage.ballotId = ballotId
+            voteMessage.ballotCreator = ballotCreator
+            voteMessage.ballotVotes.addAll(List(5) { index ->
+                BallotVote().also {
+                    it.id = index
+                    it.value = 0
+                }
+            })
+        }.enrich()
+
+        assertSuccessfulMessageProcessing(pollVoteMessage, contactA)
+    }
+
+    @Test
+    fun testIncomingDeliveryReceipt() = runTest {
+        val messageId = MessageId()
+
+        // Test 'received'
+        assertSuccessfulMessageProcessing(
+            DeliveryReceiptMessage().also {
+                it.receiptType = DELIVERYRECEIPT_MSGRECEIVED
+                it.receiptMessageIds = arrayOf(messageId)
+                it.messageId = MessageId(0)
+            }.enrich(), contactA
+        )
+
+        // Test 'consumed'
+        assertSuccessfulMessageProcessing(
+            DeliveryReceiptMessage().also {
+                it.receiptType = DELIVERYRECEIPT_MSGCONSUMED
+                it.receiptMessageIds = arrayOf(messageId)
+            }.enrich(), contactA
+        )
+
+        // Test 'read'
+        assertSuccessfulMessageProcessing(
+            DeliveryReceiptMessage().also {
+                it.receiptType = DELIVERYRECEIPT_MSGREAD
+                it.receiptMessageIds = arrayOf(messageId)
+            }.enrich(), contactA
+        )
+
+        // Test 'userack'
+        assertSuccessfulMessageProcessing(
+            DeliveryReceiptMessage().also {
+                it.receiptType = DELIVERYRECEIPT_MSGUSERACK
+                it.receiptMessageIds = arrayOf(messageId)
+            }.enrich(), contactA
+        )
+
+        // Test 'userdec'
+        assertSuccessfulMessageProcessing(
+            DeliveryReceiptMessage().also {
+                it.receiptType = DELIVERYRECEIPT_MSGUSERDEC
+                it.receiptMessageIds = arrayOf(messageId)
+            }.enrich(), contactA
+        )
+
+        // Test 'received' with two times the same message id
+        assertSuccessfulMessageProcessing(
+            DeliveryReceiptMessage().also {
+                it.receiptType = DELIVERYRECEIPT_MSGRECEIVED
+                it.receiptMessageIds = arrayOf(messageId, messageId)
+                it.messageId = MessageId(0)
+            }.enrich(), contactA
+        )
+
+        // Test 'received' with many message ids
+        assertSuccessfulMessageProcessing(
+            DeliveryReceiptMessage().also {
+                it.receiptType = DELIVERYRECEIPT_MSGRECEIVED
+                it.receiptMessageIds = Array(100) { MessageId() }
+                it.messageId = MessageId(0)
+            }.enrich(), contactA
+        )
+    }
+
+    @Test
+    fun testIncomingTypingIndicator() = runTest {
+        assertSuccessfulMessageProcessing(
+            TypingIndicatorMessage().also { it.isTyping = true }.enrich(),
+            contactA
+        )
+        assertSuccessfulMessageProcessing(
+            TypingIndicatorMessage().also { it.isTyping = false }.enrich(),
+            contactA
+        )
+    }
+
+    @Test
+    fun testInvalidMessage() = runTest {
+        val badMessage = TextMessage().also {
+            it.fromIdentity = contactA.identity
+            it.toIdentity = myContact.identity
+            it.messageId = MessageId()
+            it.date = Date()
+            it.text = "" // Bad message; cannot be decoded due to invalid length
+        }
+
+        // Processing the message should not result in a crash, it should just ack the message
+        // towards the server, discard it and no delivery receipt should be sent
+        processMessage(badMessage, contactA.identityStore)
+
+        // Assert that no messages are sent (also no delivery receipt, as it is an invalid message)
+        assertTrue(sentMessagesNewTask.isEmpty())
+        assertTrue(sentMessagesInsideTask.isEmpty())
+    }
+
+    @Test
+    fun testMessageToSomeoneElse() = runTest {
+        val messageToB = TextMessage().also {
+            it.fromIdentity = contactA.identity
+            it.toIdentity = contactB.identity
+            it.messageId = MessageId()
+            it.date = Date()
+            it.text = "This message is for contact B!"
+        }
+
+        assertFailingMessageProcessing(messageToB, contactA)
+    }
+
+    private suspend fun assertSuccessfulMessageProcessing(
+        message: AbstractMessage,
+        fromContact: TestContact,
+    ) {
+        val messageId = message.messageId
+        processMessage(
+            message.also { it.fromIdentity = fromContact.identity },
+            fromContact.identityStore
+        )
+
+        val expectDeliveryReceiptSent = message.sendAutomaticDeliveryReceipt()
+                && !message.hasFlags(ProtocolDefines.MESSAGE_FLAG_NO_DELIVERY_RECEIPTS)
+        if (expectDeliveryReceiptSent) {
+            val deliveryReceiptMessage = sentMessagesInsideTask.poll()
+            if (deliveryReceiptMessage is DeliveryReceiptMessage) {
+                assertArrayEquals(messageId.messageId, deliveryReceiptMessage.receiptMessageIds[0].messageId)
+                assertEquals(DELIVERYRECEIPT_MSGRECEIVED, deliveryReceiptMessage.receiptType)
+            } else {
+                fail("Instead of delivery receipt we got $deliveryReceiptMessage")
+            }
+        }
+
+        assertTrue(sentMessagesInsideTask.isEmpty())
+        assertTrue(sentMessagesNewTask.isEmpty())
+    }
+
+    private suspend fun assertFailingMessageProcessing(
+        message: AbstractMessage,
+        fromContact: TestContact,
+    ) {
+        processMessage(
+            message.also { it.fromIdentity = fromContact.identity },
+            fromContact.identityStore
+        )
+
+        assertTrue(sentMessagesInsideTask.isEmpty())
+        assertTrue(sentMessagesNewTask.isEmpty())
+    }
+
+    private fun AbstractMessage.enrich(): AbstractMessage {
+        toIdentity = myContact.identity
+        date = Date()
+        messageId = MessageId()
+        return this
+    }
+
+}

+ 0 - 74
app/src/androidTest/java/ch/threema/app/processors/MessageAckProcessorTest.java

@@ -1,74 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2021-2024 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.processors;
-
-import org.junit.Assert;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import java.util.Objects;
-
-import androidx.test.ext.junit.runners.AndroidJUnit4;
-import androidx.test.filters.LargeTest;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.services.MessageService;
-import ch.threema.app.testutils.CaptureLogcatOnTestFailureRule;
-import ch.threema.domain.models.MessageId;
-import ch.threema.domain.models.QueueMessageId;
-
-@RunWith(AndroidJUnit4.class)
-@LargeTest
-public class MessageAckProcessorTest {
-	// Test rules
-	@Rule public CaptureLogcatOnTestFailureRule captureLogcatOnTestFailureRule = new CaptureLogcatOnTestFailureRule();
-
-	// Services
-	private MessageService messageService;
-
-	// Message ack processor
-	private MessageAckProcessor messageAckProcessor;
-
-	@Before
-	public void setUp() throws Exception {
-		// Load services
-		final ServiceManager serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
-		this.messageService = serviceManager.getMessageService();
-
-		// Create processor
-		this.messageAckProcessor = new MessageAckProcessor();
-		this.messageAckProcessor.setMessageService(this.messageService);
-	}
-
-	/**
-	 * Ensure that {@link MessageAckProcessor#wasRecentlyAcked(MessageId)} works.
-	 */
-	@Test
-	public void wasRecentlyAcked() {
-		final QueueMessageId queueMessageId = new QueueMessageId(new MessageId(), "09BNNVR2");
-		Assert.assertFalse(this.messageAckProcessor.wasRecentlyAcked(queueMessageId.getMessageId()));
-		this.messageAckProcessor.processAck(queueMessageId);
-		Assert.assertTrue(this.messageAckProcessor.wasRecentlyAcked(queueMessageId.getMessageId()));
-	}
-}

+ 516 - 0
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt

@@ -0,0 +1,516 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2023-2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.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.ThreemaApplication
+import ch.threema.app.managers.ServiceManager
+import ch.threema.app.services.FileService
+import ch.threema.app.services.LifetimeService
+import ch.threema.app.testutils.TestHelpers
+import ch.threema.app.testutils.TestHelpers.TestContact
+import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.app.utils.ForwardSecurityStatusSender
+import ch.threema.base.crypto.NonceFactory
+import ch.threema.base.crypto.NonceStore
+import ch.threema.domain.fs.DHSession
+import ch.threema.domain.helpers.InMemoryContactStore
+import ch.threema.domain.helpers.InMemoryDHSessionStore
+import ch.threema.domain.helpers.InMemoryNonceStore
+import ch.threema.domain.helpers.DecryptTaskCodec
+import ch.threema.domain.models.Contact
+import ch.threema.domain.models.GroupId
+import ch.threema.domain.protocol.ThreemaFeature
+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
+import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
+import ch.threema.domain.protocol.csp.messages.AbstractMessage
+import ch.threema.domain.protocol.csp.messages.TextMessage
+import ch.threema.domain.protocol.csp.messages.fs.ForwardSecurityDataInit
+import ch.threema.domain.protocol.csp.messages.fs.ForwardSecurityEnvelopeMessage
+import ch.threema.domain.stores.ContactStore
+import ch.threema.domain.stores.IdentityStoreInterface
+import ch.threema.domain.taskmanager.ActiveTaskCodec
+import ch.threema.domain.taskmanager.QueueSendCompleteListener
+import ch.threema.domain.taskmanager.Task
+import ch.threema.domain.taskmanager.TaskCodec
+import ch.threema.domain.taskmanager.TaskManager
+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 junit.framework.TestCase.assertEquals
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.rules.Timeout
+import java.io.File
+import java.util.LinkedList
+import java.util.Queue
+
+open class MessageProcessorProvider {
+
+    protected val myContact: TestContact = TestHelpers.TEST_CONTACT
+    protected val contactA = TestContact("12345678")
+    protected val contactB = TestContact("ABCDEFGH")
+    protected val contactC = TestContact("SX96PM5A")
+
+    protected val myGroup = TestGroup(GroupId(0), myContact, listOf(myContact, contactA), "MyGroup")
+    protected val myGroupWithProfilePicture =
+        TestGroup(
+            GroupId(1),
+            myContact,
+            listOf(myContact, contactA),
+            "MyGroupWithPicture",
+            byteArrayOf(0, 1, 2, 3)
+        )
+    protected val groupA =
+        TestGroup(GroupId(2), contactA, listOf(myContact, contactA), "GroupA")
+    protected val groupB =
+        TestGroup(GroupId(3), contactB, listOf(myContact, contactB), "GroupB")
+    protected val groupAB =
+        TestGroup(GroupId(4), contactA, listOf(myContact, contactA, contactB), "GroupAB")
+    protected val groupAUnknown =
+        TestGroup(GroupId(5), contactA, listOf(myContact, contactA, contactB), "GroupAUnknown")
+    protected val groupALeft =
+        TestGroup(GroupId(6), contactA, listOf(contactA, contactB), "GroupALeft")
+    protected val myUnknownGroup =
+        TestGroup(GroupId(7), myContact, listOf(myContact, contactA), "MyUnknownGroup")
+    protected val myLeftGroup =
+        TestGroup(GroupId(8), myContact, listOf(contactA), "MyLeftGroup")
+    protected val newAGroup =
+        TestGroup(GroupId(9), contactA, listOf(myContact, contactA, contactB), "NewAGroup")
+
+    protected val serviceManager: ServiceManager = ThreemaApplication.requireServiceManager()
+    private val contactStore: ContactStore = InMemoryContactStore().apply {
+        addContact(myContact.contact, true)
+        addContact(contactA.contact, true)
+        addContact(contactB.contact, true)
+        addContact(contactC.contact, true)
+    }
+
+    private val identityMap = listOf(
+        myContact.identity to myContact.identityStore,
+        contactA.identity to contactA.identityStore,
+        contactB.identity to contactB.identityStore,
+        contactC.identity to contactC.identityStore,
+    ).toMap()
+
+    private val forwardSecurityStatusListener = object : ForwardSecurityStatusSender(serviceManager.contactService, serviceManager.messageService, null) {
+        override fun messageWithoutFSReceived(
+            contact: Contact,
+            session: DHSession,
+            message: AbstractMessage
+        ) {
+            throw AssertionError("We do not accept messages without forward security")
+        }
+    }
+
+    private val forwardSecurityMessageProcessorMap = listOf(
+        myContact.identity to serviceManager.forwardSecurityMessageProcessor,
+        contactA.identity to ForwardSecurityMessageProcessor(
+            InMemoryDHSessionStore(),
+            contactStore,
+            contactA.identityStore,
+            NonceFactory(InMemoryNonceStore()),
+            forwardSecurityStatusListener
+        ),
+        contactB.identity to ForwardSecurityMessageProcessor(
+            InMemoryDHSessionStore(),
+            contactStore,
+            contactB.identityStore, NonceFactory(InMemoryNonceStore()),
+            forwardSecurityStatusListener
+        ),
+        contactC.identity to ForwardSecurityMessageProcessor(
+            InMemoryDHSessionStore(),
+            contactStore,
+            contactC.identityStore,
+            NonceFactory(InMemoryNonceStore()),
+            forwardSecurityStatusListener
+        ),
+    ).toMap()
+
+    /**
+     * Do not use this field in tests! This is only to restore the original task manager in the
+     * service manager after the test.
+     */
+    private lateinit var originalTaskManager: TaskManager
+
+    /**
+     * The local task codec is used for running tasks directly in the tests. We can use this to
+     * check that messages are being sent inside the directly run task. Note that the test task
+     * codec automatically enqueues server acks for outgoing message and decrypts outgoing
+     * forward security messages.
+     */
+    private val localTaskCodec =
+        DecryptTaskCodec(contactStore, identityMap, forwardSecurityMessageProcessorMap)
+
+    /**
+     * The global task codec is used when new tasks are created.
+     */
+    private val globalTaskCodec =
+        DecryptTaskCodec(contactStore, identityMap, forwardSecurityMessageProcessorMap)
+
+    private val globalTaskQueue: Queue<QueueEntry<*>> = LinkedList()
+
+    private data class QueueEntry<R>(
+        private val task: Task<R, TaskCodec>,
+        private val done: CompletableDeferred<R>,
+        private val taskCodec: TaskCodec,
+    ) {
+        fun run() = runBlocking {
+            done.complete(task.invoke(taskCodec))
+        }
+    }
+
+    protected val sentMessagesInsideTask: Queue<AbstractMessage> =
+        localTaskCodec.outboundAbstractMessages
+
+    protected val sentMessagesNewTask: Queue<AbstractMessage> =
+        globalTaskCodec.outboundAbstractMessages
+
+    protected val initialContacts = listOf(myContact, contactA, contactB, contactC)
+
+    protected val initialGroups =
+        listOf(myGroup, myGroupWithProfilePicture, groupA, groupB, groupAB, groupALeft, myLeftGroup)
+
+    @Rule
+    @JvmField
+    val timeout: Timeout = Timeout.seconds(300)
+
+    @JvmField
+    @Rule
+    val grantPermissionRule: GrantPermissionRule =
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
+        } else {
+            GrantPermissionRule.grant()
+        }
+
+    /**
+     * Asserts that the correct identity is set up and fills the database with the initial data.
+     */
+    @Before
+    fun setup() {
+        assert(myContact.identity == TestHelpers.ensureIdentity(ThreemaApplication.requireServiceManager()))
+
+        // 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
+
+        val mockTaskManager = object : TaskManager {
+            override fun <R> schedule(task: Task<R, TaskCodec>): Deferred<R> {
+                val deferred = CompletableDeferred<R>()
+                globalTaskQueue.add(QueueEntry(task, deferred, globalTaskCodec))
+                return deferred
+            }
+
+            override fun hasPendingTasks(): Boolean = false
+
+            @Deprecated(
+                "We should only be able to send and receive messages from within tasks.",
+                replaceWith = ReplaceWith("TaskManager#schedule")
+            )
+            override fun getMigrationTaskHandle(): ActiveTaskCodec = globalTaskCodec
+
+            override fun addQueueSendCompleteListener(listener: QueueSendCompleteListener) {
+                // Nothing to do
+            }
+
+            override fun removeQueueSendCompleteListener(listener: QueueSendCompleteListener) {
+                // Nothing to do
+            }
+        }
+
+        setTaskManager(mockTaskManager)
+
+        disableLifetimeService()
+
+        clearData()
+
+        fillDatabase()
+
+        val nonceFactory = NonceFactory(InMemoryNonceStore())
+
+        val myForwardSecurityMessageProcessor = serviceManager.forwardSecurityMessageProcessor
+        initialContacts.filter { it != myContact }.forEach {
+            val textMessage = TextMessage().apply {
+                toIdentity = it.identity
+                fromIdentity = myContact.identity
+                text = "Text"
+            }
+            // Making the message triggers an fs init message. We do not need to send the
+            // encapsulated message as we only want to initiate a new fs session. Therefore we just
+            // need to send the first message, which is the init.
+            val result =
+                myForwardSecurityMessageProcessor.makeMessage(it.contact, textMessage, globalTaskCodec)
+
+            // Commit the dh session state
+            myForwardSecurityMessageProcessor.commitSessionState(result)
+
+            // Process the init message
+            val initCspMessage = result
+                .outgoingMessages
+                .first()
+                .apply { toIdentity = it.contact.identity }
+                .toCspMessage(myContact.identityStore, contactStore, nonceFactory, nonceFactory.next(false))
+
+            val initMessageBox = MessageBox.parseBinary(initCspMessage.toOutgoingMessageData().data)
+            val init = MessageCoder(contactStore, it.identityStore).decode(initMessageBox) as ForwardSecurityEnvelopeMessage
+            runBlocking {
+                forwardSecurityMessageProcessorMap[it.identity]!!.processInit(
+                    myContact.contact,
+                    init.data as ForwardSecurityDataInit,
+                    globalTaskCodec
+                )
+            }
+
+            // Note that we do not need to explicitly process the accept as this is already done by
+            // decapsulating the message. The message is decapsulated as soon as it is put into the
+            // global task handle. Processing an init immediately sends out an accept message.
+        }
+    }
+
+    /**
+     * Clean the data after the tests. This includes the deletion of the database entries, the
+     * avatar files, and the blocked contacts.
+     */
+    @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()
+            groupRequestSyncLogModelFactory.deleteAll()
+            ballotModelFactory.deleteAll()
+            ballotChoiceModelFactory.deleteAll()
+            ballotVoteModelFactory.deleteAll()
+            identityBallotModelFactory.deleteAll()
+            webClientSessionModelFactory.deleteAll()
+            conversationTagFactory.deleteAll()
+            outgoingGroupJoinRequestModelFactory.deleteAll()
+            incomingGroupJoinRequestModelFactory.deleteAll()
+            serverMessageModelFactory.deleteAll()
+            taskArchiveFactory.deleteAll()
+        }
+
+        // Delete dh sessions
+        initialContacts.forEach {
+            serviceManager.dhSessionStore.deleteAllDHSessions(myContact.identity, it.identity)
+        }
+
+        // Remove files
+        serviceManager.fileService.removeAllAvatars()
+        serviceManager.fileService.remove(
+            File(
+                InstrumentationRegistry.getInstrumentation().context.filesDir,
+                "taskArchive"
+            ), true
+        )
+
+        // Unblock contacts
+        serviceManager.blackListService.removeAll()
+    }
+
+    private fun setTaskManager(taskManager: TaskManager) {
+        val field = ServiceManager::class.java.getDeclaredField("taskManager")
+        field.isAccessible = true
+        field.set(ThreemaApplication.getServiceManager(), taskManager)
+    }
+
+    private fun disableLifetimeService() {
+        val field = ServiceManager::class.java.getDeclaredField("lifetimeService")
+        field.isAccessible = true
+        field.set(ThreemaApplication.getServiceManager(), object : LifetimeService {
+            override fun acquireConnection(sourceTag: String, unpauseable: Boolean) = Unit
+            override fun acquireConnection(source: String) = Unit
+            override fun acquireUnpauseableConnection(source: String) = Unit
+            override fun releaseConnection(sourceTag: String) = Unit
+            override fun releaseConnectionLinger(sourceTag: String, timeoutMs: Long) = Unit
+            override fun ensureConnection() = Unit
+            override fun alarm(intent: Intent?) = Unit
+            override fun isActive(): Boolean = true
+            override fun pause() = Unit
+            override fun unpause() = Unit
+            override fun addListener(listener: LifetimeService.LifetimeServiceListener?) = Unit
+        })
+    }
+
+    /**
+     * Fills basic data into the database. This is executed before each test. Override this if other
+     * database entries are needed.
+     */
+    open fun fillDatabase() {
+        val databaseService = serviceManager.databaseServiceNew
+        val contactStore = serviceManager.contactStore
+        val fileService = serviceManager.fileService
+
+        initialContacts.forEach { addContactToDatabase(it, databaseService, contactStore, AcquaintanceLevel.GROUP) }
+
+        initialGroups.forEach { addGroupToDatabase(it, databaseService, fileService) }
+    }
+
+    private fun addContactToDatabase(
+        testContact: TestContact,
+        databaseService: DatabaseServiceNew,
+        contactStore: ContactStore,
+        acquaintanceLevel: AcquaintanceLevel = AcquaintanceLevel.DIRECT,
+    ) {
+        databaseService.contactModelFactory.createOrUpdate(
+            testContact.contactModel.setAcquaintanceLevel(acquaintanceLevel)
+                .setFeatureMask(ThreemaFeature.FORWARD_SECURITY)
+        )
+
+        contactStore.addCachedContact(testContact.contact)
+    }
+
+    private fun addGroupToDatabase(
+        testGroup: TestGroup,
+        databaseService: DatabaseServiceNew,
+        fileService: FileService,
+    ) {
+        val groupModel = testGroup.groupModel
+        databaseService.groupModelFactory.createOrUpdate(groupModel)
+        testGroup.setLocalGroupId(groupModel.id)
+        testGroup.members.forEach { member ->
+            val memberModel = GroupMemberModel()
+                .setGroupId(groupModel.id)
+                .setIdentity(member.identity)
+            databaseService.groupMemberModelFactory.createOrUpdate(memberModel)
+        }
+        if (testGroup.profilePicture != null) {
+            fileService.writeGroupAvatar(groupModel, testGroup.profilePicture)
+        }
+    }
+
+    /**
+     * Send a message from a user with the provided identity store.
+     */
+    protected suspend fun processMessage(
+        message: AbstractMessage,
+        identityStore: IdentityStoreInterface,
+    ) {
+        val messageBox = createMessageBox(
+            message,
+            identityStore,
+            forwardSecurityMessageProcessorMap[message.fromIdentity]!!
+        )
+
+        // Process the group message
+        val messageProcessor = serviceManager.let {
+            IncomingMessageProcessorImpl(
+                it.messageService,
+                it.nonceFactory,
+                it.forwardSecurityMessageProcessor,
+                it.contactService,
+                it.contactStore,
+                it.identityStore,
+                it.blackListService,
+                it.preferenceService,
+                it
+            )
+        }
+        messageProcessor.processIncomingMessage(messageBox, localTaskCodec)
+
+        // Assert that this message has been acked towards the server
+        assertEquals(
+            message.hasFlags(ProtocolDefines.MESSAGE_FLAG_NO_SERVER_ACK),
+            !localTaskCodec.ackedIncomingMessages.contains(message.messageId)
+        )
+
+        while (globalTaskQueue.isNotEmpty()) {
+            globalTaskQueue.poll()?.run()
+        }
+    }
+
+    /**
+     * Create a message box from a user with the given identity store.
+     */
+    private fun createMessageBox(
+        msg: AbstractMessage,
+        identityStore: IdentityStoreInterface,
+        forwardSecurityMessageProcessor: ForwardSecurityMessageProcessor,
+    ): MessageBox {
+        val nonceFactory = NonceFactory(object : NonceStore {
+            override fun exists(nonce: ByteArray) = false
+            override fun store(nonce: ByteArray) = true
+            override fun getAllHashedNonces() = listOf<ByteArray>()
+        })
+
+        val encapsulated = forwardSecurityMessageProcessor.makeMessage(
+            contactStore.getContactForIdentityIncludingCache(
+                msg.toIdentity
+            )!!, msg, globalTaskCodec
+        ).outgoingMessages.last()
+
+        val messageCoder = MessageCoder(contactStore, identityStore)
+        return messageCoder.encode(encapsulated, nonceFactory.next(false), nonceFactory)
+    }
+
+}

+ 0 - 240
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorTest.java

@@ -1,240 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2021-2024 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.processors;
-
-import org.junit.Assert;
-import org.junit.Before;
-import org.junit.Ignore;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.TestName;
-import org.junit.runner.RunWith;
-
-import java.util.Objects;
-
-import androidx.test.ext.junit.runners.AndroidJUnit4;
-import androidx.test.filters.LargeTest;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.services.ContactService;
-import ch.threema.app.services.FileService;
-import ch.threema.app.services.GroupService;
-import ch.threema.app.services.IdListService;
-import ch.threema.app.services.MessageService;
-import ch.threema.app.services.NotificationService;
-import ch.threema.app.services.PreferenceService;
-import ch.threema.app.services.ballot.BallotService;
-import ch.threema.app.services.group.GroupJoinResponseService;
-import ch.threema.app.services.group.IncomingGroupJoinRequestService;
-import ch.threema.app.testutils.CaptureLogcatOnTestFailureRule;
-import ch.threema.app.testutils.TestHelpers;
-import ch.threema.app.testutils.ThreemaAssert;
-import ch.threema.app.voip.groupcall.GroupCallManager;
-import ch.threema.app.voip.services.VoipStateService;
-import ch.threema.base.ThreemaException;
-import ch.threema.base.crypto.NonceFactory;
-import ch.threema.base.utils.Utils;
-import ch.threema.domain.helpers.InMemoryContactStore;
-import ch.threema.domain.helpers.InMemoryIdentityStore;
-import ch.threema.domain.helpers.InMemoryNonceStore;
-import ch.threema.domain.models.Contact;
-import ch.threema.domain.models.MessageId;
-import ch.threema.domain.protocol.ServerAddressProvider;
-import ch.threema.domain.protocol.csp.ProtocolDefines;
-import ch.threema.domain.protocol.csp.coders.MessageBox;
-import ch.threema.domain.protocol.csp.coders.MessageCoder;
-import ch.threema.domain.protocol.csp.connection.MessageProcessorInterface.ProcessIncomingResult;
-import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor;
-import ch.threema.domain.protocol.csp.messages.DeliveryReceiptMessage;
-import ch.threema.domain.stores.ContactStore;
-import ch.threema.domain.stores.IdentityStoreInterface;
-
-@RunWith(AndroidJUnit4.class)
-@LargeTest
-public class MessageProcessorTest {
-	// Test rules
-	@Rule public TestName name = new TestName();
-	@Rule public CaptureLogcatOnTestFailureRule captureLogcatOnTestFailureRule = new CaptureLogcatOnTestFailureRule();
-
-	private final static Contact TEST_CONTACT_1 = new Contact("09BNNVR2", Utils.hexStringToByteArray("e4613bbe5408d342fdabc3edf4509d1a3aecd7cb0598773987eef8400e74c81a"));
-	private final static Contact TEST_CONTACT_2 = new Contact("0BSXZ4P8", Utils.hexStringToByteArray("dee1cd341de88f783a768941eac702951c8bbb21e836da4a43ab8f3776fc0a65"));
-
-	private NonceFactory nonceFactory;
-
-	// Stores
-	private IdentityStoreInterface identityStore;
-	private IdentityStoreInterface identityStore2;
-	private ContactStore contactStore;
-
-	// Message processor
-	private MessageProcessor messageProcessor;
-
-	@Before
-	public void setUp() throws Exception {
-		// Load services
-		// Services
-		final ServiceManager serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
-		MessageService messageService = serviceManager.getMessageService();
-		ContactService contactService = serviceManager.getContactService();
-		PreferenceService preferenceService = serviceManager.getPreferenceService();
-		GroupService groupService = serviceManager.getGroupService();
-		GroupJoinResponseService groupJoinResponseService = serviceManager.getGroupJoinResponseService();
-		IncomingGroupJoinRequestService incomingGroupJoinRequestService = serviceManager.getIncomingGroupJoinRequestService();
-		IdListService blackListService = serviceManager.getBlackListService();
-		BallotService ballotService = serviceManager.getBallotService();
-		FileService fileService = serviceManager.getFileService();
-		NotificationService notificationService = serviceManager.getNotificationService();
-		VoipStateService voipStateService = serviceManager.getVoipStateService();
-		this.nonceFactory = new NonceFactory(new InMemoryNonceStore());
-		ForwardSecurityMessageProcessor forwardSecurityMessageProcessor = serviceManager.getForwardSecurityMessageProcessor();
-		GroupCallManager groupCallManager = serviceManager.getGroupCallManager();
-		ServerAddressProvider serverAddressProvider = serviceManager.getServerAddressProviderService().getServerAddressProvider();
-
-		// Create in-memory stores
-		this.contactStore = new InMemoryContactStore();
-
-		// Create two identities
-		this.identityStore = new InMemoryIdentityStore(
-			"07N3PDDA",
-			"86",
-			Utils.hexStringToByteArray("e2c457e6f90b9e4dd1f9feedb078382d1dbd1e64c616b2e8ac8ae28b8cede36e"),
-			"07N3PDDA");
-		this.identityStore2 = new InMemoryIdentityStore(
-			"07ZKBCYU",
-			"3a",
-			Utils.hexStringToByteArray("4a7983f3e4dc7d5d1a591a94dfc03b16b94a4ca5a15e4e68c3bdba4dd030dd3e"),
-			"07ZKBCYU"
-		);
-
-		// Store contacts (including ourselves, so we can encrypt messages to ourselves)
-		this.contactStore.addContact(TEST_CONTACT_1);
-		this.contactStore.addContact(TEST_CONTACT_2);
-		this.contactStore.addContact(new Contact(this.identityStore.getIdentity(), this.identityStore.getPublicKey()));
-		this.contactStore.addContact(new Contact(this.identityStore2.getIdentity(), this.identityStore2.getPublicKey()));
-
-		// Create message processor
-		this.messageProcessor = new MessageProcessor(
-			serviceManager,
-			messageService,
-			contactService,
-			this.identityStore,
-			this.contactStore,
-			preferenceService,
-			groupService,
-			groupJoinResponseService,
-			incomingGroupJoinRequestService,
-			blackListService,
-			ballotService,
-			fileService,
-			notificationService,
-			voipStateService,
-			forwardSecurityMessageProcessor,
-			groupCallManager,
-			serverAddressProvider
-		);
-	}
-
-	/**
-	 * Return logcat logs for this test (without clearing the log).
-	 */
-	private String getLogs() {
-		final String testName = this.name.getMethodName() + "(" + MessageProcessorTest.class.getName() + ")";
-		return TestHelpers.getTestLogs(testName);
-	}
-
-	/**
-	 * When a message is processed that is not directed at us, processing fails.
-	 */
-	@Test
-	@Ignore("because getLogs does not work consistently.") // TODO(ANDR-1484)
-	public void messageForOtherIdentity() throws ThreemaException {
-		final MessageBox boxmsg = new MessageBox();
-		boxmsg.setFromIdentity(TEST_CONTACT_1.getIdentity());
-		boxmsg.setToIdentity(TEST_CONTACT_2.getIdentity());
-		boxmsg.setBox(new byte[] { 0, 1, 2, 3 });
-		boxmsg.setNonce(this.nonceFactory.next());
-		final ProcessIncomingResult result = this.messageProcessor.processIncomingMessage(boxmsg);
-		Assert.assertFalse(result.wasProcessed());
-		final String logs = this.getLogs();
-		ThreemaAssert.assertContains(logs, "BadMessageException: Message is not for own identity, cannot decode");
-	}
-
-	/**
-	 * When processing an invalid box, no exception should be thrown.
-	 */
-	@Test
-	@Ignore("because getLogs does not work consistently.") // TODO(ANDR-1484)
-	public void processInvalidBox() throws ThreemaException {
-		final MessageBox boxmsg = new MessageBox();
-		boxmsg.setFromIdentity(TEST_CONTACT_1.getIdentity());
-		boxmsg.setToIdentity(this.identityStore.getIdentity());
-		boxmsg.setBox(new byte[] { 0, 1, 2, 3 });
-		boxmsg.setNonce(this.nonceFactory.next());
-		final ProcessIncomingResult result = this.messageProcessor.processIncomingMessage(boxmsg);
-		Assert.assertFalse(result.wasProcessed());
-		final String logs = this.getLogs();
-		ThreemaAssert.assertContains(logs, "ch.threema.domain.protocol.csp.messages.BadMessageException: Decryption of message from");
-	}
-
-	/**
-	 * Process a delivery receipt for an unknown message.
-	 *
-	 * Because the confirmed message is not known, a log is created,
-	 * but nothing in the database is changed.
-	 */
-	@Test
-	@Ignore("because getLogs does not work consistently.") // TODO(ANDR-1484)
-	public void processDeliveryReceiptForUnknownMessage() throws ThreemaException {
-		// Message IDs
-		final MessageId deliveryMessageId = new MessageId();
-		final MessageId deliveredMessageId = new MessageId();
-
-		// Create message box
-		final DeliveryReceiptMessage msg = new DeliveryReceiptMessage();
-		msg.setMessageId(deliveryMessageId);
-		msg.setFromIdentity(this.identityStore2.getIdentity());
-		msg.setToIdentity(this.identityStore.getIdentity());
-		msg.setReceiptType(ProtocolDefines.DELIVERYRECEIPT_MSGRECEIVED);
-		msg.setReceiptMessageIds(new MessageId[] { deliveredMessageId });
-
-		MessageCoder messageCoder = new MessageCoder(this.contactStore, this.identityStore2);
-		final MessageBox boxmsg = messageCoder.encode(msg, this.nonceFactory);
-
-		// Process message
-		final ProcessIncomingResult result = this.messageProcessor.processIncomingMessage(boxmsg);
-		Assert.assertTrue(result.wasProcessed());
-
-		// Assert log messages
-		final String logs = this.getLogs();
-		ThreemaAssert.assertContains(
-			logs,
-			"MessageProcessor: Incoming message " + deliveryMessageId
-				+ " from " + this.identityStore2.getIdentity()
-				+ " to " + this.identityStore.getIdentity()
-				+ " (type " + Utils.byteToHex((byte) ProtocolDefines.MSGTYPE_DELIVERY_RECEIPT, false, true) + ")"
-		);
-		ThreemaAssert.assertContains(
-			logs,
-			"MessageServiceImpl: Updated message state (DELIVERED) for unknown message with id " + deliveredMessageId.toString()
-		);
-	}
-}

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

@@ -253,7 +253,7 @@ public class GroupInviteServiceTest {
 			}
 			}
 
 
 			@Override
 			@Override
-			public boolean sendFlags() {
+			public boolean sendFeatureMask() {
 				return false;
 				return false;
 			}
 			}
 
 
@@ -271,6 +271,11 @@ public class GroupInviteServiceTest {
 			public void checkRevocationKey(boolean force) {
 			public void checkRevocationKey(boolean force) {
 
 
 			}
 			}
+
+			@Override
+			public void setForwardSecurityEnabled(boolean isFsEnabled) {
+
+			}
 		};
 		};
 		try {
 		try {
 			this.groupService = ThreemaApplication.getServiceManager().getGroupService();
 			this.groupService = ThreemaApplication.getServiceManager().getGroupService();

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

@@ -0,0 +1,57 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2023-2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.services.systemupdate
+
+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)
+
+    @Before
+    fun setUp() {
+        this.inMemoryDatabase.execSQL("CREATE TABLE IF NOT EXISTS testtable (hello TEXT, world INTEGER)")
+    }
+
+    @Test
+    fun testFieldExistNonExistingTable() {
+        assertFalse {
+            fieldExists(this.inMemoryDatabase, "non_existing_table", "non_existing_field")
+        }
+    }
+
+    @Test
+    fun testFieldExistExistingTable() {
+        assertFalse {
+            fieldExists(this.inMemoryDatabase, "testtable", "non_existing_field")
+        }
+        assertTrue {
+            fieldExists(this.inMemoryDatabase, "testtable", "hello")
+        }
+        assertTrue {
+            fieldExists(this.inMemoryDatabase, "testtable", "world")
+        }
+    }
+}

+ 249 - 0
app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt

@@ -0,0 +1,249 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2023-2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.tasks
+
+import ch.threema.app.ThreemaApplication
+import ch.threema.domain.models.Contact
+import ch.threema.domain.taskmanager.Task
+import ch.threema.domain.taskmanager.TaskCodec
+import com.neilalexander.jnacl.NaCl
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertNotNull
+import junit.framework.TestCase.fail
+import kotlinx.serialization.json.Json
+import org.junit.Test
+
+/**
+ * These tests are useful to detect when a task cannot be created out of a persisted representation
+ * of the task. If any of these tests fails, then it is probably because there were some changes in
+ * the serialized task data. Tasks that cannot be created due to the changed serialized
+ * representation will be dropped.
+ */
+class PersistableTasksTest {
+    private val serviceManager = ThreemaApplication.requireServiceManager()
+
+    @Test
+    fun testContactDeliveryReceiptMessageTask() {
+        assertValidEncoding(
+            OutgoingContactDeliveryReceiptMessageTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OutgoingContactDeliveryReceiptMessageTask.OutgoingDeliveryReceiptMessageData\",\"receiptType\":1,\"messageIds\":[\"0000000000000000\"],\"date\":\"1234567890\",\"toIdentity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    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]}"
+        )
+    }
+
+    @Test
+    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]}"
+        )
+    }
+
+    @Test
+    fun testGroupDeliveryReceiptMessageTask() {
+        assertValidEncoding(
+            OutgoingGroupDeliveryReceiptMessageTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupDeliveryReceiptMessageTask.OutgoingGroupDeliveryReceiptMessageData\",\"messageModelId\":0,\"recipientIdentities\":[\"01234567\",\"01234567\"],\"receiptType\":0}"
+        )
+    }
+
+    @Test
+    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]}"
+        )
+    }
+
+    @Test
+    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]}"
+        )
+    }
+
+    @Test
+    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]}"
+        )
+    }
+
+    @Test
+    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]}"
+        )
+    }
+
+    @Test
+    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]}"
+        )
+    }
+
+    @Test
+    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\"]}"
+        )
+    }
+
+    @Test
+    fun testLocationMessageTask() {
+        assertValidEncoding(
+            OutgoingLocationMessageTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OutgoingLocationMessageTask.OutgoingLocationMessageTaskData\",\"messageModelId\":0,\"recipientIdentities\":[\"01234567\",\"01234567\"],\"receiverType\":0}"
+        )
+    }
+
+    @Test
+    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\\\"]}\"}"
+        )
+    }
+
+    @Test
+    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\"}"
+        )
+    }
+
+    @Test
+    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\"}"
+        )
+    }
+
+    @Test
+    fun testTextMessageTask() {
+        assertValidEncoding(
+            OutgoingTextMessageTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OutgoingTextMessageTask.OutgoingTextMessageData\",\"messageModelId\":0,\"recipientIdentities\":[\"01234567\",\"01234567\"],\"receiverType\":0}"
+        )
+    }
+
+    @Test
+    fun testSendProfilePictureTask() {
+        assertValidEncoding(
+            SendProfilePictureTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.SendProfilePictureTask.SendProfilePictureData\",\"toIdentity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    fun testSendPushTokenTask() {
+        assertValidEncoding(
+            SendPushTokenTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.SendPushTokenTask.SendPushTokenData\",\"token\":\"token\",\"tokenType\":0}"
+        )
+    }
+
+    @Test
+    fun testOutgoingContactRequestProfilePictureTask() {
+        assertValidEncoding(
+            OutgoingContactRequestProfilePictureTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OutgoingContactRequestProfilePictureTask.OutgoingContactRequestProfilePictureData\",\"toIdentity\":\"01234567\"}"
+        )
+    }
+
+    @Test
+    fun testDeleteAndTerminateFSSessionsTask() {
+        // Add the contact '01234567' so that restoring the tasks works
+        serviceManager.contactStore.addCachedContact(Contact("01234567", ByteArray(NaCl.PUBLICKEYBYTES)))
+
+        assertValidEncoding(
+            DeleteAndTerminateFSSessionsTask::class.java,
+            "{\"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\"}"
+        )
+        assertValidEncoding(
+            DeleteAndTerminateFSSessionsTask::class.java,
+            "{\"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\"}"
+        )
+    }
+
+    @Test
+    fun testApplicationUpdateStepsTask() {
+        assertValidEncoding(
+            ApplicationUpdateStepsTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ApplicationUpdateStepsTask.ApplicationUpdateStepsData\"}"
+        )
+    }
+
+    @Test
+    fun testFSRefreshStepsTask() {
+        assertValidEncoding(
+            FSRefreshStepsTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.FSRefreshStepsTask.FSRefreshStepsTaskData\",\"contactIdentities\":[\"01234567\"]}"
+        )
+    }
+
+    @Test
+    fun testOutgoingDropDeviceTask() {
+        assertValidEncoding(
+            OutgoingDropDeviceTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OutgoingDropDeviceTask.OutgoingDropDeviceData\",\"deviceId\":0}"
+        )
+    }
+
+    private fun <T> assertValidEncoding(expectedTaskClass: Class<T>, encodedTask: String) {
+        val decodedTask = encodedTask.decodeToTask()
+        assertNotNull(decodedTask)
+        assertEquals(expectedTaskClass, decodedTask!!::class.java)
+    }
+
+    private fun String.decodeToTask(): Task<*, TaskCodec>? {
+        return try {
+            Json.decodeFromString<SerializableTaskData>(this).createTask(serviceManager)
+        } catch (e: Exception) {
+            fail("Task data decoding error for task '$this'. Error: $e")
+            null
+        }
+    }
+}

+ 169 - 0
app/src/androidTest/java/ch/threema/app/utils/BackgroundExecutorTest.kt

@@ -0,0 +1,169 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.utils
+
+import android.os.Looper
+import ch.threema.app.utils.executor.BackgroundExecutor
+import ch.threema.app.utils.executor.BackgroundTask
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.Timeout
+
+class BackgroundExecutorTest {
+
+    @Rule
+    @JvmField
+    val timeout: Timeout = Timeout.seconds(10)
+
+    private val executor = BackgroundExecutor()
+
+    @Test
+    fun testCorrectThreads() {
+        val initialThread = Thread.currentThread()
+
+        executor.execute(object : BackgroundTask<Unit> {
+            override fun runBefore() {
+                Assert.assertEquals(initialThread.id, Thread.currentThread().id)
+            }
+
+            override fun runInBackground() {
+                val currentThreadId = Thread.currentThread().id
+                Assert.assertNotEquals(initialThread.id, currentThreadId)
+                Assert.assertNotEquals(Looper.getMainLooper().thread.id, currentThreadId)
+            }
+
+            override fun runAfter(result: Unit) {
+                Assert.assertEquals(Looper.getMainLooper().thread.id, Thread.currentThread().id)
+            }
+        })
+    }
+
+    @Test
+    fun testReturnValue() {
+        executor.execute(object : BackgroundTask<Int> {
+            override fun runInBackground() = 42
+            override fun runAfter(result: Int) = Assert.assertEquals(42, result)
+        })
+    }
+
+    @Test
+    fun testOrder() = runBlocking {
+        val methodExecutionList = mutableListOf<Int>()
+        val expected = arrayOf(0, 1, 2, 3, 4, 5)
+
+        executor.executeDeferred(object : BackgroundTask<Unit> {
+            override fun runBefore() {
+                methodExecutionList.add(0)
+                Thread.sleep(500)
+                methodExecutionList.add(1)
+            }
+
+            override fun runInBackground() {
+                methodExecutionList.add(2)
+                Thread.sleep(300)
+                methodExecutionList.add(3)
+            }
+
+            override fun runAfter(result: Unit) {
+                methodExecutionList.add(4)
+                Thread.sleep(200)
+                methodExecutionList.add(5)
+
+                // Sleep again to test that the completable is completed after the runAfter method
+                // is run.
+                Thread.sleep(500)
+            }
+        }).await()
+
+        Assert.assertArrayEquals(
+            expected,
+            methodExecutionList.toTypedArray()
+        )
+    }
+
+    @Test
+    fun testFailingBefore() {
+        val deferred = executor.executeDeferred(object : BackgroundTask<Unit> {
+            override fun runBefore() {
+                throw IllegalStateException()
+            }
+
+            override fun runInBackground() {
+                // This should never be executed as run before failed
+                Assert.fail()
+            }
+
+            override fun runAfter(result: Unit) {
+                // This should never be executed as run before failed
+                Assert.fail()
+            }
+        })
+
+        Assert.assertThrows(IllegalStateException::class.java) {
+            runBlocking {
+                deferred.await()
+            }
+        }
+    }
+
+    @Test
+    fun testFailingBackground() {
+        val deferred = executor.executeDeferred(object : BackgroundTask<Unit> {
+            override fun runInBackground() {
+                throw IllegalStateException()
+            }
+
+            override fun runAfter(result: Unit) {
+                // This should never be executed as run in background failed
+                Assert.fail()
+            }
+        })
+
+        Assert.assertThrows(IllegalStateException::class.java) {
+            runBlocking {
+                deferred.await()
+            }
+        }
+    }
+
+    @Test
+    fun testFailingAfter() {
+        val deferred = executor.executeDeferred(object : BackgroundTask<Unit> {
+            override fun runInBackground() {
+                // Nothing to do
+            }
+
+            override fun runAfter(result: Unit) {
+                throw IllegalStateException()
+            }
+        })
+
+        Assert.assertThrows(IllegalStateException::class.java) {
+            runBlocking {
+                deferred.await()
+            }
+        }
+    }
+
+}

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

@@ -21,8 +21,7 @@
 
 
 package ch.threema.app.utils
 package ch.threema.app.utils
 
 
-import android.text.Spannable
-import android.text.SpannableString
+import android.text.Spanned
 import android.text.style.URLSpan
 import android.text.style.URLSpan
 import android.widget.TextView
 import android.widget.TextView
 import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.platform.app.InstrumentationRegistry
@@ -35,14 +34,14 @@ class LinkifyUtilTest {
      * Get the spannable and a list of the URL spans as a pair. If there is no spannable, a pair
      * Get the spannable and a list of the URL spans as a pair. If there is no spannable, a pair
      * containing of null and an empty list is returned.
      * containing of null and an empty list is returned.
      */
      */
-    private fun getSpanPair(text: String, includePhoneNumbers: Boolean = true): Pair<Spannable?, List<URLSpan>> {
+    private fun getSpanPair(text: String, includePhoneNumbers: Boolean = true): Pair<Spanned?, List<URLSpan>> {
         val textView = TextView(InstrumentationRegistry.getInstrumentation().context)
         val textView = TextView(InstrumentationRegistry.getInstrumentation().context)
         textView.text = text
         textView.text = text
         InstrumentationRegistry.getInstrumentation().runOnMainSync{
         InstrumentationRegistry.getInstrumentation().runOnMainSync{
             LinkifyUtil.getInstance().linkifyText(textView, includePhoneNumbers)
             LinkifyUtil.getInstance().linkifyText(textView, includePhoneNumbers)
         }
         }
         val spannableText = textView.text
         val spannableText = textView.text
-        if (spannableText !is SpannableString) {
+        if (spannableText !is Spanned) {
             return null to listOf()
             return null to listOf()
         }
         }
         val spans = spannableText.getSpans(0, text.length + 1, URLSpan::class.java).toList()
         val spans = spannableText.getSpans(0, text.length + 1, URLSpan::class.java).toList()

+ 52 - 12
app/src/androidTest/java/ch/threema/storage/SQLDHSessionStoreTest.java

@@ -21,20 +21,26 @@
 
 
 package ch.threema.storage;
 package ch.threema.storage;
 
 
+import org.hamcrest.MatcherAssert;
+import org.hamcrest.Matchers;
 import org.junit.After;
 import org.junit.After;
 import org.junit.Assert;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.Test;
 
 
 import java.nio.charset.StandardCharsets;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
 
 
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.core.app.ApplicationProvider;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.domain.fs.DHSession;
 import ch.threema.domain.fs.DHSession;
 import ch.threema.domain.fs.DHSessionId;
 import ch.threema.domain.fs.DHSessionId;
 import ch.threema.domain.helpers.DummyUsers;
 import ch.threema.domain.helpers.DummyUsers;
+import ch.threema.domain.helpers.UnusedTaskCodec;
 import ch.threema.domain.protocol.csp.messages.BadMessageException;
 import ch.threema.domain.protocol.csp.messages.BadMessageException;
 import ch.threema.domain.stores.DHSessionStoreException;
 import ch.threema.domain.stores.DHSessionStoreException;
+import ch.threema.domain.taskmanager.TaskCodec;
 
 
 public class SQLDHSessionStoreTest {
 public class SQLDHSessionStoreTest {
 
 
@@ -45,6 +51,7 @@ public class SQLDHSessionStoreTest {
 	private SQLDHSessionStore store;
 	private SQLDHSessionStore store;
 	private DHSession initiatorDHSession;
 	private DHSession initiatorDHSession;
 	private DHSession responderDHSession;
 	private DHSession responderDHSession;
+	private final TaskCodec taskCodec = new UnusedTaskCodec();
 
 
 	@Before
 	@Before
 	public void setup() {
 	public void setup() {
@@ -88,7 +95,7 @@ public class SQLDHSessionStoreTest {
 
 
 		// Delete any stored initiator session to start with a clean slate
 		// Delete any stored initiator session to start with a clean slate
 		store.deleteAllDHSessions(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity());
 		store.deleteAllDHSessions(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity());
-		Assert.assertNull(this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity()));
+		Assert.assertNull(this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec));
 
 
 		// Insert an initiator DH session in 2DH mode
 		// Insert an initiator DH session in 2DH mode
 		Assert.assertNotNull(this.initiatorDHSession.getMyRatchet2DH());
 		Assert.assertNotNull(this.initiatorDHSession.getMyRatchet2DH());
@@ -96,12 +103,12 @@ public class SQLDHSessionStoreTest {
 		store.storeDHSession(this.initiatorDHSession);
 		store.storeDHSession(this.initiatorDHSession);
 
 
 		// Retrieve the session again and ensure that the details match
 		// Retrieve the session again and ensure that the details match
-		Assert.assertEquals(this.initiatorDHSession, this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity()));
+		Assert.assertEquals(this.initiatorDHSession, this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec));
 
 
 		// Turn 2DH ratchets once (need to do this here, as responder sessions are always 4DH)
 		// Turn 2DH ratchets once (need to do this here, as responder sessions are always 4DH)
 		this.initiatorDHSession.getMyRatchet2DH().turn();
 		this.initiatorDHSession.getMyRatchet2DH().turn();
 		store.storeDHSession(this.initiatorDHSession);
 		store.storeDHSession(this.initiatorDHSession);
-		Assert.assertEquals(this.initiatorDHSession, this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity()));
+		Assert.assertEquals(this.initiatorDHSession, this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec));
 
 
 		// Now Bob sends his ephemeral public key back to Alice
 		// Now Bob sends his ephemeral public key back to Alice
 		this.initiatorDHSession.processAccept(
 		this.initiatorDHSession.processAccept(
@@ -114,7 +121,7 @@ public class SQLDHSessionStoreTest {
 		// initiatorDHSession has now been upgraded to 4DH - store and retrieve it again
 		// initiatorDHSession has now been upgraded to 4DH - store and retrieve it again
 		Assert.assertNotNull(this.initiatorDHSession.getMyRatchet4DH());
 		Assert.assertNotNull(this.initiatorDHSession.getMyRatchet4DH());
 		store.storeDHSession(this.initiatorDHSession);
 		store.storeDHSession(this.initiatorDHSession);
-		DHSession bestSession = this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity());
+		DHSession bestSession = this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec);
 		Assert.assertNotNull(bestSession);
 		Assert.assertNotNull(bestSession);
 		Assert.assertEquals(this.initiatorDHSession, bestSession);
 		Assert.assertEquals(this.initiatorDHSession, bestSession);
 
 
@@ -123,7 +130,7 @@ public class SQLDHSessionStoreTest {
 
 
 		// Delete initiator DH session
 		// Delete initiator DH session
 		store.deleteDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), this.initiatorDHSession.getId());
 		store.deleteDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), this.initiatorDHSession.getId());
-		Assert.assertNull(this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity()));
+		Assert.assertNull(this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec));
 	}
 	}
 
 
 	@Test
 	@Test
@@ -133,7 +140,7 @@ public class SQLDHSessionStoreTest {
 
 
 		// Store and retrieve the responder session
 		// Store and retrieve the responder session
 		store.storeDHSession(this.responderDHSession);
 		store.storeDHSession(this.responderDHSession);
-		Assert.assertEquals(this.responderDHSession, this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity()));
+		Assert.assertEquals(this.responderDHSession, this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), taskCodec));
 
 
 		// Turn the 4DH ratchets once, store, retrieve and compare again
 		// Turn the 4DH ratchets once, store, retrieve and compare again
 		Assert.assertNotNull(this.responderDHSession.getMyRatchet4DH());
 		Assert.assertNotNull(this.responderDHSession.getMyRatchet4DH());
@@ -141,14 +148,14 @@ public class SQLDHSessionStoreTest {
 		this.responderDHSession.getMyRatchet4DH().turn();
 		this.responderDHSession.getMyRatchet4DH().turn();
 		this.responderDHSession.getPeerRatchet4DH().turn();
 		this.responderDHSession.getPeerRatchet4DH().turn();
 		store.storeDHSession(this.responderDHSession);
 		store.storeDHSession(this.responderDHSession);
-		Assert.assertEquals(this.responderDHSession, this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity()));
+		Assert.assertEquals(this.responderDHSession, this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), taskCodec));
 
 
 		// Try to retrieve a responder session with a random session ID
 		// Try to retrieve a responder session with a random session ID
-		Assert.assertNull(this.store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), new DHSessionId()));
+		Assert.assertNull(this.store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), new DHSessionId(), taskCodec));
 
 
 		// Delete DH session
 		// Delete DH session
 		store.deleteDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId());
 		store.deleteDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId());
-		Assert.assertNull(this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity()));
+		Assert.assertNull(this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), taskCodec));
 	}
 	}
 
 
 	@Test
 	@Test
@@ -163,7 +170,7 @@ public class SQLDHSessionStoreTest {
 		store.storeDHSession(this.responderDHSession);
 		store.storeDHSession(this.responderDHSession);
 
 
 		// There should still be a 2DH ratchet at this point
 		// There should still be a 2DH ratchet at this point
-		DHSession retrievedSession = store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId());
+		DHSession retrievedSession = store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId(), taskCodec);
 		Assert.assertNotNull(retrievedSession);
 		Assert.assertNotNull(retrievedSession);
 		Assert.assertNotNull(retrievedSession.getPeerRatchet2DH());
 		Assert.assertNotNull(retrievedSession.getPeerRatchet2DH());
 
 
@@ -175,7 +182,7 @@ public class SQLDHSessionStoreTest {
 		store.storeDHSession(this.responderDHSession);
 		store.storeDHSession(this.responderDHSession);
 
 
 		// Ensure that the 2DH ratchet is really gone
 		// Ensure that the 2DH ratchet is really gone
-		retrievedSession = store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId());
+		retrievedSession = store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId(), taskCodec);
 		Assert.assertNotNull(retrievedSession);
 		Assert.assertNotNull(retrievedSession);
 		Assert.assertNull(retrievedSession.getPeerRatchet2DH());
 		Assert.assertNull(retrievedSession.getPeerRatchet2DH());
 	}
 	}
@@ -192,6 +199,39 @@ public class SQLDHSessionStoreTest {
 		}
 		}
 	}
 	}
 
 
+	@Test
+	public void testGetAllSessions() throws DHSessionStoreException {
+		// Create sessions and its id's hashes
+		List<DHSession> dhSessions = new ArrayList<>();
+		for (int i = 0; i < 5; i++) {
+			dhSessions.add(new DHSession(
+				DummyUsers.getContactForUser(DummyUsers.BOB),
+				DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
+			));
+		}
+		List<Integer> dhSessionIdHashes = new ArrayList<>(dhSessions.size());
+		for (DHSession session : dhSessions) {
+			dhSessionIdHashes.add(session.getId().hashCode());
+		}
+
+		// Store the sessions
+		for (DHSession session : dhSessions) {
+			store.storeDHSession(session);
+		}
+
+		// Load the sessions again and calculate the hashes
+		List<DHSession> storedDHSessions = store.getAllDHSessions(
+			DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec
+		);
+		List<Integer> storedDHSessionIdHashes = new ArrayList<>(storedDHSessions.size());
+		for (DHSession session : storedDHSessions) {
+			storedDHSessionIdHashes.add(session.getId().hashCode());
+		}
+
+		// Assert that the hashes match (note that the ordering does not matter)
+		MatcherAssert.assertThat(storedDHSessionIdHashes, Matchers.containsInAnyOrder(dhSessionIdHashes.toArray()));
+	}
+
 	private void testRaceConditionOnce() throws DHSession.MissingEphemeralPrivateKeyException, DHSessionStoreException, BadMessageException {
 	private void testRaceConditionOnce() throws DHSession.MissingEphemeralPrivateKeyException, DHSessionStoreException, BadMessageException {
 		createSessions();
 		createSessions();
 
 
@@ -233,7 +273,7 @@ public class SQLDHSessionStoreTest {
 		} else {
 		} else {
 			lowestSessionId = this.initiatorDHSession.getId();
 			lowestSessionId = this.initiatorDHSession.getId();
 		}
 		}
-		DHSession bestSession = store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity());
+		DHSession bestSession = store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), taskCodec);
 		Assert.assertNotNull(bestSession);
 		Assert.assertNotNull(bestSession);
 		Assert.assertEquals(lowestSessionId, bestSession.getId());
 		Assert.assertEquals(lowestSessionId, bestSession.getId());
 	}
 	}

+ 101 - 0
app/src/androidTest/java/ch/threema/storage/TaskArchiveFactoryTest.kt

@@ -0,0 +1,101 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2023-2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.storage
+
+import ch.threema.app.ThreemaApplication
+import ch.threema.storage.factories.TaskArchiveFactory
+import junit.framework.TestCase.assertEquals
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+class TaskArchiveFactoryTest {
+    private lateinit var taskArchiveFactory: TaskArchiveFactory
+
+    @Before
+    fun setup() {
+        taskArchiveFactory = ThreemaApplication.requireServiceManager().databaseServiceNew.taskArchiveFactory
+        taskArchiveFactory.deleteAll()
+    }
+
+    @After
+    fun tearDown() {
+        taskArchiveFactory.deleteAll()
+    }
+
+    @Test
+    fun testAdd() {
+        val encodedTasks = listOf(
+            "encoded task 1",
+            "encoded task 2",
+            "encoded task 3",
+        )
+        encodedTasks.forEach { taskArchiveFactory.insert(it) }
+        assertEquals(encodedTasks, taskArchiveFactory.getAll())
+    }
+
+    @Test
+    fun testRemove() {
+        val encodedTasks = listOf(
+            "oldestTask",
+            "firstRemoved",
+            "task1",
+            "task",
+            "task2",
+            "task",
+        )
+        encodedTasks.forEach { taskArchiveFactory.insert(it) }
+        assertEquals(encodedTasks, taskArchiveFactory.getAll())
+
+        taskArchiveFactory.remove("does not exist, so it should not have an effect")
+        assertEquals(encodedTasks, taskArchiveFactory.getAll())
+
+        taskArchiveFactory.remove("firstRemoved")
+        assertEquals(listOf("task1", "task", "task2", "task"), taskArchiveFactory.getAll())
+
+        taskArchiveFactory.removeOne("task")
+        assertEquals(listOf("task1", "task2", "task"), taskArchiveFactory.getAll())
+
+        taskArchiveFactory.removeOne("task2")
+        assertEquals(listOf("task1", "task"), taskArchiveFactory.getAll())
+
+        taskArchiveFactory.remove("task1")
+        assertEquals(listOf("task"), taskArchiveFactory.getAll())
+
+        taskArchiveFactory.remove("task")
+        assertEquals(emptyList<String>(), taskArchiveFactory.getAll())
+
+        taskArchiveFactory.remove("does not exist anymore, so it should not have an effect")
+        assertEquals(emptyList<String>(), taskArchiveFactory.getAll())
+    }
+
+    @Test
+    fun testTrim() {
+        val encodedTasks = listOf(
+            "encoded task 1",
+            "encoded task 2",
+            "encoded task 3",
+        )
+        encodedTasks.forEach { taskArchiveFactory.insert(" $it\n\n") }
+        assertEquals(encodedTasks, taskArchiveFactory.getAll())
+    }
+}

+ 0 - 0
app/src/red/AndroidManifest.xml → app/src/blue/AndroidManifest.xml


+ 0 - 0
app/src/red/ic_launcher-web.png → app/src/blue/ic_launcher-web.png


+ 0 - 0
app/src/red/java/ch/threema/app/activities/DownloadApkActivity.java → app/src/blue/java/ch/threema/app/activities/DownloadApkActivity.java


+ 0 - 0
app/src/red/java/ch/threema/app/utils/DownloadUtil.java → app/src/blue/java/ch/threema/app/utils/DownloadUtil.java


+ 0 - 0
app/src/red/res/drawable-hdpi/ic_notification_multi.png → app/src/blue/res/drawable-hdpi/ic_notification_multi.png


+ 0 - 0
app/src/red/res/drawable-hdpi/ic_notification_small.png → app/src/blue/res/drawable-hdpi/ic_notification_small.png


+ 0 - 0
app/src/red/res/drawable-hdpi/logo_main_white.png → app/src/blue/res/drawable-hdpi/logo_main_white.png


+ 0 - 0
app/src/red/res/drawable-mdpi/ic_notification_multi.png → app/src/blue/res/drawable-mdpi/ic_notification_multi.png


+ 0 - 0
app/src/red/res/drawable-mdpi/ic_notification_small.png → app/src/blue/res/drawable-mdpi/ic_notification_small.png


+ 0 - 0
app/src/red/res/drawable-mdpi/logo_main_white.png → app/src/blue/res/drawable-mdpi/logo_main_white.png


+ 41 - 0
app/src/blue/res/drawable-v24/ic_launcher_foreground.xml

@@ -0,0 +1,41 @@
+<vector
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="1500"
+    android:viewportHeight="1500">
+      <group android:translateX="238"
+          android:translateY="238">
+<!--    <group>-->
+        <!-- background color of icon -->
+        <path android:fillColor="#FFF"
+            android:fillType="evenOdd"
+            android:pathData="M0,0h1024v1024h-1024z" 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: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 -->
+        <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"/>
+    </group>
+</vector>

+ 0 - 0
app/src/red/res/drawable-v24/ic_launcher_monochrome.xml → app/src/blue/res/drawable-v24/ic_launcher_monochrome.xml


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_01_40ms.png → app/src/blue/res/drawable-xhdpi/anim_01_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_02_40ms.png → app/src/blue/res/drawable-xhdpi/anim_02_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_03_40ms.png → app/src/blue/res/drawable-xhdpi/anim_03_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_04_40ms.png → app/src/blue/res/drawable-xhdpi/anim_04_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_05_40ms.png → app/src/blue/res/drawable-xhdpi/anim_05_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_06_40ms.png → app/src/blue/res/drawable-xhdpi/anim_06_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_07_40ms.png → app/src/blue/res/drawable-xhdpi/anim_07_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_08_40ms.png → app/src/blue/res/drawable-xhdpi/anim_08_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_09_40ms.png → app/src/blue/res/drawable-xhdpi/anim_09_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_10_40ms.png → app/src/blue/res/drawable-xhdpi/anim_10_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_11_40ms.png → app/src/blue/res/drawable-xhdpi/anim_11_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_12_40ms.png → app/src/blue/res/drawable-xhdpi/anim_12_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_13_40ms.png → app/src/blue/res/drawable-xhdpi/anim_13_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_14_40ms.png → app/src/blue/res/drawable-xhdpi/anim_14_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_15_40ms.png → app/src/blue/res/drawable-xhdpi/anim_15_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_16_40ms.png → app/src/blue/res/drawable-xhdpi/anim_16_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_17_40ms.png → app/src/blue/res/drawable-xhdpi/anim_17_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_18_40ms.png → app/src/blue/res/drawable-xhdpi/anim_18_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_19_40ms.png → app/src/blue/res/drawable-xhdpi/anim_19_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_20_40ms.png → app/src/blue/res/drawable-xhdpi/anim_20_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_21_40ms.png → app/src/blue/res/drawable-xhdpi/anim_21_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_22_40ms.png → app/src/blue/res/drawable-xhdpi/anim_22_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_23_40ms.png → app/src/blue/res/drawable-xhdpi/anim_23_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_24_40ms.png → app/src/blue/res/drawable-xhdpi/anim_24_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_25_40ms.png → app/src/blue/res/drawable-xhdpi/anim_25_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_26_40ms.png → app/src/blue/res/drawable-xhdpi/anim_26_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_27_40ms.png → app/src/blue/res/drawable-xhdpi/anim_27_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_28_40ms.png → app/src/blue/res/drawable-xhdpi/anim_28_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_29_40ms.png → app/src/blue/res/drawable-xhdpi/anim_29_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_30_40ms.png → app/src/blue/res/drawable-xhdpi/anim_30_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_31_40ms.png → app/src/blue/res/drawable-xhdpi/anim_31_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_32_400ms.png → app/src/blue/res/drawable-xhdpi/anim_32_400ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_33_40ms.png → app/src/blue/res/drawable-xhdpi/anim_33_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_34_40ms.png → app/src/blue/res/drawable-xhdpi/anim_34_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_35_40ms.png → app/src/blue/res/drawable-xhdpi/anim_35_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_36_40ms.png → app/src/blue/res/drawable-xhdpi/anim_36_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_37_40ms.png → app/src/blue/res/drawable-xhdpi/anim_37_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_38_40ms.png → app/src/blue/res/drawable-xhdpi/anim_38_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_39_40ms.png → app/src/blue/res/drawable-xhdpi/anim_39_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_40_40ms.png → app/src/blue/res/drawable-xhdpi/anim_40_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_41_40ms.png → app/src/blue/res/drawable-xhdpi/anim_41_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_42_40ms.png → app/src/blue/res/drawable-xhdpi/anim_42_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_43_40ms.png → app/src/blue/res/drawable-xhdpi/anim_43_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_44_40ms.png → app/src/blue/res/drawable-xhdpi/anim_44_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_45_40ms.png → app/src/blue/res/drawable-xhdpi/anim_45_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_46_40ms.png → app/src/blue/res/drawable-xhdpi/anim_46_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_47_40ms.png → app/src/blue/res/drawable-xhdpi/anim_47_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_48_40ms.png → app/src/blue/res/drawable-xhdpi/anim_48_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_49_40ms.png → app/src/blue/res/drawable-xhdpi/anim_49_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_50_40ms.png → app/src/blue/res/drawable-xhdpi/anim_50_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_51_40ms.png → app/src/blue/res/drawable-xhdpi/anim_51_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_52_40ms.png → app/src/blue/res/drawable-xhdpi/anim_52_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_53_40ms.png → app/src/blue/res/drawable-xhdpi/anim_53_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_54_40ms.png → app/src/blue/res/drawable-xhdpi/anim_54_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_55_40ms.png → app/src/blue/res/drawable-xhdpi/anim_55_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_56_40ms.png → app/src/blue/res/drawable-xhdpi/anim_56_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_70_40ms.png → app/src/blue/res/drawable-xhdpi/anim_70_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_71_40ms.png → app/src/blue/res/drawable-xhdpi/anim_71_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_72_1200ms.png → app/src/blue/res/drawable-xhdpi/anim_72_1200ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_73_40ms.png → app/src/blue/res/drawable-xhdpi/anim_73_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_74_40ms.png → app/src/blue/res/drawable-xhdpi/anim_74_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_75_40ms.png → app/src/blue/res/drawable-xhdpi/anim_75_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_76_40ms.png → app/src/blue/res/drawable-xhdpi/anim_76_40ms.png


+ 0 - 0
app/src/red/res/drawable-xhdpi/anim_77_40ms.png → app/src/blue/res/drawable-xhdpi/anim_77_40ms.png


Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff