Browse Source

Version 5.3

Threema 1 year ago
parent
commit
66da899e56
100 changed files with 1848 additions and 787 deletions
  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:
 
-| 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
   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.
 
-    Copyright (c) 2013-2023 Threema GmbH
+    Copyright (c) 2013-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,

+ 2 - 2
app/assets/license.html

@@ -308,14 +308,14 @@ POSSIBILITY OF SUCH DAMAGE.</p>
 
 <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>
 
 
 <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>
 

+ 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
 
 plugins {
     id 'org.sonarqube'
+    id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
 }
 
 apply plugin: 'com.android.application'
@@ -18,9 +16,16 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 }
 
 // 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.
@@ -80,7 +85,7 @@ def keystores = [
     release: findKeystore("threema"),
     hms_release: findKeystore("threema_hms"),
     onprem_release: findKeystore("onprem"),
-    red_release: findKeystore("red"),
+    blue_release: findKeystore("red"),
 ]
 
 android {
@@ -89,7 +94,7 @@ android {
     //       `.gitlab-ci.yml` as well!
     compileSdk 34
     buildToolsVersion = '34.0.0'
-    ndkVersion '26.0.10792818'
+    ndkVersion '25.2.9519653'
 
     defaultConfig {
         // 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", "WORK_SERVER_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_IPV6_URL", "\"https://ds-blobp-{blobIdPrefix}.threema.ch/{blobId}\""
         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_IPV6_URL", "\"https://ds-blobp-upload.threema.ch/upload\""
         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", "APP_RATING_URL", "\"https://threema.ch/app-rating/android/{rating}\""
         buildConfigField "byte[]", "THREEMA_PUSH_PUBLIC_KEY", "new byte[] {(byte) 0xfd, (byte) 0x71, (byte) 0x1e, (byte) 0x1a, (byte) 0x0d, (byte) 0xb0, (byte) 0xe2, (byte) 0xf0, (byte) 0x3f, (byte) 0xca, (byte) 0xab, (byte) 0x6c, (byte) 0x43, (byte) 0xda, (byte) 0x25, (byte) 0x75, (byte) 0xb9, (byte) 0x51, (byte) 0x36, (byte) 0x64, (byte) 0xa6, (byte) 0x2a, (byte) 0x12, (byte) 0xbd, (byte) 0x07, (byte) 0x28, (byte) 0xd8, (byte) 0x7f, (byte) 0x71, (byte) 0x25, (byte) 0xcc, (byte) 0x24}"
         buildConfigField "String", "ONPREM_ID_PREFIX", "\"O\""
         buildConfigField "String", "LOG_TAG", "\"3ma\""
@@ -140,7 +147,7 @@ android {
 
         buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "null"
         buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "false"
-        buildConfigField "boolean", "FORWARD_SECURITY", "true"
+        buildConfigField "boolean", "MD_ENABLED", "false"
 
         // config fields for action URLs / deep links
         buildConfigField "String", "uriScheme", "\"threema\""
@@ -157,10 +164,6 @@ android {
             callMimeType: "vnd.android.cursor.item/vnd.ch.threema.app.call",
         ]
 
-        ndk {
-            abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
-        }
-
         testInstrumentationRunner 'ch.threema.app.ThreemaTestRunner'
 
         // Only include language resources for those languages
@@ -172,6 +175,7 @@ android {
             "de",
             "es",
             "fr",
+            "gsw",
             "hu",
             "it",
             "ja",
@@ -195,7 +199,7 @@ android {
             reset()
             include 'armeabi-v7a', 'x86', 'arm64-v8a', 'x86_64'
             exclude 'armeabi', 'mips', 'mips64'
-            universalApk true
+            universalApk project.hasProperty("buildUniversalApk")
         }
     }
 
@@ -230,6 +234,7 @@ android {
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaWork\""
             buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.threema.ch/\""
+            buildConfigField "String", "APP_RATING_URL", "\"https://threema.ch/app-rating/android-work/{rating}\""
             buildConfigField "String", "LOG_TAG", "\"3mawrk\""
             buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
 
@@ -243,20 +248,24 @@ android {
                 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"
             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", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.sandbox.profile"
             resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.sandbox.call"
-            buildConfigField "String", "MEDIA_PATH", "\"ThreemaSandbox\""
+            buildConfigField "String", "MEDIA_PATH", "\"ThreemaGreen\""
             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_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
             buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-apip.test.threema.ch/\""
+            buildConfigField "String", "MEDIATOR_SERVER_URL", "\"wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
             buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
+            buildConfigField "String", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android/{rating}\""
+            buildConfigField "boolean", "MD_ENABLED", "true"
         }
         sandbox_work {
             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", "WORK_SERVER_URL", "\"https://apip-work.test.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.test.threema.ch/\""
+            buildConfigField "String", "MEDIATOR_SERVER_URL", "\"wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
             buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
+            buildConfigField "String", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android-work/{rating}\""
             buildConfigField "String", "LOG_TAG", "\"3mawrk\""
             buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
 
+
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemawork\""
             buildConfigField "String", "actionUrl", "\"work.test.threema.ch\""
 
+            buildConfigField "boolean", "MD_ENABLED", "true"
+
             manifestPlaceholders = [
                 uriScheme       : "threemawork",
                 actionUrl       : "work.test.threema.ch",
@@ -328,39 +342,79 @@ android {
                 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}"
+            // The app was previously named `red`. The app id remains unchanged to still be able to install updates.
             applicationId "ch.threema.app.red"
-            testApplicationId 'ch.threema.app.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", "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_IPV6_PREFIX", "\"ds.w-\""
             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_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
             buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-apip.test.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.test.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.test.threema.ch/\""
+            buildConfigField "String", "MEDIATOR_SERVER_URL", "\"wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
             buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
-            buildConfigField "String", "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"
 
             // 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 = [
-                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 {
@@ -379,6 +433,7 @@ android {
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaWork\""
             buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.threema.ch/\""
             buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.threema.ch/\""
+            buildConfigField "String", "APP_RATING_URL", "\"https://threema.ch/app-rating/android-work/{rating}\""
             buildConfigField "String", "LOG_TAG", "\"3mawrk\""
             buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
 
@@ -396,6 +451,7 @@ android {
             versionName "${app_version}l${beta_suffix}"
             applicationId "ch.threema.app.libre"
             testApplicationId 'ch.threema.app.libre.test'
+            resValue "string", "package_name",  applicationId
             resValue "string", "app_name", "Threema Libre"
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaLibre\""
         }
@@ -447,16 +503,16 @@ android {
             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 {
-            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
@@ -488,7 +544,10 @@ android {
         onprem {
             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'
             manifest.srcFile 'src/store_google/AndroidManifest.xml'
         }
@@ -497,9 +556,9 @@ android {
             res.srcDir 'src/store_google_work/res'
             manifest.srcFile 'src/store_google_work/AndroidManifest.xml'
         }
-        red {
+        blue {
             java.srcDir 'src/google_services_based/java'
-            res.srcDir 'src/red/res'
+            res.srcDir 'src/blue/res'
         }
 
         // Based on Huawei services
@@ -522,8 +581,6 @@ android {
         debug {
             debuggable true
             jniDebuggable false
-            multiDexEnabled true
-            multiDexKeepProguard file('multidex-keep.pro')
             testCoverageEnabled false
             ndk {
                 debugSymbolLevel 'FULL'
@@ -538,9 +595,6 @@ android {
             jniDebuggable false
             minifyEnabled true
             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'
             ndk {
                 debugSymbolLevel 'FULL' // 'SYMBOL_TABLE'
@@ -550,7 +604,7 @@ android {
                 productFlavors.store_google.signingConfig signingConfigs.release
                 productFlavors.store_google_work.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.none.signingConfig signingConfigs.release
             }
@@ -564,8 +618,8 @@ android {
                 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
@@ -578,7 +632,7 @@ android {
 
         if (
             variant.buildType.name == "release" && (
-                names.contains("sandbox") || names.contains("sandbox_work")
+                names.contains("green") || names.contains("sandbox_work")
             )
         ) {
             setIgnore(true)
@@ -593,13 +647,24 @@ android {
 
     packagingOptions {
         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
-            useLegacyPackaging = true
+            useLegacyPackaging = false
         }
         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'
     }
 
-    // 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 {
         // if true, stop the gradle build if errors are found
         abortOnError true
@@ -723,7 +761,6 @@ dependencies {
     coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
 
     implementation project(':domain')
-    implementation project(path: ':task-manager')
 
     implementation 'net.zetetic:sqlcipher-android:4.5.5@aar'
 
@@ -751,32 +788,32 @@ dependencies {
     implementation 'androidx.appcompat:appcompat:1.6.1'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
     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.activity:activity-ktx:1.8.2'
     implementation 'androidx.sqlite:sqlite:2.2.2'
     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.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.paging:paging-runtime-ktx:3.2.1"
     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.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 'ch.threema:webrtc-android:120.0.0'
+    implementation 'ch.threema:webrtc-android:123.0.0'
     implementation('org.saltyrtc:saltyrtc-task-webrtc:0.18.1') {
         exclude module: 'saltyrtc-client'
     }
@@ -806,13 +843,23 @@ dependencies {
     kapt '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 "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_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
     testImplementation "junit:junit:$junit_version"
@@ -857,6 +904,7 @@ dependencies {
     androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
     androidTestImplementation 'androidx.test:core-ktx:1.5.0'
     androidTestImplementation "org.mockito:mockito-core:4.8.1"
+    androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlin_coroutines_version"
 
     // Google Play Services and related libraries
     def googleDependencies = [
@@ -878,9 +926,10 @@ dependencies {
         store_google_workImplementation(dependency) { excludes.each { exclude it } }
         store_threemaImplementation(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 } }
-        redImplementation(dependency) { excludes.each { exclude it } }
+        blueImplementation(dependency) { excludes.each { exclude it } }
     }
 
     // Google Assistant Voice Action verification library
@@ -888,10 +937,11 @@ dependencies {
     store_googleImplementation(name: 'libgsaverification-client', ext: 'aar')
     store_google_workImplementation(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')
-    sandboxImplementation(name: 'libgsaverification-client', ext: 'aar')
+    greenImplementation(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)
     def maplibreDependency = 'org.maplibre.gl:android-sdk:10.3.0'
@@ -901,9 +951,10 @@ dependencies {
     store_threemaImplementation maplibreDependency
     libreImplementation maplibreDependency, { exclude group: 'com.google.android.gms' }
     onpremImplementation maplibreDependency
-    sandboxImplementation maplibreDependency
+    onprem_internalImplementation maplibreDependency
+    greenImplementation maplibreDependency
     sandbox_workImplementation maplibreDependency
-    redImplementation maplibreDependency
+    blueImplementation maplibreDependency
     hmsImplementation maplibreDependency
     hms_workImplementation maplibreDependency
 

+ 2 - 2
app/jni/Application.mk

@@ -1,4 +1,4 @@
 APP_ABI := armeabi-v7a x86 arm64-v8a x86_64
 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"
 -dontwarn org.webrtc.**
 
-
-
 # hms requirements
 -ignorewarnings
 -keep class com.huawei.updatesdk.**{*;}
@@ -230,3 +228,4 @@ public static <fields>;
 -dontwarn org.conscrypt.**
 -dontwarn org.bouncycastle.**
 -dontwarn org.openjsse.**
+

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

@@ -21,6 +21,7 @@
 
 package ch.threema.app
 
+import android.Manifest
 import android.os.Build
 import androidx.test.rule.GrantPermissionRule
 
@@ -30,7 +31,7 @@ import androidx.test.rule.GrantPermissionRule
  */
 fun getNotificationPermissionRule(): GrantPermissionRule {
     return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-        GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS)
+        GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
     } else {
         GrantPermissionRule.grant()
     }
@@ -46,8 +47,58 @@ fun getReadWriteExternalStoragePermissionRule(): GrantPermissionRule {
         GrantPermissionRule.grant()
     } else {
         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.os.Bundle;
 
-import androidx.multidex.MultiDex;
 import androidx.test.runner.AndroidJUnitRunner;
 
 public class ThreemaTestRunner extends AndroidJUnitRunner {
 	@Override
 	public void onCreate(Bundle arguments) {
-		MultiDex.install(getTargetContext());
 		super.onCreate(arguments);
 	}
 

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

@@ -21,8 +21,6 @@
 
 package ch.threema.app.groupmanagement
 
-import android.Manifest
-import android.os.Build
 import androidx.test.core.app.ActivityScenario
 import androidx.test.core.app.launchActivity
 import androidx.test.espresso.Espresso
@@ -30,161 +28,29 @@ import androidx.test.espresso.NoMatchingViewException
 import androidx.test.espresso.action.ViewActions
 import androidx.test.espresso.intent.Intents
 import androidx.test.espresso.matcher.ViewMatchers
-import androidx.test.rule.GrantPermissionRule
 import ch.threema.app.R
-import ch.threema.app.ThreemaApplication
 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.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.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.GroupRequestSyncMessage
-import ch.threema.domain.stores.ContactStore
+import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
 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.assertEquals
 import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Rule
 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
  * group receive steps should not be executed for a certain message type, the common group step
  * 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
@@ -197,8 +63,6 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
 
         val scenario = launchActivity<HomeActivity>()
 
-        Thread.sleep(200)
-
         do {
             var switchedToMessages = false
             try {
@@ -211,36 +75,21 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
 
         Intents.release()
 
-        Thread.sleep(200)
-
         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
      * is the creator of the group (as alleged by the message). The message should be discarded.
      */
     @Test
-    open fun testCommonGroupReceiveStep2_1() {
+    open fun testCommonGroupReceiveStep2_1() = runTest {
         val (message, identityStore) = getMyUnknownGroupMessage()
         setupAndProcessMessage(message, identityStore)
 
         // 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.
      */
     @Test
-    open fun testCommonGroupReceiveStep2_2() {
+    open fun testCommonGroupReceiveStep2_2() = runTest {
         val (message, identityStore) = getUnknownGroupMessage()
         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.toIdentity, firstMessage.fromIdentity)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         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.
      */
     @Test
-    open fun testCommonGroupReceiveStep3_1() {
+    open fun testCommonGroupReceiveStep3_1() = runTest {
         val (message, identityStore) = getMyLeftGroupMessage()
         setupAndProcessMessage(message, identityStore)
 
         // 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(myContact.identity, firstMessage.fromIdentity)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
         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.
      */
     @Test
-    open fun testCommonGroupReceiveStep3_2() {
+    open fun testCommonGroupReceiveStep3_2() = runTest {
         // First, test the common group receive steps for a message from the group creator
         val (firstIncomingMessage, firstIdentityStore) = getLeftGroupMessageFromCreator()
         setupAndProcessMessage(firstIncomingMessage, firstIdentityStore)
 
         // 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(myContact.identity, firstSentMessage.fromIdentity)
         assertEquals(firstIncomingMessage.apiGroupId, firstSentMessage.apiGroupId)
         assertEquals(firstIncomingMessage.groupCreator, firstSentMessage.groupCreator)
 
+        assertTrue(sentMessagesInsideTask.isEmpty())
+
         // Second, test the common group receive steps for a message from a group member
         val (secondIncomingMessage, secondIdentityStore) = getLeftGroupMessage()
         setupAndProcessMessage(secondIncomingMessage, secondIdentityStore)
 
         // 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(myContact.identity, secondSentMessage.fromIdentity)
         assertEquals(secondIncomingMessage.apiGroupId, secondSentMessage.apiGroupId)
         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.
      */
     @Test
-    open fun testCommonGroupReceiveStep4_1() {
+    open fun testCommonGroupReceiveStep4_1() = runTest {
         val (message, identityStore) = getSenderNotMemberOfMyGroupMessage()
         setupAndProcessMessage(message, identityStore)
 
         // 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(myContact.identity, firstMessage.fromIdentity)
         assertEquals(message.apiGroupId, firstMessage.apiGroupId)
         assertEquals(message.groupCreator, firstMessage.groupCreator)
         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.
      */
     @Test
-    open fun testCommonGroupReceiveStep4_2() {
+    open fun testCommonGroupReceiveStep4_2() = runTest {
         val (message, identityStore) = getSenderNotMemberMessage()
         setupAndProcessMessage(message, identityStore)
 
         // 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,
-        identityStore: IdentityStoreInterface
+        identityStore: IdentityStoreInterface,
     ) {
         // Start home activity and navigate to chat section
         launchActivity<HomeActivity>()
@@ -391,6 +221,9 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
      */
     private fun getMyUnknownGroupMessage() = createMessageForGroup().apply {
         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
 
     /**
@@ -452,48 +285,6 @@ abstract class GroupControlTest<T : AbstractGroupMessage> {
         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) =
         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.filters.LargeTest
 import ch.threema.app.DangerousTest
+import kotlinx.coroutines.test.runTest
 import ch.threema.app.activities.HomeActivity
 import ch.threema.app.listeners.GroupListener
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.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 junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertFalse
 import junit.framework.TestCase.assertTrue
 import junit.framework.TestCase.fail
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import org.junit.After
 import org.junit.Assert.assertArrayEquals
 import org.junit.Ignore
@@ -46,6 +48,7 @@ import org.junit.runner.RunWith
 /**
  * Tests that incoming group leave messages are handled correctly.
  */
+@ExperimentalCoroutinesApi
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @DangerousTest
@@ -55,7 +58,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * Test that contact A leaving my group works as expected.
      */
     @Test
-    fun testValidLeaveInMyGroup() {
+    fun testValidLeaveInMyGroup() = runTest {
         assertSuccessfulLeave(myGroup, contactA, true)
     }
 
@@ -63,7 +66,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * Test that contact B leaving groupAB works as expected.
      */
     @Test
-    fun testValidLeave() {
+    fun testValidLeave() = runTest {
         assertSuccessfulLeave(groupAB, contactB)
     }
 
@@ -72,7 +75,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      */
     @Test
     @Ignore("TODO(ANDR-2385): ignore group leave messages from group creators")
-    fun testLeaveFromSender() {
+    fun testLeaveFromSender() = runTest {
         assertUnsuccessfulLeave(groupA, contactA)
         assertUnsuccessfulLeave(groupB, contactB)
     }
@@ -82,7 +85,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * not change anything).
      */
     @Test
-    fun testLeaveOfMyNonExistingGroup() {
+    fun testLeaveOfMyNonExistingGroup() = runTest {
         assertUnsuccessfulLeave(myUnknownGroup, contactA, emptyList())
     }
 
@@ -91,7 +94,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * no effect.
      */
     @Test
-    fun testLeaveOfNonExistingGroup() {
+    fun testLeaveOfNonExistingGroup() = runTest {
         assertUnsuccessfulLeave(groupAUnknown, contactB, emptyList(), true)
     }
 
@@ -100,7 +103,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * does not change anything).
      */
     @Test
-    fun testLeaveOfLeftGroup() {
+    fun testLeaveOfLeftGroup() = runTest {
         assertUnsuccessfulLeave(groupALeft, contactB, null, true)
     }
 
@@ -109,7 +112,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * changed).
      */
     @Test
-    fun testLeaveOfMyLeftGroup() {
+    fun testLeaveOfMyLeftGroup() = runTest {
         assertUnsuccessfulLeave(myLeftGroup, contactA)
     }
 
@@ -118,7 +121,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * effect.
      */
     @Test
-    fun testLeaveOfNonMember() {
+    fun testLeaveOfNonMember() = runTest {
         assertUnsuccessfulLeave(groupA, contactB)
     }
 
@@ -153,7 +156,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
         // 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>()
 
         serviceManager.groupService.resetCache(group.groupModel.id)
@@ -183,10 +186,10 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             serviceManager.groupService.getGroupMemberModels(group.groupModel).map { it.identity })
 
         // 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,
         contact: TestContact,
         expectedMembers: List<String>? = null,
@@ -223,15 +226,15 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
         if (shouldSendSyncRequest) {
             // 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(group.groupCreator.identity, sentMessage.toIdentity)
             assertEquals(group.apiGroupId, sentMessage.apiGroupId)
             assertEquals(group.groupCreator.identity, sentMessage.groupCreator)
         } else {
             // 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.TestGroup
 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 junit.framework.TestCase.*
 import kotlinx.coroutines.*
+import kotlinx.coroutines.test.runTest
 import org.junit.After
 import org.junit.Assert.assertArrayEquals
 import org.junit.Test
@@ -42,20 +43,22 @@ import java.util.*
 /**
  * Tests that incoming group name messages are handled correctly.
  */
+@ExperimentalCoroutinesApi
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @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.
      */
     @Test
-    fun testValidGroupRename() {
+    fun testValidGroupRename() = runTest {
         // Start home activity and navigate to chat section
         val activityScenario = startScenario()
 
@@ -91,7 +94,7 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupRenameMessage>() {
      * does not lead to a group name change.
      */
     @Test
-    fun testInvalidGroupRenameSender() {
+    fun testInvalidGroupRenameSender() = runTest {
         // Start home activity and navigate to chat section
         val activityScenario = startScenario()
 
@@ -121,7 +124,8 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupRenameMessage>() {
     }
 
     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() {
@@ -162,7 +166,7 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupRenameMessage>() {
         groupCreatorIdentity: String,
         apiGroupId: GroupId,
         fromContact: TestContact,
-    ) = GroupRenameMessage().apply {
+    ) = GroupNameMessage().apply {
         groupName = newGroupName
         groupCreator = groupCreatorIdentity
         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.testutils.TestHelpers.TestContact
 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.storage.models.GroupModel
 import junit.framework.TestCase
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
 import org.junit.After
 import org.junit.Assert
 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
  * protocol.
  */
+@ExperimentalCoroutinesApi
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @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
-    fun testUnknownGroupNotMember() {
+    fun testUnknownGroupNotMember() = runTest {
         val scenario = startScenario()
 
         // Assert initial group conversations
@@ -83,7 +86,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         assertGroupConversations(scenario, initialGroups)
 
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
         // Assert that no action has been triggered
         setupTracker.assertAllNewMembersAdded()
@@ -96,7 +99,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
      * Test a group setup message of an unknown group that has no members.
      */
     @Test
-    fun testUnknownEmptyGroup() {
+    fun testUnknownEmptyGroup() = runTest {
         val scenario = startScenario()
 
         // Assert initial group conversations
@@ -123,7 +126,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         assertGroupConversations(scenario, initialGroups)
 
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
         // Assert that no action has been triggered
         setupTracker.assertAllNewMembersAdded()
@@ -136,7 +139,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
      * Test a group setup message of a blocked contact.
      */
     @Test
-    fun testBlocked() {
+    fun testBlocked() = runTest {
         val scenario = startScenario()
 
         // 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
         // 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(newAGroup.apiGroupId, first.apiGroupId)
         assertEquals(newAGroup.groupCreator.identity, first.groupCreator)
-        val second = sentMessages.last() as GroupLeaveMessage
+        val second = sentMessagesInsideTask.last() as GroupLeaveMessage
         assertEquals(myContact.identity, second.fromIdentity)
         assertEquals(newAGroup.apiGroupId, second.apiGroupId)
         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
-    fun testKicked() {
+    fun testKicked() = runTest {
         val scenario = startScenario()
 
         // Assert initial group conversations
@@ -218,7 +221,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         assertGroupConversations(scenario, initialGroups)
 
         // 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
         setupTracker.assertAllNewMembersAdded()
@@ -231,7 +234,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
      * Test a group setup message of a group where the members changed.
      */
     @Test
-    fun testMembersChanged() {
+    fun testMembersChanged() = runTest {
         val scenario = startScenario()
 
         // Assert initial group conversations
@@ -259,7 +262,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         assertGroupConversations(scenario, initialGroups)
 
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
         // Assert that the members have changed
         setupTracker.assertAllNewMembersAdded()
@@ -272,7 +275,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
      * Test a group setup message of a newly created group.
      */
     @Test
-    fun testNewGroup() {
+    fun testNewGroup() = runTest {
         val scenario = startScenario()
 
         // Assert initial group conversations
@@ -306,7 +309,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         assertGroupConversations(scenario, initialGroups + newGroup)
 
         // 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
         setupTracker.assertAllNewMembersAdded()
@@ -319,7 +322,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
      * Test two group setup messages that remove and then add the user.
      */
     @Test
-    fun testRemoveJoin() {
+    fun testRemoveJoin() = runTest {
         val scenario = startScenario()
 
         // Assert initial group conversations
@@ -343,7 +346,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         processMessage(removeMessage, groupAB.groupCreator.identityStore)
 
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
         // Create the group setup message (now again with this user)
         val addMessage = createGroupSetupMessage(groupAB)
@@ -353,7 +356,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         processMessage(addMessage, groupAB.groupCreator.identityStore)
 
         // Assert that no message is sent
-        assertEquals(0, sentMessages.size)
+        assertEquals(0, sentMessagesInsideTask.size)
 
         // Assert that the user has been kicked and added again
         setupTracker.assertAllNewMembersAdded()
@@ -363,7 +366,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
     }
 
     @Test
-    fun testGroupContainingInvalidIDs() {
+    fun testGroupContainingInvalidIDs() = runTest {
         val scenario = startScenario()
 
         // Assert initial group conversations
@@ -400,7 +403,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         assertGroupConversations(scenario, initialGroups + newGroup)
 
         // 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
         setupTracker.assertAllNewMembersAdded()
@@ -409,7 +412,8 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupCreateMessage>() {
         setupTracker.stop()
     }
 
-    private fun createGroupSetupMessage(testGroup: TestGroup) = GroupCreateMessage().apply {
+    private fun createGroupSetupMessage(testGroup: TestGroup) = GroupSetupMessage()
+        .apply {
         apiGroupId = testGroup.apiGroupId
         groupCreator = 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.testutils.TestHelpers.TestContact
 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.assertEquals
 import org.junit.Assert.assertTrue
@@ -41,12 +43,13 @@ import org.junit.runner.RunWith
 /**
  * Tests that incoming group sync request messages are handled correctly.
  */
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @DangerousTest
-class IncomingGroupSyncRequestTest : GroupControlTest<GroupRequestSyncMessage>() {
+class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>() {
 
-    override fun createMessageForGroup() = GroupRequestSyncMessage()
+    override fun createMessageForGroup() = GroupSyncRequestMessage()
 
     @Test
     fun testValidSyncRequest() {
@@ -54,22 +57,17 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupRequestSyncMessage>()
     }
 
     @Test
-    fun testSyncRequestToMember() {
+    fun testSyncRequestToMember() = runTest {
         assertIgnoredGroupSyncRequest(groupAB, contactB)
     }
 
     @Test
-    fun testSyncRequestFromNonMember() {
+    fun testSyncRequestFromNonMember() = runTest {
         assertLeftGroupSyncRequest(myGroup, contactB)
     }
 
     @Test
-    fun testSyncRequestFromMyself() {
-        assertIgnoredGroupSyncRequest(myGroup, myContact)
-    }
-
-    @Test
-    fun testSyncRequestToLeftGroup() {
+    fun testSyncRequestToLeftGroup() = runTest {
         assertLeftGroupSyncRequest(myLeftGroup, contactA)
     }
 
@@ -97,11 +95,12 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupRequestSyncMessage>()
         // 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>()
 
         // Create group sync request message
-        val groupRequestSyncMessage = GroupRequestSyncMessage().apply {
+        val groupSyncRequestMessage = GroupSyncRequestMessage()
+            .apply {
             fromIdentity = contact.identity
             toIdentity = myContact.identity
             apiGroupId = group.apiGroupId
@@ -109,12 +108,10 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupRequestSyncMessage>()
         }
 
         // 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
-        val setupMessage = sentMessages[0] as GroupCreateMessage
+        val setupMessage = sentMessagesInsideTask.poll() as GroupSetupMessage
         assertArrayEquals(group.members.map { it.identity }.toTypedArray(), setupMessage.members)
         assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
         assertEquals(contact.identity, setupMessage.toIdentity)
@@ -122,7 +119,7 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupRequestSyncMessage>()
         assertEquals(group.apiGroupId, setupMessage.apiGroupId)
 
         // 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(myContact.identity, renameMessage.fromIdentity)
         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)
 
         // 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(contact.identity, deletePhotoMessage.toIdentity)
         assertEquals(group.groupCreator.identity, deletePhotoMessage.groupCreator)
         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>()
 
         // Create group sync request message
-        val groupRequestSyncMessage = GroupRequestSyncMessage().apply {
+        val groupSyncRequestMessage = GroupSyncRequestMessage()
+            .apply {
             fromIdentity = contact.identity
             toIdentity = myContact.identity
             apiGroupId = group.apiGroupId
             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>()
 
         // Create group sync request message
-        val groupRequestSyncMessage = GroupRequestSyncMessage().apply {
+        val groupSyncRequestMessage = GroupSyncRequestMessage()
+            .apply {
             fromIdentity = contact.identity
             toIdentity = myContact.identity
             apiGroupId = group.apiGroupId
             groupCreator = group.groupCreator.identity
         }
 
-        processMessage(groupRequestSyncMessage, contact.identityStore)
+        processMessage(groupSyncRequestMessage, contact.identityStore)
 
         // 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)
         assertEquals(myContact.contact.identity, setupMessage.fromIdentity)
         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 ch.threema.app.DangerousTest
 import ch.threema.domain.protocol.csp.messages.GroupTextMessage
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert
+import org.junit.Test
 import org.junit.runner.RunWith
 
 /**
  * Tests that the common group receive steps are executed for a group text message.
  */
+@OptIn(ExperimentalCoroutinesApi::class)
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @DangerousTest
 class IncomingGroupTextTest : GroupControlTest<GroupTextMessage>() {
+
+    @Test
+    fun testForwardSecureTextMessages() = runBlocking {
+        val firstMessage = GroupTextMessage()
+        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 {
         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
-			public boolean sendFlags() {
+			public boolean sendFeatureMask() {
 				return false;
 			}
 
@@ -271,6 +271,11 @@ public class GroupInviteServiceTest {
 			public void checkRevocationKey(boolean force) {
 
 			}
+
+			@Override
+			public void setForwardSecurityEnabled(boolean isFsEnabled) {
+
+			}
 		};
 		try {
 			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
 
-import android.text.Spannable
-import android.text.SpannableString
+import android.text.Spanned
 import android.text.style.URLSpan
 import android.widget.TextView
 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
      * 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)
         textView.text = text
         InstrumentationRegistry.getInstrumentation().runOnMainSync{
             LinkifyUtil.getInstance().linkifyText(textView, includePhoneNumbers)
         }
         val spannableText = textView.text
-        if (spannableText !is SpannableString) {
+        if (spannableText !is Spanned) {
             return null to listOf()
         }
         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;
 
+import org.hamcrest.MatcherAssert;
+import org.hamcrest.Matchers;
 import org.junit.After;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
 
 import androidx.test.core.app.ApplicationProvider;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.domain.fs.DHSession;
 import ch.threema.domain.fs.DHSessionId;
 import ch.threema.domain.helpers.DummyUsers;
+import ch.threema.domain.helpers.UnusedTaskCodec;
 import ch.threema.domain.protocol.csp.messages.BadMessageException;
 import ch.threema.domain.stores.DHSessionStoreException;
+import ch.threema.domain.taskmanager.TaskCodec;
 
 public class SQLDHSessionStoreTest {
 
@@ -45,6 +51,7 @@ public class SQLDHSessionStoreTest {
 	private SQLDHSessionStore store;
 	private DHSession initiatorDHSession;
 	private DHSession responderDHSession;
+	private final TaskCodec taskCodec = new UnusedTaskCodec();
 
 	@Before
 	public void setup() {
@@ -88,7 +95,7 @@ public class SQLDHSessionStoreTest {
 
 		// Delete any stored initiator session to start with a clean slate
 		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
 		Assert.assertNotNull(this.initiatorDHSession.getMyRatchet2DH());
@@ -96,12 +103,12 @@ public class SQLDHSessionStoreTest {
 		store.storeDHSession(this.initiatorDHSession);
 
 		// 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)
 		this.initiatorDHSession.getMyRatchet2DH().turn();
 		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
 		this.initiatorDHSession.processAccept(
@@ -114,7 +121,7 @@ public class SQLDHSessionStoreTest {
 		// initiatorDHSession has now been upgraded to 4DH - store and retrieve it again
 		Assert.assertNotNull(this.initiatorDHSession.getMyRatchet4DH());
 		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.assertEquals(this.initiatorDHSession, bestSession);
 
@@ -123,7 +130,7 @@ public class SQLDHSessionStoreTest {
 
 		// Delete initiator DH session
 		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
@@ -133,7 +140,7 @@ public class SQLDHSessionStoreTest {
 
 		// Store and retrieve the responder session
 		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
 		Assert.assertNotNull(this.responderDHSession.getMyRatchet4DH());
@@ -141,14 +148,14 @@ public class SQLDHSessionStoreTest {
 		this.responderDHSession.getMyRatchet4DH().turn();
 		this.responderDHSession.getPeerRatchet4DH().turn();
 		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
-		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
 		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
@@ -163,7 +170,7 @@ public class SQLDHSessionStoreTest {
 		store.storeDHSession(this.responderDHSession);
 
 		// 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.getPeerRatchet2DH());
 
@@ -175,7 +182,7 @@ public class SQLDHSessionStoreTest {
 		store.storeDHSession(this.responderDHSession);
 
 		// 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.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 {
 		createSessions();
 
@@ -233,7 +273,7 @@ public class SQLDHSessionStoreTest {
 		} else {
 			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.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


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