Browse Source

Version 6.4.0-1136

Threema 2 weeks ago
parent
commit
65acf422e7
100 changed files with 3072 additions and 4399 deletions
  1. 2 0
      .editorconfig
  2. 190 237
      app/assets/license.html
  3. 61 16
      app/build.gradle.kts
  4. 1 7
      app/proguard-project.txt
  5. 13 0
      app/protobuf/key-storage.proto
  6. 30 0
      app/src/androidTest/java/ch/threema/KoinTestRule.kt
  7. 16 13
      app/src/androidTest/java/ch/threema/app/TestCoreServiceManager.kt
  8. 61 44
      app/src/androidTest/java/ch/threema/app/contacts/AddOrUpdateContactBackgroundTaskTest.kt
  9. 23 8
      app/src/androidTest/java/ch/threema/app/contacts/MarkContactAsDeletedBackgroundTaskTest.kt
  10. 34 10
      app/src/androidTest/java/ch/threema/app/contacts/ReflectedContactSyncTaskTest.kt
  11. 41 32
      app/src/androidTest/java/ch/threema/app/edithistory/EditHistoryTest.kt
  12. 28 7
      app/src/androidTest/java/ch/threema/app/groupmanagement/CreateGroupFlowTest.kt
  13. 2 1
      app/src/androidTest/java/ch/threema/app/groupmanagement/DisbandGroupFlowTest.kt
  14. 13 48
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupControlTest.kt
  15. 38 32
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupConversationListTest.kt
  16. 18 3
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupFlowTest.kt
  17. 1 1
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupResyncFlowTest.kt
  18. 22 31
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt
  19. 28 19
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt
  20. 60 53
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt
  21. 0 8
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSyncRequestTest.kt
  22. 1 1
      app/src/androidTest/java/ch/threema/app/groupmanagement/LeaveGroupFlowTest.kt
  23. 3 2
      app/src/androidTest/java/ch/threema/app/groupmanagement/RemoveGroupFlowTest.kt
  24. 22 3
      app/src/androidTest/java/ch/threema/app/groupmanagement/UpdateGroupFlowTest.kt
  25. 147 103
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt
  26. 114 255
      app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt
  27. 19 25
      app/src/androidTest/java/ch/threema/app/services/BlockedIdentitiesServiceTest.kt
  28. 102 17
      app/src/androidTest/java/ch/threema/app/stores/EncryptedPreferenceStoreImplTest.kt
  29. 66 1
      app/src/androidTest/java/ch/threema/app/stores/PreferencesStoreImplTest.kt
  30. 36 44
      app/src/androidTest/java/ch/threema/app/tasks/GroupCreateTaskTest.kt
  31. 1 92
      app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt
  32. 4 2
      app/src/androidTest/java/ch/threema/app/testutils/AndroidTestUtils.kt
  33. 16 0
      app/src/androidTest/java/ch/threema/app/testutils/PreferenceStoreMock.kt
  34. 12 23
      app/src/androidTest/java/ch/threema/app/testutils/TestHelpers.java
  35. 6 3
      app/src/androidTest/java/ch/threema/app/utils/BundledMessagesSendStepsTest.kt
  36. 0 38
      app/src/androidTest/java/ch/threema/app/utils/TextUtilTest.java
  37. 3 1
      app/src/androidTest/java/ch/threema/app/voip/VoipStatusMessageTest.java
  38. 15 10
      app/src/androidTest/java/ch/threema/app/webclient/activities/SessionsActivityTest.java
  39. 0 13
      app/src/androidTest/java/ch/threema/data/TestDatabaseService.kt
  40. 117 37
      app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt
  41. 24 25
      app/src/androidTest/java/ch/threema/data/repositories/EditHistoryRepositoryTest.kt
  42. 44 27
      app/src/androidTest/java/ch/threema/data/repositories/EmojiReactionsRepositoryTest.kt
  43. 21 23
      app/src/androidTest/java/ch/threema/data/repositories/GroupModelRepositoryTest.kt
  44. 73 0
      app/src/androidTest/java/ch/threema/localcrypto/KeyStoreCryptoTest.kt
  45. 0 130
      app/src/androidTest/java/ch/threema/logging/backend/DebugLogFileBackendTest.kt
  46. 3 3
      app/src/androidTest/java/ch/threema/storage/DatabaseNonceStoreTest.kt
  47. 4 4
      app/src/androidTest/java/ch/threema/storage/TaskArchiveFactoryTest.kt
  48. 20 0
      app/src/androidTest/java/ch/threema/storage/TestDatabaseProvider.kt
  49. 211 217
      app/src/foss_based/assets/license.html
  50. 18 46
      app/src/google_services_based/java/ch/threema/app/services/VoiceActionService.java
  51. 0 4
      app/src/google_services_based/java/com/google/android/vending/licensing/LicenseChecker.java
  52. 0 3
      app/src/google_services_based/java/com/google/android/vending/licensing/LicenseValidator.java
  53. 3 2
      app/src/libre/play/release-notes/de/default.txt
  54. 3 2
      app/src/libre/play/release-notes/en-US/default.txt
  55. 55 13
      app/src/main/AndroidManifest.xml
  56. 5 0
      app/src/main/java/ch/threema/app/AppConstants.kt
  57. 110 97
      app/src/main/java/ch/threema/app/GlobalListeners.java
  58. 0 346
      app/src/main/java/ch/threema/app/NamedFileProvider.java
  59. 50 114
      app/src/main/java/ch/threema/app/ThreemaApplication.kt
  60. 47 45
      app/src/main/java/ch/threema/app/activities/AddContactActivity.java
  61. 20 21
      app/src/main/java/ch/threema/app/activities/AppLinksActivity.java
  62. 24 23
      app/src/main/java/ch/threema/app/activities/BackupAdminActivity.java
  63. 3 3
      app/src/main/java/ch/threema/app/activities/BlockedIdentitiesActivity.kt
  64. 0 281
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java
  65. 288 0
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.kt
  66. 21 13
      app/src/main/java/ch/threema/app/activities/DirectoryActivity.java
  67. 15 4
      app/src/main/java/ch/threema/app/activities/DisableBatteryOptimizationsActivity.java
  68. 13 11
      app/src/main/java/ch/threema/app/activities/DistributionListAddActivity.kt
  69. 7 8
      app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java
  70. 4 4
      app/src/main/java/ch/threema/app/activities/ExcludedSyncIdentitiesActivity.kt
  71. 2 2
      app/src/main/java/ch/threema/app/activities/ExportIDActivity.kt
  72. 17 10
      app/src/main/java/ch/threema/app/activities/ExportIDResultActivity.java
  73. 1 1
      app/src/main/java/ch/threema/app/activities/GroupAdd2Activity.java
  74. 14 12
      app/src/main/java/ch/threema/app/activities/GroupAddActivity.java
  75. 172 143
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  76. 2 4
      app/src/main/java/ch/threema/app/activities/GroupEditActivity.java
  77. 7 5
      app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java
  78. 1 2
      app/src/main/java/ch/threema/app/activities/MainActivity.kt
  79. 167 92
      app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java
  80. 27 10
      app/src/main/java/ch/threema/app/activities/MemberChooseActivity.java
  81. 0 222
      app/src/main/java/ch/threema/app/activities/PinLockActivity.kt
  82. 4 6
      app/src/main/java/ch/threema/app/activities/ProfilePicRecipientsActivity.kt
  83. 47 40
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  84. 0 86
      app/src/main/java/ch/threema/app/activities/SMSVerificationLinkActivity.java
  85. 67 0
      app/src/main/java/ch/threema/app/activities/SMSVerificationLinkActivity.kt
  86. 33 33
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  87. 7 0
      app/src/main/java/ch/threema/app/activities/ServerMessageActivity.java
  88. 0 391
      app/src/main/java/ch/threema/app/activities/StarredMessagesActivity.kt
  89. 0 553
      app/src/main/java/ch/threema/app/activities/StorageManagementActivity.java
  90. 1 9
      app/src/main/java/ch/threema/app/activities/ThreemaActivity.java
  91. 14 43
      app/src/main/java/ch/threema/app/activities/ThreemaToolbarActivity.java
  92. 1 1
      app/src/main/java/ch/threema/app/activities/WhatsNewActivity.kt
  93. 3 3
      app/src/main/java/ch/threema/app/activities/WorkIntroActivity.kt
  94. 2 0
      app/src/main/java/ch/threema/app/activities/ballot/BallotChooserActivity.java
  95. 11 6
      app/src/main/java/ch/threema/app/activities/ballot/BallotMatrixActivity.java
  96. 10 3
      app/src/main/java/ch/threema/app/activities/ballot/BallotOverviewActivity.java
  97. 0 2
      app/src/main/java/ch/threema/app/activities/ballot/BallotWizardFragment.java
  98. 2 2
      app/src/main/java/ch/threema/app/activities/notificationpolicy/ContactNotificationsActivity.kt
  99. 2 3
      app/src/main/java/ch/threema/app/activities/notificationpolicy/NotificationsActivity.java
  100. 6 6
      app/src/main/java/ch/threema/app/activities/referral/ReferralActivity.kt

+ 2 - 0
.editorconfig

@@ -20,6 +20,8 @@ ktlint_function_naming_ignore_when_annotated_with = Composable
 # and do not match our preferences
 ktlint_standard_function-signature = disabled
 ktlint_standard_no-wildcard-imports = disabled
+ktlint_standard_class-signature = disabled
+ktlint_standard_function-expression-body = disabled
 
 [*.{yml,yaml}]
 indent_size = 2

+ 190 - 237
app/assets/license.html

@@ -17,7 +17,6 @@
 
         h1 {
             font-size: 14px;
-            color: #555;
             border-top: 1px solid #aaa;
             padding-top: 0.5em;
             margin-top: 1.5em;
@@ -25,122 +24,117 @@
 
         h2 {
             font-size: 13px;
-            color: #777;
             border-top: 1px solid #aaa;
             padding-top: 0.5em;
             margin-top: 1.5em;
         }
 
-        a {
-            color: #0086C9;
+        body, a {
+            color: #151513;
         }
 
         .maincopyright {
             font-size: 14px;
             line-height: 1.3em;
         }
+        @media screen and (prefers-color-scheme: dark) {
+            body {
+                background: #1d1d1b;
+            }
+            body, a {
+                color: #ffffff;
+            }
+        }
     </style>
 </head>
 
 <body>
 
-<p class="maincopyright">Copyright © Threema GmbH.<br/>
-    All rights reserved.</p>
+<p class="maincopyright">
+    Copyright © Threema GmbH.<br/>
+    All rights reserved.
+</p>
 
 <h1>Translations</h1>
 
-<p>The app localizations were realized with kind support from various translators.<br />
-    Thank you!</p>
+<p>
+    The app localizations were realized with kind support from various translators.<br/>
+    Thank you!
+</p>
 
-<p>If you would like to help translate the app, please contact us at <a href="mailto:support@threema.ch">support@threema.ch</a>.</p>
+<p>
+    If you would like to help translate the app, please contact us at <a href="mailto:support@threema.ch">support@threema.ch</a>.
+</p>
 
 <h1>Software</h1>
-
 <p>This product contains artwork and code from the following rights holders:</p>
 
 <h2>Android Fast Scroll</h2>
-
-<p>Copyright 2019 Google LLC</p>
-
+<p>Copyright © 2019 Google LLC</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Android Gesture Detectors Framework</h2>
-
-<p>Copyright (c) 2013, Almer Thie</p>
-
-<p>All rights reserved.</p>
-
-<p>Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:</p>
-
-<ul>
-    <li>Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.</li>
-    <li>Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.</li>
-</ul>
-
-<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
+<p>Copyright © 2022, Alex Vasilkov</p>
+<p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Android Image Cropper</h2>
-
-<p>Copyright 2016 Arthur Teplitzki, 2013 Edmodo, Inc.</p>
-
+<p>Copyright © 2016 Arthur Teplitzki, 2013 Edmodo, Inc.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Android Video Kit</h2>
-
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Base32</h2>
-
 <p>Copyright © 2010, Data Base Architects, Inc. All rights reserved.</p>
-
-<p>Redistribution and use in source and binary forms, with or without modification,
-are permitted provided that the following conditions are met:</p>
-
+<p>
+    Redistribution and use in source and binary forms, with or without modification,
+    are permitted provided that the following conditions are met:
+</p>
 <ul>
-    <li>Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.</li>
-    <li>Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.</li>
-    <li>Neither the names of Kalinda Software, DBA Software, Data Base Architects, Itemscript nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.</li>
+    <li>
+        Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+    </li>
+    <li>
+        Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
+        documentation and/or other materials provided with the distribution.
+    </li>
+    <li>
+        Neither the names of Kalinda Software, DBA Software, Data Base Architects, Itemscript nor the names of its contributors may be used to endorse
+        or promote products derived from this software without specific prior written permission.
+    </li>
 </ul>
-
-<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
-EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
-OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
-SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
-INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
-TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
-BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
-ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGE.</p>
-
-
-<h2>Commons IO</h2>
-
-<p>Copyright (c) 2016 The Apache Software Foundation. All rights reserved.</p>
-
-<p>Licensed under the Apache License, version 2.0 (copy below).</p>
+<p>
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+    EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+    OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
+    SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+    INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+    TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+    BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+    CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+    ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+    SUCH DAMAGE.
+</p>
 
 
 <h2>Emoji art supplied by <a href="http://emojione.com">EmojiOne</a></h2>
-
 <p>Licensed under Creative Commons License (CC-BY 4.0).</p>
 
 
 <h2>ez-vcard</h2>
-
-<p>Copyright (c) 2012-2021, Michael Angstadt</p>
-
+<p>Copyright © 2012-2021, Michael Angstadt</p>
 <p>All rights reserved.</p>
-
-<p>Redistribution and use in source and binary forms, with or without
-    modification, are permitted provided that the following conditions are met:</p>
-
+<p>
+    Redistribution and use in source and binary forms, with or without
+    modification, are permitted provided that the following conditions are met:
+</p>
 <ol>
-    <li>Redistributions of source code must retain the above copyright notice, this
+    <li>
+        Redistributions of source code must retain the above copyright notice, this
         list of conditions and the following disclaimer.
     </li>
     <li>
@@ -149,8 +143,8 @@ SUCH DAMAGE.</p>
         and/or other materials provided with the distribution.
     </li>
 </ol>
-
-<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+<p>
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
     ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
     WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
     DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
@@ -159,260 +153,238 @@ SUCH DAMAGE.</p>
     LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
     ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
     (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
-    SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
-
-<p>The views and conclusions contained in the software and documentation are those
+    SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+</p>
+<p>
+    The views and conclusions contained in the software and documentation are those
     of the authors and should not be interpreted as representing official policies,
-    either expressed or implied, of the FreeBSD Project.</p>
+    either expressed or implied, of the FreeBSD Project.
+</p>
 
 
 <h2>Fluent Emoji</h2>
-
-<p>Copyright (c) Microsoft Corporation</p>
-
+<p>Copyright © Microsoft Corporation</p>
 <p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>Gesture Views</h2>
-
-<p>Copyright (c) 2022 Alex Vasilkov</p>
-
+<p>Copyright © 2022 Alex Vasilkov</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
-<h2>Jackson JSON-processor</h2>
 
-<p>Copyright (c) 2007-2017 Tatu Saloranta, tatu.saloranta@iki.fi</p>
+<h2>Glide</h2>
+<p>Copyright © 2025 Sam Judd</p>
+<p>BSD, part MIT and Apache 2.0. See <a href="https://github.com/bumptech/glide/blob/master/LICENSE">https://github.com/bumptech/glide/blob/master/LICENSE</a> for details.</p>
+
 
+<h2>Jackson JSON-processor</h2>
+<p>Copyright © 2007-2017 Tatu Saloranta, tatu.saloranta@iki.fi</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Bouncy Castle – Open-source cryptographic APIs</h2>
-
-<p>Copyright (c) 2000-2023 The Legion Of The Bouncy Castle Inc. (https://www.bouncycastle.org)</p>
-
+<p>Copyright © 2000-2023 The Legion Of The Bouncy Castle Inc. (https://www.bouncycastle.org)</p>
 <p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>Koin</h2>
-
-<p>Copyright (c) 2025 Kotzilla</p>
-
+<p>Copyright © 2025 Kotzilla</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>libphonenumber</h2>
-
-<p>Copyright (c) 2011-2017 The Libphonenumber Authors</p>
-
+<p>Copyright © 2011-2017 The Libphonenumber Authors</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>MapLibre GL Native</h2>
-
-<p>Copyright (c) 2021 MapLibre contributors</p>
-
-<p>Copyright (c) 2018-2021 MapTiler.com</p>
-
-<p>Copyright 2014-2020 Mapbox</p>
-
+<p>Copyright © 2021 MapLibre contributors</p>
+<p>Copyright © 2018-2021 MapTiler.com</p>
+<p>Copyright © 2014-2020 Mapbox</p>
 <p>Licensed under the BSD 2-Clause License.</p>
-
 <p>Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:</p>
-
 <p>Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.</p>
-
-<p>Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.</p>
-
-<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
+<p>
+    Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation
+    and/or other materials provided with the distribution.
+</p>
+<p>
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+    THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+    CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+    PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+    LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+    EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+</p>
 
 
 <h2>MessagePack for Java</h2>
-
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>MotionViews-Android</h2>
-
-<p>Copyright (c) 2016 UPTech</p>
-
-<p>Licensed under the MIT License (copy below).</p>
+<p>Copyright © 2013, Almer Thie (code.almeros.com)</p>
+<p>All rights reserved.</p>
+<p>Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:</p>
+<ul>
+    <li>
+        Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+    </li>
+    <li>
+        Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
+        in the documentation and/or other materials provided with the distribution.
+    </li>
+</ul>
+<p>
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+    INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+    IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
+    OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
+    OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+    OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
+    OF SUCH DAMAGE.
+</p>
 
 
 <h2>nv-websocket-client</h2>
+<p>Copyright © 2015-2016 Neo Visionaries Inc.</p>
+<p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
-<p>Copyright (C) 2015-2016 Neo Visionaries Inc.</p>
 
+<h2>OkHttp</h2>
+<p>Copyright © 2019 Square, Inc.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>OpenCSV</h2>
-
-<p>Copyright (c) 2016 The OpenCSV Contributors. All rights reserved.</p>
-
+<p>Copyright © 2016 The OpenCSV Contributors. All rights reserved.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>saltyrtc-client-java</h2>
-
-<p>Copyright (c) Threema GmbH</p>
-
+<p>Copyright © Threema GmbH</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>saltyrtc-task-webrtc-java</h2>
-
-<p>Copyright (c) Threema GmbH</p>
-
+<p>Copyright © Threema GmbH</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
-<h2>scrypt</h2>
-
-<p>Copyright 2009 Colin Percival</p>
-<p>All rights reserved.</p>
-
-<p>Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions
-are met:</p>
-
-<ol>
-    <li>Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.</li>
-    <li>Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.</li>
-</ol>
-
-<p>THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
-ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
-FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
-OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
-HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
-LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
-OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGE.</p>
-
-
 <h2>SLF4j</h2>
-
-<p>Copyright (c) 2004-2017 QOS.ch All rights reserved.</p>
-
+<p>Copyright © 2004-2017 QOS.ch All rights reserved.</p>
 <p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>SQLCipher</h2>
-
-<p>Copyright (c) 2025 Zetetic LLC<br />
-All rights reserved.</p>
-
-<p>Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:</p>
-
+<p>Copyright © 2025 Zetetic LLC<br/>
+    All rights reserved.</p>
+<p>
+    Redistribution and use in source and binary forms, with or without
+    modification, are permitted provided that the following conditions are met:
+</p>
 <ul>
-    <li>Redistributions of source code must retain the above copyright
-      notice, this list of conditions and the following disclaimer.</li>
-    <li>Redistributions in binary form must reproduce the above copyright
-      notice, this list of conditions and the following disclaimer in the
-      documentation and/or other materials provided with the distribution.</li>
-    <li>Neither the name of the ZETETIC LLC nor the
-      names of its contributors may be used to endorse or promote products
-      derived from this software without specific prior written permission.</li>
+    <li>
+        Redistributions of source code must retain the above copyright
+        notice, this list of conditions and the following disclaimer.
+    </li>
+    <li>
+        Redistributions in binary form must reproduce the above copyright
+        notice, this list of conditions and the following disclaimer in the
+        documentation and/or other materials provided with the distribution.
+    </li>
+    <li>
+        Neither the name of the ZETETIC LLC nor the
+        names of its contributors may be used to endorse or promote products
+        derived from this software without specific prior written permission.
+    </li>
 </ul>
-
-<p>THIS SOFTWARE IS PROVIDED BY ZETETIC LLC "AS IS" AND ANY
-EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL ZETETIC LLC BE LIABLE FOR ANY
-DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
-(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
-LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
-ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
-SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
+<p>
+    THIS SOFTWARE IS PROVIDED BY ZETETIC LLC "AS IS" AND ANY
+    EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+    WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+    DISCLAIMED. IN NO EVENT SHALL ZETETIC LLC BE LIABLE FOR ANY
+    DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+    (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+    LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+    ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+    (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+    SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+</p>
 
 
 <h2>StepPagerStrip</h2>
-
-<p>Copyright 2012 Roman Nurik</p>
-
+<p>Copyright © 2012 Roman Nurik</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Subsampling Scale Image View</h2>
-
-<p>Copyright (c) 2015 David Morrissey</p>
-
+<p>Copyright © 2018 David Morrissey</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>TapTargetView</h2>
-
-<p>Copyright (c) 2016 Keepsafe Software Inc.</p>
-
+<p>Copyright © 2016 Keepsafe Software Inc.</p>
 <p>Licensed under the Apache License, Version 2.0 (copy below)</p>
 
 
 <h2>The Android Open Source Project</h2>
-
-<p>Copyright (c) 2019 Google Inc.</p>
-
+<p>Copyright © 2025 Google Inc.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>WebRTC</h2>
-
-<p>Copyright (c) 2011, The WebRTC project authors. All rights reserved.</p>
-
-<p>Redistribution and use in source and binary forms, with or without modification, are permitted
-provided that the following conditions are met:</p>
-
+<p>Copyright © 2011, The WebRTC project authors. All rights reserved.</p>
+<p>
+    Redistribution and use in source and binary forms, with or without modification, are permitted
+    provided that the following conditions are met:
+</p>
 <ul>
-    <li>Redistributions of source code must retain the above copyright notice, this list of
-    conditions and the following disclaimer.</li>
-    <li>Redistributions in binary form must reproduce the above copyright notice, this list of
-    conditions and the following disclaimer in the documentation and/or other materials provided
-    with the distribution.</li>
-    <li>Neither the name of Google nor the names of its contributors may be used to endorse or
-    promote products derived from this software without specific prior written permission.</li>
+    <li>
+        Redistributions of source code must retain the above copyright notice, this list of
+        conditions and the following disclaimer.
+    </li>
+    <li>
+        Redistributions in binary form must reproduce the above copyright notice, this list of
+        conditions and the following disclaimer in the documentation and/or other materials provided
+        with the distribution.
+    </li>
+    <li>
+        Neither the name of Google nor the names of its contributors may be used to endorse or
+        promote products derived from this software without specific prior written permission.
+    </li>
 </ul>
-
-<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
-IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
-FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
-CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
-SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
-OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-POSSIBILITY OF SUCH DAMAGE.</p>
+<p>
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
+    IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
+    FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+    CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+    CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+    SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+    THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+    OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+    POSSIBILITY OF SUCH DAMAGE.
+</p>
 
 
 <h2>Zip4j</h2>
-
-<p>Copyright (c) 2013 Srikanth Reddy Lingala. All rights reserved.</p>
-
+<p>Copyright © 2013 Srikanth Reddy Lingala. All rights reserved.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>ZXing</h2>
-
-<p>Copyright (c) 2009-2012 ZXing authors. All rights reserved.</p>
-
+<p>Copyright © 2024 ZXing authors. All rights reserved.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Apache License<br/>Version 2.0, January 2004</h2>
 <p><a href="http://www.apache.org/licenses/">http://www.apache.org/licenses/</a></p>
 <p>TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION</p>
-
 <p><strong><a name="definitions">1. Definitions</a></strong>.</p>
-
 <p>"License" shall mean the terms and conditions for use, reproduction, and
     distribution as defined by Sections 1 through 9 of this document.</p>
-
 <p>"Licensor" shall mean the copyright owner or entity authorized by the
     copyright owner that is granting the License.</p>
-
 <p>"Legal Entity" shall mean the union of the acting entity and all other
     entities that control, are controlled by, or are under common control with
     that entity. For the purposes of this definition, "control" means (i) the
@@ -420,23 +392,18 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     entity, whether by contract or otherwise, or (ii) ownership of fifty
     percent (50%) or more of the outstanding shares, or (iii) beneficial
     ownership of such entity.</p>
-
 <p>"You" (or "Your") shall mean an individual or Legal Entity exercising
     permissions granted by this License.</p>
-
 <p>"Source" form shall mean the preferred form for making modifications,
     including but not limited to software source code, documentation source,
     and configuration files.</p>
-
 <p>"Object" form shall mean any form resulting from mechanical transformation
     or translation of a Source form, including but not limited to compiled
     object code, generated documentation, and conversions to other media types.</p>
-
 <p>"Work" shall mean the work of authorship, whether in Source or Object form,
     made available under the License, as indicated by a copyright notice that
     is included in or attached to the work (an example is provided in the
     Appendix below).</p>
-
 <p>"Derivative Works" shall mean any work, whether in Source or Object form,
     that is based on (or derived from) the Work and for which the editorial
     revisions, annotations, elaborations, or other modifications represent, as
@@ -444,7 +411,6 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     Derivative Works shall not include works that remain separable from, or
     merely link (or bind by name) to the interfaces of, the Work and Derivative
     Works thereof.</p>
-
 <p>"Contribution" shall mean any work of authorship, including the original
     version of the Work and any modifications or additions to that Work or
     Derivative Works thereof, that is intentionally submitted to Licensor for
@@ -458,18 +424,15 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     and improving the Work, but excluding communication that is conspicuously
     marked or otherwise designated in writing by the copyright owner as "Not a
     Contribution."</p>
-
 <p>"Contributor" shall mean Licensor and any individual or Legal Entity on
     behalf of whom a Contribution has been received by Licensor and
     subsequently incorporated within the Work.</p>
-
 <p><strong><a name="copyright">2. Grant of Copyright License</a></strong>. Subject to the
     terms and conditions of this License, each Contributor hereby grants to You
     a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
     copyright license to reproduce, prepare Derivative Works of, publicly
     display, publicly perform, sublicense, and distribute the Work and such
     Derivative Works in Source or Object form.</p>
-
 <p><strong><a name="patent">3. Grant of Patent License</a></strong>. Subject to the terms
     and conditions of this License, each Contributor hereby grants to You a
     perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
@@ -484,7 +447,6 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     direct or contributory patent infringement, then any patent licenses
     granted to You under this License for that Work shall terminate as of the
     date such litigation is filed.</p>
-
 <p><strong><a name="redistribution">4. Redistribution</a></strong>. You may reproduce and
     distribute copies of the Work or Derivative Works thereof in any medium,
     with or without modifications, and in Source or Object form, provided that
@@ -533,13 +495,11 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     Notwithstanding the above, nothing herein shall supersede or modify the
     terms of any separate license agreement you may have executed with Licensor
     regarding such Contributions.</p>
-
 <p><strong><a name="trademarks">6. Trademarks</a></strong>. This License does not grant
     permission to use the trade names, trademarks, service marks, or product
     names of the Licensor, except as required for reasonable and customary use
     in describing the origin of the Work and reproducing the content of the
     NOTICE file.</p>
-
 <p><strong><a name="no-warranty">7. Disclaimer of Warranty</a></strong>. Unless required by
     applicable law or agreed to in writing, Licensor provides the Work (and
     each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT
@@ -549,7 +509,6 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     are solely responsible for determining the appropriateness of using or
     redistributing the Work and assume any risks associated with Your exercise
     of permissions under this License.</p>
-
 <p><strong><a name="no-liability">8. Limitation of Liability</a></strong>. In no event and
     under no legal theory, whether in tort (including negligence), contract, or
     otherwise, unless required by applicable law (such as deliberate and
@@ -560,7 +519,6 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     but not limited to damages for loss of goodwill, work stoppage, computer
     failure or malfunction, or any and all other commercial damages or losses),
     even if such Contributor has been advised of the possibility of such damages.</p>
-
 <p><strong><a name="add-liability">9. Accepting Warranty or Additional Liability</a></strong>.
     While redistributing the Work or Derivative Works thereof, You may choose to offer,
     and charge a fee for, acceptance of support, warranty, indemnity, or other liability
@@ -570,13 +528,11 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     and hold each Contributor harmless for any liability incurred by, or claims
     asserted against, such Contributor by reason of your accepting any such warranty
     or additional liability.</p>
-
 <p>END OF TERMS AND CONDITIONS</p>
 
 
 <h2>MIT License</h2>
 <a href="https://opensource.org/licenses/MIT">https://opensource.org/licenses/MIT</a>
-
 <p>Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
     in the Software without restriction, including without limitation the rights
@@ -589,14 +545,11 @@ POSSIBILITY OF SUCH DAMAGE.</p>
 
     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
     AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
     LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
     OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
     THE SOFTWARE.
 </p>
-
 </body>
-
 </html>
-

+ 61 - 16
app/build.gradle.kts

@@ -2,7 +2,9 @@ import com.android.build.gradle.internal.api.ApkVariantOutputImpl
 import com.android.build.gradle.internal.tasks.factory.dependsOn
 import config.BuildFeatureFlags
 import config.PublicKeys
+import config.SentryConfig
 import config.setProductNames
+import config.setSentryConfig
 import org.gradle.api.tasks.testing.logging.TestExceptionFormat
 import org.gradle.kotlin.dsl.lintChecks
 import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
@@ -30,7 +32,7 @@ if (gradle.startParameter.taskRequests.toString().contains("Hms")) {
 /**
  * Only use the scheme "<major>.<minor>.<patch>" for the appVersion
  */
-val appVersion = "6.3.2"
+val appVersion = "6.4.0"
 
 /**
  * betaSuffix with leading dash (e.g. `-beta1`).
@@ -39,7 +41,7 @@ val appVersion = "6.3.2"
  */
 val betaSuffix = ""
 
-val defaultVersionCode = 1118
+val defaultVersionCode = 1136
 
 /**
  * Map with keystore paths (if found).
@@ -100,7 +102,7 @@ android {
         stringBuildConfigField("DIRECTORY_SERVER_IPV6_URL", "https://ds-apip.threema.ch/")
         stringBuildConfigField("WORK_SERVER_URL", null)
         stringBuildConfigField("WORK_SERVER_IPV6_URL", null)
-        stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.threema.ch/{deviceGroupIdPrefix8}")
+        stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.threema.ch/{deviceGroupIdPrefix8}/")
 
         // Base blob url used for "download" and "done" calls
         stringBuildConfigField("BLOB_SERVER_URL", "https://blobp-{blobIdPrefix}.threema.ch")
@@ -128,7 +130,7 @@ android {
         stringArrayBuildConfigField("ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", emptyArray())
         booleanBuildConfigField("MD_SYNC_DISTRIBUTION_LISTS", false)
         booleanBuildConfigField("AVAILABILITY_STATUS_ENABLED", BuildFeatureFlags["availability_status"] ?: false)
-        booleanBuildConfigField("CRASH_REPORTING_SUPPORTED", BuildFeatureFlags["crash_reporting"] ?: false)
+        booleanBuildConfigField("ERROR_REPORTING_SUPPORTED", false)
 
         // config fields for action URLs / deep links
         stringBuildConfigField("uriScheme", "threema")
@@ -138,6 +140,11 @@ android {
         // The OPPF url must be null in the default config. Do not change this.
         stringBuildConfigField("PRESET_OPPF_URL", null)
 
+        setSentryConfig(
+            projectId = 0,
+            publicApikey = "",
+        )
+
         with(manifestPlaceholders) {
             put("uriScheme", "threema")
             put("contactActionUrl", "threema.id")
@@ -235,6 +242,9 @@ android {
             stringBuildConfigField("uriScheme", "threemawork")
             stringBuildConfigField("actionUrl", "work.threema.ch")
 
+            stringBuildConfigField("SCREENSHOT_TEST_WORK_USERNAME", LocalProperties.getString("screenshotTestWorkUsername"))
+            stringBuildConfigField("SCREENSHOT_TEST_WORK_PASSWORD", LocalProperties.getString("screenshotTestWorkPassword"))
+
             with(manifestPlaceholders) {
                 put("uriScheme", "threemawork")
                 put("actionUrl", "work.threema.ch")
@@ -245,6 +255,10 @@ android {
             applicationId = "ch.threema.app.green"
             testApplicationId = "$applicationId.test"
             setProductNames(appName = "Threema Green")
+            setSentryConfig(
+                projectId = SentryConfig.SANDBOX_PROJECT_ID,
+                publicApikey = SentryConfig.SANDBOX_PUBLIC_API_KEY,
+            )
             stringResValue("package_name", applicationId!!)
             stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.$applicationId.profile")
             stringResValue("call_mime_type", "vnd.android.cursor.item/vnd.$applicationId.call")
@@ -255,7 +269,7 @@ android {
             byteArrayBuildConfigField("SERVER_PUBKEY_ALT", PublicKeys.sandboxServer)
             stringBuildConfigField("DIRECTORY_SERVER_URL", "https://apip.test.threema.ch/")
             stringBuildConfigField("DIRECTORY_SERVER_IPV6_URL", "https://ds-apip.test.threema.ch/")
-            stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
+            stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}/")
             stringBuildConfigField("BLOB_SERVER_URL", "https://blobp-{blobIdPrefix}.test.threema.ch")
             stringBuildConfigField("BLOB_SERVER_IPV6_URL", "https://ds-blobp-{blobIdPrefix}.test.threema.ch")
             stringBuildConfigField("BLOB_SERVER_URL_UPLOAD", "https://blobp-upload.test.threema.ch/upload")
@@ -266,7 +280,7 @@ android {
             stringBuildConfigField("MAP_POI_AROUND_URL", "https://poi.test.threema.ch/around/{latitude}/{longitude}/{radius}/")
             stringBuildConfigField("MAP_POI_NAMES_URL", "https://poi.test.threema.ch/names/{latitude}/{longitude}/{query}/")
             stringBuildConfigField("BLOB_MIRROR_SERVER_URL", "https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
-            booleanBuildConfigField("CRASH_REPORTING_SUPPORTED", true)
+            booleanBuildConfigField("ERROR_REPORTING_SUPPORTED", true)
         }
         create("sandbox_work") {
             versionName = "${appVersion}k$betaSuffix"
@@ -276,6 +290,10 @@ android {
                 appName = "Threema Sandbox Work",
                 appNameDesktop = "Threema Blue",
             )
+            setSentryConfig(
+                projectId = SentryConfig.SANDBOX_PROJECT_ID,
+                publicApikey = SentryConfig.SANDBOX_PUBLIC_API_KEY,
+            )
             stringResValue("package_name", applicationId!!)
             stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.$applicationId.profile")
             stringResValue("call_mime_type", "vnd.android.cursor.item/vnd.$applicationId.call")
@@ -290,7 +308,7 @@ android {
             stringBuildConfigField("DIRECTORY_SERVER_IPV6_URL", "https://ds-apip.test.threema.ch/")
             stringBuildConfigField("WORK_SERVER_URL", "https://apip-work.test.threema.ch/")
             stringBuildConfigField("WORK_SERVER_IPV6_URL", "https://ds-apip-work.test.threema.ch/")
-            stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
+            stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}/")
             stringBuildConfigField("BLOB_SERVER_URL", "https://blobp-{blobIdPrefix}.test.threema.ch")
             stringBuildConfigField("BLOB_SERVER_IPV6_URL", "https://ds-blobp-{blobIdPrefix}.test.threema.ch")
             stringBuildConfigField("BLOB_SERVER_URL_UPLOAD", "https://blobp-upload.test.threema.ch/upload")
@@ -308,7 +326,7 @@ android {
             stringBuildConfigField("uriScheme", "threemawork")
             stringBuildConfigField("actionUrl", "work.test.threema.ch")
 
-            booleanBuildConfigField("CRASH_REPORTING_SUPPORTED", true)
+            booleanBuildConfigField("ERROR_REPORTING_SUPPORTED", true)
 
             stringBuildConfigField("MD_CLIENT_DOWNLOAD_URL", "https://three.ma/mdw")
 
@@ -357,6 +375,10 @@ android {
 
             stringBuildConfigField("MD_CLIENT_DOWNLOAD_URL", "https://three.ma/mdo")
 
+            stringBuildConfigField("SCREENSHOT_TEST_ONPREM_USERNAME", LocalProperties.getString("screenshotTestOnPremUsername"))
+            stringBuildConfigField("SCREENSHOT_TEST_ONPREM_PASSWORD", LocalProperties.getString("screenshotTestOnPremPassword"))
+            stringBuildConfigField("SCREENSHOT_TEST_ONPREM_SERVER_URL", LocalProperties.getString("screenshotTestOnPremServerUrl"))
+
             with(manifestPlaceholders) {
                 put("uriScheme", uriScheme)
                 put("actionUrl", actionUrl)
@@ -370,6 +392,10 @@ android {
             applicationId = "ch.threema.app.red"
             testApplicationId = "ch.threema.app.blue.test"
             setProductNames(appName = "Threema Blue")
+            setSentryConfig(
+                projectId = SentryConfig.SANDBOX_PROJECT_ID,
+                publicApikey = SentryConfig.SANDBOX_PUBLIC_API_KEY,
+            )
             stringResValue("package_name", applicationId!!)
             stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.blue.profile")
             stringResValue("call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.blue.call")
@@ -385,7 +411,7 @@ android {
             stringBuildConfigField("DIRECTORY_SERVER_IPV6_URL", "https://ds-apip.test.threema.ch/")
             stringBuildConfigField("WORK_SERVER_URL", "https://apip-work.test.threema.ch/")
             stringBuildConfigField("WORK_SERVER_IPV6_URL", "https://ds-apip-work.test.threema.ch/")
-            stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
+            stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}/")
             stringBuildConfigField("BLOB_SERVER_URL", "https://blobp-{blobIdPrefix}.test.threema.ch")
             stringBuildConfigField("BLOB_SERVER_IPV6_URL", "https://ds-blobp-{blobIdPrefix}.test.threema.ch")
             stringBuildConfigField("BLOB_SERVER_URL_UPLOAD", "https://blobp-upload.test.threema.ch/upload")
@@ -398,7 +424,7 @@ android {
             stringBuildConfigField("LOG_TAG", "3mablue")
             stringBuildConfigField("BLOB_MIRROR_SERVER_URL", "https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
 
-            booleanBuildConfigField("CRASH_REPORTING_SUPPORTED", true)
+            booleanBuildConfigField("ERROR_REPORTING_SUPPORTED", true)
 
             // config fields for action URLs / deep links
             stringBuildConfigField("uriScheme", "threemablue")
@@ -521,6 +547,26 @@ android {
             java.srcDir("./build/generated/source/protobuf/main/kotlin")
         }
 
+        // Include Dev features only in debug builds and in release builds for flavors used only internally
+        val devFlavors = arrayOf("blue", "none")
+        productFlavors.names
+            .flatMap { productFlavor ->
+                buildList {
+                    add("${productFlavor}Debug" to true)
+                    if (productFlavor != "green" && productFlavor != "sandbox_work") {
+                        add("${productFlavor}Release" to (productFlavor in devFlavors))
+                    }
+                }
+            }
+            .forEach { (name, needsDevFeatures) ->
+                create(name) {
+                    val srcPath = if (needsDevFeatures) "src/with_dev_support" else "src/without_dev_support"
+                    java.srcDir("$srcPath/java")
+                    manifest.srcFile("$srcPath/AndroidManifest.xml")
+                    res.srcDir("$srcPath/res")
+                }
+            }
+
         // Based on Google services
         getByName("none") {
             java.srcDir("src/google_services_based/java")
@@ -684,12 +730,12 @@ android {
 
     java {
         toolchain {
-            languageVersion.set(JavaLanguageVersion.of(17))
+            languageVersion.set(JavaLanguageVersion.of(21))
         }
     }
 
     kotlin {
-        jvmToolchain(17)
+        jvmToolchain(21)
     }
 
     androidResources {
@@ -779,7 +825,6 @@ dependencies {
     implementation(libs.opencsv)
     implementation(libs.zip4j)
     implementation(libs.taptargetview)
-    implementation(libs.commonsIo)
     implementation(libs.slf4j.api)
     implementation(libs.androidImageCropper)
     implementation(libs.fastscroll)
@@ -831,6 +876,7 @@ dependencies {
     implementation(libs.androidx.lifecycle.viewmodel.compose)
     implementation(libs.androidx.lifecycle.runtime.compose)
     debugImplementation(libs.androidx.ui.tooling)
+    debugImplementation(libs.androidx.ui.test.manifest)
     androidTestImplementation(platform(libs.compose.bom))
 
     implementation(libs.bcprov.jdk15to18)
@@ -844,8 +890,6 @@ dependencies {
     implementation(libs.jackson.core)
     implementation(libs.nvWebsocket.client)
 
-    implementation(libs.streamsupport.cfuture)
-
     implementation(libs.saltyrtc.client) {
         exclude(group = "org.json")
     }
@@ -915,6 +959,7 @@ dependencies {
     androidTestImplementation(libs.androidx.test.uiautomator)
     androidTestImplementation(libs.androidx.test.core)
     androidTestImplementation(libs.kotlinx.coroutines.test)
+    androidTestImplementation(libs.androidx.ui.test.junit4)
     testImplementation(libs.kotlinx.coroutines.test)
 
     // Google Play Services and related libraries
@@ -1084,7 +1129,7 @@ tasks.whenTaskAdded {
 // Let the compose compiler generate stability reports
 tasks.withType<KotlinCompile>().configureEach {
     compilerOptions {
-        val composeCompilerReportsPath = "${project.layout.buildDirectory.get().dir("compose_conpiler").asFile.absolutePath}/reports"
+        val composeCompilerReportsPath = "${project.layout.buildDirectory.get().dir("compose_compiler").asFile.absolutePath}/reports"
         freeCompilerArgs.addAll(
             listOf(
                 "-P",

+ 1 - 7
app/proguard-project.txt

@@ -28,8 +28,6 @@
 -keeppackagenames org.saltyrtc.**
 
 -dontnote android.net.http.*
--dontnote org.apache.commons.codec.**
--dontnote org.apache.http.**
 
 # JNA library classes are needed for Uniffi Bindings
 -keep class com.sun.jna.** { *; }
@@ -162,6 +160,7 @@ public static <fields>;
 
 # WebRTC
 -keep class org.webrtc.** { *; }
+-keep class org.jni_zero.** { *; }
 
 # Messages are serialized using reflection
 -keep class ch.threema.app.webclient.messages.** { *; }
@@ -181,9 +180,6 @@ public static <fields>;
 # Firebase analytics removal
 -dontwarn com.google.firebase.analytics.connector.AnalyticsConnector
 
-# keep camera classes - 1.0.0-alpha03 causes VerifyError in Android 4.4
--keep class androidx.camera.** { *; }
-
 # protobuf uses reflection
 -keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
   <fields>;
@@ -222,8 +218,6 @@ public static <fields>;
    <init>();
 }
 
--keep class java8.util.ImmutableCollections { *; }
-
 # https://stackoverflow.com/questions/73748946/proguard-r8-warnings
 -dontwarn org.conscrypt.**
 -dontwarn org.bouncycastle.**

+ 13 - 0
app/protobuf/key-storage.proto

@@ -123,3 +123,16 @@ message OuterKeyStorageV1 {
     Argon2idProtected argon2id_protected_intermediate = 2;
   }
 }
+
+// Outer outer key storage, wraps an instance of `OuterKeyStorage`,
+// encrypted with a secret key stored in the Android Key Store.
+message KeyWrapper {
+  // Alias of the secret key from the Android KeyStore that was used to encrypt the data in `encrypted_key_storage_data`
+  string key_store_alias = 1;
+
+  // The IV associated with the encrypted data in `encrypted_key_storage_data`
+  bytes iv = 2;
+
+  // The encrypted bytes of `OuterKeyStorage`
+  bytes encrypted_key_storage_data = 3;
+}

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

@@ -0,0 +1,30 @@
+package ch.threema
+
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.context.GlobalContext.getKoinApplicationOrNull
+import org.koin.core.context.loadKoinModules
+import org.koin.core.context.startKoin
+import org.koin.core.context.unloadKoinModules
+import org.koin.core.module.Module
+
+class KoinTestRule(
+    private val modules: List<Module>,
+) : TestWatcher() {
+    override fun starting(description: Description) {
+        if (getKoinApplicationOrNull() == null) {
+            startKoin {
+                androidContext(InstrumentationRegistry.getInstrumentation().targetContext.applicationContext)
+                modules(modules)
+            }
+        } else {
+            loadKoinModules(modules)
+        }
+    }
+
+    override fun finished(description: Description) {
+        unloadKoinModules(modules)
+    }
+}

+ 16 - 13
app/src/androidTest/java/ch/threema/app/TestCoreServiceManager.kt

@@ -9,10 +9,10 @@ import ch.threema.app.multidevice.linking.DeviceLinkingStatus
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.UserService
 import ch.threema.app.stores.EncryptedPreferenceStore
-import ch.threema.app.stores.IdentityProviderImpl
-import ch.threema.app.stores.IdentityStoreImpl
+import ch.threema.app.stores.IdentityProvider
 import ch.threema.app.stores.PreferenceStore
 import ch.threema.app.tasks.TaskCreator
+import ch.threema.app.utils.AppVersionProvider
 import ch.threema.base.crypto.HashedNonce
 import ch.threema.base.crypto.Nonce
 import ch.threema.base.crypto.NonceFactory
@@ -38,8 +38,10 @@ import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskArchiver
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
+import ch.threema.storage.DatabaseProvider
 import ch.threema.storage.DatabaseService
 import ch.threema.testhelpers.MUST_NOT_BE_CALLED
+import io.mockk.mockk
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Deferred
@@ -48,21 +50,22 @@ import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.runBlocking
-
-class TestCoreServiceManager(
-    override val version: AppVersion,
-    override val databaseService: DatabaseService,
-    override val preferenceStore: PreferenceStore,
-    override val encryptedPreferenceStore: EncryptedPreferenceStore,
+import org.koin.mp.KoinPlatform
+
+class TestCoreServiceManager
+@JvmOverloads
+constructor(
+    override val version: AppVersion = AppVersionProvider.appVersion,
+    override val databaseProvider: DatabaseProvider,
+    identityProvider: IdentityProvider = mockk(),
+    override val databaseService: DatabaseService = DatabaseService(databaseProvider, identityProvider),
+    override val preferenceStore: PreferenceStore = KoinPlatform.getKoin().get(),
+    override val encryptedPreferenceStore: EncryptedPreferenceStore = KoinPlatform.getKoin().get(),
     override val taskArchiver: TaskArchiver = TestTaskArchiver(),
     override val deviceCookieManager: DeviceCookieManager = TestDeviceCookieManager(),
     override val taskManager: TaskManager = TestTaskManager(TransactionAckTaskCodec()),
     override val multiDeviceManager: MultiDeviceManager = TestMultiDeviceManager(),
-    override val identityStore: IdentityStore = IdentityStoreImpl(
-        identityProvider = IdentityProviderImpl(preferenceStore),
-        preferenceStore = preferenceStore,
-        encryptedPreferenceStore = encryptedPreferenceStore,
-    ),
+    override val identityStore: IdentityStore = mockk(),
     override val nonceFactory: NonceFactory = NonceFactory(TestNonceStore()),
 ) : CoreServiceManager
 

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

@@ -2,7 +2,6 @@ package ch.threema.app.contacts
 
 import android.os.Looper
 import ch.threema.app.TestCoreServiceManager
-import ch.threema.app.ThreemaApplication
 import ch.threema.app.asynctasks.AddContactRestrictionPolicy
 import ch.threema.app.asynctasks.AddOrUpdateContactBackgroundTask
 import ch.threema.app.asynctasks.AlreadyVerified
@@ -15,11 +14,11 @@ import ch.threema.app.asynctasks.InvalidThreemaId
 import ch.threema.app.asynctasks.RemotePublicKeyMismatch
 import ch.threema.app.asynctasks.UserIdentity
 import ch.threema.app.managers.CoreServiceManager
-import ch.threema.app.utils.AppVersionProvider
+import ch.threema.app.restrictions.AppRestrictions
+import ch.threema.app.stores.IdentityProvider
 import ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.base.crypto.NaCl
 import ch.threema.common.Http
-import ch.threema.data.TestDatabaseService
 import ch.threema.data.repositories.ContactModelRepository
 import ch.threema.data.repositories.ModelRepositories
 import ch.threema.domain.models.IdentityState
@@ -29,8 +28,13 @@ import ch.threema.domain.protocol.Version
 import ch.threema.domain.protocol.api.APIConnector
 import ch.threema.domain.protocol.api.APIConnector.FetchIdentityResult
 import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
+import ch.threema.domain.stores.IdentityStore
 import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
+import ch.threema.storage.TestDatabaseProvider
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import io.mockk.every
+import io.mockk.mockk
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
@@ -41,24 +45,36 @@ import kotlin.test.assertTrue
 import kotlin.test.fail
 import kotlinx.coroutines.runBlocking
 import okhttp3.OkHttpClient
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+class AddOrUpdateContactBackgroundTaskTest : KoinComponent {
+    private val appRestrictions: AppRestrictions by inject()
 
-class AddOrUpdateContactBackgroundTaskTest {
     private val backgroundExecutor = BackgroundExecutor()
-    private lateinit var databaseService: TestDatabaseService
+    private lateinit var databaseProvider: TestDatabaseProvider
     private lateinit var coreServiceManager: CoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
 
+    private val myIdentity = "00000000"
+
     @BeforeTest
     fun before() {
-        databaseService = TestDatabaseService()
-        val serviceManager = ThreemaApplication.requireServiceManager()
+        databaseProvider = TestDatabaseProvider()
+        val identityProviderMock = mockk<IdentityProvider> {
+            every { getIdentity() } returns Identity(myIdentity)
+            every { getIdentityString() } returns myIdentity
+        }
+        val identityStoreMock = mockk<IdentityStore> {
+            every { getIdentity() } returns Identity(myIdentity)
+            every { getIdentityString() } returns myIdentity
+        }
         coreServiceManager = TestCoreServiceManager(
-            version = AppVersionProvider.appVersion,
-            databaseService = databaseService,
-            preferenceStore = serviceManager.preferenceStore,
-            encryptedPreferenceStore = serviceManager.encryptedPreferenceStore,
+            databaseProvider = databaseProvider,
+            identityProvider = identityProviderMock,
+            identityStore = identityStoreMock,
         )
-        contactModelRepository = ModelRepositories(coreServiceManager).contacts
+        contactModelRepository = ModelRepositories(coreServiceManager, identityProviderMock).contacts
     }
 
     @Test
@@ -152,7 +168,6 @@ class AddOrUpdateContactBackgroundTaskTest {
 
     @Test
     fun testAddMyIdentity() {
-        val myIdentity = "00000000"
         testAddingContact(
             { identity ->
                 FetchIdentityResult().also {
@@ -204,7 +219,7 @@ class AddOrUpdateContactBackgroundTaskTest {
 
     @Test
     fun testAddExistingContact() {
-        val apiConnectorResult: (identity: Identity) -> FetchIdentityResult = { identity ->
+        val apiConnectorResult: (identity: IdentityString) -> FetchIdentityResult = { identity ->
             FetchIdentityResult().also {
                 it.identity = identity
                 it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
@@ -235,7 +250,7 @@ class AddOrUpdateContactBackgroundTaskTest {
     fun testVerifyTwice() {
         val publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES).apply { fill(2) }
 
-        val apiConnectorResult: (identity: Identity) -> FetchIdentityResult = { identity ->
+        val apiConnectorResult: (identity: IdentityString) -> FetchIdentityResult = { identity ->
             FetchIdentityResult().also {
                 it.identity = identity
                 it.publicKey = publicKey
@@ -268,7 +283,7 @@ class AddOrUpdateContactBackgroundTaskTest {
     fun testUpgradeGroupContact() {
         val newIdentity = "01234567"
 
-        val apiConnectorResult: (identity: Identity) -> FetchIdentityResult = { identity ->
+        val apiConnectorResult: (identity: IdentityString) -> FetchIdentityResult = { identity ->
             FetchIdentityResult().also {
                 it.identity = identity
                 it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
@@ -312,7 +327,7 @@ class AddOrUpdateContactBackgroundTaskTest {
     fun testVerificationLevelUpgrade() {
         val newIdentity = "01234567"
 
-        val apiConnectorResult: (identity: Identity) -> FetchIdentityResult = { identity ->
+        val apiConnectorResult: (identity: IdentityString) -> FetchIdentityResult = { identity ->
             FetchIdentityResult().also {
                 it.identity = identity
                 it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
@@ -357,7 +372,7 @@ class AddOrUpdateContactBackgroundTaskTest {
     fun testAddAndVerifyGroupContact() {
         val newIdentity = "01234567"
 
-        val apiConnectorResult: (identity: Identity) -> FetchIdentityResult = { identity ->
+        val apiConnectorResult: (identity: IdentityString) -> FetchIdentityResult = { identity ->
             FetchIdentityResult().also {
                 it.identity = identity
                 it.publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES)
@@ -432,19 +447,19 @@ class AddOrUpdateContactBackgroundTaskTest {
 
         val addTask = object : AddOrUpdateContactBackgroundTask<Boolean>(
             identity = identity,
-            AcquaintanceLevel.DIRECT,
+            acquaintanceLevel = AcquaintanceLevel.DIRECT,
             myIdentity = myIdentity,
-            unusedAPIConnector,
-            contactModelRepository,
-            AddContactRestrictionPolicy.CHECK,
-            ThreemaApplication.getAppContext(),
-            null,
+            apiConnector = unusedAPIConnector,
+            contactModelRepository = contactModelRepository,
+            addContactRestrictionPolicy = AddContactRestrictionPolicy.CHECK,
+            appRestrictions = appRestrictions,
+            expectedPublicKey = null,
         ) {
             override fun onBefore() {
                 assertEquals(testThreadId, Thread.currentThread().id)
             }
 
-            override fun onContactAdded(result: ContactResult): Boolean {
+            override fun onContactResult(result: ContactResult): Boolean {
                 assertTrue(result is ContactExists)
                 assertNotEquals(testThreadId, Thread.currentThread().id)
                 assertNotEquals(Looper.getMainLooper(), Looper.myLooper())
@@ -463,11 +478,11 @@ class AddOrUpdateContactBackgroundTaskTest {
     }
 
     private fun testAddingContact(
-        fetchIdentity: (identity: Identity) -> FetchIdentityResult,
+        fetchIdentity: (identity: IdentityString) -> FetchIdentityResult,
         runOnFinished: (result: ContactResult) -> Unit,
-        newIdentity: Identity = "01234567",
+        newIdentity: IdentityString = "01234567",
         acquaintanceLevel: AcquaintanceLevel = AcquaintanceLevel.DIRECT,
-        myIdentity: Identity = "00000000",
+        myIdentity: IdentityString = "00000000",
         publicKey: ByteArray? = null,
     ) {
         val apiConnector = getTestApiConnector {
@@ -479,20 +494,22 @@ class AddOrUpdateContactBackgroundTaskTest {
         }
 
         val contactAdded =
-            backgroundExecutor.executeDeferred(object : BasicAddOrUpdateContactBackgroundTask(
-                newIdentity,
-                acquaintanceLevel,
-                myIdentity,
-                apiConnector,
-                contactModelRepository,
-                AddContactRestrictionPolicy.CHECK,
-                ThreemaApplication.getAppContext(),
-                publicKey,
-            ) {
-                override fun onFinished(result: ContactResult) {
-                    runOnFinished(result)
-                }
-            })
+            backgroundExecutor.executeDeferred(
+                object : BasicAddOrUpdateContactBackgroundTask(
+                    identity = newIdentity,
+                    acquaintanceLevel = acquaintanceLevel,
+                    myIdentity = myIdentity,
+                    apiConnector = apiConnector,
+                    contactModelRepository = contactModelRepository,
+                    addContactRestrictionPolicy = AddContactRestrictionPolicy.CHECK,
+                    appRestrictions = appRestrictions,
+                    expectedPublicKey = publicKey,
+                ) {
+                    override fun onFinished(result: ContactResult) {
+                        runOnFinished(result)
+                    }
+                },
+            )
 
         // Assert that the test is not stopped before running the background task completely
         runBlocking {
@@ -500,9 +517,9 @@ class AddOrUpdateContactBackgroundTaskTest {
         }
     }
 
-    private fun getTestApiConnector(onIdentityFetchCalled: (identity: Identity) -> FetchIdentityResult): APIConnector {
+    private fun getTestApiConnector(onIdentityFetchCalled: (identity: IdentityString) -> FetchIdentityResult): APIConnector {
         return object : APIConnector(false, null, false, OkHttpClient(), Version(), null, null) {
-            override fun fetchIdentity(identity: Identity) = onIdentityFetchCalled(identity)
+            override fun fetchIdentity(identity: IdentityString) = onIdentityFetchCalled(identity)
         }
     }
 }

+ 23 - 8
app/src/androidTest/java/ch/threema/app/contacts/MarkContactAsDeletedBackgroundTaskTest.kt

@@ -16,12 +16,11 @@ import ch.threema.app.multidevice.PersistedMultiDeviceProperties
 import ch.threema.app.multidevice.linking.DeviceLinkingStatus
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.UserService
+import ch.threema.app.stores.IdentityProvider
 import ch.threema.app.tasks.ReflectContactSyncUpdateTask
 import ch.threema.app.tasks.TaskCreator
-import ch.threema.app.utils.AppVersionProvider
 import ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.base.crypto.NaCl
-import ch.threema.data.TestDatabaseService
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.repositories.ContactModelRepository
 import ch.threema.data.repositories.ModelRepositories
@@ -37,13 +36,19 @@ import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseListener
 import ch.threema.domain.protocol.connection.data.DeviceId
 import ch.threema.domain.protocol.connection.data.InboundD2mMessage.DevicesInfo
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
+import ch.threema.domain.stores.IdentityStore
 import ch.threema.domain.taskmanager.ActiveTaskCodec
 import ch.threema.domain.taskmanager.QueueSendCompleteListener
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
+import ch.threema.domain.types.Identity
+import ch.threema.storage.TestDatabaseProvider
+import ch.threema.storage.factories.ContactModelFactory
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.testhelpers.MUST_NOT_BE_CALLED
+import io.mockk.every
+import io.mockk.mockk
 import java.util.Date
 import kotlin.test.BeforeTest
 import kotlin.test.Test
@@ -80,7 +85,7 @@ class MarkContactAsDeletedBackgroundTaskTest {
             // Nothing to do
         }
     }
-    private lateinit var databaseService: TestDatabaseService
+    private lateinit var databaseProvider: TestDatabaseProvider
     private val multiDeviceManager = object : MultiDeviceManager {
         var multiDeviceEnabled = false
 
@@ -166,16 +171,26 @@ class MarkContactAsDeletedBackgroundTaskTest {
 
     @BeforeTest
     fun before() {
-        databaseService = TestDatabaseService()
+        databaseProvider = TestDatabaseProvider()
         val serviceManager = ThreemaApplication.requireServiceManager()
         testTaskCodec = TransactionAckTaskCodec()
+        val myIdentity = "00000000"
+        val identityProviderMock = mockk<IdentityProvider> {
+            every { getIdentity() } returns Identity(myIdentity)
+            every { getIdentityString() } returns myIdentity
+        }
+        val identityStoreMock = mockk<IdentityStore> {
+            every { getIdentity() } returns Identity(myIdentity)
+            every { getIdentityString() } returns myIdentity
+        }
         coreServiceManager = TestCoreServiceManager(
-            version = AppVersionProvider.appVersion,
-            databaseService = databaseService,
+            databaseProvider = databaseProvider,
+            identityProvider = identityProviderMock,
             preferenceStore = serviceManager.preferenceStore,
             encryptedPreferenceStore = serviceManager.encryptedPreferenceStore,
             multiDeviceManager = multiDeviceManager,
             taskManager = testTaskManager,
+            identityStore = identityStoreMock,
         )
         deleteContactServices = DeleteContactServices(
             serviceManager.userService,
@@ -189,9 +204,9 @@ class MarkContactAsDeletedBackgroundTaskTest {
             serviceManager.excludedSyncIdentitiesService,
             serviceManager.dhSessionStore,
             serviceManager.notificationService,
-            serviceManager.databaseService,
+            ContactModelFactory(databaseProvider, identityProviderMock),
         )
-        contactModelRepository = ModelRepositories(coreServiceManager).contacts
+        contactModelRepository = ModelRepositories(coreServiceManager, identityProviderMock).contacts
 
         // Add a contact "from sync". This has no side effects and does not reflect the contact.
         contactModelRepository.createFromSync(testContactModelData)

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

@@ -1,14 +1,15 @@
 package ch.threema.app.contacts
 
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import ch.threema.KoinTestRule
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestMultiDeviceManager
 import ch.threema.app.TestTaskManager
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.multidevice.MultiDeviceManager
 import ch.threema.app.processors.reflectedd2dsync.ReflectedContactSyncTask
-import ch.threema.app.utils.AppVersionProvider
+import ch.threema.app.stores.IdentityProvider
 import ch.threema.base.crypto.NaCl
-import ch.threema.data.TestDatabaseService
 import ch.threema.data.datatypes.IdColor
 import ch.threema.data.models.ContactModel
 import ch.threema.data.models.ContactModelData
@@ -22,6 +23,8 @@ import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.stores.IdentityStore
+import ch.threema.domain.types.Identity
 import ch.threema.protobuf.d2d.ContactSyncKt.create
 import ch.threema.protobuf.d2d.ContactSyncKt.update
 import ch.threema.protobuf.d2d.contactSync
@@ -34,8 +37,11 @@ import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.ReadReceiptPolicyOverride
 import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.TypingIndicatorPolicyOverride
 import ch.threema.protobuf.d2d.sync.contact
 import ch.threema.protobuf.unit
+import ch.threema.storage.TestDatabaseProvider
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import com.google.protobuf.kotlin.toByteString
+import io.mockk.every
+import io.mockk.mockk
 import java.util.Date
 import kotlin.test.BeforeTest
 import kotlin.test.Test
@@ -45,11 +51,13 @@ import kotlin.test.assertNull
 import kotlin.test.assertTrue
 import kotlin.test.fail
 import kotlinx.coroutines.runBlocking
+import org.junit.Rule
 import org.junit.runner.RunWith
+import org.koin.dsl.module
 
 @RunWith(AndroidJUnit4::class)
 class ReflectedContactSyncTaskTest {
-    private lateinit var databaseService: TestDatabaseService
+    private lateinit var databaseProvider: TestDatabaseProvider
     private lateinit var taskCodec: TransactionAckTaskCodec
     private lateinit var coreServiceManager: TestCoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
@@ -81,23 +89,39 @@ class ReflectedContactSyncTaskTest {
         notificationTriggerPolicyOverride = null,
     )
 
+    private val instrumentedTestModule = module {
+        factory<MultiDeviceManager> { coreServiceManager.multiDeviceManager }
+    }
+
+    @get:Rule
+    val koinTestRule = KoinTestRule(
+        modules = listOf(instrumentedTestModule),
+    )
+
     @BeforeTest
     fun before() {
-        databaseService = TestDatabaseService()
+        databaseProvider = TestDatabaseProvider()
         taskCodec = TransactionAckTaskCodec()
-        val serviceManager = ThreemaApplication.requireServiceManager()
+        val myIdentity = "00000000"
+        val identityProviderMock = mockk<IdentityProvider> {
+            every { getIdentity() } returns Identity(myIdentity)
+            every { getIdentityString() } returns myIdentity
+        }
+        val identityStoreMock = mockk<IdentityStore> {
+            every { getIdentity() } returns Identity(myIdentity)
+            every { getIdentityString() } returns myIdentity
+        }
         coreServiceManager = TestCoreServiceManager(
-            version = AppVersionProvider.appVersion,
-            databaseService = databaseService,
-            preferenceStore = serviceManager.preferenceStore,
-            encryptedPreferenceStore = serviceManager.encryptedPreferenceStore,
+            databaseProvider = databaseProvider,
+            identityProvider = identityProviderMock,
             multiDeviceManager = TestMultiDeviceManager(
                 isMultiDeviceActive = true,
                 isMdDisabledOrSupportsFs = false,
             ),
             taskManager = TestTaskManager(taskCodec),
+            identityStore = identityStoreMock,
         )
-        contactModelRepository = ModelRepositories(coreServiceManager).contacts
+        contactModelRepository = ModelRepositories(coreServiceManager, identityProviderMock).contacts
     }
 
     @Test

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

@@ -9,15 +9,20 @@ import ch.threema.app.asynctasks.ContactSyncPolicy
 import ch.threema.app.asynctasks.DeleteContactServices
 import ch.threema.app.asynctasks.EmptyOrDeleteConversationsAsyncTask
 import ch.threema.app.asynctasks.MarkContactAsDeletedBackgroundTask
+import ch.threema.app.di.injectNonBinding
 import ch.threema.app.groupflows.GroupLeaveIntent
 import ch.threema.app.home.HomeActivity
 import ch.threema.app.processors.MessageProcessorProvider
 import ch.threema.app.services.ContactService
+import ch.threema.app.services.ConversationService
+import ch.threema.app.services.DistributionListService
+import ch.threema.app.services.GroupFlowDispatcher
 import ch.threema.app.services.GroupService
 import ch.threema.app.services.MessageService
 import ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.data.repositories.GroupModelRepository
 import ch.threema.data.storage.EditHistoryDao
 import ch.threema.data.storage.EditHistoryDaoImpl
 import ch.threema.domain.models.MessageId
@@ -29,12 +34,11 @@ import ch.threema.domain.protocol.csp.messages.GroupDeleteMessage
 import ch.threema.domain.protocol.csp.messages.GroupEditMessage
 import ch.threema.domain.protocol.csp.messages.GroupTextMessage
 import ch.threema.domain.protocol.csp.messages.TextMessage
-import ch.threema.storage.DatabaseService
 import ch.threema.storage.factories.GroupMessageModelFactory
 import ch.threema.storage.factories.MessageModelFactory
 import ch.threema.storage.models.AbstractMessageModel
-import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.MessageModel
+import ch.threema.storage.models.group.GroupMessageModel
 import java.util.Date
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -42,35 +46,40 @@ import kotlin.test.assertNotNull
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.test.runTest
 import org.junit.runner.RunWith
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
 
 @RunWith(AndroidJUnit4::class)
 @LargeTest
 @DangerousTest
-class EditHistoryTest : MessageProcessorProvider() {
-    private val messageService: MessageService by lazy { serviceManager.messageService }
-    private val contactService: ContactService by lazy { serviceManager.contactService }
-    private val groupService: GroupService by lazy { serviceManager.groupService }
-    private val databaseService: DatabaseService by lazy { serviceManager.databaseService }
-    private val messageModelFactory: MessageModelFactory by lazy { databaseService.messageModelFactory }
-    private val groupMessageModelFactory: GroupMessageModelFactory by lazy { databaseService.groupMessageModelFactory }
-    private val editHistoryDao: EditHistoryDao by lazy { EditHistoryDaoImpl(databaseService) }
-    private val contactModelRepository: ContactModelRepository by lazy { serviceManager.modelRepositories.contacts }
-    private val deleteContactServices: DeleteContactServices by lazy {
-        DeleteContactServices(
-            serviceManager.userService,
-            contactService,
-            serviceManager.conversationService,
-            serviceManager.ringtoneService,
-            serviceManager.conversationCategoryService,
-            serviceManager.profilePicRecipientsService,
-            serviceManager.wallpaperService,
-            serviceManager.fileService,
-            serviceManager.excludedSyncIdentitiesService,
-            serviceManager.dhSessionStore,
-            serviceManager.notificationService,
-            databaseService,
+class EditHistoryTest : MessageProcessorProvider(), KoinComponent {
+    private val groupModelRepository: GroupModelRepository by injectNonBinding()
+    private val groupFlowDispatcher: GroupFlowDispatcher by injectNonBinding()
+    private val conversationService: ConversationService by injectNonBinding()
+    private val distributionListService: DistributionListService by injectNonBinding()
+    private val messageService: MessageService by injectNonBinding()
+    private val contactService: ContactService by injectNonBinding()
+    private val groupService: GroupService by injectNonBinding()
+    private val messageModelFactory: MessageModelFactory by injectNonBinding()
+    private val groupMessageModelFactory: GroupMessageModelFactory by injectNonBinding()
+    private val editHistoryDao: EditHistoryDao
+        get() = EditHistoryDaoImpl(databaseProvider = get())
+    private val contactModelRepository: ContactModelRepository by injectNonBinding()
+    private val deleteContactServices: DeleteContactServices
+        get() = DeleteContactServices(
+            userService = get(),
+            contactService = get(),
+            conversationService = get(),
+            ringtoneService = get(),
+            conversationCategoryService = get(),
+            profilePictureRecipientsService = get(),
+            wallpaperService = get(),
+            fileService = get(),
+            excludedSyncIdentitiesService = get(),
+            dhSessionStore = get(),
+            notificationService = get(),
+            contactModelFactory = get(),
         )
-    }
 
     @Test
     fun testHistoryDeletedOnContactMessageDelete() = runTest {
@@ -279,14 +288,14 @@ class EditHistoryTest : MessageProcessorProvider() {
 
         messageModel.assertHistorySize(1)
 
-        val groupModel = serviceManager.modelRepositories.groups.getByGroupIdentity(
+        val groupModel = groupModelRepository.getByGroupIdentity(
             GroupIdentity(
                 creatorIdentity = groupA.groupCreator.identity,
                 groupId = groupA.apiGroupId.toLong(),
             ),
         )
         assertNotNull(groupModel)
-        serviceManager.groupFlowDispatcher.runLeaveGroupFlow(
+        groupFlowDispatcher.runLeaveGroupFlow(
             intent = GroupLeaveIntent.LEAVE_AND_REMOVE,
             groupModel = groupModel,
         ).await()
@@ -301,10 +310,10 @@ class EditHistoryTest : MessageProcessorProvider() {
         EmptyOrDeleteConversationsAsyncTask(
             mode,
             arrayOf(receiver),
-            serviceManager.conversationService,
-            serviceManager.distributionListService,
-            serviceManager.modelRepositories.groups,
-            serviceManager.groupFlowDispatcher,
+            conversationService,
+            distributionListService,
+            groupModelRepository,
+            groupFlowDispatcher,
             myContact.identity,
             null,
             null,

+ 28 - 7
app/src/androidTest/java/ch/threema/app/groupmanagement/CreateGroupFlowTest.kt

@@ -2,9 +2,10 @@ package ch.threema.app.groupmanagement
 
 import android.text.format.DateUtils
 import ch.threema.app.DangerousTest
-import ch.threema.app.ThreemaApplication
 import ch.threema.app.groupflows.GroupCreateProperties
 import ch.threema.app.groupflows.GroupFlowResult
+import ch.threema.app.multidevice.MultiDeviceManager
+import ch.threema.app.restrictions.AppRestrictions
 import ch.threema.app.tasks.GroupCreateTask
 import ch.threema.app.tasks.ReflectGroupSyncCreateTask
 import ch.threema.app.testutils.TestHelpers
@@ -19,12 +20,13 @@ import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.UserState
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
+import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.storage.models.ContactModel
-import ch.threema.storage.models.GroupModel.UserState
 import java.util.Date
 import kotlin.test.BeforeTest
 import kotlin.test.Test
@@ -35,13 +37,19 @@ import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
 import kotlinx.coroutines.test.runTest
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import org.koin.core.module.Module
+import org.koin.dsl.module
 
 /**
  * This test asserts that the corresponding tasks have been scheduled when running the create group
  * flow.
  */
 @DangerousTest
-class CreateGroupFlowTest : GroupFlowTest() {
+class CreateGroupFlowTest : GroupFlowTest(), KoinComponent {
+    private val appRestrictions: AppRestrictions by inject()
+
     private val myContact: TestContact = TestHelpers.TEST_CONTACT
 
     private val initialContactModelData = ContactModelData(
@@ -70,6 +78,14 @@ class CreateGroupFlowTest : GroupFlowTest() {
         notificationTriggerPolicyOverride = null,
     )
 
+    private lateinit var taskManager: ControlledTaskManager
+    private lateinit var multiDeviceManager: MultiDeviceManager
+
+    override fun getInstrumentationTestModule(): Module = module {
+        factory<TaskManager> { taskManager }
+        factory<MultiDeviceManager> { multiDeviceManager }
+    }
+
     @BeforeTest
     fun setup() {
         clearDatabaseAndCaches(serviceManager)
@@ -169,7 +185,7 @@ class CreateGroupFlowTest : GroupFlowTest() {
 
         // act
         val groupFlowResult: GroupFlowResult = groupFlowDispatcher.runCreateGroupFlow(
-            ThreemaApplication.getAppContext(),
+            appRestrictions,
             GroupCreateProperties(
                 name = "Test",
                 profilePicture = null,
@@ -217,7 +233,12 @@ class CreateGroupFlowTest : GroupFlowTest() {
         }
 
         // Prepare task manager and group flow dispatcher
-        val taskManager = ControlledTaskManager(scheduledTaskAssertions)
+        taskManager = ControlledTaskManager(scheduledTaskAssertions)
+        multiDeviceManager = if (reflectionExpectation.setupConfig == SetupConfig.MULTI_DEVICE_ENABLED) {
+            testMultiDeviceManagerEnabled
+        } else {
+            testMultiDeviceManagerDisabled
+        }
         val groupFlowDispatcher = getGroupFlowDispatcher(
             reflectionExpectation.setupConfig,
             taskManager,
@@ -226,13 +247,13 @@ class CreateGroupFlowTest : GroupFlowTest() {
 
         // Run create group flow
         val groupFlowResult: GroupFlowResult = groupFlowDispatcher.runCreateGroupFlow(
-            ThreemaApplication.getAppContext(),
+            appRestrictions,
             groupCreateProperties,
         ).await()
 
         // Assert that all expected tasks have been scheduled
         assert(taskManager.pendingTaskAssertions.isEmpty()) {
-            "There are ${taskManager.pendingTaskAssertions} pending task assertions left"
+            "There are ${taskManager.pendingTaskAssertions.size} pending task assertions left"
         }
 
         return groupFlowResult

+ 2 - 1
app/src/androidTest/java/ch/threema/app/groupmanagement/DisbandGroupFlowTest.kt

@@ -18,12 +18,12 @@ import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.UserState
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
-import ch.threema.storage.models.GroupModel.UserState
 import java.util.Date
 import kotlin.test.BeforeTest
 import kotlin.test.Test
@@ -82,6 +82,7 @@ class DisbandGroupFlowTest : GroupFlowTest() {
     private val initialGroupModelData = myInitialGroupModelData.copy(
         groupIdentity = GroupIdentity(initialContactData.identity, 43),
         name = "ExistingGroup",
+        otherMembers = emptySet(),
     )
 
     private val myInitialLeftGroupModelData = myInitialGroupModelData.copy(

+ 13 - 48
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupControlTest.kt

@@ -1,13 +1,6 @@
 package ch.threema.app.groupmanagement
 
-import androidx.test.core.app.ActivityScenario
-import androidx.test.core.app.launchActivity
-import androidx.test.espresso.Espresso
-import androidx.test.espresso.NoMatchingViewException
-import androidx.test.espresso.action.ViewActions
-import androidx.test.espresso.intent.Intents
-import androidx.test.espresso.matcher.ViewMatchers
-import ch.threema.app.R
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
 import ch.threema.app.home.HomeActivity
 import ch.threema.app.processors.MessageProcessorProvider
 import ch.threema.app.testutils.TestHelpers.TestGroup
@@ -15,12 +8,12 @@ import ch.threema.domain.protocol.csp.messages.AbstractGroupMessage
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
 import ch.threema.domain.protocol.csp.messages.GroupSetupMessage
 import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
-import ch.threema.domain.stores.IdentityStore
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
 import kotlin.test.assertEquals
 import kotlin.test.assertTrue
 import kotlinx.coroutines.test.runTest
+import org.junit.Rule
 
 /**
  * A collection of basic data and utility functions to test group control messages. If the common
@@ -28,32 +21,16 @@ import kotlinx.coroutines.test.runTest
  * receive methods should be overridden.
  */
 abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProvider() {
+
+    @get:Rule
+    val composeTestRule = createAndroidComposeRule<HomeActivity>()
+
     /**
      * Create a message of the tested group message type. This is used to create a message that will
      * be used to test the common group receive steps.
      */
     abstract fun createMessageForGroup(): T
 
-    protected fun startScenario(): ActivityScenario<HomeActivity> {
-        Intents.init()
-
-        val scenario = launchActivity<HomeActivity>()
-
-        do {
-            var switchedToMessages = false
-            try {
-                Espresso.onView(ViewMatchers.withId(R.id.messages)).perform(ViewActions.click())
-                switchedToMessages = true
-            } catch (exception: NoMatchingViewException) {
-                Espresso.onView(ViewMatchers.withId(R.id.close_button)).perform(ViewActions.click())
-            }
-        } while (!switchedToMessages)
-
-        Intents.release()
-
-        return scenario
-    }
-
     /**
      * Check common group receive steps: The group could not be found and the user is the creator of
      * the group (as alleged by the message). The message should be discarded.
@@ -61,7 +38,7 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
     @Test
     open fun testCommonGroupReceiveStepUnknownGroupUserCreator() = runTest {
         val (message, identityStore) = getMyUnknownGroupMessage()
-        setupAndProcessMessage(message, identityStore)
+        processMessage(message, identityStore)
 
         // Nothing is expected to be sent
         assertTrue(sentMessagesInsideTask.isEmpty())
@@ -75,7 +52,7 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
     @Test
     open fun testCommonGroupReceiveStepUnknownGroupUserNotCreator() = runTest {
         val (message, identityStore) = getUnknownGroupMessage()
-        setupAndProcessMessage(message, identityStore)
+        processMessage(message, identityStore)
 
         val firstMessage = sentMessagesInsideTask.poll() as GroupSyncRequestMessage
         assertEquals(message.groupCreator, firstMessage.toIdentity)
@@ -95,7 +72,7 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
     @Test
     open fun testCommonGroupReceiveStepLeftGroupUserCreator() = runTest {
         val (message, identityStore) = getMyLeftGroupMessage()
-        setupAndProcessMessage(message, identityStore)
+        processMessage(message, identityStore)
 
         // Check that empty sync is sent.
         val firstMessage = sentMessagesInsideTask.poll() as GroupSetupMessage
@@ -117,7 +94,7 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
     open fun testCommonGroupReceiveStepLeftGroupUserNotCreator() = runTest {
         // First, test the common group receive steps for a message from the group creator
         val (firstIncomingMessage, firstIdentityStore) = getLeftGroupMessageFromCreator()
-        setupAndProcessMessage(firstIncomingMessage, firstIdentityStore)
+        processMessage(firstIncomingMessage, firstIdentityStore)
 
         // Check that a group leave is sent back to the sender
         val firstSentMessage = sentMessagesInsideTask.poll() as GroupLeaveMessage
@@ -130,7 +107,7 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
 
         // Second, test the common group receive steps for a message from a group member
         val (secondIncomingMessage, secondIdentityStore) = getLeftGroupMessage()
-        setupAndProcessMessage(secondIncomingMessage, secondIdentityStore)
+        processMessage(secondIncomingMessage, secondIdentityStore)
 
         // Check that a group leave is sent back to the sender
         val secondSentMessage = sentMessagesInsideTask.poll() as GroupLeaveMessage
@@ -151,7 +128,7 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
     @Test
     open fun testCommonGroupReceiveStepSenderNotMemberUserCreator() = runTest {
         val (message, identityStore) = getSenderNotMemberOfMyGroupMessage()
-        setupAndProcessMessage(message, identityStore)
+        processMessage(message, identityStore)
 
         // Check that a group setup with empty member list is sent back to the sender
         val firstMessage = sentMessagesInsideTask.poll() as GroupSetupMessage
@@ -172,7 +149,7 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
     @Test
     open fun testCommonGroupReceiveStepSenderNotMemberUserNotCreator() = runTest {
         val (message, identityStore) = getSenderNotMemberMessage()
-        setupAndProcessMessage(message, identityStore)
+        processMessage(message, identityStore)
 
         // Check that a group sync request has been sent to the creator of the group
         val firstMessage = sentMessagesInsideTask.poll() as GroupSyncRequestMessage
@@ -185,18 +162,6 @@ abstract class GroupControlTest<T : AbstractGroupMessage> : MessageProcessorProv
         assertTrue(sentMessagesNewTask.isEmpty())
     }
 
-    private suspend fun setupAndProcessMessage(
-        message: AbstractGroupMessage,
-        identityStore: IdentityStore,
-    ) {
-        // Start home activity and navigate to chat section
-        launchActivity<HomeActivity>()
-
-        Espresso.onView(ViewMatchers.withId(R.id.messages)).perform(ViewActions.click())
-
-        processMessage(message, identityStore)
-    }
-
     /**
      * Get a group message where the user is the creator (as alleged by the received message).
      * Common Group Receive Step 2.1

+ 38 - 32
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupConversationListTest.kt

@@ -1,49 +1,55 @@
 package ch.threema.app.groupmanagement
 
-import androidx.recyclerview.widget.RecyclerView
-import androidx.test.core.app.ActivityScenario
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.core.app.launchActivity
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.NoMatchingViewException
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.intent.Intents
+import androidx.test.espresso.matcher.ViewMatchers.withId
 import ch.threema.app.R
-import ch.threema.app.adapters.MessageListAdapter
 import ch.threema.app.home.HomeActivity
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.domain.protocol.csp.messages.AbstractGroupMessage
-import junit.framework.TestCase
+import kotlin.test.BeforeTest
 
 /**
  * This class provides a utility method to verify that the correct group names are displayed.
  */
 abstract class GroupConversationListTest<T : AbstractGroupMessage> : GroupControlTest<T>() {
-    /**
-     * Assert that in the given scenario the expected groups are listed.
-     */
-    protected fun assertGroupConversations(
-        scenario: ActivityScenario<HomeActivity>,
-        expectedGroups: List<TestGroup>,
-        errorMessage: String = "",
-    ) {
-        Thread.sleep(500)
-
-        scenario.onActivity { activity ->
-            val adapter = activity.findViewById<RecyclerView>(R.id.list)?.adapter
-            assertGroups(expectedGroups, adapter as MessageListAdapter, errorMessage)
-        }
+
+    @BeforeTest
+    override fun setup() {
+        super.setup()
+        startScenario()
+    }
+
+    private fun startScenario() {
+        Intents.init()
+
+        launchActivity<HomeActivity>()
+
+        do {
+            var switchedToMessages = false
+            try {
+                onView(withId(R.id.messages)).perform(click())
+                switchedToMessages = true
+            } catch (_: NoMatchingViewException) {
+                onView(withId(R.id.close_button)).perform(click())
+            }
+        } while (!switchedToMessages)
+
+        Intents.release()
     }
 
     /**
-     * Assert that the given recycler view shows the given
+     * Assert that in the given scenario the expected groups are listed.
      */
-    private fun assertGroups(
-        testGroups: List<TestGroup>,
-        adapter: MessageListAdapter,
-        errorMessage: String,
-    ) {
-        val expectedGroupNames: Set<String> = testGroups.map { it.groupName }.toSet()
-
-        val actualGroupNames = (0 until adapter.itemCount)
-            .mapNotNull { adapter.getEntity(it) }
-            .map { it.messageReceiver.displayName }
-            .toSet()
-
-        TestCase.assertEquals(errorMessage, expectedGroupNames, actualGroupNames)
+    protected fun assertGroupConversations(expectedGroups: List<TestGroup>) {
+        Thread.sleep(1500)
+        expectedGroups.forEach { testGroup ->
+            composeTestRule.onNodeWithText(testGroup.groupName).assertIsDisplayed()
+        }
     }
 }

+ 18 - 3
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupFlowTest.kt

@@ -1,12 +1,16 @@
 package ch.threema.app.groupmanagement
 
+import ch.threema.KoinTestRule
 import ch.threema.app.TestMultiDeviceManager
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.di.modules.sessionScopedModule
 import ch.threema.app.services.GroupFlowDispatcher
 import ch.threema.domain.protocol.connection.ConnectionState
 import ch.threema.domain.protocol.connection.ConnectionStateListener
 import ch.threema.domain.protocol.connection.ServerConnection
 import ch.threema.domain.taskmanager.TaskManager
+import org.junit.Rule
+import org.koin.core.module.Module
 
 enum class SetupConfig {
     MULTI_DEVICE_ENABLED,
@@ -37,14 +41,14 @@ abstract class GroupFlowTest {
     protected val contactModelRepository by lazy { serviceManager.modelRepositories.contacts }
     protected val groupModelRepository by lazy { serviceManager.modelRepositories.groups }
 
-    private val testMultiDeviceManagerEnabled by lazy {
+    protected val testMultiDeviceManagerEnabled by lazy {
         TestMultiDeviceManager(
             isMdDisabledOrSupportsFs = false,
             isMultiDeviceActive = true,
         )
     }
 
-    private val testMultiDeviceManagerDisabled by lazy {
+    protected val testMultiDeviceManagerDisabled by lazy {
         TestMultiDeviceManager(
             isMdDisabledOrSupportsFs = true,
             isMultiDeviceActive = false,
@@ -66,8 +70,8 @@ abstract class GroupFlowTest {
         serviceManager.identityStore,
         serviceManager.forwardSecurityMessageProcessor,
         serviceManager.nonceFactory,
-        serviceManager.blockedIdentitiesService,
         serviceManager.preferenceService,
+        serviceManager.synchronizedSettingsService,
         when (setupConfig) {
             SetupConfig.MULTI_DEVICE_ENABLED -> testMultiDeviceManagerEnabled
             SetupConfig.MULTI_DEVICE_DISABLED -> testMultiDeviceManagerDisabled
@@ -78,6 +82,17 @@ abstract class GroupFlowTest {
         serviceManager.databaseService,
         taskManager,
         connection,
+        serviceManager.identityBlockedSteps,
+    )
+
+    /**
+     * This module is added to the koin modules so that specific dependencies can be provided.
+     */
+    protected open fun getInstrumentationTestModule(): Module? = null
+
+    @get:Rule
+    val koinTestRule = KoinTestRule(
+        modules = listOfNotNull(sessionScopedModule, getInstrumentationTestModule()),
     )
 
     data object ConnectionDisconnected : ServerConnection {

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

@@ -15,12 +15,12 @@ import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.UserState
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.storage.models.ContactModel
-import ch.threema.storage.models.GroupModel.UserState
 import java.util.Date
 import kotlin.test.BeforeTest
 import kotlin.test.Test

+ 22 - 31
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt

@@ -1,10 +1,8 @@
 package ch.threema.app.groupmanagement
 
-import androidx.test.core.app.launchActivity
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import ch.threema.app.DangerousTest
-import ch.threema.app.home.HomeActivity
 import ch.threema.app.listeners.GroupListener
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.testutils.TestHelpers.TestContact
@@ -12,7 +10,7 @@ import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.data.models.GroupIdentity
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
 import ch.threema.domain.protocol.csp.messages.GroupSyncRequestMessage
-import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertFalse
 import junit.framework.TestCase.assertTrue
@@ -45,22 +43,13 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
         assertSuccessfulLeave(groupAB, contactB)
     }
 
-    /**
-     * Test that the creator of a group cannot leave the group.
-     */
-    @Test
-    fun testLeaveFromCreator() = runTest {
-        assertUnsuccessfulLeave(groupA, contactA)
-        assertUnsuccessfulLeave(groupB, contactB)
-    }
-
     /**
      * Test that a leave message of an unknown group (where I am the owner) is discarded (and does
      * not change anything).
      */
     @Test
     fun testLeaveOfMyNonExistingGroup() = runTest {
-        assertUnsuccessfulLeave(myUnknownGroup, contactA, emptyList())
+        assertUnsuccessfulLeave(myUnknownGroup, contactA, emptySet())
     }
 
     /**
@@ -69,7 +58,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      */
     @Test
     fun testLeaveOfNonExistingGroup() = runTest {
-        assertUnsuccessfulLeave(groupAUnknown, contactB, emptyList(), true)
+        assertUnsuccessfulLeave(groupAUnknown, contactB, emptySet(), true)
     }
 
     /**
@@ -96,7 +85,10 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      */
     @Test
     fun testLeaveOfNonMember() = runTest {
-        assertUnsuccessfulLeave(groupA, contactB)
+        assertUnsuccessfulLeave(
+            group = groupA,
+            contact = contactB,
+        )
     }
 
     @AfterTest
@@ -135,13 +127,13 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
         contact: TestContact,
         expectStateChange: Boolean = false,
     ) {
-        launchActivity<HomeActivity>()
-
         serviceManager.groupService.resetCache(group.groupModel.id)
+        val groupIdentity = GroupIdentity(group.groupCreator.identity, group.apiGroupId.toLong())
+        val previousMemberCount = serviceManager.groupService.countMembers(group.groupModel)
 
         assertEquals(
-            group.members.map { it.identity },
-            serviceManager.groupService.getGroupMemberIdentities(group.groupModel).toList(),
+            group.members.map { it.identity } - myContact.identity,
+            serviceManager.modelRepositories.groups.getByGroupIdentity(groupIdentity)?.data?.otherMembers?.toList(),
         )
 
         val leaveTracker = GroupLeaveTracker(group, contact.identity, expectStateChange)
@@ -157,12 +149,12 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
         serviceManager.groupService.resetCache(group.groupModel.id)
 
         assertEquals(
-            group.members.size - 1,
+            previousMemberCount - 1,
             serviceManager.groupService.countMembers(group.groupModel),
         )
         assertEquals(
-            group.members.map { it.identity }.filter { it != contact.identity },
-            serviceManager.groupService.getGroupMemberIdentities(group.groupModel).toList(),
+            group.members.map { it.identity } - myContact.identity - contact.identity,
+            serviceManager.modelRepositories.groups.getByGroupIdentity(groupIdentity)?.data?.otherMembers?.toList(),
         )
 
         // Assert that no message has been sent as a response to a group leave
@@ -172,12 +164,10 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
     private suspend fun assertUnsuccessfulLeave(
         group: TestGroup,
         contact: TestContact,
-        expectedMembers: List<String>? = null,
+        expectedMembers: Set<String>? = null,
         shouldSendSyncRequest: Boolean = false,
     ) {
-        launchActivity<HomeActivity>()
-
-        val expectedMemberList = expectedMembers ?: group.members.map { it.identity }
+        val expectedMemberList = expectedMembers ?: (setOf(group.groupCreator.identity) + group.members.map { it.identity })
 
         serviceManager.groupService.resetCache(group.groupModel.id)
 
@@ -220,9 +210,10 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             toIdentity = myContact.identity
         }
 
-    private fun assertGroupIdentities(expectedMemberList: List<String>, group: TestGroup) {
+    private fun assertGroupIdentities(expectedMemberList: Set<String>, group: TestGroup) {
         if (serviceManager.groupService.getByApiGroupIdAndCreator(
-                group.apiGroupId, group.groupCreator.identity,
+                group.apiGroupId,
+                group.groupCreator.identity,
             ) != null
         ) {
             // We check the expected members if the group is available in the database. If there is
@@ -230,7 +221,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             // retrieve a group model.
             assertEquals(
                 expectedMemberList,
-                serviceManager.groupService.getGroupMemberIdentities(group.groupModel).toList(),
+                serviceManager.groupService.getGroupMemberIdentities(group.groupModel).toSet(),
             )
         }
     }
@@ -242,7 +233,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
             ) != null
         ) {
             // We only check the expected members if the group is available in the database.
-            // Otherwise the check does not make sense as we would not be able to retrieve a group
+            // Otherwise, the check does not make sense as we would not be able to retrieve a group
             // model.
             assertEquals(
                 expectedMemberCount,
@@ -253,7 +244,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
 
     private class GroupLeaveTracker(
         private val group: TestGroup?,
-        private val leavingIdentity: Identity?,
+        private val leavingIdentity: IdentityString?,
         private val expectStateChange: Boolean = false,
     ) {
         private var memberHasLeft = false

+ 28 - 19
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupNameTest.kt

@@ -10,8 +10,11 @@ import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.data.models.GroupIdentity
 import ch.threema.domain.models.GroupId
 import ch.threema.domain.protocol.csp.messages.GroupNameMessage
-import ch.threema.domain.types.Identity
-import junit.framework.TestCase.*
+import ch.threema.domain.types.IdentityString
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertFalse
+import junit.framework.TestCase.assertTrue
+import junit.framework.TestCase.fail
 import kotlin.test.AfterTest
 import kotlin.test.Test
 import kotlinx.coroutines.test.runTest
@@ -34,11 +37,10 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
      */
     @Test
     fun testValidGroupRename() = runTest {
-        // Start home activity and navigate to chat section
-        val activityScenario = startScenario()
-
         // Assert initial groups
-        assertGroupConversations(activityScenario, initialGroups)
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         // Create group rename message
         val groupARenamed =
@@ -53,10 +55,10 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
         val renameTracker = GroupRenameTracker(groupARenamed).apply { start() }
 
         val message = createEncryptedRenameMessage(
-            groupARenamed.groupName,
-            groupARenamed.groupCreator.identity,
-            groupARenamed.apiGroupId,
-            groupARenamed.groupCreator,
+            newGroupName = groupARenamed.groupName,
+            groupCreatorIdentity = groupARenamed.groupCreator.identity,
+            apiGroupId = groupARenamed.apiGroupId,
+            fromContact = groupARenamed.groupCreator,
         )
 
         // Process the group rename message
@@ -67,7 +69,9 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
         renameTracker.stop()
 
         // Assert that the group name change has been processed
-        assertGroupConversations(activityScenario, initialGroups.replace(groupA, groupARenamed))
+        assertGroupConversations(
+            expectedGroups = initialGroups.replace(groupA, groupARenamed),
+        )
     }
 
     /**
@@ -76,11 +80,10 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
      */
     @Test
     fun testInvalidGroupRenameSender() = runTest {
-        // Start home activity and navigate to chat section
-        val activityScenario = startScenario()
-
         // Assert initial groups
-        assertGroupConversations(activityScenario, initialGroups)
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         // Create group rename message (from wrong sender)
         val groupARenamed =
@@ -109,7 +112,9 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
         renameTracker.assertNoRename()
         renameTracker.stop()
 
-        assertGroupConversations(activityScenario, initialGroups)
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
     }
 
     override fun testCommonGroupReceiveStepUnknownGroupUserCreator() {
@@ -118,7 +123,9 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
     }
 
     override fun testCommonGroupReceiveStepUnknownGroupUserNotCreator() {
-        runWithoutGroupRename { super.testCommonGroupReceiveStepUnknownGroupUserNotCreator() }
+        runWithoutGroupRename {
+            super.testCommonGroupReceiveStepUnknownGroupUserNotCreator()
+        }
     }
 
     override fun testCommonGroupReceiveStepLeftGroupUserCreator() {
@@ -128,7 +135,9 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
     }
 
     override fun testCommonGroupReceiveStepLeftGroupUserNotCreator() {
-        runWithoutGroupRename { super.testCommonGroupReceiveStepLeftGroupUserNotCreator() }
+        runWithoutGroupRename {
+            super.testCommonGroupReceiveStepLeftGroupUserNotCreator()
+        }
     }
 
     override fun testCommonGroupReceiveStepSenderNotMemberUserCreator() {
@@ -152,7 +161,7 @@ class IncomingGroupNameTest : GroupConversationListTest<GroupNameMessage>() {
 
     private fun createEncryptedRenameMessage(
         newGroupName: String,
-        groupCreatorIdentity: Identity,
+        groupCreatorIdentity: IdentityString,
         apiGroupId: GroupId,
         fromContact: TestContact,
     ) = GroupNameMessage().apply {

+ 60 - 53
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt

@@ -15,12 +15,12 @@ import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.UserState
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.csp.messages.GroupSetupMessage
-import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
-import ch.threema.storage.models.GroupModel
 import java.util.Date
 import junit.framework.TestCase
 import kotlin.test.AfterTest
@@ -52,10 +52,10 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
      */
     @Test
     fun testUnknownGroupNotMember() = runTest {
-        val scenario = startScenario()
-
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups, "initial groups")
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         val setupTracker = GroupSetupTracker(
             groupAUnknown,
@@ -75,7 +75,9 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, groupAUnknown.groupCreator.identityStore)
 
         // Assert that group conversations did not appear, disappear, or change their name
-        assertGroupConversations(scenario, initialGroups, "no changes")
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
@@ -92,10 +94,10 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
      */
     @Test
     fun testUnknownEmptyGroup() = runTest {
-        val scenario = startScenario()
-
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups, "epect initial group")
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         val setupTracker = GroupSetupTracker(
             groupAUnknown,
@@ -115,7 +117,9 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, groupAUnknown.groupCreator.identityStore)
 
         // Assert that group conversations did not appear, disappear, or change their name
-        assertGroupConversations(scenario, initialGroups, "no changes")
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
@@ -132,10 +136,10 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
      */
     @Test
     fun testBlocked() = runTest {
-        val scenario = startScenario()
-
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         serviceManager.blockedIdentitiesService.blockIdentity(contactA.identity)
         serviceManager.blockedIdentitiesService.blockIdentity(contactB.identity)
@@ -149,7 +153,6 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             "Me, 12345678, ABCDEFGH",
             myContact.identity,
         )
-
         testNewGroup(newGroup)
     }
 
@@ -158,15 +161,15 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
      */
     @Test
     fun testKicked() = runTest {
-        val scenario = startScenario()
-
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups, "initial groups")
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         // Assert that the user is a member of groupAB
         val beforeKicked = groupService.getById(groupAB.groupModel.id)
         assertNotNull(beforeKicked)
-        assertEquals(GroupModel.UserState.MEMBER, beforeKicked.userState)
+        assertEquals(UserState.MEMBER, beforeKicked.userState)
         assertTrue(groupService.isGroupMember(beforeKicked))
 
         val setupTracker = GroupSetupTracker(
@@ -191,10 +194,12 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             GroupIdentity(groupAB.groupCreator.identity, groupAB.apiGroupId.toLong()),
         )
         assertNotNull(afterKicked)
-        assertEquals(GroupModel.UserState.KICKED, afterKicked.data?.userState)
+        assertEquals(UserState.KICKED, afterKicked.data?.userState)
 
         // Assert that group conversations did not appear, disappear, or change their name
-        assertGroupConversations(scenario, initialGroups, "no changes")
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
@@ -211,10 +216,10 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
      */
     @Test
     fun testMembersChanged() = runTest {
-        val scenario = startScenario()
-
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         val setupTracker = GroupSetupTracker(
             groupAB,
@@ -235,7 +240,9 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, groupAB.groupCreator.identityStore)
 
         // Assert that group conversations did not appear, disappear, or change their name
-        assertGroupConversations(scenario, initialGroups, "no changes")
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
@@ -270,10 +277,10 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
      */
     @Test
     fun testRemoveJoin() = runTest {
-        val scenario = startScenario()
-
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups, "initial groups")
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         val setupTracker = GroupSetupTracker(
             groupAB,
@@ -314,10 +321,10 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
 
     @Test
     fun testGroupContainingInvalidIDs() = runTest {
-        val scenario = startScenario()
-
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         val invalidMemberId = ",,,,,,,,"
 
@@ -333,13 +340,13 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         )
 
         val setupTracker = GroupSetupTracker(
-            newGroup,
-            myContact.identity,
+            group = newGroup,
+            myIdentity = myContact.identity,
             expectCreate = true,
             expectKick = false,
-            newGroup.members.filter { it.identity != invalidMemberId }
+            newMembers = newGroup.members.filter { it.identity != invalidMemberId }
                 .map { it.identity } + newGroup.groupCreator.identity,
-            emptyList(),
+            kickedMembers = emptyList(),
         )
         setupTracker.start()
 
@@ -349,7 +356,9 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, newGroup.groupCreator.identityStore)
 
         // Assert that the new group appears in the list
-        assertGroupConversations(scenario, listOf(newGroup) + initialGroups)
+        assertGroupConversations(
+            expectedGroups = listOf(newGroup) + initialGroups,
+        )
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
@@ -363,10 +372,10 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
 
     @Test
     fun testGroupContainingRevokedButKnownContact() = runTest {
-        val scenario = startScenario()
-
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups)
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         // Add a revoked contact
         serviceManager.modelRepositories.contacts.createFromLocal(revokedContactModelData)
@@ -403,7 +412,9 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         processMessage(message, newGroup.groupCreator.identityStore)
 
         // Assert that the new group appears in the list
-        assertGroupConversations(scenario, listOf(newGroup) + initialGroups)
+        assertGroupConversations(
+            expectedGroups = listOf(newGroup) + initialGroups,
+        )
 
         // Assert that no message is sent
         assertEquals(0, sentMessagesInsideTask.size)
@@ -430,10 +441,10 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             )?.data,
         )
 
-        val scenario = startScenario()
-
         // Assert initial group conversations
-        assertGroupConversations(scenario, initialGroups, "initial groups")
+        assertGroupConversations(
+            expectedGroups = initialGroups,
+        )
 
         val setupTracker = GroupSetupTracker(
             newGroup,
@@ -472,17 +483,11 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
             newGroup.groupCreator.identity,
         )
         assertNotNull(group!!)
-        val expectedMemberCount = newGroup.members.size
-        // Assert that there is one more member than member models (as the user is not stored into
-        // the database).
+        val expectedMemberCount = newGroup.members.size + 1
+        // Assert that there are two more members than member models (as the user and the creator is not stored into the database).
         assertEquals(
             expectedMemberCount,
-            serviceManager.databaseService.groupMemberModelFactory.getByGroupId(group.id).size + 1,
-        )
-        assertEquals(
-            expectedMemberCount,
-            serviceManager.databaseService.groupMemberModelFactory.countMembersWithoutUser(group.id)
-                .toInt() + 1,
+            serviceManager.databaseService.groupMemberModelFactory.getByGroupId(group.id).size + 2,
         )
 
         // Assert that the group service returns the member lists including the user
@@ -493,7 +498,9 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         assertEquals(expectedMemberCount, groupService.countMembersWithoutUser(group) + 1)
 
         // Assert that the new group appears in the list
-        assertGroupConversations(scenario, listOf(newGroup) + initialGroups)
+        assertGroupConversations(
+            expectedGroups = listOf(newGroup) + initialGroups,
+        )
     }
 
     private fun createGroupSetupMessage(testGroup: TestGroup) = GroupSetupMessage()
@@ -510,7 +517,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
 
     private class GroupSetupTracker(
         private val group: TestGroup?,
-        private val myIdentity: Identity,
+        private val myIdentity: IdentityString,
         private val expectCreate: Boolean,
         private val expectKick: Boolean,
         private val newMembers: List<String>,

+ 0 - 8
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSyncRequestTest.kt

@@ -1,10 +1,8 @@
 package ch.threema.app.groupmanagement
 
-import androidx.test.core.app.launchActivity
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import ch.threema.app.DangerousTest
-import ch.threema.app.home.HomeActivity
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.domain.protocol.csp.messages.GroupDeleteProfilePictureMessage
@@ -81,8 +79,6 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
     }
 
     private suspend fun assertValidGroupSyncRequest(group: TestGroup, contact: TestContact) {
-        launchActivity<HomeActivity>()
-
         // Create group sync request message
         val groupSyncRequestMessage = GroupSyncRequestMessage()
             .apply {
@@ -127,8 +123,6 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
     }
 
     private suspend fun assertIgnoredGroupSyncRequest(group: TestGroup, contact: TestContact) {
-        launchActivity<HomeActivity>()
-
         // Create group sync request message
         val groupSyncRequestMessage = GroupSyncRequestMessage()
             .apply {
@@ -144,8 +138,6 @@ class IncomingGroupSyncRequestTest : GroupControlTest<GroupSyncRequestMessage>()
     }
 
     private suspend fun assertLeftGroupSyncRequest(group: TestGroup, contact: TestContact) {
-        launchActivity<HomeActivity>()
-
         // Create group sync request message
         val groupSyncRequestMessage = GroupSyncRequestMessage()
             .apply {

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

@@ -18,12 +18,12 @@ import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.UserState
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.storage.models.ContactModel
-import ch.threema.storage.models.GroupModel.UserState
 import java.util.Date
 import kotlin.test.BeforeTest
 import kotlin.test.Test

+ 3 - 2
app/src/androidTest/java/ch/threema/app/groupmanagement/RemoveGroupFlowTest.kt

@@ -1,6 +1,7 @@
 package ch.threema.app.groupmanagement
 
 import ch.threema.app.DangerousTest
+import ch.threema.app.compose.preview.PreviewData
 import ch.threema.app.groupflows.GroupFlowResult
 import ch.threema.app.tasks.ReflectGroupSyncDeleteTask
 import ch.threema.app.testutils.TestHelpers
@@ -15,12 +16,12 @@ import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.UserState
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.storage.models.ContactModel
-import ch.threema.storage.models.GroupModel.UserState
 import java.util.Date
 import kotlin.test.BeforeTest
 import kotlin.test.Test
@@ -35,7 +36,7 @@ class RemoveGroupFlowTest : GroupFlowTest() {
     private val myContact = TestHelpers.TEST_CONTACT
 
     private val initialContactData = ContactModelData(
-        identity = "12345678",
+        identity = PreviewData.IDENTITY_OTHER_1.value,
         publicKey = ByteArray(32),
         createdAt = Date(),
         firstName = "",

+ 22 - 3
app/src/androidTest/java/ch/threema/app/groupmanagement/UpdateGroupFlowTest.kt

@@ -4,6 +4,7 @@ import ch.threema.app.DangerousTest
 import ch.threema.app.groupflows.GroupChanges
 import ch.threema.app.groupflows.GroupChanges.ProfilePictureChange.NoChange
 import ch.threema.app.groupflows.GroupFlowResult
+import ch.threema.app.multidevice.MultiDeviceManager
 import ch.threema.app.tasks.GroupUpdateTask
 import ch.threema.app.tasks.ReflectLocalGroupUpdate
 import ch.threema.app.testutils.TestHelpers
@@ -18,12 +19,13 @@ import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.UserState
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
+import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.storage.models.ContactModel
-import ch.threema.storage.models.GroupModel.UserState
 import java.util.Date
 import kotlin.test.BeforeTest
 import kotlin.test.Test
@@ -35,6 +37,8 @@ import kotlin.test.assertNull
 import kotlin.test.assertTrue
 import kotlin.test.fail
 import kotlinx.coroutines.test.runTest
+import org.koin.core.module.Module
+import org.koin.dsl.module
 
 @DangerousTest
 class UpdateGroupFlowTest : GroupFlowTest() {
@@ -128,6 +132,14 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         notificationTriggerPolicyOverride = null,
     )
 
+    private lateinit var taskManager: ControlledTaskManager
+    private lateinit var multiDeviceManager: MultiDeviceManager
+
+    override fun getInstrumentationTestModule(): Module = module {
+        factory<TaskManager> { taskManager }
+        factory<MultiDeviceManager> { multiDeviceManager }
+    }
+
     @BeforeTest
     fun setup() {
         clearDatabaseAndCaches(serviceManager)
@@ -367,7 +379,9 @@ class UpdateGroupFlowTest : GroupFlowTest() {
     fun shouldNotUpdateGroupWhenMdActiveButConnectionIsLost() = runTest {
         // arrange
         val groupModel = groupModelRepository.getByGroupIdentity(myInitialGroupModelData.groupIdentity)
-        val taskManager = ControlledTaskManager(emptyList())
+        taskManager = ControlledTaskManager(emptyList())
+        multiDeviceManager = testMultiDeviceManagerEnabled
+
         val groupFlowDispatcher = getGroupFlowDispatcher(
             setupConfig = SetupConfig.MULTI_DEVICE_ENABLED,
             taskManager = taskManager,
@@ -430,9 +444,14 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         val groupModelData = groupModel.data
 
         // Prepare task manager and group flow dispatcher
-        val taskManager = ControlledTaskManager(
+        taskManager = ControlledTaskManager(
             getExpectedTaskAssertions(groupModelData, reflectionExpectation, successExpected),
         )
+        multiDeviceManager = if (reflectionExpectation.setupConfig == SetupConfig.MULTI_DEVICE_ENABLED) {
+            testMultiDeviceManagerEnabled
+        } else {
+            testMultiDeviceManagerDisabled
+        }
         val groupFlowDispatcher = getGroupFlowDispatcher(
             reflectionExpectation.setupConfig,
             taskManager,

+ 147 - 103
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt

@@ -3,9 +3,12 @@ package ch.threema.app.processors
 import android.Manifest
 import android.content.Intent
 import android.os.Build
+import androidx.annotation.CallSuper
 import androidx.test.rule.GrantPermissionRule
+import ch.threema.KoinTestRule
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.di.modules.sessionScopedModule
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.multidevice.MultiDeviceManagerImpl
@@ -36,6 +39,8 @@ import ch.threema.domain.models.Contact
 import ch.threema.domain.models.GroupId
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.ThreemaFeature
 import ch.threema.domain.protocol.Version
 import ch.threema.domain.protocol.api.APIConnector
@@ -56,8 +61,9 @@ import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.domain.taskmanager.toCspMessage
 import ch.threema.storage.DatabaseService
+import ch.threema.storage.factories.TaskArchiveFactory
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
-import ch.threema.storage.models.GroupMemberModel
+import ch.threema.storage.models.group.GroupMemberModel
 import java.io.ByteArrayInputStream
 import java.util.Queue
 import java.util.concurrent.ConcurrentLinkedQueue
@@ -70,8 +76,11 @@ import kotlinx.coroutines.runBlocking
 import okhttp3.OkHttpClient
 import org.junit.Rule
 import org.junit.rules.Timeout
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
+import org.koin.dsl.module
 
-open class MessageProcessorProvider {
+open class MessageProcessorProvider : KoinComponent {
     protected val myContact: TestContact = TestHelpers.TEST_CONTACT
     protected val contactA = TestContact("12345678")
     protected val contactB = TestContact("ABCDEFGH")
@@ -94,14 +103,14 @@ open class MessageProcessorProvider {
             myContact.identity,
         )
     protected val groupA =
-        TestGroup(GroupId(2), contactA, listOf(myContact, contactA), "GroupA", myContact.identity)
+        TestGroup(GroupId(2), contactA, listOf(myContact), "GroupA", myContact.identity)
     protected val groupB =
-        TestGroup(GroupId(3), contactB, listOf(myContact, contactB), "GroupB", myContact.identity)
+        TestGroup(GroupId(3), contactB, listOf(myContact), "GroupB", myContact.identity)
     protected val groupAB =
         TestGroup(
             GroupId(4),
             contactA,
-            listOf(myContact, contactA, contactB),
+            listOf(myContact, contactB),
             "GroupAB",
             myContact.identity,
         )
@@ -109,7 +118,7 @@ open class MessageProcessorProvider {
         TestGroup(
             GroupId(5),
             contactA,
-            listOf(myContact, contactA, contactB),
+            listOf(myContact, contactB),
             "GroupAUnknown",
             myContact.identity,
         )
@@ -117,7 +126,7 @@ open class MessageProcessorProvider {
         TestGroup(
             GroupId(6),
             contactA,
-            listOf(contactA, contactB),
+            listOf(contactB),
             "GroupALeft",
             myContact.identity,
         )
@@ -125,7 +134,7 @@ open class MessageProcessorProvider {
         TestGroup(
             GroupId(7),
             myContact,
-            listOf(myContact, contactA),
+            listOf(contactA),
             "MyUnknownGroup",
             myContact.identity,
         )
@@ -135,7 +144,7 @@ open class MessageProcessorProvider {
         TestGroup(
             GroupId(9),
             contactA,
-            listOf(myContact, contactA, contactB),
+            listOf(myContact, contactB),
             "NewAGroup",
             myContact.identity,
         )
@@ -143,12 +152,14 @@ open class MessageProcessorProvider {
         TestGroup(
             GroupId(10),
             contactB,
-            listOf(myContact, contactB),
+            listOf(myContact),
             "NewBGroup",
             myContact.identity,
         )
 
-    protected val serviceManager: ServiceManager = ThreemaApplication.requireServiceManager()
+    protected val serviceManager: ServiceManager by lazy {
+        ThreemaApplication.requireServiceManager()
+    }
     private val contactStore: ContactStore = InMemoryContactStore().apply {
         addContact(myContact.contact)
         addContact(contactA.contact)
@@ -163,60 +174,65 @@ open class MessageProcessorProvider {
         contactC.identity to contactC.identityStore,
     ).toMap()
 
-    private val forwardSecurityStatusListener = object : ForwardSecurityStatusSender(
-        serviceManager.contactService,
-        serviceManager.messageService,
-        APIConnector(
-            /* ipv6 = */
-            false,
-            /* serverAddressProvider = */
-            null,
-            /* isWork = */
-            false,
-            /* okHttpClient = */
-            OkHttpClient(),
-            /* version = */
-            Version(),
-            /* language = */
-            null,
-            /* authenticator= */
-            null,
-        ),
-        serviceManager.userService,
-        serviceManager.modelRepositories.contacts,
-    ) {
-        override fun messageWithoutFSReceived(
-            contact: Contact,
-            session: DHSession,
-            message: AbstractMessage,
+    private val forwardSecurityStatusListener by lazy {
+        object : ForwardSecurityStatusSender(
+            serviceManager.contactService,
+            serviceManager.messageService,
+            APIConnector(
+                /* ipv6 = */
+                false,
+                /* serverAddressProvider = */
+                null,
+                /* isWork = */
+                false,
+                /* okHttpClient = */
+                OkHttpClient(),
+                /* version = */
+                Version(),
+                /* language = */
+                null,
+                /* authenticator= */
+                null,
+            ),
+            serviceManager.userService,
+            serviceManager.modelRepositories.contacts,
         ) {
-            throw AssertionError("We do not accept messages without forward security")
+            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()
+    private val forwardSecurityMessageProcessorMap by lazy {
+        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
@@ -224,20 +240,24 @@ open class MessageProcessorProvider {
      */
     private lateinit var originalTaskManager: TaskManager
 
+    private lateinit var taskManager: TaskManager
+
     /**
      * The local task codec is used for running tasks directly in the tests. We can use this to
      * check that messages are being sent inside the directly run task. Note that the test task
      * codec automatically enqueues server acks for outgoing message and decrypts outgoing
      * forward security messages.
      */
-    private val localTaskCodec =
+    private val localTaskCodec by lazy {
         DecryptTaskCodec(contactStore, identityMap, forwardSecurityMessageProcessorMap)
+    }
 
     /**
      * The global task codec is used when new tasks are created.
      */
-    private val globalTaskCodec =
+    private val globalTaskCodec by lazy {
         DecryptTaskCodec(contactStore, identityMap, forwardSecurityMessageProcessorMap)
+    }
 
     private val globalTaskQueue: Queue<QueueEntry<*>> = ConcurrentLinkedQueue()
 
@@ -251,11 +271,13 @@ open class MessageProcessorProvider {
         }
     }
 
-    protected val sentMessagesInsideTask: Queue<AbstractMessage> =
+    protected val sentMessagesInsideTask: Queue<AbstractMessage> by lazy {
         localTaskCodec.outboundAbstractMessages
+    }
 
-    protected val sentMessagesNewTask: Queue<AbstractMessage> =
+    protected val sentMessagesNewTask: Queue<AbstractMessage> by lazy {
         globalTaskCodec.outboundAbstractMessages
+    }
 
     protected val initialContacts = listOf(myContact, contactA, contactB, contactC)
 
@@ -275,18 +297,28 @@ open class MessageProcessorProvider {
             GrantPermissionRule.grant()
         }
 
+    private val instrumentedTestModule = module {
+        factory<TaskManager> { taskManager }
+    }
+
+    @get:Rule
+    val koinTestRule = KoinTestRule(
+        modules = listOf(sessionScopedModule, instrumentedTestModule),
+    )
+
     /**
      * Asserts that the correct identity is set up and fills the database with the initial data.
      */
     @BeforeTest
-    fun setup() {
+    @CallSuper
+    open fun setup() {
         TestHelpers.setIdentity(
             ThreemaApplication.requireServiceManager(),
             TestHelpers.TEST_CONTACT,
         )
 
         // Delete persisted tasks as they are not needed for tests
-        serviceManager.databaseService.taskArchiveFactory.deleteAll()
+        get<TaskArchiveFactory>().deleteAll()
 
         // Replace original task manager (save a copy of it)
         originalTaskManager = serviceManager.taskManager
@@ -309,6 +341,8 @@ open class MessageProcessorProvider {
             }
         }
 
+        taskManager = mockTaskManager
+
         setTaskManager(mockTaskManager)
 
         disableLifetimeService()
@@ -398,16 +432,17 @@ open class MessageProcessorProvider {
     private fun setTaskManager(taskManager: TaskManager) {
         val serviceManager = ThreemaApplication.requireServiceManager()
         val coreServiceManager = TestCoreServiceManager(
-            AppVersionProvider.appVersion,
-            serviceManager.databaseService,
-            serviceManager.preferenceStore,
-            serviceManager.encryptedPreferenceStore,
-            TaskArchiverImpl(serviceManager.databaseService.taskArchiveFactory, TaskRecoveryManagerImpl(), getDebugString),
-            serviceManager.deviceCookieManager,
-            taskManager,
-            serviceManager.multiDeviceManager as MultiDeviceManagerImpl,
-            serviceManager.identityStore,
-            serviceManager.nonceFactory,
+            version = AppVersionProvider.appVersion,
+            databaseProvider = get(),
+            databaseService = serviceManager.databaseService,
+            preferenceStore = serviceManager.preferenceStore,
+            encryptedPreferenceStore = serviceManager.encryptedPreferenceStore,
+            taskArchiver = TaskArchiverImpl(get(), TaskRecoveryManagerImpl(), getDebugString),
+            deviceCookieManager = serviceManager.deviceCookieManager,
+            taskManager = taskManager,
+            multiDeviceManager = serviceManager.multiDeviceManager as MultiDeviceManagerImpl,
+            identityStore = serviceManager.identityStore,
+            nonceFactory = serviceManager.nonceFactory,
         )
 
         val field = ServiceManager::class.java.getDeclaredField("coreServiceManager")
@@ -482,12 +517,15 @@ open class MessageProcessorProvider {
         val groupModel = testGroup.groupModel
         databaseService.groupModelFactory.createOrUpdate(groupModel)
         testGroup.setLocalGroupId(groupModel.id)
-        testGroup.members.filter { it.identity != myContact.identity }.forEach { member ->
-            val memberModel = GroupMemberModel()
-                .setGroupId(groupModel.id)
-                .setIdentity(member.identity)
-            databaseService.groupMemberModelFactory.createOrUpdate(memberModel)
-        }
+        testGroup.members
+            .filter { member -> member.identity != myContact.identity }
+            .filter { member -> member.identity != groupModel.groupIdentity.creatorIdentity }
+            .map { member ->
+                GroupMemberModel()
+                    .setGroupId(groupModel.id)
+                    .setIdentity(member.identity)
+            }
+            .forEach(databaseService.groupMemberModelFactory::createOrUpdate)
         if (testGroup.profilePicture != null) {
             fileService.writeGroupProfilePicture(
                 groupModel.groupIdentity,
@@ -548,21 +586,23 @@ open class MessageProcessorProvider {
         identityStore: IdentityStore,
         forwardSecurityMessageProcessor: ForwardSecurityMessageProcessor,
     ): MessageBox {
-        val nonceFactory = NonceFactory(object : NonceStore {
-            override fun exists(scope: NonceScope, nonce: Nonce) = false
-            override fun store(scope: NonceScope, nonce: Nonce) = true
-            override fun getAllHashedNonces(scope: NonceScope) = listOf<HashedNonce>()
-            override fun getCount(scope: NonceScope) = 0L
-            override fun addHashedNoncesChunk(
-                scope: NonceScope,
-                chunkSize: Int,
-                offset: Int,
-                hashedNonces: MutableList<HashedNonce>,
-            ) {
-            }
-
-            override fun insertHashedNonces(scope: NonceScope, nonces: List<HashedNonce>) = true
-        })
+        val nonceFactory = NonceFactory(
+            object : NonceStore {
+                override fun exists(scope: NonceScope, nonce: Nonce) = false
+                override fun store(scope: NonceScope, nonce: Nonce) = true
+                override fun getAllHashedNonces(scope: NonceScope) = listOf<HashedNonce>()
+                override fun getCount(scope: NonceScope) = 0L
+                override fun addHashedNoncesChunk(
+                    scope: NonceScope,
+                    chunkSize: Int,
+                    offset: Int,
+                    hashedNonces: MutableList<HashedNonce>,
+                ) {
+                }
+
+                override fun insertHashedNonces(scope: NonceScope, nonces: List<HashedNonce>) = true
+            },
+        )
 
         val encapsulated = forwardSecurityMessageProcessor.runFsEncapsulationSteps(
             contactStore.getContactForIdentityIncludingCache(
@@ -579,9 +619,9 @@ open class MessageProcessorProvider {
     }
 
     private fun Contact.enhanceToBasicContact() = BasicContact(
-        identity,
-        publicKey,
-        ThreemaFeature.Builder()
+        identity = identity,
+        publicKey = publicKey,
+        featureMask = ThreemaFeature.Builder()
             .audio(true)
             .group(true)
             .ballot(true)
@@ -593,7 +633,11 @@ open class MessageProcessorProvider {
             .editMessages(true)
             .deleteMessages(true)
             .build().toULong(),
-        IdentityState.ACTIVE,
-        IdentityType.NORMAL,
+        identityState = IdentityState.ACTIVE,
+        identityType = IdentityType.NORMAL,
+        verificationLevel = VerificationLevel.UNVERIFIED,
+        workVerificationLevel = WorkVerificationLevel.NONE,
+        jobTitle = null,
+        department = null,
     )
 }

+ 114 - 255
app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt

@@ -1,53 +1,29 @@
 package ch.threema.app.protocol
 
+import android.os.Build
 import ch.threema.app.DangerousTest
-import ch.threema.app.TestCoreServiceManager
-import ch.threema.app.TestTaskManager
-import ch.threema.app.ThreemaApplication
-import ch.threema.app.preference.service.PreferenceService
-import ch.threema.app.preference.service.PreferenceServiceImpl
+import ch.threema.app.preference.service.SynchronizedSettingsService
+import ch.threema.app.protocolsteps.BlockState
+import ch.threema.app.protocolsteps.IdentityBlockedSteps
 import ch.threema.app.services.BlockedIdentitiesService
 import ch.threema.app.services.GroupService
-import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
-import ch.threema.app.testutils.clearDatabaseAndCaches
-import ch.threema.app.utils.AppVersionProvider
-import ch.threema.data.TestDatabaseService
-import ch.threema.data.datatypes.IdColor
+import ch.threema.data.models.ContactModel
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.repositories.ContactModelRepository
-import ch.threema.data.repositories.ModelRepositories
-import ch.threema.domain.helpers.UnusedTaskCodec
-import ch.threema.domain.models.ContactSyncState
-import ch.threema.domain.models.GroupId
-import ch.threema.domain.models.IdentityState
-import ch.threema.domain.models.IdentityType
-import ch.threema.domain.models.ReadReceiptPolicy
-import ch.threema.domain.models.TypingIndicatorPolicy
-import ch.threema.domain.models.VerificationLevel
-import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.stores.ContactStore
-import ch.threema.domain.types.Identity
-import ch.threema.storage.DatabaseService
-import ch.threema.storage.models.ContactModel
-import ch.threema.storage.models.GroupMemberModel
-import ch.threema.storage.models.GroupModel
+import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import ch.threema.storage.models.group.GroupModelOld
+import io.mockk.every
+import io.mockk.mockk
 import java.util.Date
-import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
-import kotlinx.coroutines.runBlocking
+import org.junit.Assume.assumeTrue
+import org.junit.Before
 
 @DangerousTest
 class IdentityBlockedStepsTest {
-    private lateinit var contactModelRepository: ContactModelRepository
-    private lateinit var contactStore: ContactStore
-    private lateinit var groupService: GroupService
-    private lateinit var blockUnknownPreferenceService: PreferenceService
-    private lateinit var noBlockPreferenceService: PreferenceService
-    private lateinit var blockedIdentitiesService: BlockedIdentitiesService
-
-    private val myContact = TestHelpers.TEST_CONTACT
     private val knownContact = TestContact("12345678")
     private val unknownContact = TestContact("TESTTEST")
     private val specialContact = TestContact("*3MAPUSH")
@@ -56,70 +32,45 @@ class IdentityBlockedStepsTest {
     private val inNoGroup = TestContact("********")
     private val inLeftGroup = TestContact("--------")
 
-    @BeforeTest
-    fun setup() {
-        val serviceManager = ThreemaApplication.requireServiceManager()
-
-        clearDatabaseAndCaches(serviceManager)
-
-        assert(myContact.identity == TestHelpers.ensureIdentity(serviceManager))
+    private lateinit var contactModelRepositoryMock: ContactModelRepository
+    private lateinit var contactStoreMock: ContactStore
+    private lateinit var groupServiceMock: GroupService
+    private lateinit var blockedIdentitiesServiceMock: BlockedIdentitiesService
+    private lateinit var noBlockUnknownSynchronizedSettingsServiceMock: SynchronizedSettingsService
+    private lateinit var blockUnknownSynchronizedSettingsServiceMock: SynchronizedSettingsService
 
-        val databaseService = TestDatabaseService()
-        val coreServiceManager = TestCoreServiceManager(
-            version = AppVersionProvider.appVersion,
-            databaseService = databaseService,
-            preferenceStore = serviceManager.preferenceStore,
-            encryptedPreferenceStore = serviceManager.encryptedPreferenceStore,
-            taskManager = TestTaskManager(UnusedTaskCodec()),
+    @Before
+    fun setup() {
+        // Because mockk does not support mocking objects below android P, we skip this test in this case.
+        assumeTrue(
+            Build.VERSION.SDK_INT > Build.VERSION_CODES.P,
         )
-        contactModelRepository = ModelRepositories(coreServiceManager).contacts
-        contactStore = serviceManager.contactStore
-        groupService = serviceManager.groupService
-        blockedIdentitiesService = serviceManager.blockedIdentitiesService
-        blockedIdentitiesService.blockIdentity(explicitlyBlockedContact.identity)
 
-        blockUnknownPreferenceService = object : PreferenceServiceImpl(
-            ThreemaApplication.getAppContext(),
-            serviceManager.preferenceStore,
-            serviceManager.encryptedPreferenceStore,
-            coreServiceManager.taskManager,
-            coreServiceManager.multiDeviceManager,
-            coreServiceManager.nonceFactory,
-        ) {
-            override fun isBlockUnknown(): Boolean {
-                return true
+        contactModelRepositoryMock = getContactModelRepositoryMock()
+        contactStoreMock = getContactStoreMock()
+        groupServiceMock = getGroupServiceMock()
+        blockedIdentitiesServiceMock = mockk {
+            every { isBlocked(any()) } answers {
+                firstArg<String>() == explicitlyBlockedContact.identity
             }
         }
-
-        noBlockPreferenceService = object : PreferenceServiceImpl(
-            ThreemaApplication.getAppContext(),
-            serviceManager.preferenceStore,
-            serviceManager.encryptedPreferenceStore,
-            coreServiceManager.taskManager,
-            coreServiceManager.multiDeviceManager,
-            coreServiceManager.nonceFactory,
-        ) {
-            override fun isBlockUnknown(): Boolean {
-                return false
-            }
+        noBlockUnknownSynchronizedSettingsServiceMock = mockk {
+            every { isBlockUnknown() } returns false
+        }
+        blockUnknownSynchronizedSettingsServiceMock = mockk {
+            every { isBlockUnknown() } returns true
         }
-
-        addKnownContacts()
-        addGroups(serviceManager.databaseService)
     }
 
     @Test
     fun testExplicitlyBlockedContact() {
         assertEquals(
             BlockState.EXPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(explicitlyBlockedContact.identity, noBlockPreferenceService),
+            runIdentityBlockedSteps(explicitlyBlockedContact.identity, noBlockUnknownSynchronizedSettingsServiceMock),
         )
         assertEquals(
             BlockState.EXPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(
-                explicitlyBlockedContact.identity,
-                blockUnknownPreferenceService,
-            ),
+            runIdentityBlockedSteps(explicitlyBlockedContact.identity, blockUnknownSynchronizedSettingsServiceMock),
         )
     }
 
@@ -127,7 +78,7 @@ class IdentityBlockedStepsTest {
     fun testImplicitlyBlockedContact() {
         assertEquals(
             BlockState.IMPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(unknownContact.identity, blockUnknownPreferenceService),
+            runIdentityBlockedSteps(unknownContact.identity, blockUnknownSynchronizedSettingsServiceMock),
         )
     }
 
@@ -135,7 +86,7 @@ class IdentityBlockedStepsTest {
     fun testImplicitlyBlockedSpecialContact() {
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(specialContact.identity, blockUnknownPreferenceService),
+            runIdentityBlockedSteps(specialContact.identity, blockUnknownSynchronizedSettingsServiceMock),
         )
     }
 
@@ -143,7 +94,7 @@ class IdentityBlockedStepsTest {
     fun testGroupContactWithGroup() {
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(inGroup.identity, blockUnknownPreferenceService),
+            runIdentityBlockedSteps(inGroup.identity, blockUnknownSynchronizedSettingsServiceMock),
         )
     }
 
@@ -151,7 +102,7 @@ class IdentityBlockedStepsTest {
     fun testGroupContactWithoutGroup() {
         assertEquals(
             BlockState.IMPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(inNoGroup.identity, blockUnknownPreferenceService),
+            runIdentityBlockedSteps(inNoGroup.identity, blockUnknownSynchronizedSettingsServiceMock),
         )
     }
 
@@ -159,7 +110,7 @@ class IdentityBlockedStepsTest {
     fun testGroupContactWithLeftGroup() {
         assertEquals(
             BlockState.IMPLICITLY_BLOCKED,
-            runIdentityBlockedSteps(inLeftGroup.identity, blockUnknownPreferenceService),
+            runIdentityBlockedSteps(inLeftGroup.identity, blockUnknownSynchronizedSettingsServiceMock),
         )
     }
 
@@ -167,7 +118,7 @@ class IdentityBlockedStepsTest {
     fun testKnownContact() {
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(knownContact.identity, blockUnknownPreferenceService),
+            runIdentityBlockedSteps(knownContact.identity, blockUnknownSynchronizedSettingsServiceMock),
         )
     }
 
@@ -175,193 +126,101 @@ class IdentityBlockedStepsTest {
     fun testWithoutBlockingUnknown() {
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(knownContact.identity, noBlockPreferenceService),
+            runIdentityBlockedSteps(knownContact.identity, noBlockUnknownSynchronizedSettingsServiceMock),
         )
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(unknownContact.identity, noBlockPreferenceService),
+            runIdentityBlockedSteps(unknownContact.identity, noBlockUnknownSynchronizedSettingsServiceMock),
         )
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(specialContact.identity, noBlockPreferenceService),
+            runIdentityBlockedSteps(specialContact.identity, noBlockUnknownSynchronizedSettingsServiceMock),
         )
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(inGroup.identity, noBlockPreferenceService),
+            runIdentityBlockedSteps(inGroup.identity, noBlockUnknownSynchronizedSettingsServiceMock),
         )
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(inNoGroup.identity, noBlockPreferenceService),
+            runIdentityBlockedSteps(inNoGroup.identity, noBlockUnknownSynchronizedSettingsServiceMock),
         )
         assertEquals(
             BlockState.NOT_BLOCKED,
-            runIdentityBlockedSteps(inLeftGroup.identity, noBlockPreferenceService),
+            runIdentityBlockedSteps(inLeftGroup.identity, noBlockUnknownSynchronizedSettingsServiceMock),
         )
     }
 
-    private fun runIdentityBlockedSteps(
-        identity: Identity,
-        preferenceService: PreferenceService,
-    ) = runIdentityBlockedSteps(
-        identity,
-        contactModelRepository,
-        contactStore,
-        groupService,
-        blockedIdentitiesService,
-        preferenceService,
-    )
+    private fun runIdentityBlockedSteps(identity: String, synchronizedSettingsService: SynchronizedSettingsService) =
+        IdentityBlockedSteps(
+            contactModelRepository = contactModelRepositoryMock,
+            contactStore = contactStoreMock,
+            groupService = groupServiceMock,
+            blockedIdentitiesService = blockedIdentitiesServiceMock,
+            synchronizedSettingsService = synchronizedSettingsService,
+        ).run(identity = identity)
 
-    private fun addKnownContacts() = runBlocking {
-        contactModelRepository.createFromLocal(
-            ContactModelData(
-                identity = knownContact.identity,
-                publicKey = knownContact.publicKey,
-                createdAt = Date(),
-                firstName = "",
-                lastName = "",
-                nickname = "",
-                idColor = IdColor(0),
-                verificationLevel = VerificationLevel.UNVERIFIED,
-                workVerificationLevel = WorkVerificationLevel.NONE,
-                identityType = IdentityType.NORMAL,
-                acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
-                activityState = IdentityState.ACTIVE,
-                syncState = ContactSyncState.INITIAL,
-                featureMask = 0u,
-                readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
-                typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-                isArchived = false,
-                androidContactLookupInfo = null,
-                localAvatarExpires = null,
-                isRestored = false,
-                profilePictureBlobId = null,
-                jobTitle = null,
-                department = null,
-                notificationTriggerPolicyOverride = null,
-            ),
-        )
-        contactModelRepository.createFromLocal(
-            ContactModelData(
-                identity = inGroup.identity,
-                publicKey = inGroup.publicKey,
-                createdAt = Date(),
-                firstName = "",
-                lastName = "",
-                nickname = "",
-                idColor = IdColor(0),
-                verificationLevel = VerificationLevel.UNVERIFIED,
-                workVerificationLevel = WorkVerificationLevel.NONE,
-                identityType = IdentityType.NORMAL,
-                acquaintanceLevel = ContactModel.AcquaintanceLevel.GROUP,
-                activityState = IdentityState.ACTIVE,
-                syncState = ContactSyncState.INITIAL,
-                featureMask = 0u,
-                readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
-                typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-                isArchived = false,
-                androidContactLookupInfo = null,
-                localAvatarExpires = null,
-                isRestored = false,
-                profilePictureBlobId = null,
-                jobTitle = null,
-                department = null,
-                notificationTriggerPolicyOverride = null,
-            ),
-        )
-        contactModelRepository.createFromLocal(
-            ContactModelData(
-                identity = inNoGroup.identity,
-                publicKey = inNoGroup.publicKey,
-                createdAt = Date(),
-                firstName = "",
-                lastName = "",
-                nickname = "",
-                idColor = IdColor(0),
-                verificationLevel = VerificationLevel.UNVERIFIED,
-                workVerificationLevel = WorkVerificationLevel.NONE,
-                identityType = IdentityType.NORMAL,
-                acquaintanceLevel = ContactModel.AcquaintanceLevel.GROUP,
-                activityState = IdentityState.ACTIVE,
-                syncState = ContactSyncState.INITIAL,
-                featureMask = 0u,
-                readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
-                typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-                isArchived = false,
-                androidContactLookupInfo = null,
-                localAvatarExpires = null,
-                isRestored = false,
-                profilePictureBlobId = null,
-                jobTitle = null,
-                department = null,
-                notificationTriggerPolicyOverride = null,
-            ),
-        )
-        contactModelRepository.createFromLocal(
-            ContactModelData(
-                identity = inLeftGroup.identity,
-                publicKey = inLeftGroup.publicKey,
-                createdAt = Date(),
-                firstName = "",
-                lastName = "",
-                nickname = "",
-                idColor = IdColor(0),
-                verificationLevel = VerificationLevel.UNVERIFIED,
-                workVerificationLevel = WorkVerificationLevel.NONE,
-                identityType = IdentityType.NORMAL,
-                acquaintanceLevel = ContactModel.AcquaintanceLevel.GROUP,
-                activityState = IdentityState.ACTIVE,
-                syncState = ContactSyncState.INITIAL,
-                featureMask = 0u,
-                readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
-                typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-                isArchived = false,
-                androidContactLookupInfo = null,
-                localAvatarExpires = null,
-                isRestored = false,
-                profilePictureBlobId = null,
-                jobTitle = null,
-                department = null,
-                notificationTriggerPolicyOverride = null,
-            ),
-        )
+    private fun getContactModelRepositoryMock(): ContactModelRepository = mockk {
+        every { getByIdentity(knownContact.identity) } returns getContactModelMock(knownContact, AcquaintanceLevel.DIRECT)
+
+        every { getByIdentity(unknownContact.identity) } returns null
+
+        every { getByIdentity(inGroup.identity) } returns getContactModelMock(inGroup, AcquaintanceLevel.GROUP)
+
+        every { getByIdentity(inNoGroup.identity) } returns getContactModelMock(inNoGroup, AcquaintanceLevel.GROUP)
+
+        every { getByIdentity(inLeftGroup.identity) } returns getContactModelMock(inLeftGroup, AcquaintanceLevel.GROUP)
     }
 
-    private fun addGroups(databaseService: DatabaseService) = runBlocking {
-        databaseService.groupModelFactory.apply {
-            create(
-                GroupModel()
-                    .setApiGroupId(GroupId(0))
-                    .setCreatorIdentity(myContact.identity)
-                    .setUserState(GroupModel.UserState.MEMBER)
-                    .setCreatedAt(Date()),
-            )
-            create(
-                GroupModel()
-                    .setApiGroupId(GroupId(1))
-                    .setCreatorIdentity(myContact.identity)
-                    .setUserState(GroupModel.UserState.LEFT)
-                    .setCreatedAt(Date()),
-            )
+    private fun getContactStoreMock(): ContactStore = mockk {
+        every { isSpecialContact(any()) } answers {
+            firstArg<String>() == specialContact.identity
         }
-        val memberGroup = databaseService.groupModelFactory.getByApiGroupIdAndCreator(
-            GroupId(0).toString(),
-            myContact.identity,
-        )
-        val leftGroup = databaseService.groupModelFactory.getByApiGroupIdAndCreator(
-            GroupId(1).toString(),
-            myContact.identity,
-        )
-        databaseService.groupMemberModelFactory.apply {
-            create(
-                GroupMemberModel()
-                    .setGroupId(memberGroup.id)
-                    .setIdentity(inGroup.identity),
-            )
-            create(
-                GroupMemberModel()
-                    .setGroupId(leftGroup.id)
-                    .setIdentity(inLeftGroup.identity),
-            )
+    }
+
+    private fun getGroupServiceMock(): GroupService = mockk {
+        val groupModelMock: GroupModelOld = mockk()
+        val leftGroupModelMock: GroupModelOld = mockk()
+
+        every { getGroupsByIdentity(any()) } answers {
+            when (firstArg<String>()) {
+                inGroup.identity -> listOf(groupModelMock)
+                inLeftGroup.identity -> listOf(leftGroupModelMock)
+                else -> emptyList()
+            }
         }
+
+        every { isGroupMember(groupModelMock) } returns true
+        every { isGroupMember(leftGroupModelMock) } returns false
+    }
+
+    private fun getContactModelMock(contact: TestContact, acquaintanceLevel: AcquaintanceLevel): ContactModel = mockk {
+        val contactModelData = ContactModelData(
+            identity = contact.identity,
+            publicKey = contact.publicKey,
+            createdAt = Date(),
+            firstName = "",
+            lastName = "",
+            nickname = null,
+            idColor = mockk(),
+            verificationLevel = mockk(),
+            workVerificationLevel = mockk(),
+            identityType = mockk(),
+            acquaintanceLevel = acquaintanceLevel,
+            activityState = mockk(),
+            syncState = mockk(),
+            featureMask = 0u,
+            readReceiptPolicy = mockk(),
+            typingIndicatorPolicy = mockk(),
+            isArchived = false,
+            androidContactLookupInfo = null,
+            localAvatarExpires = null,
+            isRestored = false,
+            profilePictureBlobId = null,
+            jobTitle = null,
+            department = null,
+            notificationTriggerPolicyOverride = mockk(),
+        )
+
+        every { data } returns contactModelData
     }
 }

+ 19 - 25
app/src/androidTest/java/ch/threema/app/services/BlockedIdentitiesServiceTest.kt

@@ -1,14 +1,11 @@
 package ch.threema.app.services
 
 import ch.threema.app.TestMultiDeviceManager
-import ch.threema.app.TestNonceStore
-import ch.threema.app.TestTaskManager
 import ch.threema.app.listeners.ContactListener
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.preference.service.PreferenceServiceImpl
-import ch.threema.base.crypto.NonceFactory
-import ch.threema.domain.helpers.ServerAckTaskCodec
-import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
+import io.mockk.mockk
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -22,42 +19,39 @@ class BlockedIdentitiesServiceTest : KoinComponent {
         isMultiDeviceActive = false,
     )
 
-    private val taskManager = TestTaskManager(ServerAckTaskCodec())
-
-    private val preferenceService =
+    private val preferenceService by lazy {
         PreferenceServiceImpl(
-            /* context = */
+            /* appContext = */
             get(),
             /* preferenceStore = */
             get(),
             /* encryptedPreferenceStore = */
             get(),
-            /* taskManager = */
-            taskManager,
-            /* multiDeviceManager = */
-            multiDeviceManager,
-            /* nonceFactory = */
-            NonceFactory(TestNonceStore()),
         )
+    }
 
-    private val blockedIdentitiesService: BlockedIdentitiesService = BlockedIdentitiesServiceImpl(
-        preferenceService = preferenceService,
-        multiDeviceManager = multiDeviceManager,
-        taskCreator = get(),
-    )
+    private val blockedIdentitiesService: BlockedIdentitiesService by lazy {
+        BlockedIdentitiesServiceImpl(
+            preferenceService = preferenceService,
+            multiDeviceManager = multiDeviceManager,
+            taskCreator = mockk(),
+        )
+    }
 
     private val onModified = ArrayDeque<String>()
 
-    init {
+    @BeforeTest
+    fun initListener() {
+        // Remove all listeners to prevent side effects
+        ListenerManager.contactListeners.clear()
+
+        // Add listener to track which contact has been modified
         ListenerManager.contactListeners.add(object : ContactListener {
-            override fun onModified(identity: Identity) {
+            override fun onModified(identity: IdentityString) {
                 onModified.addLast(identity)
             }
         })
-    }
 
-    @BeforeTest
-    fun initListener() {
         blockedIdentitiesService.persistBlockedIdentities(emptySet())
         onModified.clear()
         // Assert that initially no identities are blocked

+ 102 - 17
app/src/androidTest/java/ch/threema/app/stores/EncryptedPreferenceStoreImplTest.kt

@@ -1,8 +1,10 @@
 package ch.threema.app.stores
 
 import ch.threema.common.stateFlowOf
+import ch.threema.localcrypto.MasterKey
 import ch.threema.localcrypto.MasterKeyImpl
 import ch.threema.localcrypto.MasterKeyProvider
+import ch.threema.localcrypto.exceptions.MasterKeyLockedException
 import ch.threema.testhelpers.createTempDirectory
 import java.io.File
 import kotlin.test.AfterTest
@@ -15,6 +17,7 @@ import kotlin.test.assertFalse
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
+import kotlinx.coroutines.flow.MutableStateFlow
 import org.json.JSONArray
 import org.json.JSONObject
 
@@ -26,12 +29,11 @@ class EncryptedPreferenceStoreImplTest {
 
     @BeforeTest
     fun setUp() {
-        val masterKeyData = ByteArray(32) { it.toByte() }
         directory = createTempDirectory()
         store = EncryptedPreferenceStoreImpl(
             directory = directory,
             masterKeyProvider = MasterKeyProvider(
-                masterKeyFlow = stateFlowOf(MasterKeyImpl(masterKeyData)),
+                masterKeyFlow = stateFlowOf(MasterKeyImpl(MASTER_KEY_DATA)),
             ),
             onChanged = { _, _ -> onChangedCalled = true },
         )
@@ -71,8 +73,8 @@ class EncryptedPreferenceStoreImplTest {
     }
 
     @Test
-    fun saveAndRestoreString() {
-        assertEquals("", store.getString("foo"))
+    fun saveAndGetString() {
+        assertNull(store.getString("foo"))
 
         store.save("foo", "Hello Wörld")
 
@@ -81,7 +83,18 @@ class EncryptedPreferenceStoreImplTest {
     }
 
     @Test
-    fun saveAndRestoreByteArray() {
+    fun saveAndGetNullString() {
+        store.save("foo", "Hello Wörld")
+        onChangedCalled = false
+
+        store.save("foo", null as String?)
+
+        assertNull(store.getString("foo"))
+        assertTrue(onChangedCalled)
+    }
+
+    @Test
+    fun saveAndGetByteArray() {
         val bytes = byteArrayOf(1, 2, 3)
 
         store.save("foo", bytes)
@@ -91,13 +104,14 @@ class EncryptedPreferenceStoreImplTest {
     }
 
     @Test
-    fun saveAndRestoreJsonArray() {
-        val jsonArray = JSONArray(arrayOf(1, true, "Hello"))
+    fun saveAndGetJsonArray() {
+        val jsonArray = JSONArray(arrayOf<Any>(1, true, "Hello"))
 
         store.save("foo", jsonArray)
 
         val readJsonArray = store.getJSONArray("foo")
 
+        assertNotNull(readJsonArray)
         assertEquals(3, readJsonArray.length())
         assertEquals(1, readJsonArray.getInt(0))
         assertEquals(true, readJsonArray.getBoolean(1))
@@ -106,7 +120,13 @@ class EncryptedPreferenceStoreImplTest {
     }
 
     @Test
-    fun saveAndRestoreJsonObject() {
+    fun saveAndGetInvalidJsonArray() {
+        store.save("foo", "not a valid json array")
+        assertNull(store.getJSONArray("foo"))
+    }
+
+    @Test
+    fun saveAndGetJsonObject() {
         val jsonObject = JSONObject(mapOf("a" to "Hello", "b" to 123))
 
         store.save("foo", jsonObject)
@@ -121,7 +141,14 @@ class EncryptedPreferenceStoreImplTest {
     }
 
     @Test
-    fun saveAndRestoreStringArray() {
+    fun saveAndGetInvalidJsonObject() {
+        store.save("foo", "not a valid json object")
+
+        assertNull(store.getJSONObject("foo"))
+    }
+
+    @Test
+    fun saveAndGetStringArray() {
         val strings = arrayOf("Hello", "World")
 
         store.save("foo", strings)
@@ -131,7 +158,17 @@ class EncryptedPreferenceStoreImplTest {
     }
 
     @Test
-    fun saveAndRestoreStringQuietlyArray() {
+    fun saveAndGetEmptyStringArray() {
+        val strings = arrayOf<String>()
+
+        store.save("foo", strings)
+
+        assertContentEquals(strings, store.getStringArray("foo"))
+        assertTrue(onChangedCalled)
+    }
+
+    @Test
+    fun saveAndGetStringQuietlyArray() {
         val strings = arrayOf("Hello", "World")
 
         store.saveQuietly("foo", strings)
@@ -141,7 +178,7 @@ class EncryptedPreferenceStoreImplTest {
     }
 
     @Test
-    fun saveAndRestoreMap() {
+    fun saveAndGetMap() {
         val map = mapOf("a" to "Hello", "b" to "World", "c" to null)
 
         store.save("foo", map)
@@ -150,18 +187,25 @@ class EncryptedPreferenceStoreImplTest {
         assertTrue(onChangedCalled)
     }
 
+    @Test
+    fun saveAndGetInvalidMap() {
+        store.save("foo", "not a map")
+
+        assertNull(store.getMap("foo"))
+    }
+
     @Test
     fun defaultValues() {
-        assertEquals("", store.getString("foo"))
+        assertNull(store.getString("foo"))
         assertNull(store.getStringArray("foo"))
-        assertEquals(emptyMap(), store.getMap("foo"))
-        assertContentEquals(ByteArray(0), store.getBytes("foo"))
-        assertEquals(JSONArray(), store.getJSONArray("foo"))
-        assertEquals(null, store.getJSONObject("foo"))
+        assertNull(store.getMap("foo"))
+        assertNull(store.getBytes("foo"))
+        assertNull(store.getJSONArray("foo"))
+        assertNull(store.getJSONObject("foo"))
     }
 
     @Test
-    fun restoringFromPreviouslyEncryptedFile() {
+    fun getStringFromPreviouslyEncryptedFile() {
         File(directory, ".crs-test")
             .writeBytes(
                 byteArrayOf(
@@ -188,4 +232,45 @@ class EncryptedPreferenceStoreImplTest {
             store.save("foo", arrayOf("Hi", "Hello;World"))
         }
     }
+
+    @Test
+    fun readFailsWhenMasterKeyIsLocked() {
+        val masterKeyFlow = MutableStateFlow<MasterKey?>(MasterKeyImpl(MASTER_KEY_DATA))
+        store = EncryptedPreferenceStoreImpl(
+            directory = directory,
+            masterKeyProvider = MasterKeyProvider(
+                masterKeyFlow = masterKeyFlow,
+            ),
+            onChanged = { _, _ -> onChangedCalled = true },
+        )
+        store.clear()
+        store.save("foo", "Test")
+
+        masterKeyFlow.value = null
+
+        assertFailsWith<MasterKeyLockedException> {
+            store.getString("foo")
+        }
+    }
+
+    @Test
+    fun saveFailsWhenMasterKeyIsLocked() {
+        store = EncryptedPreferenceStoreImpl(
+            directory = directory,
+            masterKeyProvider = MasterKeyProvider(
+                masterKeyFlow = stateFlowOf(null),
+            ),
+            onChanged = { _, _ -> onChangedCalled = true },
+        )
+        store.clear()
+
+        assertFailsWith<MasterKeyLockedException> {
+            store.save("foo", "Hello")
+        }
+        assertFalse(onChangedCalled)
+    }
+
+    companion object {
+        private val MASTER_KEY_DATA = ByteArray(32) { it.toByte() }
+    }
 }

+ 66 - 1
app/src/androidTest/java/ch/threema/app/stores/PreferencesStoreImplTest.kt

@@ -2,7 +2,11 @@ package ch.threema.app.stores
 
 import androidx.core.content.edit
 import androidx.preference.PreferenceManager
+import app.cash.turbine.test
 import ch.threema.app.ThreemaApplication
+import ch.threema.common.emptyByteArray
+import ch.threema.testhelpers.expectItem
+import java.time.Instant
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
@@ -12,6 +16,7 @@ import kotlin.test.assertFalse
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
+import kotlinx.coroutines.test.runTest
 import org.json.JSONArray
 import org.json.JSONObject
 import org.koin.core.component.KoinComponent
@@ -27,6 +32,7 @@ class PreferencesStoreImplTest : KoinComponent {
         store = PreferenceStoreImpl(
             sharedPreferences = get(),
             onChanged = { _, _ -> onChangedCalled = true },
+            commit = true,
         )
         store.clear()
         onChangedCalled = false
@@ -78,6 +84,22 @@ class PreferencesStoreImplTest : KoinComponent {
         assertTrue(onChangedCalled)
     }
 
+    @Test
+    fun saveAndRestoreInstant() {
+        assertNull(store.getInstant("foo"))
+        assertFalse(store.containsKey("foo"))
+
+        store.save("foo", Instant.ofEpochMilli(1_766_407_316_000L))
+
+        assertEquals(Instant.ofEpochMilli(1_766_407_316_000L), store.getInstant("foo"))
+        assertTrue(onChangedCalled)
+
+        store.save("foo", null as Instant?)
+
+        assertNull(store.getInstant("foo"))
+        assertFalse(store.containsKey("foo"))
+    }
+
     @Test
     fun saveAndRestoreBoolean() {
         assertEquals(false, store.getBoolean("foo"))
@@ -187,7 +209,7 @@ class PreferencesStoreImplTest : KoinComponent {
         assertEquals(false, store.getBoolean("foo"))
         assertNull(store.getStringArray("foo"))
         assertEquals(emptyMap(), store.getMap("foo"))
-        assertContentEquals(ByteArray(0), store.getBytes("foo"))
+        assertContentEquals(emptyByteArray(), store.getBytes("foo"))
         assertEquals(JSONArray(), store.getJSONArray("foo"))
         assertNull(store.getJSONObject("foo"))
     }
@@ -208,4 +230,47 @@ class PreferencesStoreImplTest : KoinComponent {
             store.save("foo", arrayOf("Hi", "Hello;World"))
         }
     }
+
+    @Test
+    fun watchBooleanShouldEmitCorrectValueChangesToKey() = runTest {
+        val key = "is_colored"
+        store.watchBoolean(key, false).test {
+            // Expect the defined default value (as the key does not exist on disk right now)
+            expectItem(false)
+
+            // Change the value
+            store.save(key, true)
+            expectItem(true)
+
+            // Should emit the defined default value when removing the preference
+            store.remove(key)
+            expectItem(false)
+
+            // Add the key again
+            store.save(key, true)
+            expectItem(true)
+
+            // Expect no distinct change
+            store.save(key, true)
+            expectNoEvents()
+
+            // Change the value (to the default value)
+            store.save(key, false)
+            expectItem(false)
+
+            // Expecting no distinct change, as the last saved value was already the default value
+            store.remove(key)
+            expectNoEvents()
+
+            // Adding the key again (with its default value)
+            store.save(key, false)
+            expectNoEvents()
+
+            // Changing the value
+            store.save(key, true)
+            expectItem(true)
+
+            cancelAndIgnoreRemainingEvents()
+        }
+    }
 }

+ 36 - 44
app/src/androidTest/java/ch/threema/app/tasks/GroupCreateTaskTest.kt

@@ -1,14 +1,16 @@
 package ch.threema.app.tasks
 
+import ch.threema.KoinTestRule
 import ch.threema.app.DangerousTest
 import ch.threema.app.TestMultiDeviceManager
 import ch.threema.app.ThreemaApplication
-import ch.threema.app.protocol.ExpectedProfilePictureChange
-import ch.threema.app.protocol.PredefinedMessageIds
+import ch.threema.app.di.modules.sessionScopedModule
+import ch.threema.app.multidevice.MultiDeviceManager
+import ch.threema.app.protocolsteps.ExpectedProfilePictureChange
+import ch.threema.app.protocolsteps.PredefinedMessageIds
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.clearDatabaseAndCaches
-import ch.threema.app.utils.OutgoingCspMessageServices
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModelData
@@ -19,19 +21,20 @@ import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.UserState
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.connection.data.CspMessage
 import ch.threema.domain.protocol.connection.data.OutboundD2mMessage
 import ch.threema.storage.models.ContactModel
-import ch.threema.storage.models.GroupModel
-import io.mockk.mockk
 import java.util.Date
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertIs
 import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.koin.dsl.module
 
 @DangerousTest
 class GroupCreateTaskTest {
@@ -65,6 +68,8 @@ class GroupCreateTaskTest {
 
     private val serviceManager by lazy { ThreemaApplication.requireServiceManager() }
 
+    private var isMultiDeviceEnabled = false
+
     private val testMultiDeviceManagerMdEnabled by lazy {
         TestMultiDeviceManager(
             isMdDisabledOrSupportsFs = false,
@@ -79,6 +84,21 @@ class GroupCreateTaskTest {
         )
     }
 
+    private val instrumentedTestModule = module {
+        factory<MultiDeviceManager> {
+            if (isMultiDeviceEnabled) {
+                testMultiDeviceManagerMdEnabled
+            } else {
+                testMultiDeviceManagerMdDisabled
+            }
+        }
+    }
+
+    @get:Rule
+    val koinTestRule = KoinTestRule(
+        modules = listOf(sessionScopedModule, instrumentedTestModule),
+    )
+
     @BeforeTest
     fun setup() {
         clearDatabaseAndCaches(serviceManager)
@@ -99,7 +119,7 @@ class GroupCreateTaskTest {
                     synchronizedAt = null,
                     lastUpdate = now,
                     isArchived = false,
-                    userState = GroupModel.UserState.MEMBER,
+                    userState = UserState.MEMBER,
                     otherMembers = setOf(initialContactModelData.identity),
                     groupDescription = null,
                     groupDescriptionChangedAt = null,
@@ -113,6 +133,8 @@ class GroupCreateTaskTest {
 
     @Test
     fun testSimpleGroupMd() = runTest {
+        enableMultiDevice()
+
         val predefinedMessageIds = PredefinedMessageIds.random()
         val groupCreateTask = GroupCreateTask(
             name = "My Group",
@@ -120,11 +142,6 @@ class GroupCreateTaskTest {
             members = setOf(initialContactModelData.identity),
             groupIdentity = GroupIdentity(myContact.identity, 42),
             predefinedMessageIds = predefinedMessageIds,
-            outgoingCspMessageServices = getOutgoingCspMessageServicesMd(),
-            groupCallManager = serviceManager.groupCallManager,
-            fileService = serviceManager.fileService,
-            groupProfilePictureUploader = mockk(),
-            groupModelRepository = serviceManager.modelRepositories.groups,
         )
 
         val handle = TransactionAckTaskCodec()
@@ -156,6 +173,8 @@ class GroupCreateTaskTest {
 
     @Test
     fun testSimpleGroupNonMd() = runTest {
+        disableMultiDevice()
+
         val predefinedMessageIds = PredefinedMessageIds.random()
         val groupCreateTask = GroupCreateTask(
             name = "My Group",
@@ -163,11 +182,6 @@ class GroupCreateTaskTest {
             members = setOf("12345678"),
             groupIdentity = GroupIdentity(myContact.identity, 42),
             predefinedMessageIds = predefinedMessageIds,
-            outgoingCspMessageServices = getOutgoingCspMessageServicesNonMd(),
-            groupCallManager = serviceManager.groupCallManager,
-            fileService = serviceManager.fileService,
-            groupProfilePictureUploader = mockk(),
-            groupModelRepository = serviceManager.modelRepositories.groups,
         )
 
         val handle = TransactionAckTaskCodec()
@@ -191,35 +205,13 @@ class GroupCreateTaskTest {
         }
     }
 
-    private fun getOutgoingCspMessageServicesMd() = OutgoingCspMessageServices(
-        forwardSecurityMessageProcessor = serviceManager.forwardSecurityMessageProcessor,
-        identityStore = serviceManager.identityStore,
-        userService = serviceManager.userService,
-        contactStore = serviceManager.contactStore,
-        contactService = serviceManager.contactService,
-        contactModelRepository = serviceManager.modelRepositories.contacts,
-        groupService = serviceManager.groupService,
-        nonceFactory = serviceManager.nonceFactory,
-        blockedIdentitiesService = serviceManager.blockedIdentitiesService,
-        preferenceService = serviceManager.preferenceService,
-        multiDeviceManager = testMultiDeviceManagerMdEnabled,
-    ).apply {
-        forwardSecurityMessageProcessor.setForwardSecurityEnabled(false)
+    private fun enableMultiDevice() {
+        isMultiDeviceEnabled = true
+        serviceManager.forwardSecurityMessageProcessor.setForwardSecurityEnabled(false)
     }
 
-    private fun getOutgoingCspMessageServicesNonMd() = OutgoingCspMessageServices(
-        forwardSecurityMessageProcessor = serviceManager.forwardSecurityMessageProcessor,
-        identityStore = serviceManager.identityStore,
-        userService = serviceManager.userService,
-        contactStore = serviceManager.contactStore,
-        contactService = serviceManager.contactService,
-        contactModelRepository = serviceManager.modelRepositories.contacts,
-        groupService = serviceManager.groupService,
-        nonceFactory = serviceManager.nonceFactory,
-        blockedIdentitiesService = serviceManager.blockedIdentitiesService,
-        preferenceService = serviceManager.preferenceService,
-        multiDeviceManager = testMultiDeviceManagerMdDisabled,
-    ).apply {
-        forwardSecurityMessageProcessor.setForwardSecurityEnabled(true)
+    private fun disableMultiDevice() {
+        isMultiDeviceEnabled = false
+        serviceManager.forwardSecurityMessageProcessor.setForwardSecurityEnabled(true)
     }
 }

+ 1 - 92
app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt

@@ -1,25 +1,9 @@
 package ch.threema.app.tasks
 
-import ch.threema.app.ThreemaApplication
-import ch.threema.base.crypto.NaCl
-import ch.threema.data.datatypes.IdColor
-import ch.threema.data.models.ContactModelData
-import ch.threema.data.models.GroupIdentity
-import ch.threema.data.models.GroupModelData
-import ch.threema.domain.models.ContactSyncState
-import ch.threema.domain.models.IdentityState
-import ch.threema.domain.models.IdentityType
-import ch.threema.domain.models.ReadReceiptPolicy
-import ch.threema.domain.models.TypingIndicatorPolicy
-import ch.threema.domain.models.VerificationLevel
-import ch.threema.domain.models.WorkVerificationLevel
-import ch.threema.storage.models.ContactModel
-import java.util.Date
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertNotNull
 import kotlin.reflect.KClass
 import kotlin.test.Test
-import kotlinx.coroutines.runBlocking
 import kotlinx.serialization.json.Json
 
 /**
@@ -29,8 +13,6 @@ import kotlinx.serialization.json.Json
  * representation will be dropped.
  */
 class PersistableTasksTest {
-    private val serviceManager = ThreemaApplication.requireServiceManager()
-
     @Test
     fun testContactDeliveryReceiptMessageTask() {
         assertValidEncoding(
@@ -203,9 +185,6 @@ class PersistableTasksTest {
 
     @Test
     fun testDeleteAndTerminateFSSessionsTask() {
-        // Add the contact '01234567' so that creating the tasks works
-        addTestData()
-
         assertValidEncoding(
             DeleteAndTerminateFSSessionsTask::class,
             """{"type":"ch.threema.app.tasks.DeleteAndTerminateFSSessionsTask.DeleteAndTerminateFSSessionsTaskData",""" +
@@ -500,9 +479,6 @@ class PersistableTasksTest {
 
     @Test
     fun testOnFSFeatureMaskDowngradedTask() {
-        // Add the contact '01234567' so that creating the tasks works
-        addTestData()
-
         assertValidEncoding(
             OnFSFeatureMaskDowngradedTask::class,
             """{"type":"ch.threema.app.tasks.OnFSFeatureMaskDowngradedTask.OnFSFeatureMaskDowngradedData","identity":"01234567"}""",
@@ -763,7 +739,6 @@ class PersistableTasksTest {
 
     @Test
     fun testGroupNotificationTriggerPolicyOverrideUpdate() {
-        addTestData()
         assertValidEncoding(
             ReflectGroupSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate::class,
             """{"type":"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectNotificationTriggerPolicyOverrideUpdate.""" +
@@ -784,7 +759,6 @@ class PersistableTasksTest {
 
     @Test
     fun testReflectGroupConversationCategoryUpdate() {
-        addTestData()
         assertValidEncoding(
             expectedTaskClass = ReflectGroupSyncUpdateTask.ReflectGroupConversationCategoryUpdateTask::class,
             encodedTask = "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectGroupConversationCategoryUpdateTask" +
@@ -804,7 +778,6 @@ class PersistableTasksTest {
 
     @Test
     fun testReflectGroupConversationVisibilityArchiveUpdate() {
-        addTestData()
         assertValidEncoding(
             ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityArchiveUpdate::class,
             "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityArchiveUpdate" +
@@ -824,7 +797,6 @@ class PersistableTasksTest {
 
     @Test
     fun testReflectGroupConversationVisibilityPinnedUpdate() {
-        addTestData()
         assertValidEncoding(
             ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityPinnedUpdate::class,
             "{\"type\":\"ch.threema.app.tasks.ReflectGroupSyncUpdateTask.ReflectGroupConversationVisibilityPinnedUpdate" +
@@ -866,71 +838,8 @@ class PersistableTasksTest {
         )
     }
 
-    private fun addTestData() = runBlocking {
-        val identity = "01234567"
-        if (serviceManager.modelRepositories.contacts.getByIdentity(identity) != null) {
-            // If the contact already exists, we do not add it again
-            return@runBlocking
-        }
-
-        val keyPair = NaCl.generateKeypair()
-        serviceManager.identityStore.storeIdentity(
-            identity = identity,
-            serverGroup = "",
-            privateKey = keyPair.privateKey,
-        )
-
-        serviceManager.modelRepositories.contacts.createFromLocal(
-            ContactModelData(
-                identity = identity,
-                publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES),
-                createdAt = Date(42),
-                firstName = "0123",
-                lastName = "4567",
-                nickname = "01",
-                verificationLevel = VerificationLevel.SERVER_VERIFIED,
-                workVerificationLevel = WorkVerificationLevel.NONE,
-                identityType = IdentityType.NORMAL,
-                acquaintanceLevel = ContactModel.AcquaintanceLevel.DIRECT,
-                activityState = IdentityState.ACTIVE,
-                syncState = ContactSyncState.INITIAL,
-                featureMask = 0u,
-                typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-                readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
-                isArchived = false,
-                androidContactLookupInfo = null,
-                localAvatarExpires = null,
-                isRestored = false,
-                profilePictureBlobId = null,
-                jobTitle = null,
-                department = null,
-                notificationTriggerPolicyOverride = null,
-            ),
-        )
-
-        serviceManager.modelRepositories.groups.persistNewGroup(
-            GroupModelData(
-                groupIdentity = GroupIdentity(
-                    creatorIdentity = identity,
-                    groupId = 6361180283070237492,
-                ),
-                name = null,
-                createdAt = Date(),
-                synchronizedAt = null,
-                lastUpdate = null,
-                isArchived = false,
-                precomputedIdColor = IdColor.invalid(),
-                groupDescription = null,
-                groupDescriptionChangedAt = null,
-                otherMembers = setOf(identity),
-                userState = ch.threema.storage.models.GroupModel.UserState.MEMBER,
-                notificationTriggerPolicyOverride = null,
-            ),
-        )
-    }
-
     private fun assertValidEncoding(expectedTaskClass: KClass<*>, encodedTask: String) {
-        val decodedTask = Json.decodeFromString<SerializableTaskData>(encodedTask).createTask(serviceManager)
+        val decodedTask = Json.decodeFromString<SerializableTaskData>(encodedTask).createTask()
         assertNotNull(decodedTask)
         assertEquals(expectedTaskClass, decodedTask::class)
     }

+ 4 - 2
app/src/androidTest/java/ch/threema/app/testutils/AndroidTestUtils.kt

@@ -3,7 +3,9 @@ package ch.threema.app.testutils
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ServiceManager
 import ch.threema.data.models.GroupIdentity
+import ch.threema.storage.DatabaseProvider
 import ch.threema.storage.runTransaction
+import org.koin.mp.KoinPlatform
 
 fun clearDatabaseAndCaches(serviceManager: ServiceManager) {
     // First get all available contacts and groups
@@ -22,7 +24,7 @@ fun clearDatabaseAndCaches(serviceManager: ServiceManager) {
         }
 
     // Clear entire database
-    serviceManager.databaseService.writableDatabase.runTransaction {
+    KoinPlatform.getKoin().get<DatabaseProvider>().writableDatabase.runTransaction {
         rawExecSQL("PRAGMA foreign_keys = OFF;")
 
         query("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").use { cursor ->
@@ -36,7 +38,7 @@ fun clearDatabaseAndCaches(serviceManager: ServiceManager) {
 
     // Clear caches in services and trigger listeners to refresh the new models from database
     val contactService = serviceManager.contactService
-    val myIdentity = serviceManager.identityStore.getIdentity()
+    val myIdentity = serviceManager.identityStore.getIdentityString()
     contactIdentities.forEach { identity ->
         contactService.invalidateCache(identity)
         ListenerManager.contactListeners.handle { it.onRemoved(identity) }

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

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

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

@@ -1,8 +1,5 @@
 package ch.threema.app.testutils;
 
-import android.app.ActivityManager;
-import android.app.ActivityManager.RunningServiceInfo;
-import android.content.Context;
 import android.util.Log;
 
 import ch.threema.base.crypto.NaCl;
@@ -27,11 +24,13 @@ import ch.threema.domain.models.BasicContact;
 import ch.threema.domain.models.GroupId;
 import ch.threema.domain.models.IdentityState;
 import ch.threema.domain.models.IdentityType;
+import ch.threema.domain.models.UserState;
 import ch.threema.domain.models.VerificationLevel;
+import ch.threema.domain.models.WorkVerificationLevel;
 import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.domain.stores.IdentityStore;
 import ch.threema.storage.models.ContactModel;
-import ch.threema.storage.models.GroupModel;
+import ch.threema.storage.models.group.GroupModelOld;
 
 import static org.junit.Assert.assertNotNull;
 
@@ -104,7 +103,11 @@ public class TestHelpers {
                     .deleteMessages(true)
                     .build(),
                 IdentityState.ACTIVE,
-                IdentityType.NORMAL
+                IdentityType.NORMAL,
+                VerificationLevel.UNVERIFIED,
+                WorkVerificationLevel.NONE,
+                null,
+                null
             );
         }
     }
@@ -160,7 +163,7 @@ public class TestHelpers {
         }
 
         @NonNull
-        public GroupModel getGroupModel() {
+        public GroupModelOld getGroupModel() {
             boolean isMember = false;
             for (TestContact member : members) {
                 if (member.identity.equals(userIdentity)) {
@@ -168,12 +171,12 @@ public class TestHelpers {
                     break;
                 }
             }
-            return getGroupModel(isMember ? GroupModel.UserState.MEMBER : GroupModel.UserState.LEFT);
+            return getGroupModel(isMember ? UserState.MEMBER : UserState.LEFT);
         }
 
         @NonNull
-        private GroupModel getGroupModel(@NonNull GroupModel.UserState userState) {
-            return new GroupModel()
+        private GroupModelOld getGroupModel(@NonNull UserState userState) {
+            return new GroupModelOld()
                 .setApiGroupId(apiGroupId)
                 .setCreatedAt(new Date())
                 .setName(this.groupName)
@@ -210,20 +213,6 @@ public class TestHelpers {
         );
     }
 
-    /**
-     * Source: https://stackoverflow.com/a/5921190/284318
-     */
-    public static boolean iServiceRunning(@NonNull Context appContext, @NonNull Class<?> serviceClass) {
-        ActivityManager manager = (ActivityManager) appContext.getSystemService(Context.ACTIVITY_SERVICE);
-        assert manager != null;
-        for (RunningServiceInfo service : manager.getRunningServices(Integer.MAX_VALUE)) {
-            if (serviceClass.getName().equals(service.service.getClassName())) {
-                return true;
-            }
-        }
-        return false;
-    }
-
     /**
      * Set the provided identity if the current identity is not set or different.
      */

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

@@ -28,8 +28,8 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
             serviceManager.modelRepositories.contacts,
             serviceManager.groupService,
             serviceManager.nonceFactory,
-            serviceManager.blockedIdentitiesService,
             serviceManager.preferenceService,
+            serviceManager.synchronizedSettingsService,
             serviceManager.multiDeviceManager,
         )
     }
@@ -55,6 +55,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
             handle.runBundledMessagesSendSteps(
                 outgoingCspMessageHandle,
                 outgoingCspMessageServices,
+                serviceManager.identityBlockedSteps,
             )
             assertMessageHandleSent(outgoingCspMessageHandle) { message ->
                 message as TextMessage
@@ -65,7 +66,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
 
             assertTrue(hasBeenMarkedAsSent)
             assertEquals(1, forwardSecurityModes!!.keys.size)
-            forwardSecurityModes!!.values.forEach {
+            forwardSecurityModes.values.forEach {
                 assertEquals(ForwardSecurityMode.FOURDH, it)
             }
         }
@@ -93,6 +94,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
             handle.runBundledMessagesSendSteps(
                 outgoingCspMessageHandle,
                 outgoingCspMessageServices,
+                serviceManager.identityBlockedSteps,
             )
 
             assertMessageHandleSent(outgoingCspMessageHandle) { message ->
@@ -104,7 +106,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
 
             assertTrue(hasBeenMarkedAsSent)
             assertEquals(group.members.size - 1, forwardSecurityModes!!.keys.size)
-            forwardSecurityModes!!.values.forEach {
+            forwardSecurityModes.values.forEach {
                 assertEquals(ForwardSecurityMode.FOURDH, it)
             }
         }
@@ -152,6 +154,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
         handle.runBundledMessagesSendSteps(
             handles,
             outgoingCspMessageServices,
+            serviceManager.identityBlockedSteps,
         )
 
         assertMessageHandleSent(handles[0]) { message ->

+ 0 - 38
app/src/androidTest/java/ch/threema/app/utils/TextUtilTest.java

@@ -1,54 +1,16 @@
 package ch.threema.app.utils;
 
-import android.content.Context;
-
 import org.junit.Assert;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
 import androidx.test.filters.MediumTest;
 import androidx.test.runner.AndroidJUnit4;
-import ch.threema.app.ThreemaApplication;
-
-import static junit.framework.Assert.assertFalse;
-import static junit.framework.Assert.assertTrue;
 
-/**
- * Ensure the Call SDP does not contain any "funny" easter eggs such as silly header extensions
- * that are not encrypted and contain sensitive information.
- * <p>
- * This may need updating from time to time, so if it breaks, you will have to do some
- * research on what changed and why.
- */
 @MediumTest
 @RunWith(AndroidJUnit4.class)
 public class TextUtilTest {
 
-    @Test
-    public void testCheckBadPasswordNumericOnly() {
-        final Context context = ThreemaApplication.getAppContext();
-        assertTrue(TextUtil.checkBadPassword(context, "1234"));
-        assertTrue(TextUtil.checkBadPassword(context, "1234567890"));
-        assertTrue(TextUtil.checkBadPassword(context, "123456789012345"));
-        assertFalse(TextUtil.checkBadPassword(context, "1234567890123456"));
-        assertFalse(TextUtil.checkBadPassword(context, "12345678901234567890"));
-    }
-
-    @Test
-    public void testCheckBadPasswordSameCharacter() {
-        final Context context = ThreemaApplication.getAppContext();
-        assertTrue(TextUtil.checkBadPassword(context, "aaaaaaaaaaaa"));
-        assertFalse(TextUtil.checkBadPassword(context, "aaaaaaaaaaab"));
-    }
-
-    @Test
-    public void testCheckBadPasswordWarnList() {
-        final Context context = ThreemaApplication.getAppContext();
-        assertTrue(TextUtil.checkBadPassword(context, "1Rainbow"));
-        assertTrue(TextUtil.checkBadPassword(context, "apples123"));
-        assertFalse(TextUtil.checkBadPassword(context, "kajsdlfkjalskdjflkajsdfl"));
-    }
-
     @Test
     public void testMatchesQueryDiacriticInsensitive() {
         Assert.assertTrue(TextUtil.matchesQueryDiacriticInsensitive("aàáâãäå", "aaaaaaa"));

+ 3 - 1
app/src/androidTest/java/ch/threema/app/voip/VoipStatusMessageTest.java

@@ -11,7 +11,9 @@ import java.util.Locale;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.platform.app.InstrumentationRegistry;
 import ch.threema.app.R;
+import ch.threema.app.ui.models.MessageViewElement;
 import ch.threema.app.utils.MessageUtil;
+import ch.threema.data.datatypes.ContactNameFormat;
 import ch.threema.domain.protocol.csp.messages.voip.VoipCallAnswerData;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.MessageModel;
@@ -69,7 +71,7 @@ public class VoipStatusMessageTest {
         }
 
         public void test() {
-            final MessageUtil.MessageViewElement element = MessageUtil.getViewElement(this.context, this.messageModel);
+            final MessageViewElement element = MessageUtil.getViewElement(this.context, this.messageModel, ContactNameFormat.DEFAULT);
             assertEquals((Integer) this.expectedIcon, element.icon);
             assertEquals((Integer) this.expectedColor, element.color);
             assertEquals(this.expectedPlaceholder, element.placeholder);

+ 15 - 10
app/src/androidTest/java/ch/threema/app/webclient/activities/SessionsActivityTest.java

@@ -12,9 +12,10 @@ import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.RuleChain;
 import org.junit.runner.RunWith;
+import org.koin.java.KoinJavaComponent;
 
+import java.util.Collections;
 import java.util.Date;
-import java.util.Objects;
 
 import androidx.annotation.NonNull;
 import androidx.preference.PreferenceManager;
@@ -22,11 +23,11 @@ import androidx.test.InstrumentationRegistry;
 import androidx.test.filters.LargeTest;
 import androidx.test.rule.ActivityTestRule;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
+import ch.threema.KoinTestRule;
 import ch.threema.app.DangerousTest;
 import ch.threema.app.R;
 import ch.threema.app.ScreenshotTakingRule;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.storage.DatabaseService;
+import ch.threema.storage.factories.WebClientSessionModelFactory;
 import ch.threema.storage.models.WebClientSessionModel;
 
 import static androidx.test.InstrumentationRegistry.getInstrumentation;
@@ -37,7 +38,8 @@ import static androidx.test.espresso.assertion.ViewAssertions.matches;
 import static androidx.test.espresso.matcher.RootMatchers.isDialog;
 import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
 import static androidx.test.espresso.matcher.ViewMatchers.withText;
-import static ch.threema.app.services.BrowserDetectionService.Browser;
+import static ch.threema.app.di.modules.SessionScopedKt.getSessionScopedModule;
+import static ch.threema.app.webclient.usecases.DetectBrowserUseCase.Browser;
 
 /**
  * Sessions activity UI tests.
@@ -54,6 +56,11 @@ public class SessionsActivityTest {
     public ActivityTestRule<SessionsActivity> activityTestRule
         = new ActivityTestRule<>(SessionsActivity.class, false, false);
 
+    @Rule
+    public final KoinTestRule koinTestRule = new KoinTestRule(
+        Collections.singletonList(getSessionScopedModule())
+    );
+
     @Rule
     public final RuleChain activityRule = ScreenshotTakingRule.getRuleChain();
 
@@ -81,10 +88,8 @@ public class SessionsActivityTest {
      * Clear all sessions.
      */
     private static void clearSessions() {
-        final DatabaseService databaseService = ThreemaApplication
-            .getServiceManager()
-            .getDatabaseService();
-        databaseService.getWebClientSessionModelFactory().deleteAll();
+        WebClientSessionModelFactory webClientSessionModelFactory = KoinJavaComponent.get(WebClientSessionModelFactory.class);
+        webClientSessionModelFactory.deleteAll();
     }
 
     /**
@@ -98,7 +103,6 @@ public class SessionsActivityTest {
         @NonNull Date lastConnection,
         @NonNull Browser browser
     ) {
-        final DatabaseService databaseService = Objects.requireNonNull(ThreemaApplication.getServiceManager()).getDatabaseService();
         final WebClientSessionModel model = new WebClientSessionModel();
 
         model.setLabel(label);
@@ -126,7 +130,8 @@ public class SessionsActivityTest {
         model.setSaltyRtcHost("saltyrtc.threema.example");
         model.setSaltyRtcPort(8080);
 
-        databaseService.getWebClientSessionModelFactory().createOrUpdate(model);
+        WebClientSessionModelFactory webClientSessionModelFactory = KoinJavaComponent.get(WebClientSessionModelFactory.class);
+        webClientSessionModelFactory.createOrUpdate(model);
     }
 
     @Before

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

@@ -1,13 +0,0 @@
-package ch.threema.data
-
-import androidx.test.core.app.ApplicationProvider
-import ch.threema.storage.DatabaseService
-
-/**
- * An in-memory database used in android tests.
- */
-class TestDatabaseService : DatabaseService(
-    context = ApplicationProvider.getApplicationContext(),
-    databaseName = null,
-    password = "test-database-key".toByteArray(),
-)

+ 117 - 37
app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt

@@ -1,13 +1,16 @@
 package ch.threema.data.repositories
 
+import ch.threema.KoinTestRule
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestMultiDeviceManager
 import ch.threema.app.TestTaskManager
-import ch.threema.app.ThreemaApplication
+import ch.threema.app.multidevice.MultiDeviceManager
+import ch.threema.app.stores.EncryptedPreferenceStore
+import ch.threema.app.stores.IdentityProvider
+import ch.threema.app.stores.PreferenceStore
 import ch.threema.app.testutils.TestHelpers
-import ch.threema.app.utils.AppVersionProvider
+import ch.threema.app.testutils.mockUser
 import ch.threema.base.crypto.NaCl
-import ch.threema.data.TestDatabaseService
 import ch.threema.data.datatypes.AndroidContactLookupInfo
 import ch.threema.data.datatypes.IdColor
 import ch.threema.data.models.ContactModelData
@@ -20,11 +23,17 @@ import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.domain.stores.IdentityStore
 import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
+import ch.threema.storage.DatabaseService
+import ch.threema.storage.TestDatabaseProvider
 import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.testhelpers.nonSecureRandomArray
 import ch.threema.testhelpers.randomIdentity
+import io.mockk.every
+import io.mockk.mockk
 import java.util.Date
 import junit.framework.TestCase.assertNotNull
 import kotlin.test.BeforeTest
@@ -36,18 +45,22 @@ import kotlin.test.assertNull
 import kotlin.test.assertTrue
 import kotlin.test.fail
 import kotlinx.coroutines.runBlocking
+import org.junit.Rule
 import org.junit.runner.RunWith
 import org.junit.runners.Parameterized
+import org.koin.dsl.module
 
 @RunWith(value = Parameterized::class)
 class ContactModelRepositoryTest(private val contactModelData: ContactModelData) {
     // Services where MD is disabled
-    private lateinit var databaseService: TestDatabaseService
+    private lateinit var databaseProvider: TestDatabaseProvider
+    private lateinit var databaseService: DatabaseService
     private lateinit var coreServiceManager: TestCoreServiceManager
     private lateinit var contactModelRepository: ContactModelRepository
 
     // Services where MD is enabled
-    private lateinit var databaseServiceMd: TestDatabaseService
+    private lateinit var databaseProviderMd: TestDatabaseProvider
+    private lateinit var databaseServiceMd: DatabaseService
     private lateinit var taskCodecMd: TransactionAckTaskCodec
     private lateinit var coreServiceManagerMd: TestCoreServiceManager
     private lateinit var contactModelRepositoryMd: ContactModelRepository
@@ -57,6 +70,23 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         FROM_REMOTE,
     }
 
+    private var isMultiDeviceEnabled = false
+
+    private val instrumentedTestModule = module {
+        factory<MultiDeviceManager> {
+            if (isMultiDeviceEnabled) {
+                coreServiceManagerMd.multiDeviceManager
+            } else {
+                coreServiceManager.multiDeviceManager
+            }
+        }
+    }
+
+    @get:Rule
+    val koinTestRule = KoinTestRule(
+        modules = listOf(instrumentedTestModule),
+    )
+
     companion object {
         @JvmStatic
         @Parameterized.Parameters
@@ -71,7 +101,7 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         )
 
         private fun getInitialContactModelData(
-            identity: Identity = "ABCDEFGH",
+            identity: IdentityString = "ABCDEFGH",
             publicKey: ByteArray = ByteArray(NaCl.PUBLIC_KEY_BYTES),
             createdAt: Date = Date(),
             firstName: String = "",
@@ -122,38 +152,51 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
 
     @BeforeTest
     fun before() {
-        val serviceManager = ThreemaApplication.requireServiceManager()
-        TestHelpers.setIdentity(
-            serviceManager,
-            TestHelpers.TEST_CONTACT,
-        )
+        val preferenceStore: PreferenceStore = mockk {
+            mockUser(TestHelpers.TEST_CONTACT)
+        }
+        val encryptedPreferenceStore: EncryptedPreferenceStore = mockk {
+            mockUser(TestHelpers.TEST_CONTACT)
+        }
+        val identityProviderMock: IdentityProvider = mockk {
+            every { getIdentity() } returns Identity(TestHelpers.TEST_CONTACT.identity)
+            every { getIdentityString() } returns TestHelpers.TEST_CONTACT.identity
+        }
+        val identityStoreMock = mockk<IdentityStore> {
+            every { getIdentity() } returns Identity(TestHelpers.TEST_CONTACT.identity)
+            every { getIdentityString() } returns TestHelpers.TEST_CONTACT.identity
+        }
 
         // Instantiate services where MD is disabled
-        this.databaseService = TestDatabaseService()
+        this.databaseProvider = TestDatabaseProvider()
         this.coreServiceManager = TestCoreServiceManager(
-            version = AppVersionProvider.appVersion,
-            databaseService = databaseService,
-            preferenceStore = serviceManager.preferenceStore,
-            encryptedPreferenceStore = serviceManager.encryptedPreferenceStore,
+            databaseProvider = databaseProvider,
+            identityProvider = identityProviderMock,
+            preferenceStore = preferenceStore,
+            encryptedPreferenceStore = encryptedPreferenceStore,
             taskManager = TestTaskManager(UnusedTaskCodec()),
+            identityStore = identityStoreMock,
         )
-        this.contactModelRepository = ModelRepositories(coreServiceManager).contacts
+        this.databaseService = coreServiceManager.databaseService
+        this.contactModelRepository = ModelRepositories(coreServiceManager, identityProviderMock).contacts
 
         // Instantiate services where MD is enabled
-        this.databaseServiceMd = TestDatabaseService()
+        this.databaseProviderMd = TestDatabaseProvider()
         this.taskCodecMd = TransactionAckTaskCodec()
         this.coreServiceManagerMd = TestCoreServiceManager(
-            version = AppVersionProvider.appVersion,
-            databaseService = databaseServiceMd,
-            preferenceStore = serviceManager.preferenceStore,
-            encryptedPreferenceStore = serviceManager.encryptedPreferenceStore,
+            databaseProvider = databaseProviderMd,
+            identityProvider = identityProviderMock,
+            preferenceStore = preferenceStore,
+            encryptedPreferenceStore = encryptedPreferenceStore,
             multiDeviceManager = TestMultiDeviceManager(
                 isMultiDeviceActive = true,
                 isMdDisabledOrSupportsFs = false,
             ),
             taskManager = TestTaskManager(taskCodecMd),
+            identityStore = identityStoreMock,
         )
-        this.contactModelRepositoryMd = ModelRepositories(coreServiceManagerMd).contacts
+        this.databaseServiceMd = coreServiceManagerMd.databaseService
+        this.contactModelRepositoryMd = ModelRepositories(coreServiceManagerMd, identityProviderMock).contacts
     }
 
     /**
@@ -234,6 +277,15 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         assertContentEquals(publicKey, model.data?.publicKey)
     }
 
+    @Test
+    fun userCannotBeAddedAsContact() {
+        assertFailsWith<InvalidContactException> {
+            contactModelRepository.createFromSync(
+                contactModelData = contactModelData.copy(identity = TestHelpers.TEST_CONTACT.identity),
+            )
+        }
+    }
+
     private fun testCreateFromLocalOrRemote(
         contactModelData: ContactModelData,
         triggerSource: TestTriggerSource,
@@ -244,19 +296,30 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         val (newModel, newModelMd) = runBlocking {
             when (triggerSource) {
                 TestTriggerSource.FROM_LOCAL -> {
-                    contactModelRepository.createFromLocal(contactModelData) to
-                        contactModelRepositoryMd.createFromLocal(contactModelData)
+                    declareNonMdDependencies()
+                    val newModel = contactModelRepository.createFromLocal(contactModelData)
+
+                    declareMdDependencies()
+                    val newModelMd = contactModelRepositoryMd.createFromLocal(contactModelData)
+
+                    newModel to newModelMd
                 }
 
                 TestTriggerSource.FROM_REMOTE -> {
-                    contactModelRepository.createFromRemote(
+                    declareNonMdDependencies()
+                    val newContactModel = contactModelRepository.createFromRemote(
                         contactModelData = contactModelData,
                         handle = UnusedTaskCodec(),
-                    ) to
+                    )
+
+                    declareMdDependencies()
+                    val newContactModelMd =
                         contactModelRepositoryMd.createFromRemote(
                             contactModelData = contactModelData,
                             handle = taskCodecMd,
                         )
+
+                    newContactModel to newContactModelMd
                 }
             }
         }
@@ -293,27 +356,36 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
 
         val (runCreation, runCreationMd) = when (triggerSource) {
             TestTriggerSource.FROM_LOCAL -> {
-                suspend {
+                val runCreation = suspend {
+                    declareNonMdDependencies()
                     contactModelRepository.createFromLocal(contactModelData)
-                } to
+                }
+                val runCreationMd =
                     suspend {
+                        declareMdDependencies()
                         contactModelRepositoryMd.createFromLocal(contactModelData)
                     }
+
+                runCreation to runCreationMd
             }
 
             TestTriggerSource.FROM_REMOTE -> {
-                suspend {
+                val runCreation = suspend {
+                    declareNonMdDependencies()
                     contactModelRepository.createFromRemote(
                         contactModelData = contactModelData,
                         handle = UnusedTaskCodec(),
                     )
-                } to
-                    suspend {
-                        contactModelRepositoryMd.createFromRemote(
-                            contactModelData = contactModelData,
-                            handle = taskCodecMd,
-                        )
-                    }
+                }
+                val runCreationMd = suspend {
+                    declareMdDependencies()
+                    contactModelRepositoryMd.createFromRemote(
+                        contactModelData = contactModelData,
+                        handle = taskCodecMd,
+                    )
+                }
+
+                runCreation to runCreationMd
             }
         }
 
@@ -420,6 +492,14 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         assertEquals("testnick", model.data?.nickname)
     }
 
+    private fun declareNonMdDependencies() {
+        isMultiDeviceEnabled = false
+    }
+
+    private fun declareMdDependencies() {
+        isMultiDeviceEnabled = true
+    }
+
     private fun assertContentEquals(expected: ContactModelData?, actual: ContactModelData?) {
         if (expected == null && actual == null) {
             return

+ 24 - 25
app/src/androidTest/java/ch/threema/data/repositories/EditHistoryRepositoryTest.kt

@@ -2,46 +2,45 @@ package ch.threema.data.repositories
 
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestTaskManager
-import ch.threema.app.ThreemaApplication
 import ch.threema.app.testutils.TestHelpers
-import ch.threema.app.utils.AppVersionProvider
-import ch.threema.data.TestDatabaseService
+import ch.threema.app.testutils.mockUser
 import ch.threema.data.storage.EditHistoryDao
 import ch.threema.data.storage.EditHistoryDaoImpl
 import ch.threema.domain.helpers.UnusedTaskCodec
+import ch.threema.storage.TestDatabaseProvider
 import ch.threema.storage.models.AbstractMessageModel
-import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageType
+import ch.threema.storage.models.group.GroupMessageModel
+import io.mockk.mockk
 import java.util.UUID
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
+import org.koin.core.component.get
 
 class EditHistoryRepositoryTest {
-    private lateinit var databaseService: TestDatabaseService
+    private lateinit var databaseProvider: TestDatabaseProvider
+    private lateinit var coreServiceManager: TestCoreServiceManager
     private lateinit var editHistoryRepository: EditHistoryRepository
     private lateinit var editHistoryDao: EditHistoryDao
 
     @BeforeTest
     fun before() {
-        TestHelpers.setIdentity(
-            ThreemaApplication.requireServiceManager(),
-            TestHelpers.TEST_CONTACT,
-        )
-
-        databaseService = TestDatabaseService()
-        val serviceManager = ThreemaApplication.requireServiceManager()
-        val testCoreServiceManager = TestCoreServiceManager(
-            version = AppVersionProvider.appVersion,
-            databaseService = databaseService,
-            preferenceStore = serviceManager.preferenceStore,
-            encryptedPreferenceStore = serviceManager.encryptedPreferenceStore,
+        databaseProvider = TestDatabaseProvider()
+        coreServiceManager = TestCoreServiceManager(
+            databaseProvider = databaseProvider,
+            preferenceStore = mockk {
+                mockUser(TestHelpers.TEST_CONTACT)
+            },
+            encryptedPreferenceStore = mockk {
+                mockUser(TestHelpers.TEST_CONTACT)
+            },
             taskManager = TestTaskManager(UnusedTaskCodec()),
         )
-        editHistoryRepository = ModelRepositories(testCoreServiceManager).editHistory
-        editHistoryDao = EditHistoryDaoImpl(databaseService)
+        editHistoryRepository = ModelRepositories(coreServiceManager, mockk()).editHistory
+        editHistoryDao = EditHistoryDaoImpl(databaseProvider)
     }
 
     @Test
@@ -52,18 +51,18 @@ class EditHistoryRepositoryTest {
             editHistoryRepository.createEntry(contactMessage)
         }
 
-        databaseService.messageModelFactory.create(contactMessage)
+        coreServiceManager.databaseService.messageModelFactory.create(contactMessage)
 
         contactMessage.assertEditHistorySize(0)
 
         contactMessage.body = "Edited"
 
         editHistoryRepository.createEntry(contactMessage)
-        databaseService.messageModelFactory.update(contactMessage)
+        coreServiceManager.databaseService.messageModelFactory.update(contactMessage)
 
         contactMessage.assertEditHistorySize(1)
 
-        databaseService.messageModelFactory.delete(contactMessage)
+        coreServiceManager.databaseService.messageModelFactory.delete(contactMessage)
 
         contactMessage.assertEditHistorySize(0)
     }
@@ -76,18 +75,18 @@ class EditHistoryRepositoryTest {
             editHistoryRepository.createEntry(groupMessage)
         }
 
-        databaseService.groupMessageModelFactory.create(groupMessage)
+        coreServiceManager.databaseService.groupMessageModelFactory.create(groupMessage)
 
         groupMessage.assertEditHistorySize(0)
 
         groupMessage.body = "Edited"
 
         editHistoryRepository.createEntry(groupMessage)
-        databaseService.groupMessageModelFactory.update(groupMessage)
+        coreServiceManager.databaseService.groupMessageModelFactory.update(groupMessage)
 
         groupMessage.assertEditHistorySize(1)
 
-        databaseService.groupMessageModelFactory.delete(groupMessage)
+        coreServiceManager.databaseService.groupMessageModelFactory.delete(groupMessage)
 
         groupMessage.assertEditHistorySize(0)
     }

+ 44 - 27
app/src/androidTest/java/ch/threema/data/repositories/EmojiReactionsRepositoryTest.kt

@@ -2,21 +2,26 @@ package ch.threema.data.repositories
 
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestTaskManager
-import ch.threema.app.ThreemaApplication
-import ch.threema.app.utils.AppVersionProvider
+import ch.threema.app.stores.IdentityProvider
+import ch.threema.app.testutils.TestHelpers
+import ch.threema.app.testutils.mockUser
 import ch.threema.data.ModelTypeCache
-import ch.threema.data.TestDatabaseService
 import ch.threema.data.models.EmojiReactionData
 import ch.threema.data.models.EmojiReactionsModel
 import ch.threema.data.repositories.EmojiReactionsRepository.ReactionMessageIdentifier
 import ch.threema.data.storage.EmojiReactionsDao
 import ch.threema.data.storage.EmojiReactionsDaoImpl
 import ch.threema.domain.helpers.UnusedTaskCodec
+import ch.threema.domain.stores.IdentityStore
+import ch.threema.domain.types.Identity
+import ch.threema.storage.TestDatabaseProvider
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.DistributionListMessageModel
-import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageType
+import ch.threema.storage.models.group.GroupMessageModel
+import io.mockk.every
+import io.mockk.mockk
 import java.time.Instant
 import java.util.UUID
 import kotlin.test.BeforeTest
@@ -30,24 +35,36 @@ import kotlin.test.fail
 
 class EmojiReactionsRepositoryTest {
     private lateinit var testCoreServiceManager: TestCoreServiceManager
-    private lateinit var databaseService: TestDatabaseService
+    private lateinit var databaseProvider: TestDatabaseProvider
     private lateinit var emojiReactionsRepository: EmojiReactionsRepository
     private lateinit var emojiReactionDao: EmojiReactionsDao
 
     @BeforeTest
     fun before() {
-        val serviceManager = ThreemaApplication.requireServiceManager()
-        databaseService = TestDatabaseService()
+        databaseProvider = TestDatabaseProvider()
+        val identityProviderMock: IdentityProvider = mockk {
+            every { getIdentity() } returns Identity(TestHelpers.TEST_CONTACT.identity)
+            every { getIdentityString() } returns TestHelpers.TEST_CONTACT.identity
+        }
+        val identityStoreMock = mockk<IdentityStore> {
+            every { getIdentity() } returns Identity(TestHelpers.TEST_CONTACT.identity)
+            every { getIdentityString() } returns TestHelpers.TEST_CONTACT.identity
+        }
         testCoreServiceManager = TestCoreServiceManager(
-            version = AppVersionProvider.appVersion,
-            databaseService = databaseService,
-            preferenceStore = serviceManager.preferenceStore,
-            encryptedPreferenceStore = serviceManager.encryptedPreferenceStore,
+            databaseProvider = databaseProvider,
+            identityProvider = identityProviderMock,
+            preferenceStore = mockk {
+                mockUser(TestHelpers.TEST_CONTACT)
+            },
+            encryptedPreferenceStore = mockk {
+                mockUser(TestHelpers.TEST_CONTACT)
+            },
             taskManager = TestTaskManager(UnusedTaskCodec()),
+            identityStore = identityStoreMock,
         )
 
-        emojiReactionsRepository = ModelRepositories(testCoreServiceManager).emojiReaction
-        emojiReactionDao = EmojiReactionsDaoImpl(databaseService)
+        emojiReactionsRepository = ModelRepositories(testCoreServiceManager, mockk()).emojiReaction
+        emojiReactionDao = EmojiReactionsDaoImpl(databaseProvider)
     }
 
     @Test
@@ -58,18 +75,18 @@ class EmojiReactionsRepositoryTest {
             emojiReactionsRepository.createEntry(contactMessage, "ABCDEFGH", "\uD83C\uDFC8")
         }
 
-        databaseService.messageModelFactory.create(contactMessage)
+        testCoreServiceManager.databaseService.messageModelFactory.create(contactMessage)
 
         contactMessage.assertEmojiReactionSize(0)
 
         contactMessage.body = "reacted"
 
         emojiReactionsRepository.createEntry(contactMessage, "ABCDEFGH", "⚽")
-        databaseService.messageModelFactory.update(contactMessage)
+        testCoreServiceManager.databaseService.messageModelFactory.update(contactMessage)
 
         contactMessage.assertEmojiReactionSize(1)
 
-        databaseService.messageModelFactory.delete(contactMessage)
+        testCoreServiceManager.databaseService.messageModelFactory.delete(contactMessage)
 
         contactMessage.assertEmojiReactionSize(0)
     }
@@ -82,18 +99,18 @@ class EmojiReactionsRepositoryTest {
             emojiReactionsRepository.createEntry(groupMessage, "ABCDEFGH", "⚾")
         }
 
-        databaseService.groupMessageModelFactory.create(groupMessage)
+        testCoreServiceManager.databaseService.groupMessageModelFactory.create(groupMessage)
 
         groupMessage.assertEmojiReactionSize(0)
 
         groupMessage.body = "Reacted"
 
         emojiReactionsRepository.createEntry(groupMessage, "ABCDEFGH", "⚽")
-        databaseService.groupMessageModelFactory.update(groupMessage)
+        testCoreServiceManager.databaseService.groupMessageModelFactory.update(groupMessage)
 
         groupMessage.assertEmojiReactionSize(1)
 
-        databaseService.groupMessageModelFactory.delete(groupMessage)
+        testCoreServiceManager.databaseService.groupMessageModelFactory.delete(groupMessage)
 
         groupMessage.assertEmojiReactionSize(0)
     }
@@ -101,13 +118,13 @@ class EmojiReactionsRepositoryTest {
     @Test
     fun testEmojiReactionUniqueness() {
         val message = MessageModel().enrich()
-        databaseService.messageModelFactory.create(message)
+        testCoreServiceManager.databaseService.messageModelFactory.create(message)
 
         message.assertEmojiReactionSize(0)
         message.body = "reacted"
 
         emojiReactionsRepository.createEntry(message, "ABCDEFGH", "⚽")
-        databaseService.messageModelFactory.update(message)
+        testCoreServiceManager.databaseService.messageModelFactory.update(message)
 
         message.assertEmojiReactionSize(1)
 
@@ -123,7 +140,7 @@ class EmojiReactionsRepositoryTest {
         val reaction = reactions.data!![0]
         assertEquals("⚽", reaction.emojiSequence)
 
-        databaseService.messageModelFactory.delete(message)
+        testCoreServiceManager.databaseService.messageModelFactory.delete(message)
     }
 
     @Test
@@ -131,8 +148,8 @@ class EmojiReactionsRepositoryTest {
         val contactMessage = MessageModel().enrich()
         val groupMessage = GroupMessageModel().enrich()
 
-        databaseService.messageModelFactory.create(contactMessage)
-        databaseService.groupMessageModelFactory.create(groupMessage)
+        testCoreServiceManager.databaseService.messageModelFactory.create(contactMessage)
+        testCoreServiceManager.databaseService.groupMessageModelFactory.create(groupMessage)
 
         assertEquals(1, contactMessage.id)
         assertEquals(1, groupMessage.id)
@@ -166,8 +183,8 @@ class EmojiReactionsRepositoryTest {
         val contactMessage = MessageModel().enrich()
         val groupMessage = GroupMessageModel().enrich()
 
-        databaseService.messageModelFactory.create(contactMessage)
-        databaseService.groupMessageModelFactory.create(groupMessage)
+        testCoreServiceManager.databaseService.messageModelFactory.create(contactMessage)
+        testCoreServiceManager.databaseService.groupMessageModelFactory.create(groupMessage)
 
         assertEquals(1, contactMessage.id)
         assertEquals(1, groupMessage.id)
@@ -201,7 +218,7 @@ class EmojiReactionsRepositoryTest {
         val testEmojiCache = ModelTypeCache<ReactionMessageIdentifier, EmojiReactionsModel>()
 
         val contactMessage = MessageModel().enrich()
-        databaseService.messageModelFactory.create(contactMessage)
+        testCoreServiceManager.databaseService.messageModelFactory.create(contactMessage)
 
         // Test successful creation of reaction-message-identifier
         val reactionMessageIdentifier = ReactionMessageIdentifier.fromMessageModel(contactMessage)

+ 21 - 23
app/src/androidTest/java/ch/threema/data/repositories/GroupModelRepositoryTest.kt

@@ -2,10 +2,8 @@ package ch.threema.data.repositories
 
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestTaskManager
-import ch.threema.app.ThreemaApplication
 import ch.threema.app.testutils.TestHelpers
-import ch.threema.app.utils.AppVersionProvider
-import ch.threema.data.TestDatabaseService
+import ch.threema.app.testutils.mockUser
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModelDataFactory
 import ch.threema.data.storage.DatabaseBackend
@@ -13,7 +11,10 @@ import ch.threema.data.storage.DbGroup
 import ch.threema.data.storage.SqliteDatabaseBackend
 import ch.threema.domain.helpers.UnusedTaskCodec
 import ch.threema.domain.models.GroupId
-import ch.threema.storage.models.GroupModel
+import ch.threema.domain.models.UserState
+import ch.threema.storage.TestDatabaseProvider
+import ch.threema.storage.models.group.GroupModelOld
+import io.mockk.mockk
 import java.util.Date
 import kotlin.test.BeforeTest
 import kotlin.test.Test
@@ -22,7 +23,7 @@ import kotlin.test.assertFailsWith
 import kotlin.test.assertNull
 
 class GroupModelRepositoryTest {
-    private lateinit var databaseService: TestDatabaseService
+    private lateinit var databaseProvider: TestDatabaseProvider
     private lateinit var databaseBackend: DatabaseBackend
     private lateinit var coreServiceManager: TestCoreServiceManager
     private lateinit var groupModelRepository: GroupModelRepository
@@ -40,29 +41,26 @@ class GroupModelRepositoryTest {
             groupDescription = "Description",
             groupDescriptionChangedAt = Date(),
             members = setOf("AAAAAAAA", "BBBBBBBB"),
-            userState = GroupModel.UserState.MEMBER,
+            userState = UserState.MEMBER,
             notificationTriggerPolicyOverride = null,
         )
     }
 
     @BeforeTest
     fun before() {
-        TestHelpers.setIdentity(
-            ThreemaApplication.requireServiceManager(),
-            TestHelpers.TEST_CONTACT,
-        )
-
-        this.databaseService = TestDatabaseService()
-        this.databaseBackend = SqliteDatabaseBackend(databaseService)
-        val serviceManager = ThreemaApplication.requireServiceManager()
+        this.databaseProvider = TestDatabaseProvider()
+        this.databaseBackend = SqliteDatabaseBackend(databaseProvider, mockk())
         this.coreServiceManager = TestCoreServiceManager(
-            version = AppVersionProvider.appVersion,
-            databaseService = databaseService,
-            preferenceStore = serviceManager.preferenceStore,
-            encryptedPreferenceStore = serviceManager.encryptedPreferenceStore,
+            databaseProvider = databaseProvider,
+            preferenceStore = mockk {
+                mockUser(TestHelpers.TEST_CONTACT)
+            },
+            encryptedPreferenceStore = mockk {
+                mockUser(TestHelpers.TEST_CONTACT)
+            },
             taskManager = TestTaskManager(UnusedTaskCodec()),
         )
-        this.groupModelRepository = ModelRepositories(coreServiceManager).groups
+        this.groupModelRepository = ModelRepositories(coreServiceManager, mockk()).groups
     }
 
     @Test
@@ -83,8 +81,8 @@ class GroupModelRepositoryTest {
         val groupIdentity = GroupIdentity("TESTTEST", 42)
 
         // Create group using the "old" model
-        databaseService.groupModelFactory.create(
-            GroupModel()
+        coreServiceManager.databaseService.groupModelFactory.create(
+            GroupModelOld()
                 .setCreatorIdentity(groupIdentity.creatorIdentity)
                 .setApiGroupId(GroupId(groupIdentity.groupId))
                 .setCreatedAt(Date()),
@@ -101,8 +99,8 @@ class GroupModelRepositoryTest {
         val groupId = GroupId(-42)
 
         // Create group using the "old" model
-        databaseService.groupModelFactory.create(
-            GroupModel()
+        coreServiceManager.databaseService.groupModelFactory.create(
+            GroupModelOld()
                 .setCreatorIdentity(creatorIdentity)
                 .setApiGroupId(groupId)
                 .setCreatedAt(Date()),

+ 73 - 0
app/src/androidTest/java/ch/threema/localcrypto/KeyStoreCryptoTest.kt

@@ -0,0 +1,73 @@
+package ch.threema.localcrypto
+
+import ch.threema.app.DangerousTest
+import ch.threema.localcrypto.protobuf.KeyWrapper
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+@DangerousTest(reason = "Deletes keys from the key store")
+class KeyStoreCryptoTest {
+
+    private lateinit var keyStoreSecretKeyManager: KeyStoreSecretKeyManager
+    private lateinit var keyStoreCrypto: KeyStoreCrypto
+
+    @BeforeTest
+    fun setUp() {
+        keyStoreSecretKeyManager = KeyStoreSecretKeyManager()
+        keyStoreCrypto = KeyStoreCrypto(keyStoreSecretKeyManager)
+        keyStoreSecretKeyManager.deleteAllSecretKeys()
+    }
+
+    @AfterTest
+    fun tearDown() {
+        keyStoreSecretKeyManager.deleteAllSecretKeys()
+    }
+
+    @Test
+    fun encryptAndDecrypt() {
+        val myData = byteArrayOf(1, 2, 3, 4, 5, 6)
+
+        val encryptedData = keyStoreCrypto.encryptWithNewSecretKey(myData, previousKeyAlias = null)
+        assertEquals(SecretKeyAlias.PRIMARY, keyStoreCrypto.extractSecretKeyAlias(encryptedData))
+
+        val keyWrapper = KeyWrapper.parseFrom(encryptedData)
+        assertEquals("threema_master_key_a", keyWrapper.keyStoreAlias)
+        assertEquals(12, keyWrapper.iv.size())
+
+        val myData2 = keyStoreCrypto.decryptWithExistingSecretKey(encryptedData)
+
+        assertContentEquals(myData, myData2)
+    }
+
+    @Test
+    fun encryptAndDecryptWithSecondaryKeyAlias() {
+        val myData = byteArrayOf(1, 2, 3, 4, 5, 6)
+
+        val encryptedData = keyStoreCrypto.encryptWithNewSecretKey(myData, previousKeyAlias = SecretKeyAlias.PRIMARY)
+        assertEquals(SecretKeyAlias.SECONDARY, keyStoreCrypto.extractSecretKeyAlias(encryptedData))
+
+        val keyWrapper = KeyWrapper.parseFrom(encryptedData)
+        assertEquals("threema_master_key_b", keyWrapper.keyStoreAlias)
+
+        val myData2 = keyStoreCrypto.decryptWithExistingSecretKey(encryptedData)
+
+        assertContentEquals(myData, myData2)
+    }
+
+    @Test
+    fun deleteSecretKeyAlias() {
+        val myData = byteArrayOf(1, 2, 3, 4, 5, 6)
+
+        val encryptedData = keyStoreCrypto.encryptWithNewSecretKey(myData, previousKeyAlias = null)
+
+        keyStoreCrypto.deleteSecretKey(SecretKeyAlias.PRIMARY)
+
+        assertFailsWith<IllegalStateException> {
+            keyStoreCrypto.decryptWithExistingSecretKey(encryptedData)
+        }
+    }
+}

+ 0 - 130
app/src/androidTest/java/ch/threema/logging/backend/DebugLogFileBackendTest.kt

@@ -1,130 +0,0 @@
-package ch.threema.logging.backend
-
-import android.util.Log
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.rule.GrantPermissionRule
-import ch.threema.app.BuildConfig
-import ch.threema.app.DangerousTest
-import ch.threema.app.ThreemaApplication
-import ch.threema.app.getReadWriteExternalStoragePermissionRule
-import ch.threema.logging.LogLevel
-import java.util.concurrent.TimeUnit
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
-import org.junit.Assume
-import org.junit.BeforeClass
-import org.junit.Rule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-@DangerousTest(reason = "Deletes logfile")
-class DebugLogFileBackendTest {
-
-    @JvmField
-    @Rule
-    val permissionRule: GrantPermissionRule = getReadWriteExternalStoragePermissionRule()
-
-    @BeforeTest
-    fun disableLogfile() {
-        DebugLogFileBackend.setEnabled(false)
-    }
-
-    /**
-     * Make sure that logging into the debug log file actually creates the debug log file.
-     * Also test that the file is only created when enabled.
-     */
-    @Test
-    fun testEnable() {
-        val logFilePath = DebugLogFileBackend.getLogFilePath()
-
-        // Log with the debug log file disabled
-        val backend = DebugLogFileBackend(Log.INFO)
-        backend.printSomething(level = Log.WARN)
-
-        // Enabling the debug log file won't create the log file just yet
-        assertFalse(logFilePath.exists())
-        DebugLogFileBackend.setEnabled(true)
-        assertFalse(logFilePath.exists())
-
-        // Logs below the min log level are filtered
-        backend.printSomething(level = Log.DEBUG)
-        assertFalse(logFilePath.exists())
-
-        // Log with the debug log file enabled
-        backend.printSomething(level = Log.WARN)
-        assertTrue(logFilePath.exists())
-
-        // Verify that the fallback file is not created when not needed
-        assertFalse(DebugLogFileBackend.getFallbackLogFilePath().exists())
-    }
-
-    /**
-     * Make sure that the fallback log file is deleted when the default log file can be created successfully.
-     */
-    @Test
-    fun testFallbackFileIsDeletedIfDefaultFileCanBeCreated() {
-        // Create the fallback log file
-        val fallbackLogFilePath = DebugLogFileBackend.getFallbackLogFilePath()
-        assertTrue(fallbackLogFilePath.createNewFile(), "Could not create fallback logfile")
-
-        // Enable logging and write a log message
-        DebugLogFileBackend.setEnabled(true)
-        val backend = DebugLogFileBackend(Log.INFO)
-        backend.printSomething(level = Log.WARN)
-
-        // Verify that the fallback file is now deleted, as it is not needed
-        assertFalse(fallbackLogFilePath.exists())
-    }
-
-    /**
-     * Make sure that disabling the debug log actually deletes the debug log file.
-     */
-    @Test
-    fun testDisableRemovesFile() {
-        val logFilePath = DebugLogFileBackend.getLogFilePath()
-        assertFalse(logFilePath.exists())
-        assertTrue(logFilePath.createNewFile(), "Could not create logfile")
-        assertTrue(logFilePath.exists())
-        DebugLogFileBackend.setEnabled(false)
-        assertFalse(logFilePath.exists())
-    }
-
-    /**
-     * Make sure that disabling the debug log actually deletes the fallback debug log file.
-     */
-    @Test
-    fun testDisableRemovesFallbackFile() {
-        val fallbackLogFilePath = DebugLogFileBackend.getFallbackLogFilePath()
-        assertFalse(fallbackLogFilePath.exists())
-        assertTrue(fallbackLogFilePath.createNewFile(), "Could not create fallback logfile")
-        assertTrue(fallbackLogFilePath.exists())
-        DebugLogFileBackend.setEnabled(false)
-        assertFalse(fallbackLogFilePath.exists())
-    }
-
-    private fun DebugLogFileBackend.printSomething(@LogLevel level: Int) {
-        printAsync(level, BuildConfig.LOG_TAG, null, "hi").get(500, TimeUnit.MILLISECONDS)
-    }
-
-    companion object {
-        /**
-         * On one of our CI devices, the access to the external storage directory is inexplicably broken, which leads these tests to fail.
-         * Since the external storage is only used for the debug log file, and a fallback is already in place to write the debug log into
-         * a different location if writing to the external storage fails, it is acceptable for the time being to simply skip these tests
-         * based on the precondition that the external storage directory exists.
-         */
-        @BeforeClass
-        @JvmStatic
-        fun assumeDeviceHasAccessToExternalStorage() {
-            Assume.assumeTrue(
-                try {
-                    ThreemaApplication.getAppContext().getExternalFilesDir(null)?.exists() == true
-                } catch (_: Exception) {
-                    false
-                },
-            )
-        }
-    }
-}

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

@@ -7,7 +7,7 @@ import ch.threema.base.crypto.Nonce
 import ch.threema.base.crypto.NonceScope
 import ch.threema.base.crypto.NonceStore
 import ch.threema.domain.stores.IdentityStore
-import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
 import javax.crypto.Mac
 import javax.crypto.spec.SecretKeySpec
 import kotlin.test.AfterTest
@@ -218,7 +218,7 @@ fun hashNonce(nonce: Nonce): HashedNonce {
 const val USER_IDENTITY = "01234567"
 
 private class TestIdentityStore : IdentityStore {
-    override fun getIdentity(): Identity = USER_IDENTITY
+    override fun getIdentityString(): IdentityString = USER_IDENTITY
 
     override fun encryptData(
         plaintext: ByteArray,
@@ -244,7 +244,7 @@ private class TestIdentityStore : IdentityStore {
     override fun getPublicNickname(): String = throw UnsupportedOperationException()
 
     override fun storeIdentity(
-        identity: Identity,
+        identity: IdentityString,
         serverGroup: String,
         privateKey: ByteArray,
     ) = throw UnsupportedOperationException()

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

@@ -1,19 +1,19 @@
 package ch.threema.storage
 
-import ch.threema.app.ThreemaApplication
 import ch.threema.storage.factories.TaskArchiveFactory
 import junit.framework.TestCase.assertEquals
 import kotlin.test.AfterTest
 import kotlin.test.BeforeTest
 import kotlin.test.Test
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
 
-class TaskArchiveFactoryTest {
+class TaskArchiveFactoryTest : KoinComponent {
     private lateinit var taskArchiveFactory: TaskArchiveFactory
 
     @BeforeTest
     fun setup() {
-        taskArchiveFactory =
-            ThreemaApplication.requireServiceManager().databaseService.taskArchiveFactory
+        taskArchiveFactory = TaskArchiveFactory(get())
         taskArchiveFactory.deleteAll()
     }
 

+ 20 - 0
app/src/androidTest/java/ch/threema/storage/TestDatabaseProvider.kt

@@ -0,0 +1,20 @@
+package ch.threema.storage
+
+import androidx.test.core.app.ApplicationProvider
+import ch.threema.common.stateFlowOf
+import net.zetetic.database.sqlcipher.SQLiteDatabase
+
+class TestDatabaseProvider : DatabaseProvider {
+    private val inMemoryDatabaseOpenHelper = DatabaseOpenHelper(
+        appContext = ApplicationProvider.getApplicationContext(),
+        databaseName = null,
+        password = "test-database-key".toByteArray(),
+    )
+
+    override val databaseState = stateFlowOf(DatabaseState.READY)
+
+    override val readableDatabase: SQLiteDatabase
+        get() = inMemoryDatabaseOpenHelper.readableDatabase
+    override val writableDatabase: SQLiteDatabase
+        get() = inMemoryDatabaseOpenHelper.writableDatabase
+}

+ 211 - 217
app/src/foss_based/assets/license.html

@@ -17,7 +17,6 @@
 
         h1 {
             font-size: 14px;
-            color: #555;
             border-top: 1px solid #aaa;
             padding-top: 0.5em;
             margin-top: 1.5em;
@@ -25,354 +24,367 @@
 
         h2 {
             font-size: 13px;
-            color: #777;
             border-top: 1px solid #aaa;
             padding-top: 0.5em;
             margin-top: 1.5em;
         }
 
-        a {
-            color: #0086C9;
+        body, a {
+            color: #151513;
         }
 
         .maincopyright {
             font-size: 14px;
             line-height: 1.3em;
         }
+        @media screen and (prefers-color-scheme: dark) {
+            body {
+                background: #1d1d1b;
+            }
+            body, a {
+                color: #ffffff;
+            }
+        }
     </style>
 </head>
 
 <body>
 
-<p class="maincopyright">Copyright © Threema GmbH.<br/>
-    All rights reserved.</p>
+<p class="maincopyright">
+    Copyright © Threema GmbH.<br/>
+    All rights reserved.
+</p>
 
 <h1>Translations</h1>
 
-<p>The app localizations were realized with kind support from various translators.<br />
-    Thank you!</p>
+<p>
+    The app localizations were realized with kind support from various translators.<br/>
+    Thank you!
+</p>
 
-<p>If you would like to help translate the app, please contact us at <a href="mailto:support@threema.ch">support@threema.ch</a>.</p>
+<p>
+    If you would like to help translate the app, please contact us at <a href="mailto:support@threema.ch">support@threema.ch</a>.
+</p>
 
 <h1>Software</h1>
-
 <p>This product contains artwork and code from the following rights holders:</p>
 
 <h2>Android Fast Scroll</h2>
-
-<p>Copyright 2019 Google LLC</p>
-
+<p>Copyright © 2019 Google LLC</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Android Gesture Detectors Framework</h2>
-
-<p>Copyright (c) 2013, Almer Thie</p>
-
-<p>All rights reserved.</p>
-
-<p>Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:</p>
-
-<ul>
-    <li>Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.</li>
-    <li>Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.</li>
-</ul>
-
-<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
+<p>Copyright © 2022, Alex Vasilkov</p>
+<p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Android Image Cropper</h2>
-
-<p>Copyright 2016 Arthur Teplitzki, 2013 Edmodo, Inc.</p>
-
+<p>Copyright © 2016 Arthur Teplitzki, 2013 Edmodo, Inc.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Android Video Kit</h2>
-
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Base32</h2>
-
 <p>Copyright © 2010, Data Base Architects, Inc. All rights reserved.</p>
-
-<p>Redistribution and use in source and binary forms, with or without modification,
-are permitted provided that the following conditions are met:</p>
-
+<p>
+    Redistribution and use in source and binary forms, with or without modification,
+    are permitted provided that the following conditions are met:
+</p>
 <ul>
-    <li>Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.</li>
-    <li>Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.</li>
-    <li>Neither the names of Kalinda Software, DBA Software, Data Base Architects, Itemscript nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.</li>
+    <li>
+        Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+    </li>
+    <li>
+        Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
+        documentation and/or other materials provided with the distribution.
+    </li>
+    <li>
+        Neither the names of Kalinda Software, DBA Software, Data Base Architects, Itemscript nor the names of its contributors may be used to endorse
+        or promote products derived from this software without specific prior written permission.
+    </li>
 </ul>
-
-<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
-EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
-OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
-SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
-INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
-TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
-BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
-ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGE.</p>
-
-
-<h2>Commons IO</h2>
-
-<p>Copyright (c) 2016 The Apache Software Foundation. All rights reserved.</p>
-
-<p>Licensed under the Apache License, version 2.0 (copy below).</p>
+<p>
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+    EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+    OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
+    SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+    INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+    TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+    BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+    CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+    ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+    SUCH DAMAGE.
+</p>
 
 
 <h2>Emoji art supplied by <a href="http://emojione.com">EmojiOne</a></h2>
-
 <p>Licensed under Creative Commons License (CC-BY 4.0).</p>
 
-<h2>Emoji supplied by <a href="https://github.com/jdecked/twemoji">Twitter Emoji (Twemoji)</a></h2>
 
+<h2>Emoji supplied by <a href="https://github.com/jdecked/twemoji">Twitter Emoji (Twemoji)</a></h2>
 <p>Licensed under Creative Commons License (CC-BY 4.0).</p>
 
-<h2>Fluent Emoji</h2>
 
-<p>Copyright (c) Microsoft Corporation</p>
+<h2>ez-vcard</h2>
+<p>Copyright © 2012-2021, Michael Angstadt</p>
+<p>All rights reserved.</p>
+<p>
+    Redistribution and use in source and binary forms, with or without
+    modification, are permitted provided that the following conditions are met:
+</p>
+<ol>
+    <li>
+        Redistributions of source code must retain the above copyright notice, this
+        list of conditions and the following disclaimer.
+    </li>
+    <li>
+        Redistributions in binary form must reproduce the above copyright notice,
+        this list of conditions and the following disclaimer in the documentation
+        and/or other materials provided with the distribution.
+    </li>
+</ol>
+<p>
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+    ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+    WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+    DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+    ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+    (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+    LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+    ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+    (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+    SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+</p>
+<p>
+    The views and conclusions contained in the software and documentation are those
+    of the authors and should not be interpreted as representing official policies,
+    either expressed or implied, of the FreeBSD Project.
+</p>
 
+<h2>Fluent Emoji</h2>
+<p>Copyright © Microsoft Corporation</p>
 <p>Licensed under the MIT License (copy below).</p>
 
-<h2>Gesture Views</h2>
-
-<p>Copyright (c) 2022 Alex Vasilkov</p>
 
+<h2>Gesture Views</h2>
+<p>Copyright © 2022 Alex Vasilkov</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
-<h2>Jackson JSON-processor</h2>
 
-<p>Copyright (c) 2007-2017 Tatu Saloranta, tatu.saloranta@iki.fi</p>
+<h2>Glide</h2>
+<p>Copyright © 2025 Sam Judd</p>
+<p>BSD, part MIT and Apache 2.0. See <a href="https://github.com/bumptech/glide/blob/master/LICENSE">https://github.com/bumptech/glide/blob/master/LICENSE</a> for details.</p>
+
 
+<h2>Jackson JSON-processor</h2>
+<p>Copyright © 2007-2017 Tatu Saloranta, tatu.saloranta@iki.fi</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Bouncy Castle – Open-source cryptographic APIs</h2>
-
-<p>Copyright (c) 2000-2023 The Legion Of The Bouncy Castle Inc. (https://www.bouncycastle.org)</p>
-
+<p>Copyright © 2000-2023 The Legion Of The Bouncy Castle Inc. (https://www.bouncycastle.org)</p>
 <p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>Koin</h2>
-
-<p>Copyright (c) 2025 Kotzilla</p>
-
+<p>Copyright © 2025 Kotzilla</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>libphonenumber</h2>
-
-<p>Copyright (c) 2011-2017 The Libphonenumber Authors</p>
-
+<p>Copyright © 2011-2017 The Libphonenumber Authors</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Mapbox Maps SDK for Android</h2>
-
-<p>Copyright 2014-2020 Mapbox.</p>
-
+<p>Copyright © 2014-2020 Mapbox.</p>
 <p>Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:</p>
-
 <p>Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.</p>
-
-<p>Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.</p>
-
-<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
+<p>
+    Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation
+    and/or other materials provided with the distribution.
+</p>
+<p>
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+    THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+    CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+    PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+    LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
+    EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+</p>
 
 
 <h2>MessagePack for Java</h2>
-
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>MotionViews-Android</h2>
-
-<p>Copyright (c) 2016 UPTech</p>
-
-<p>Licensed under the MIT License (copy below).</p>
+<p>Copyright © 2013, Almer Thie (code.almeros.com)</p>
+<p>All rights reserved.</p>
+<p>Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:</p>
+<ul>
+    <li>
+        Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+    </li>
+    <li>
+        Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
+        in the documentation and/or other materials provided with the distribution.
+    </li>
+</ul>
+<p>
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+    INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+    IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
+    OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
+    OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+    OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
+    OF SUCH DAMAGE.
+</p>
 
 
 <h2>nv-websocket-client</h2>
+<p>Copyright © 2015-2016 Neo Visionaries Inc.</p>
+<p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
-<p>Copyright (C) 2015-2016 Neo Visionaries Inc.</p>
 
+<h2>OkHttp</h2>
+<p>Copyright © 2019 Square, Inc.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>OpenCSV</h2>
-
-<p>Copyright (c) 2016 The OpenCSV Contributors. All rights reserved.</p>
-
+<p>Copyright © 2016 The OpenCSV Contributors. All rights reserved.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>saltyrtc-client-java</h2>
-
-<p>Copyright (c) Threema GmbH</p>
-
+<p>Copyright © Threema GmbH</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>saltyrtc-task-webrtc-java</h2>
-
-<p>Copyright (c) Threema GmbH</p>
-
+<p>Copyright © Threema GmbH</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
-<h2>scrypt</h2>
-
-<p>Copyright 2009 Colin Percival</p>
-<p>All rights reserved.</p>
-
-<p>Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions
-are met:</p>
-
-<ol>
-    <li>Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.</li>
-    <li>Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.</li>
-</ol>
-
-<p>THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
-ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
-FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
-OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
-HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
-LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
-OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGE.</p>
-
-
 <h2>SLF4j</h2>
-
-<p>Copyright (c) 2004-2017 QOS.ch All rights reserved.</p>
-
+<p>Copyright © 2004-2017 QOS.ch All rights reserved.</p>
 <p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>SQLCipher</h2>
-
-<p>Copyright (c) 2025 Zetetic LLC<br />
-All rights reserved.</p>
-
-<p>Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:</p>
-
+<p>Copyright © 2025 Zetetic LLC<br/>
+    All rights reserved.</p>
+<p>
+    Redistribution and use in source and binary forms, with or without
+    modification, are permitted provided that the following conditions are met:
+</p>
 <ul>
-    <li>Redistributions of source code must retain the above copyright
-      notice, this list of conditions and the following disclaimer.</li>
-    <li>Redistributions in binary form must reproduce the above copyright
-      notice, this list of conditions and the following disclaimer in the
-      documentation and/or other materials provided with the distribution.</li>
-    <li>Neither the name of the ZETETIC LLC nor the
-      names of its contributors may be used to endorse or promote products
-      derived from this software without specific prior written permission.</li>
+    <li>
+        Redistributions of source code must retain the above copyright
+        notice, this list of conditions and the following disclaimer.
+    </li>
+    <li>
+        Redistributions in binary form must reproduce the above copyright
+        notice, this list of conditions and the following disclaimer in the
+        documentation and/or other materials provided with the distribution.
+    </li>
+    <li>
+        Neither the name of the ZETETIC LLC nor the
+        names of its contributors may be used to endorse or promote products
+        derived from this software without specific prior written permission.
+    </li>
 </ul>
-
-<p>THIS SOFTWARE IS PROVIDED BY ZETETIC LLC "AS IS" AND ANY
-EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL ZETETIC LLC BE LIABLE FOR ANY
-DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
-(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
-LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
-ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
-SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
+<p>
+    THIS SOFTWARE IS PROVIDED BY ZETETIC LLC "AS IS" AND ANY
+    EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+    WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+    DISCLAIMED. IN NO EVENT SHALL ZETETIC LLC BE LIABLE FOR ANY
+    DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+    (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+    LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+    ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+    (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+    SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+</p>
 
 
 <h2>StepPagerStrip</h2>
-
-<p>Copyright 2012 Roman Nurik</p>
-
+<p>Copyright © 2012 Roman Nurik</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Subsampling Scale Image View</h2>
-
-<p>Copyright (c) 2015 David Morrissey</p>
-
+<p>Copyright © 2018 David Morrissey</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>TapTargetView</h2>
-
-<p>Copyright (c) 2016 Keepsafe Software Inc.</p>
-
+<p>Copyright © 2016 Keepsafe Software Inc.</p>
 <p>Licensed under the Apache License, Version 2.0 (copy below)</p>
 
 
 <h2>The Android Open Source Project</h2>
-
-<p>Copyright (c) 2019 Google Inc.</p>
-
+<p>Copyright © 2025 Google Inc.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>WebRTC</h2>
-
-<p>Copyright (c) 2011, The WebRTC project authors. All rights reserved.</p>
-
-<p>Redistribution and use in source and binary forms, with or without modification, are permitted
-provided that the following conditions are met:</p>
-
+<p>Copyright © 2011, The WebRTC project authors. All rights reserved.</p>
+<p>
+    Redistribution and use in source and binary forms, with or without modification, are permitted
+    provided that the following conditions are met:
+</p>
 <ul>
-    <li>Redistributions of source code must retain the above copyright notice, this list of
-    conditions and the following disclaimer.</li>
-    <li>Redistributions in binary form must reproduce the above copyright notice, this list of
-    conditions and the following disclaimer in the documentation and/or other materials provided
-    with the distribution.</li>
-    <li>Neither the name of Google nor the names of its contributors may be used to endorse or
-    promote products derived from this software without specific prior written permission.</li>
+    <li>
+        Redistributions of source code must retain the above copyright notice, this list of
+        conditions and the following disclaimer.
+    </li>
+    <li>
+        Redistributions in binary form must reproduce the above copyright notice, this list of
+        conditions and the following disclaimer in the documentation and/or other materials provided
+        with the distribution.
+    </li>
+    <li>
+        Neither the name of Google nor the names of its contributors may be used to endorse or
+        promote products derived from this software without specific prior written permission.
+    </li>
 </ul>
-
-<p>THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
-IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
-FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
-CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
-SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
-OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-POSSIBILITY OF SUCH DAMAGE.</p>
+<p>
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
+    IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
+    FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+    CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+    CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+    SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+    THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+    OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+    POSSIBILITY OF SUCH DAMAGE.
+</p>
 
 
 <h2>Zip4j</h2>
-
-<p>Copyright (c) 2013 Srikanth Reddy Lingala. All rights reserved.</p>
-
+<p>Copyright © 2013 Srikanth Reddy Lingala. All rights reserved.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>ZXing</h2>
-
-<p>Copyright (c) 2009-2012 ZXing authors. All rights reserved.</p>
-
+<p>Copyright © 2009-2012 ZXing authors. All rights reserved.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
 <h2>Apache License<br/>Version 2.0, January 2004</h2>
 <p><a href="http://www.apache.org/licenses/">http://www.apache.org/licenses/</a></p>
 <p>TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION</p>
-
 <p><strong><a name="definitions">1. Definitions</a></strong>.</p>
-
 <p>"License" shall mean the terms and conditions for use, reproduction, and
     distribution as defined by Sections 1 through 9 of this document.</p>
-
 <p>"Licensor" shall mean the copyright owner or entity authorized by the
     copyright owner that is granting the License.</p>
-
 <p>"Legal Entity" shall mean the union of the acting entity and all other
     entities that control, are controlled by, or are under common control with
     that entity. For the purposes of this definition, "control" means (i) the
@@ -380,23 +392,18 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     entity, whether by contract or otherwise, or (ii) ownership of fifty
     percent (50%) or more of the outstanding shares, or (iii) beneficial
     ownership of such entity.</p>
-
 <p>"You" (or "Your") shall mean an individual or Legal Entity exercising
     permissions granted by this License.</p>
-
 <p>"Source" form shall mean the preferred form for making modifications,
     including but not limited to software source code, documentation source,
     and configuration files.</p>
-
 <p>"Object" form shall mean any form resulting from mechanical transformation
     or translation of a Source form, including but not limited to compiled
     object code, generated documentation, and conversions to other media types.</p>
-
 <p>"Work" shall mean the work of authorship, whether in Source or Object form,
     made available under the License, as indicated by a copyright notice that
     is included in or attached to the work (an example is provided in the
     Appendix below).</p>
-
 <p>"Derivative Works" shall mean any work, whether in Source or Object form,
     that is based on (or derived from) the Work and for which the editorial
     revisions, annotations, elaborations, or other modifications represent, as
@@ -404,7 +411,6 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     Derivative Works shall not include works that remain separable from, or
     merely link (or bind by name) to the interfaces of, the Work and Derivative
     Works thereof.</p>
-
 <p>"Contribution" shall mean any work of authorship, including the original
     version of the Work and any modifications or additions to that Work or
     Derivative Works thereof, that is intentionally submitted to Licensor for
@@ -418,18 +424,15 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     and improving the Work, but excluding communication that is conspicuously
     marked or otherwise designated in writing by the copyright owner as "Not a
     Contribution."</p>
-
 <p>"Contributor" shall mean Licensor and any individual or Legal Entity on
     behalf of whom a Contribution has been received by Licensor and
     subsequently incorporated within the Work.</p>
-
 <p><strong><a name="copyright">2. Grant of Copyright License</a></strong>. Subject to the
     terms and conditions of this License, each Contributor hereby grants to You
     a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
     copyright license to reproduce, prepare Derivative Works of, publicly
     display, publicly perform, sublicense, and distribute the Work and such
     Derivative Works in Source or Object form.</p>
-
 <p><strong><a name="patent">3. Grant of Patent License</a></strong>. Subject to the terms
     and conditions of this License, each Contributor hereby grants to You a
     perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
@@ -444,7 +447,6 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     direct or contributory patent infringement, then any patent licenses
     granted to You under this License for that Work shall terminate as of the
     date such litigation is filed.</p>
-
 <p><strong><a name="redistribution">4. Redistribution</a></strong>. You may reproduce and
     distribute copies of the Work or Derivative Works thereof in any medium,
     with or without modifications, and in Source or Object form, provided that
@@ -493,13 +495,11 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     Notwithstanding the above, nothing herein shall supersede or modify the
     terms of any separate license agreement you may have executed with Licensor
     regarding such Contributions.</p>
-
 <p><strong><a name="trademarks">6. Trademarks</a></strong>. This License does not grant
     permission to use the trade names, trademarks, service marks, or product
     names of the Licensor, except as required for reasonable and customary use
     in describing the origin of the Work and reproducing the content of the
     NOTICE file.</p>
-
 <p><strong><a name="no-warranty">7. Disclaimer of Warranty</a></strong>. Unless required by
     applicable law or agreed to in writing, Licensor provides the Work (and
     each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT
@@ -509,7 +509,6 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     are solely responsible for determining the appropriateness of using or
     redistributing the Work and assume any risks associated with Your exercise
     of permissions under this License.</p>
-
 <p><strong><a name="no-liability">8. Limitation of Liability</a></strong>. In no event and
     under no legal theory, whether in tort (including negligence), contract, or
     otherwise, unless required by applicable law (such as deliberate and
@@ -520,7 +519,6 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     but not limited to damages for loss of goodwill, work stoppage, computer
     failure or malfunction, or any and all other commercial damages or losses),
     even if such Contributor has been advised of the possibility of such damages.</p>
-
 <p><strong><a name="add-liability">9. Accepting Warranty or Additional Liability</a></strong>.
     While redistributing the Work or Derivative Works thereof, You may choose to offer,
     and charge a fee for, acceptance of support, warranty, indemnity, or other liability
@@ -530,13 +528,11 @@ POSSIBILITY OF SUCH DAMAGE.</p>
     and hold each Contributor harmless for any liability incurred by, or claims
     asserted against, such Contributor by reason of your accepting any such warranty
     or additional liability.</p>
-
 <p>END OF TERMS AND CONDITIONS</p>
 
 
 <h2>MIT License</h2>
 <a href="https://opensource.org/licenses/MIT">https://opensource.org/licenses/MIT</a>
-
 <p>Permission is hereby granted, free of charge, to any person obtaining a copy
     of this software and associated documentation files (the "Software"), to deal
     in the Software without restriction, including without limitation the rights
@@ -549,13 +545,11 @@ POSSIBILITY OF SUCH DAMAGE.</p>
 
     THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
     IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
     AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
     LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
     OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
     THE SOFTWARE.
 </p>
-
 </body>
-
 </html>

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

@@ -15,6 +15,7 @@ import org.slf4j.Logger;
 
 import java.util.Collections;
 
+import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.core.app.NotificationCompat;
 import androidx.core.app.ServiceCompat;
@@ -25,7 +26,6 @@ import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.notification.NotificationService;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.app.utils.TestUtil;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.storage.models.ContactModel;
 
@@ -112,7 +112,7 @@ public class VoiceActionService extends SearchActionVerificationClientService {
             return false;
         }
 
-        logger.debug("Audio uri: " + uri);
+        logger.debug("Audio uri: {}", uri);
 
         MediaItem mediaItem = new MediaItem(uri, MediaItem.TYPE_VOICEMESSAGE);
         mediaItem.setCaption(caption);
@@ -120,7 +120,7 @@ public class VoiceActionService extends SearchActionVerificationClientService {
         messageService.sendMediaAsync(Collections.singletonList(mediaItem), Collections.singletonList(messageReceiver), new MessageServiceImpl.SendResultListener() {
             @Override
             public void onError(String errorMessage) {
-                logger.debug("Error sending audio message: " + errorMessage);
+                logger.debug("Error sending audio message: {}", errorMessage);
                 lifetimeService.releaseConnectionLinger(TAG, PollingHelper.CONNECTION_LINGER);
             }
 
@@ -135,64 +135,36 @@ public class VoiceActionService extends SearchActionVerificationClientService {
     }
 
     public void doPerformAction(Intent intent, boolean isVerified) {
-
         if (isVerified) {
             Bundle bundle = intent.getExtras();
 
             if (bundle != null) {
-                String identity = bundle.getString("com.google.android.voicesearch.extra.RECIPIENT_CONTACT_CHAT_ID");
-                String message = bundle.getString("android.intent.extra.TEXT");
-
-                if (!TestUtil.isEmptyOrNull(identity, message)) {
-                    ContactModel contactModel = contactService.getByIdentity(identity);
-
-                    if (contactModel != null) {
-                        final MessageReceiver messageReceiver = contactService.createReceiver(contactModel);
+                @Nullable String identity = bundle.getString("com.google.android.voicesearch.extra.RECIPIENT_CONTACT_CHAT_ID");
+                @Nullable String message = bundle.getString("android.intent.extra.TEXT");
 
-                        if (messageReceiver != null) {
-                            lifetimeService.acquireConnection(TAG);
+                @Nullable ContactModel contactModel = contactService.getByIdentity(identity);
 
-                            if (!sendAudioMessage(messageReceiver, intent, message)) {
-                                try {
-                                    messageService.sendText(message, messageReceiver);
-                                    messageService.markConversationAsRead(messageReceiver, notificationService);
+                if (contactModel != null) {
+                    final MessageReceiver messageReceiver = contactService.createReceiver(contactModel);
+                    lifetimeService.acquireConnection(TAG);
 
-                                    logger.debug("Message sent to: " + identity);
-                                } catch (Exception e) {
-                                    logger.error("Exception", e);
-                                }
+                    if (!sendAudioMessage(messageReceiver, intent, message) && message != null && !message.isEmpty()) {
+                        try {
+                            messageService.sendText(message, messageReceiver);
+                            messageService.markConversationAsRead(messageReceiver, notificationService);
 
-                                lifetimeService.releaseConnectionLinger(TAG, PollingHelper.CONNECTION_LINGER);
-                            }
+                            logger.debug("Message sent to: {}", identity);
+                        } catch (Exception e) {
+                            logger.error("Exception", e);
                         }
+
+                        lifetimeService.releaseConnectionLinger(TAG, PollingHelper.CONNECTION_LINGER);
                     }
                 }
             }
         }
     }
 
-    /*    @Override
-        public boolean isTestingMode() {
-            return true;
-        }
-    */
-    final protected boolean requiredInstances() {
-        if (!this.checkInstances()) {
-            this.instantiate();
-        }
-        return this.checkInstances();
-    }
-
-    protected boolean checkInstances() {
-        return TestUtil.required(
-            this.messageService,
-            this.lifetimeService,
-            this.notificationService,
-            this.contactService,
-            this.lockAppService
-        );
-    }
-
     protected void instantiate() {
         ServiceManager serviceManager = ThreemaApplication.getServiceManager();
         if (serviceManager != null) {

+ 0 - 4
app/src/google_services_based/java/com/google/android/vending/licensing/LicenseChecker.java

@@ -129,9 +129,6 @@ public class LicenseChecker implements ServiceConnection {
      * own devising.
      * <p>
      * source string: "com.android.vending.licensing.ILicensingService"
-     * <p>
-     *
-     * @param callback
      */
     public synchronized void checkAccess(LicenseCheckerCallback callback) {
         // If we have a valid recent LICENSED response, we can skip asking
@@ -360,7 +357,6 @@ public class LicenseChecker implements ServiceConnection {
     /**
      * Get version code for the application package name.
      *
-     * @param context
      * @param packageName application package name
      * @return the version code or empty string if package not found
      */

+ 0 - 3
app/src/google_services_based/java/com/google/android/vending/licensing/LicenseValidator.java

@@ -204,9 +204,6 @@ class LicenseValidator {
 
     /**
      * Confers with policy and calls appropriate callback method.
-     *
-     * @param response
-     * @param rawData
      */
     private void handleResponse(int response, ResponseData rawData) {
         // Update policy data and increment retry counter (if needed)

+ 3 - 2
app/src/libre/play/release-notes/de/default.txt

@@ -1,2 +1,3 @@
-- Abspielen eines Tons während des Rufaufbaus in Einzelchats
-- Verwendung des Standardpfads für Daten-Backups, wenn die Dateiauswahl im Systemdialog nicht funktioniert
+- Technische und visuelle Überarbeitung der Chatübersicht
+- Der Hauptschlüssel wird neu zusätzlich durch das Android Keystore-System geschützt
+- Verbesserungen und Behebung verschiedener Fehler

+ 3 - 2
app/src/libre/play/release-notes/en-US/default.txt

@@ -1,2 +1,3 @@
-- Play a sound when establishing Threema calls in individual chats
-- Use the default path for data backups if the system’s picker does not work
+- Technical and visual overhaul of the chat overview
+- The master key is now additionally protected by the Android Keystore system
+- Various under-the-hood improvements and bug fixes

+ 55 - 13
app/src/main/AndroidManifest.xml

@@ -187,7 +187,7 @@
         android:label="@string/app_name"
         android:largeHeap="true"
         android:localeConfig="@xml/locales_config"
-        android:manageSpaceActivity="ch.threema.app.activities.StorageManagementActivity"
+        android:manageSpaceActivity="ch.threema.app.storagemanagement.StorageManagementActivity"
         android:networkSecurityConfig="@xml/network_security_config"
         android:resizeableActivity="true"
         android:roundIcon="@mipmap/ic_launcher"
@@ -461,11 +461,10 @@
             android:configChanges="uiMode"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
-            android:name=".activities.PinLockActivity"
-            android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
+            android:name=".pinlock.PinLockActivity"
             android:noHistory="true"
-            android:theme="@style/Theme.Threema.WithToolbar.NoAnim"
-            android:windowSoftInputMode="stateAlwaysVisible|adjustPan" />
+            android:theme="@style/Theme.Threema.WithToolbar"
+            android:windowSoftInputMode="stateAlwaysVisible|adjustResize" />
         <activity
             android:name=".activities.GroupAddActivity"
             android:configChanges="uiMode"
@@ -545,7 +544,7 @@
             android:configChanges="uiMode"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
-            android:name=".activities.StorageManagementActivity"
+            android:name=".storagemanagement.StorageManagementActivity"
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
@@ -676,7 +675,7 @@
         <activity
             android:name=".activities.SMSVerificationLinkActivity"
             android:exported="true"
-            android:theme="@android:style/Theme.NoDisplay">
+            android:theme="@style/Theme.Threema.Translucent">
             <intent-filter>
                 <action android:name="android.intent.action.VIEW" />
 
@@ -795,7 +794,7 @@
             android:theme="@style/Theme.Threema.StarredMessages"
             android:windowSoftInputMode="adjustResize" />
         <activity
-            android:name="ch.threema.app.activities.StarredMessagesActivity"
+            android:name="ch.threema.app.activities.starred.StarredMessagesActivity"
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
             android:theme="@style/Theme.Threema.StarredMessages"
             android:windowSoftInputMode="adjustResize" />
@@ -864,9 +863,6 @@
             android:name=".problemsolving.ProblemSolverActivity"
             android:launchMode="singleTop"
             android:theme="@style/Theme.Threema.WithToolbar" />
-        <activity
-            android:name=".debug.patternlibrary.PatternLibraryActivity"
-            android:theme="@style/Theme.Threema.Translucent" />
         <activity
             android:name="ch.threema.app.emojireactions.EmojiReactionsOverviewActivity"
             android:theme="@style/Theme.Threema.Translucent"
@@ -887,6 +883,38 @@
             android:exported="false"
             android:launchMode="singleTop"
             android:theme="@style/Theme.Threema.AppStartup" />
+        <activity
+            android:name=".logging.ExportDebugLogActivity"
+            android:excludeFromRecents="true"
+            android:exported="true"
+            android:launchMode="singleTop"
+            android:theme="@style/Theme.Threema.AppStartup">
+
+            <intent-filter
+                android:autoVerify="true"
+                android:label="@string/app_name">
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data
+                    android:host="${actionUrl}"
+                    android:path="/debug"
+                    android:scheme="https" />
+            </intent-filter>
+            <intent-filter
+                android:label="@string/app_name">
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data
+                    android:host="debug"
+                    android:scheme="${uriScheme}" />
+            </intent-filter>
+        </activity>
 
         <!-- services -->
         <service
@@ -927,7 +955,7 @@
                 android:value="@string/special_use_fgs_passphrase_service_explanation" />
         </service>
         <service
-            android:name=".services.WidgetService"
+            android:name=".widget.WidgetService"
             android:exported="false"
             android:permission="android.permission.BIND_REMOTEVIEWS" />
         <service
@@ -969,6 +997,11 @@
             android:exported="false"
             android:foregroundServiceType="remoteMessaging"
             android:label="ThreemaPushService" />
+        <service
+            android:name=".services.RemoteSecretMonitorService"
+            android:exported="false"
+            android:foregroundServiceType="remoteMessaging"
+            android:label="DualLockService" />
 
         <service
             android:name=".voip.groupcall.service.GroupCallService"
@@ -1001,6 +1034,15 @@
                 android:value="true" />
         </service>
 
+        <service
+            android:name=".services.AudioPlayerService"
+            android:exported="false"
+            android:foregroundServiceType="mediaPlayback">
+            <intent-filter>
+                <action android:name="androidx.media3.session.MediaSessionService" />
+            </intent-filter>
+        </service>
+
         <!-- broadcast receivers -->
         <receiver
             android:name=".receivers.AutoStartNotifyReceiver"
@@ -1061,7 +1103,7 @@
 
         <!-- content providers -->
         <provider
-            android:name=".NamedFileProvider"
+            android:name="androidx.core.content.FileProvider"
             android:authorities="${applicationId}.fileprovider"
             android:exported="false"
             android:grantUriPermissions="true">

+ 5 - 0
app/src/main/java/ch/threema/app/AppConstants.kt

@@ -1,5 +1,7 @@
 package ch.threema.app
 
+import ch.threema.domain.types.IdentityString
+
 object AppConstants {
 
     const val INTENT_DATA_CONTACT = "identity"
@@ -36,4 +38,7 @@ object AppConstants {
     const val MAX_PW_LENGTH_BACKUP = 256
 
     const val ACTIVITY_CONNECTION_LIFETIME = 60_000L
+
+    const val THREEMA_SUPPORT_IDENTITY: IdentityString = "*SUPPORT"
+    const val THREEMA_CHANNEL_IDENTITY: IdentityString = "*THREEMA"
 }

+ 110 - 97
app/src/main/java/ch/threema/app/GlobalListeners.java

@@ -5,21 +5,29 @@ import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.database.ContentObserver;
+import android.os.Handler;
+import android.os.Looper;
 import android.provider.ContactsContract;
 
+import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
 import java.util.Date;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.core.content.ContextCompat;
+import ch.threema.android.ToastDuration;
+import ch.threema.app.apptaskexecutor.AppTaskExecutor;
 import ch.threema.app.listeners.BallotVoteListener;
 import ch.threema.app.listeners.ContactListener;
-import ch.threema.app.listeners.ContactSettingsListener;
 import ch.threema.app.listeners.ContactTypingListener;
 import ch.threema.app.listeners.ConversationListener;
 import ch.threema.app.listeners.DistributionListListener;
@@ -35,8 +43,8 @@ import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.messagereceiver.GroupMessageReceiver;
 import ch.threema.app.messagereceiver.MessageReceiver;
+import ch.threema.app.preference.service.PreferenceService;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
-import ch.threema.app.services.AvatarCacheService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationCategoryService;
 import ch.threema.app.services.ConversationService;
@@ -45,13 +53,12 @@ import ch.threema.app.services.MessageService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.services.ballot.BallotService;
 import ch.threema.app.services.notification.NotificationService;
+import ch.threema.app.androidcontactsync.usecases.UpdateContactNameUseCase;
 import ch.threema.app.utils.BallotUtil;
 import ch.threema.app.utils.ConversationNotificationUtil;
-import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.ShortcutUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.android.Toaster;
-import ch.threema.app.widget.WidgetUtil;
+import ch.threema.app.widget.WidgetUpdater;
 import ch.threema.app.voip.listeners.VoipCallEventListener;
 import ch.threema.app.voip.managers.VoipListenerManager;
 import ch.threema.app.webclient.listeners.WebClientServiceListener;
@@ -65,6 +72,7 @@ import ch.threema.base.ThreemaException;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 import ch.threema.data.models.ContactModel;
+import ch.threema.data.models.ContactModelData;
 import ch.threema.data.models.GroupIdentity;
 import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.domain.stores.IdentityStore;
@@ -73,7 +81,7 @@ import ch.threema.localcrypto.exceptions.MasterKeyLockedException;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.DistributionListModel;
-import ch.threema.storage.models.GroupModel;
+import ch.threema.storage.models.group.GroupModelOld;
 import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.ServerMessageModel;
 import ch.threema.storage.models.WebClientSessionModel;
@@ -83,16 +91,23 @@ import ch.threema.storage.models.ballot.IdentityBallotModel;
 import ch.threema.storage.models.ballot.LinkBallotModel;
 import ch.threema.storage.models.data.status.GroupStatusDataModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
+import kotlin.Unit;
 
-import static ch.threema.android.ToasterKt.showToast;
+import static ch.threema.android.ToastKt.showToast;
 
 // TODO(ANDR-3400) This code was moved out from ThreemaApplication and needs some heavy refactoring
 public class GlobalListeners {
 
     private static final Logger logger = getThreemaLogger("GlobalListeners");
 
+    private final AppTaskExecutor appTaskExecutor = KoinJavaComponent.get(AppTaskExecutor.class);
+
     public static final Lock onAndroidContactChangeLock = new ReentrantLock();
 
+    private final Handler handler = new Handler(Looper.getMainLooper());
+
+    private final ExecutorService workerExecutor = Executors.newCachedThreadPool();
+
     public GlobalListeners(
         @NonNull Context appContext,
         @NonNull ServiceManager serviceManager
@@ -100,7 +115,7 @@ public class GlobalListeners {
         this.appContext = appContext;
         this.serviceManager = serviceManager;
 
-        webClientWakeUpListener = () -> showToast(appContext, R.string.webclient_protocol_version_to_old, Toaster.Duration.LONG);
+        webClientWakeUpListener = () -> showToast(appContext, R.string.webclient_protocol_version_to_old, ToastDuration.LONG);
     }
 
     @NonNull
@@ -116,7 +131,6 @@ public class GlobalListeners {
         ListenerManager.messageDeletedForAllListener.add(messageDeletedForAllListener);
         ListenerManager.serverMessageListeners.add(serverMessageListener);
         ListenerManager.contactListeners.add(contactListener);
-        ListenerManager.contactSettingsListeners.add(contactSettingsListener);
         ListenerManager.conversationListeners.add(conversationListener);
         ListenerManager.ballotVoteListeners.add(ballotVoteListener);
         ListenerManager.synchronizeContactsListeners.add(synchronizeContactsListener);
@@ -136,7 +150,6 @@ public class GlobalListeners {
         ListenerManager.messageDeletedForAllListener.remove(messageDeletedForAllListener);
         ListenerManager.serverMessageListeners.remove(serverMessageListener);
         ListenerManager.contactListeners.remove(contactListener);
-        ListenerManager.contactSettingsListeners.remove(contactSettingsListener);
         ListenerManager.conversationListeners.remove(conversationListener);
         ListenerManager.ballotVoteListeners.remove(ballotVoteListener);
         ListenerManager.synchronizeContactsListeners.remove(synchronizeContactsListener);
@@ -146,6 +159,8 @@ public class GlobalListeners {
         WebClientListenerManager.wakeUpListener.remove(webClientWakeUpListener);
         VoipListenerManager.callEventListener.remove(voipCallEventListener);
         unregisterContactNameChangeListener();
+        handler.removeCallbacksAndMessages(null);
+        workerExecutor.shutdownNow();
     }
 
     private void registerContactNameChangeListener() {
@@ -166,7 +181,7 @@ public class GlobalListeners {
         }
     }
 
-    private void showNotesGroupNotice(GroupModel groupModel, @GroupService.GroupState int oldState, @GroupService.GroupState int newState) {
+    private void showNotesGroupNotice(GroupModelOld groupModel, @GroupService.GroupState int oldState, @GroupService.GroupState int newState) {
         if (oldState != newState) {
             try {
                 GroupService groupService = serviceManager.getGroupService();
@@ -194,31 +209,31 @@ public class GlobalListeners {
         }
     }
 
-    private void showConversationNotification(AbstractMessageModel newMessage, boolean updateExisting) {
+    private void showConversationNotification(@NonNull AbstractMessageModel newMessage, boolean updateExisting) {
         try {
-            if (!newMessage.isOutbox()
-                && !newMessage.isStatusMessage()
-                && !newMessage.isRead()) {
-
+            if (!newMessage.isOutbox() && !newMessage.isStatusMessage() && !newMessage.isRead()) {
                 NotificationService notificationService = serviceManager.getNotificationService();
                 ContactService contactService = serviceManager.getContactService();
                 GroupService groupService = serviceManager.getGroupService();
                 ConversationCategoryService conversationCategoryService = serviceManager.getConversationCategoryService();
-
-                if (TestUtil.required(notificationService, contactService, groupService)) {
-                    if (newMessage.getType() != MessageType.GROUP_CALL_STATUS) {
-                        notificationService.showConversationNotification(ConversationNotificationUtil.convert(
-                                appContext,
-                                newMessage,
-                                contactService,
-                                groupService,
-                                conversationCategoryService),
-                            updateExisting);
-                    }
-
-                    // update widget on incoming message
-                    WidgetUtil.updateWidgets(appContext);
+                PreferenceService preferenceService = serviceManager.getPreferenceService();
+
+                if (newMessage.getType() != MessageType.GROUP_CALL_STATUS) {
+                    notificationService.showConversationNotification(
+                        ConversationNotificationUtil.convert(
+                            appContext,
+                            newMessage,
+                            contactService,
+                            groupService,
+                            conversationCategoryService,
+                            preferenceService.getContactNameFormat()
+                        ),
+                        updateExisting
+                    );
                 }
+
+                // update widget on incoming message
+                WidgetUpdater.update();
             }
         } catch (ThreemaException e) {
             logger.error("Exception", e);
@@ -230,7 +245,7 @@ public class GlobalListeners {
         @Override
         public void onCreate(@NonNull GroupIdentity groupIdentity) {
             try {
-                GroupModel groupModel = getGroupModel(groupIdentity);
+                GroupModelOld groupModel = getGroupModel(groupIdentity);
                 if (groupModel == null) {
                     return;
                 }
@@ -249,9 +264,9 @@ public class GlobalListeners {
 
         @Override
         public void onRename(@NonNull GroupIdentity groupIdentity) {
-            RuntimeUtil.runOnWorkerThread(() -> {
+            workerExecutor.execute(() -> {
                 try {
-                    GroupModel groupModel = getGroupModel(groupIdentity);
+                    GroupModelOld groupModel = getGroupModel(groupIdentity);
                     if (groupModel == null) {
                         return;
                     }
@@ -268,7 +283,10 @@ public class GlobalListeners {
                         null,
                         groupName
                     );
-                    ShortcutUtil.updatePinnedShortcut(messageReceiver);
+                    ShortcutUtil.updatePinnedShortcut(
+                        messageReceiver,
+                        serviceManager.getPreferenceService().getContactNameFormat()
+                    );
                 } catch (ThreemaException e) {
                     logger.error("Exception", e);
                 }
@@ -277,9 +295,9 @@ public class GlobalListeners {
 
         @Override
         public void onUpdatePhoto(@NonNull GroupIdentity groupIdentity) {
-            RuntimeUtil.runOnWorkerThread(() -> {
+            workerExecutor.execute(() -> {
                 try {
-                    GroupModel groupModel = getGroupModel(groupIdentity);
+                    GroupModelOld groupModel = getGroupModel(groupIdentity);
                     if (groupModel == null) {
                         return;
                     }
@@ -292,7 +310,10 @@ public class GlobalListeners {
                         null,
                         null
                     );
-                    ShortcutUtil.updatePinnedShortcut(messageReceiver);
+                    ShortcutUtil.updatePinnedShortcut(
+                        messageReceiver,
+                        serviceManager.getPreferenceService().getContactNameFormat()
+                    );
                 } catch (ThreemaException e) {
                     logger.error("Exception", e);
                 }
@@ -301,7 +322,7 @@ public class GlobalListeners {
 
         @Override
         public void onNewMember(@NonNull GroupIdentity groupIdentity, String identityNew) {
-            GroupModel groupModel = getGroupModel(groupIdentity);
+            GroupModelOld groupModel = getGroupModel(groupIdentity);
             if (groupModel == null) {
                 return;
             }
@@ -324,12 +345,12 @@ public class GlobalListeners {
             }
 
             //reset avatar to recreate it!
-            serviceManager.getAvatarCacheService().reset(groupModel);
+            serviceManager.getAvatarCacheService().reset(groupIdentity);
         }
 
         @Override
         public void onMemberLeave(@NonNull GroupIdentity groupIdentity, @NonNull String identityLeft) {
-            GroupModel groupModel = getGroupModel(groupIdentity);
+            GroupModelOld groupModel = getGroupModel(groupIdentity);
             if (groupModel == null) {
                 return;
             }
@@ -356,7 +377,7 @@ public class GlobalListeners {
         public void onMemberKicked(@NonNull GroupIdentity groupIdentity, String identityKicked) {
             final String myIdentity = serviceManager.getUserService().getIdentity();
 
-            GroupModel groupModel = getGroupModel(groupIdentity);
+            GroupModelOld groupModel = getGroupModel(groupIdentity);
             if (groupModel == null) {
                 return;
             }
@@ -391,7 +412,7 @@ public class GlobalListeners {
         @Override
         public void onUpdate(@NonNull GroupIdentity groupIdentity) {
             try {
-                GroupModel groupModel = getGroupModel(groupIdentity);
+                GroupModelOld groupModel = getGroupModel(groupIdentity);
                 if (groupModel == null) {
                     return;
                 }
@@ -403,9 +424,9 @@ public class GlobalListeners {
 
         @Override
         public void onLeave(@NonNull GroupIdentity groupIdentity) {
-            RuntimeUtil.runOnWorkerThread(() -> {
+            workerExecutor.execute(() -> {
                 try {
-                    GroupModel groupModel = getGroupModel(groupIdentity);
+                    GroupModelOld groupModel = getGroupModel(groupIdentity);
                     if (groupModel == null) {
                         return;
                     }
@@ -419,7 +440,7 @@ public class GlobalListeners {
         @Override
         public void onGroupStateChanged(@NonNull GroupIdentity groupIdentity, @GroupService.GroupState int oldState, @GroupService.GroupState int newState) {
             logger.debug("onGroupStateChanged: {} -> {}", oldState, newState);
-            GroupModel groupModel = getGroupModel(groupIdentity);
+            GroupModelOld groupModel = getGroupModel(groupIdentity);
             if (groupModel == null) {
                 return;
             }
@@ -428,11 +449,11 @@ public class GlobalListeners {
         }
 
         @Nullable
-        private GroupModel getGroupModel(@NonNull GroupIdentity groupIdentity) {
+        private GroupModelOld getGroupModel(@NonNull GroupIdentity groupIdentity) {
             try {
                 GroupService groupService = serviceManager.getGroupService();
                 groupService.removeFromCache(groupIdentity);
-                GroupModel groupModel = groupService.getByGroupIdentity(groupIdentity);
+                GroupModelOld groupModel = groupService.getByGroupIdentity(groupIdentity);
                 if (groupModel == null) {
                     logger.error("Group model is null");
                 }
@@ -457,10 +478,13 @@ public class GlobalListeners {
 
         @Override
         public void onModify(DistributionListModel distributionListModel) {
-            RuntimeUtil.runOnWorkerThread(() -> {
+            workerExecutor.execute(() -> {
                 try {
                     serviceManager.getConversationService().refresh(distributionListModel);
-                    ShortcutUtil.updatePinnedShortcut(serviceManager.getDistributionListService().createReceiver(distributionListModel));
+                    ShortcutUtil.updatePinnedShortcut(
+                        serviceManager.getDistributionListService().createReceiver(distributionListModel),
+                        serviceManager.getPreferenceService().getContactNameFormat()
+                    );
                 } catch (ThreemaException e) {
                     logger.error("Exception", e);
                 }
@@ -571,10 +595,11 @@ public class GlobalListeners {
                 return;
             }
 
-            RuntimeUtil.runOnWorkerThread(() -> {
+            workerExecutor.execute(() -> {
                 try {
                     final ConversationService conversationService = serviceManager.getConversationService();
                     final ContactService contactService = serviceManager.getContactService();
+                    final PreferenceService preferenceService = serviceManager.getPreferenceService();
 
                     // Refresh conversation cache
                     conversationService.updateContactConversation(identity);
@@ -582,9 +607,9 @@ public class GlobalListeners {
 
                     ContactMessageReceiver messageReceiver = contactService.createReceiver(modifiedContactModel);
                     if (messageReceiver != null) {
-                        ShortcutUtil.updatePinnedShortcut(messageReceiver);
+                        ShortcutUtil.updatePinnedShortcut(messageReceiver, preferenceService.getContactNameFormat());
                     }
-                } catch (ThreemaException e) {
+                } catch (Exception e) {
                     logger.error("Exception", e);
                 }
             });
@@ -592,11 +617,14 @@ public class GlobalListeners {
 
         @Override
         public void onAvatarChanged(final @NonNull String identity) {
-            RuntimeUtil.runOnWorkerThread(() -> {
+            workerExecutor.execute(() -> {
                 try {
                     ContactMessageReceiver messageReceiver = serviceManager.getContactService().createReceiver(identity);
                     if (messageReceiver != null) {
-                        ShortcutUtil.updatePinnedShortcut(messageReceiver);
+                        ShortcutUtil.updatePinnedShortcut(
+                            messageReceiver,
+                            serviceManager.getPreferenceService().getContactNameFormat()
+                        );
                     }
                 } catch (ThreemaException e) {
                     logger.error("Exception", e);
@@ -605,36 +633,6 @@ public class GlobalListeners {
         }
     };
 
-    @NonNull
-    private final ContactSettingsListener contactSettingsListener = new ContactSettingsListener() {
-        @Override
-        public void onSortingChanged() {
-            //do nothing!
-        }
-
-        @Override
-        public void onNameFormatChanged() {
-            //do nothing
-        }
-
-        @Override
-        public void onAvatarSettingChanged() {
-            //reset the avatar cache!
-            AvatarCacheService s = serviceManager.getAvatarCacheService();
-            s.clear();
-        }
-
-        @Override
-        public void onInactiveContactsSettingChanged() {
-
-        }
-
-        @Override
-        public void onNotificationSettingChanged(String uid) {
-
-        }
-    };
-
     @NonNull
     private final ConversationListener conversationListener = new ConversationListener() {
         @Override
@@ -642,7 +640,7 @@ public class GlobalListeners {
         }
 
         @Override
-        public void onModified(@NonNull ConversationModel modifiedConversationModel, @Nullable Integer oldPosition) {
+        public void onModified(@NonNull ConversationModel modifiedConversationModel) {
         }
 
         @Override
@@ -678,13 +676,13 @@ public class GlobalListeners {
                     MessageService messageService = s.getMessageService();
                     UserService userService = s.getUserService();
 
-                    if (TestUtil.required(ballotModel, contactService, groupService, messageService, userService)) {
+                    if (ballotModel != null) {
                         LinkBallotModel linkBallotModel = ballotService.getLinkedBallotModel(ballotModel);
                         if (linkBallotModel != null) {
                             GroupStatusDataModel.GroupStatusType type = null;
                             MessageReceiver<? extends AbstractMessageModel> receiver = null;
                             if (linkBallotModel instanceof GroupBallotModel) {
-                                GroupModel groupModel = groupService.getById(((GroupBallotModel) linkBallotModel).getGroupId());
+                                GroupModelOld groupModel = groupService.getById(((GroupBallotModel) linkBallotModel).getGroupId());
 
                                 // its a group ballot,write status
                                 receiver = groupService.createReceiver(groupModel);
@@ -804,13 +802,28 @@ public class GlobalListeners {
                     return;
                 }
 
-                if (!serviceManager.getPreferenceService().isSyncContacts()) {
+                if (!serviceManager.getSynchronizedSettingsService().isSyncContacts()) {
                     logger.warn("Contact synchronization is not enabled. Aborting.");
                     return;
                 }
 
-                boolean success = serviceManager.getContactService().updateAllContactNamesFromAndroidContacts();
-                logger.info("Finished updating contact names from android contacts (success={})", success);
+                logger.info("Updating all contact names from android contacts");
+                List<ContactModel> allContactModels = serviceManager.getModelRepositories().getContacts().getAll();
+                Set<ContactModel> linkedContactModels = new HashSet<>();
+                for (ContactModel contactModel : allContactModels) {
+                    ContactModelData contactModelData = contactModel.getData();
+                    if (contactModelData != null && contactModelData.androidContactLookupInfo != null) {
+                        linkedContactModels.add(contactModel);
+                    }
+                }
+                appTaskExecutor.runInAppTask(continuation -> {
+                    // Note that continuation can only be used once to call a suspend function!
+                    // Inject contact name use case here to prevent creating it if contacts never change.
+                    UpdateContactNameUseCase updateContactNameUseCase = KoinJavaComponent.get(UpdateContactNameUseCase.class);
+                    updateContactNameUseCase.call(linkedContactModels, continuation);
+                    logger.info("Finished updating contact names from android contacts");
+                    return Unit.INSTANCE;
+                });
             } catch (MasterKeyLockedException masterKeyLockedException) {
                 logger.error("Cantact name change observer could not be run successfully", masterKeyLockedException);
             } finally {
@@ -896,12 +909,12 @@ public class GlobalListeners {
         ) {
             logger.info("WebClientListenerManager: onStarted");
 
-            RuntimeUtil.runOnUiThread(() -> {
+            handler.post(() -> {
                 String toastText = appContext.getString(R.string.webclient_new_connection_toast);
                 if (model.getLabel() != null) {
                     toastText += " (" + model.getLabel() + ")";
                 }
-                showToast(appContext, toastText, Toaster.Duration.LONG);
+                showToast(appContext, toastText, ToastDuration.LONG);
 
                 final Intent intent = new Intent(appContext, SessionAndroidService.class);
 
@@ -938,7 +951,7 @@ public class GlobalListeners {
             logger.info("WebClientListenerManager: onStateChanged");
 
             if (newState == WebClientSessionState.DISCONNECTED) {
-                RuntimeUtil.runOnUiThread(() -> {
+                handler.post(() -> {
                     logger.info("updating SessionAndroidService");
                     if (SessionAndroidService.isRunning()) {
                         final Intent intent = new Intent(appContext, SessionAndroidService.class);
@@ -956,7 +969,7 @@ public class GlobalListeners {
         public void onStopped(@NonNull final WebClientSessionModel model, @NonNull final DisconnectContext reason) {
             logger.info("WebClientListenerManager: onStopped");
 
-            RuntimeUtil.runOnUiThread(() -> {
+            handler.post(() -> {
                 if (SessionAndroidService.isRunning()) {
                     final Intent intent = new Intent(appContext, SessionAndroidService.class);
                     intent.setAction(SessionAndroidService.ACTION_STOP);
@@ -1040,9 +1053,9 @@ public class GlobalListeners {
                 final MessageService messageService = serviceManager.getMessageService();
 
                 // If an incoming status message is not targeted at our own identity, something's wrong
-                final String appIdentity = identityStore.getIdentity();
-                if (TestUtil.compare(identity, appIdentity) && !isOutbox) {
-                    this.logger.error("Could not save voip status (identity={}, appIdentity={}, outbox={})", identity, appIdentity, isOutbox);
+                final String ownIdentity = identityStore.getIdentityString();
+                if (TestUtil.compare(identity, ownIdentity) && !isOutbox) {
+                    this.logger.error("Could not save voip status (identity={}, appIdentity={}, outbox={})", identity, ownIdentity, isOutbox);
                     return;
                 }
 

+ 0 - 346
app/src/main/java/ch/threema/app/NamedFileProvider.java

@@ -1,346 +0,0 @@
-package ch.threema.app;
-
-import android.content.Context;
-import android.content.Intent;
-import android.content.pm.PackageManager;
-import android.content.pm.ProviderInfo;
-import android.content.res.XmlResourceParser;
-import android.database.Cursor;
-import android.database.MatrixCursor;
-import android.net.Uri;
-import android.os.Environment;
-import android.provider.OpenableColumns;
-import android.text.TextUtils;
-
-import org.xmlpull.v1.XmlPullParserException;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.HashMap;
-
-import androidx.annotation.GuardedBy;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.collection.SimpleArrayMap;
-import androidx.core.content.ContextCompat;
-import androidx.core.content.FileProvider;
-import ch.threema.app.utils.TestUtil;
-
-import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT;
-import static org.xmlpull.v1.XmlPullParser.START_TAG;
-
-/**
- * This is a copy of androidx.core.content.FileProvider that adds the option of providing a filename override.
- * <p>
- * The default implementation always uses the actual filename of the file on the file system.
- * But there are cases when we do not want to use the real filename as it may just be a temporary file with a random name.
- */
-
-public class NamedFileProvider extends FileProvider {
-    private static final String
-        META_DATA_FILE_PROVIDER_PATHS = "android.support.FILE_PROVIDER_PATHS";
-
-    private static final String TAG_ROOT_PATH = "root-path";
-    private static final String TAG_FILES_PATH = "files-path";
-    private static final String TAG_CACHE_PATH = "cache-path";
-    private static final String TAG_EXTERNAL = "external-path";
-    private static final String TAG_EXTERNAL_FILES = "external-files-path";
-    private static final String TAG_EXTERNAL_CACHE = "external-cache-path";
-    private static final String TAG_EXTERNAL_MEDIA = "external-media-path";
-
-    private static final String ATTR_NAME = "name";
-    private static final String ATTR_PATH = "path";
-
-    private static final File DEVICE_ROOT = new File("/");
-
-    private static final String[] COLUMNS = {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
-
-    private PathStrategy mStrategy;
-
-    @GuardedBy("sCache")
-    private static final HashMap<String, PathStrategy> sCache = new HashMap<>();
-    private static final SimpleArrayMap<Uri, String> sUriToDisplayNameMap = new SimpleArrayMap<>();
-
-    @Override
-    public void attachInfo(@NonNull Context context, @NonNull ProviderInfo info) {
-        super.attachInfo(context, info);
-
-        mStrategy = getPathStrategy(context, info.authority);
-    }
-
-    @NonNull
-    @Override
-    public Cursor query(@NonNull final Uri uri, String[] projection, final String selection,
-                        final String[] selectionArgs, final String sortOrder) {
-        if (projection == null) {
-            projection = COLUMNS;
-        }
-
-        final File file = mStrategy.getFileForUri(uri);
-
-        String[] cols = new String[projection.length];
-        Object[] values = new Object[projection.length];
-        int i = 0;
-        for (String col : projection) {
-            if (OpenableColumns.DISPLAY_NAME.equals(col)) {
-                cols[i] = OpenableColumns.DISPLAY_NAME;
-                synchronized (sUriToDisplayNameMap) {
-                    if (TestUtil.isEmptyOrNull(sUriToDisplayNameMap.get(uri))) {
-                        values[i++] = file.getName();
-                    } else {
-                        values[i++] = sUriToDisplayNameMap.get(uri);
-                    }
-                }
-            } else if (OpenableColumns.SIZE.equals(col)) {
-                cols[i] = OpenableColumns.SIZE;
-                values[i++] = file.length();
-            }
-        }
-
-        cols = copyOf(cols, i);
-        values = copyOf(values, i);
-
-        final MatrixCursor cursor = new MatrixCursor(cols, 1);
-        cursor.addRow(values);
-        return cursor;
-    }
-
-    /**
-     * Return a content URI for a given {@link File}. Specific temporary
-     * permissions for the content URI can be set with
-     * {@link Context#grantUriPermission(String, Uri, int)}, or added
-     * to an {@link Intent} by calling {@link Intent#setData(Uri) setData()} and then
-     * {@link Intent#setFlags(int) setFlags()}; in both cases, the applicable flags are
-     * {@link Intent#FLAG_GRANT_READ_URI_PERMISSION} and
-     * {@link Intent#FLAG_GRANT_WRITE_URI_PERMISSION}. A FileProvider can only return a
-     * <code>content</code> {@link Uri} for file paths defined in their <code>&lt;paths&gt;</code>
-     * meta-data element. See the Class Overview for more information.
-     *
-     * @param context   A {@link Context} for the current component.
-     * @param authority The authority of a {@link FileProvider} defined in a
-     *                  {@code <provider>} element in your app's manifest.
-     * @param file      A {@link File} pointing to the filename for which you want a
-     *                  <code>content</code> {@link Uri}.
-     * @param filename  File name to be used for this file. Will be provided to consumers in the DISPLAY_NAME column
-     * @return A content URI for the file.
-     * @throws IllegalArgumentException When the given {@link File} is outside
-     *                                  the paths supported by the provider.
-     */
-    public static Uri getUriForFile(@NonNull Context context, @NonNull String authority,
-                                    @NonNull File file, @Nullable String filename) {
-        final Uri uri = FileProvider.getUriForFile(context, authority, file);
-        if (!TestUtil.isEmptyOrNull(filename)) {
-            synchronized (sUriToDisplayNameMap) {
-                sUriToDisplayNameMap.put(uri, filename);
-            }
-        }
-        return uri;
-    }
-
-    /**
-     * Strategy for mapping between {@link File} and {@link Uri}.
-     * <p>
-     * Strategies must be symmetric so that mapping a {@link File} to a
-     * {@link Uri} and then back to a {@link File} points at the original
-     * target.
-     * <p>
-     * Strategies must remain consistent across app launches, and not rely on
-     * dynamic state. This ensures that any generated {@link Uri} can still be
-     * resolved if your process is killed and later restarted.
-     *
-     * @see SimplePathStrategy
-     */
-    interface PathStrategy {
-        /**
-         * Return a {@link File} that represents the given {@link Uri}.
-         */
-        File getFileForUri(Uri uri);
-    }
-
-    /**
-     * Strategy that provides access to files living under a narrow whitelist of
-     * filesystem roots. It will throw {@link SecurityException} if callers try
-     * accessing files outside the configured roots.
-     * <p>
-     * For example, if configured with
-     * {@code addRoot("myfiles", context.getFilesDir())}, then
-     * {@code context.getFileStreamPath("foo.txt")} would map to
-     * {@code content://myauthority/myfiles/foo.txt}.
-     */
-    static class SimplePathStrategy implements PathStrategy {
-        private final HashMap<String, File> mRoots = new HashMap<String, File>();
-
-        SimplePathStrategy(String authority) {
-        }
-
-        /**
-         * Add a mapping from a name to a filesystem root. The provider only offers
-         * access to files that live under configured roots.
-         */
-        void addRoot(String name, File root) {
-            if (TextUtils.isEmpty(name)) {
-                throw new IllegalArgumentException("Name must not be empty");
-            }
-
-            try {
-                // Resolve to canonical path to keep path checking fast
-                root = root.getCanonicalFile();
-            } catch (IOException e) {
-                throw new IllegalArgumentException(
-                    "Failed to resolve canonical path for " + root, e);
-            }
-
-            mRoots.put(name, root);
-        }
-
-        @Override
-        public File getFileForUri(Uri uri) {
-            String path = uri.getEncodedPath();
-
-            final int splitIndex = path.indexOf('/', 1);
-            final String tag = Uri.decode(path.substring(1, splitIndex));
-            path = Uri.decode(path.substring(splitIndex + 1));
-
-            final File root = mRoots.get(tag);
-            if (root == null) {
-                throw new IllegalArgumentException("Unable to find configured root for " + uri);
-            }
-
-            File file = new File(root, path);
-            try {
-                file = file.getCanonicalFile();
-            } catch (IOException e) {
-                throw new IllegalArgumentException("Failed to resolve canonical path for " + file);
-            }
-
-            if (!file.getPath().startsWith(root.getPath())) {
-                throw new SecurityException("Resolved path jumped beyond configured root");
-            }
-
-            return file;
-        }
-    }
-
-
-    /**
-     * Return {@link PathStrategy} for given authority, either by parsing or
-     * returning from cache.
-     */
-    private static PathStrategy getPathStrategy(Context context, String authority) {
-        PathStrategy strat;
-        synchronized (sCache) {
-            strat = sCache.get(authority);
-            if (strat == null) {
-                try {
-                    strat = parsePathStrategy(context, authority);
-                } catch (IOException e) {
-                    throw new IllegalArgumentException(
-                        "Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e);
-                } catch (XmlPullParserException e) {
-                    throw new IllegalArgumentException(
-                        "Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e);
-                }
-                sCache.put(authority, strat);
-            }
-        }
-        return strat;
-    }
-
-    /**
-     * Parse and return {@link PathStrategy} for given authority as defined in
-     * {@link #META_DATA_FILE_PROVIDER_PATHS} {@code <meta-data>}.
-     */
-    private static PathStrategy parsePathStrategy(Context context, String authority)
-        throws IOException, XmlPullParserException {
-        final SimplePathStrategy strat = new SimplePathStrategy(authority);
-
-        final ProviderInfo info = context.getPackageManager()
-            .resolveContentProvider(authority, PackageManager.GET_META_DATA);
-        if (info == null) {
-            throw new IllegalArgumentException(
-                "Couldn't find meta-data for provider with authority " + authority);
-        }
-
-        final XmlResourceParser in = info.loadXmlMetaData(
-            context.getPackageManager(), META_DATA_FILE_PROVIDER_PATHS);
-        if (in == null) {
-            throw new IllegalArgumentException(
-                "Missing " + META_DATA_FILE_PROVIDER_PATHS + " meta-data");
-        }
-
-        int type;
-        while ((type = in.next()) != END_DOCUMENT) {
-            if (type == START_TAG) {
-                final String tag = in.getName();
-
-                final String name = in.getAttributeValue(null, ATTR_NAME);
-                String path = in.getAttributeValue(null, ATTR_PATH);
-
-                File target = null;
-                if (TAG_ROOT_PATH.equals(tag)) {
-                    target = DEVICE_ROOT;
-                } else if (TAG_FILES_PATH.equals(tag)) {
-                    target = context.getFilesDir();
-                } else if (TAG_CACHE_PATH.equals(tag)) {
-                    target = context.getCacheDir();
-                } else if (TAG_EXTERNAL.equals(tag)) {
-                    target = Environment.getExternalStorageDirectory();
-                } else if (TAG_EXTERNAL_FILES.equals(tag)) {
-                    File[] externalFilesDirs = ContextCompat.getExternalFilesDirs(context, null);
-                    if (externalFilesDirs.length > 0) {
-                        target = externalFilesDirs[0];
-                    }
-                } else if (TAG_EXTERNAL_CACHE.equals(tag)) {
-                    File[] externalCacheDirs = ContextCompat.getExternalCacheDirs(context);
-                    if (externalCacheDirs.length > 0) {
-                        target = externalCacheDirs[0];
-                    }
-                } else if (TAG_EXTERNAL_MEDIA.equals(tag)) {
-                    File[] externalMediaDirs = context.getExternalMediaDirs();
-                    if (externalMediaDirs.length > 0) {
-                        target = externalMediaDirs[0];
-                    }
-                }
-
-                if (target != null) {
-                    strat.addRoot(name, buildPath(target, path));
-                }
-            }
-        }
-        return strat;
-    }
-
-    private static File buildPath(File base, String... segments) {
-        File cur = base;
-        for (String segment : segments) {
-            if (segment != null) {
-                cur = new File(cur, segment);
-            }
-        }
-        return cur;
-    }
-
-    private static String[] copyOf(String[] original, int newLength) {
-        final String[] result = new String[newLength];
-        System.arraycopy(original, 0, result, 0, newLength);
-        return result;
-    }
-
-    private static Object[] copyOf(Object[] original, int newLength) {
-        final Object[] result = new Object[newLength];
-        System.arraycopy(original, 0, result, 0, newLength);
-        return result;
-    }
-
-    /**
-     * Get an Uri for the destination file that can be shared to other apps.
-     *
-     * @param file File to get an Uri for
-     * @param filename Desired filename for this file. Can be different from the filename of destFile
-     * @return The shareable Uri, using the 'content' scheme
-     */
-    @NonNull
-    public static Uri getShareFileUri(@NonNull Context context, @NonNull File file, @Nullable String filename) {
-        return getUriForFile(context, context.getPackageName() + ".fileprovider", file, filename);
-    }
-}

+ 50 - 114
app/src/main/java/ch/threema/app/ThreemaApplication.kt

@@ -13,17 +13,14 @@ import androidx.core.app.NotificationManagerCompat
 import androidx.core.content.edit
 import androidx.lifecycle.ProcessLifecycleOwner
 import androidx.preference.PreferenceManager
-import ch.threema.android.Toaster.Duration.LONG
-import ch.threema.android.showToast
 import ch.threema.app.AppConstants.ACTIVITY_CONNECTION_LIFETIME
 import ch.threema.app.apptaskexecutor.AppTaskExecutor
-import ch.threema.app.crashreporting.ThreemaUncaughtExceptionHandler
 import ch.threema.app.debug.StrictModeMonitor
 import ch.threema.app.di.MasterKeyLockStateChangeHandler
-import ch.threema.app.di.Qualifiers
 import ch.threema.app.di.getOrNull
 import ch.threema.app.di.initDependencyInjection
 import ch.threema.app.drafts.DraftManagerImpl
+import ch.threema.app.errorreporting.ThreemaUncaughtExceptionHandler
 import ch.threema.app.logging.AppVersionLogger
 import ch.threema.app.logging.DebugLogHelper
 import ch.threema.app.logging.ExitReasonLogger
@@ -34,14 +31,13 @@ import ch.threema.app.passphrase.PassphraseStateMonitor
 import ch.threema.app.preference.service.PreferenceService
 import ch.threema.app.push.PushService
 import ch.threema.app.restrictions.AppRestrictionService
-import ch.threema.app.services.AvatarCacheService
 import ch.threema.app.services.ServiceManagerProvider
 import ch.threema.app.services.ThreemaPushService
+import ch.threema.app.services.avatarcache.AvatarCacheService
 import ch.threema.app.startup.AppProcessLifecycleObserver
-import ch.threema.app.startup.AppStartupError
 import ch.threema.app.startup.AppStartupMonitorImpl
 import ch.threema.app.startup.MasterKeyEventMonitor
-import ch.threema.app.startup.RemoteSecretMonitorRetryController
+import ch.threema.app.startup.RemoteSecretProtectionStateMonitor
 import ch.threema.app.startup.deleteOrphanedUserData
 import ch.threema.app.startup.models.AppSystem
 import ch.threema.app.stores.EncryptedPreferenceStore
@@ -82,24 +78,23 @@ import ch.threema.libthreema.initialize as initLibthreema
 import ch.threema.localcrypto.MasterKey
 import ch.threema.localcrypto.MasterKeyManager
 import ch.threema.localcrypto.MasterKeyManagerImpl
-import ch.threema.localcrypto.exceptions.BlockedByAdminException
 import ch.threema.localcrypto.exceptions.MasterKeyLockedException
-import ch.threema.localcrypto.exceptions.RemoteSecretMonitorException
 import ch.threema.localcrypto.models.MasterKeyReadResult
 import ch.threema.logging.LibthreemaLogger
+import ch.threema.logging.backend.DebugLogFileBackend
+import ch.threema.logging.backend.DebugLogFileManager
 import ch.threema.storage.DatabaseDowngradeException
 import ch.threema.storage.DatabaseNonceStore
+import ch.threema.storage.DatabaseProvider
+import ch.threema.storage.DatabaseProviderImpl
 import ch.threema.storage.DatabaseService
 import ch.threema.storage.DatabaseState
 import ch.threema.storage.DatabaseUpdateException
 import ch.threema.storage.SQLDHSessionStore
-import ch.threema.storage.deriveDatabasePassword
 import ch.threema.storage.setupDatabaseLogging
 import kotlin.getValue
-import kotlin.system.exitProcess
-import kotlinx.coroutines.CancellationException
+import kotlin.time.measureTime
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.cancel
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.isActive
@@ -116,6 +111,7 @@ class ThreemaApplication : Application() {
 
     // TODO(ANDR-4187): Move these dependencies and the logic that uses them to a better place
     private val passphraseStateMonitor: PassphraseStateMonitor by inject()
+    private val remoteSecretProtectionStateMonitor: RemoteSecretProtectionStateMonitor by inject()
     private val masterKeyEventMonitor: MasterKeyEventMonitor by inject()
     private val appTaskExecutor: AppTaskExecutor by inject()
     private val appStartupMonitor: AppStartupMonitorImpl by inject()
@@ -127,6 +123,9 @@ class ThreemaApplication : Application() {
         }
         instance = this
 
+        // Enable the debug log file initially, such that any potential crashes during app startup are captured
+        DebugLogFileBackend.setEnabled(DebugLogFileManager(this), true)
+
         StrictModeMonitor.enableIfNeeded()
 
         super.onCreate()
@@ -137,6 +136,8 @@ class ThreemaApplication : Application() {
 
         AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
 
+        logger.info("*** App launched")
+
         initLibthreema(LogLevel.TRACE, LibthreemaLogger())
 
         setUpSecureRandom()
@@ -145,9 +146,6 @@ class ThreemaApplication : Application() {
 
         initDependencyInjection(this)
 
-        logger.info("*** App launched")
-
-        // TODO(ANDR-4310): consolidate this logging
         with(get<AppVersionLogger>()) {
             logAppVersionInfo()
             updateAppVersionHistory()
@@ -180,16 +178,9 @@ class ThreemaApplication : Application() {
             }
 
             // TODO(ANDR-4187): Move all of these coroutines to a better place
-            launch(dispatcherProvider.worker) {
-                monitorRemoteSecret(
-                    masterKeyManager = masterKeyManager,
-                    appStartupMonitor = appStartupMonitor,
-                )
-            }
             launch(dispatcherProvider.main) {
                 monitorMasterKey(
                     masterKeyManager = masterKeyManager,
-                    appStartupMonitor = appStartupMonitor,
                 )
             }
             launch(dispatcherProvider.worker) {
@@ -198,6 +189,9 @@ class ThreemaApplication : Application() {
             launch(dispatcherProvider.worker) {
                 passphraseStateMonitor.monitorPassphraseLock()
             }
+            launch(dispatcherProvider.worker) {
+                remoteSecretProtectionStateMonitor.monitorRemoteSecretProtectionState()
+            }
             launch(dispatcherProvider.worker) {
                 appTaskExecutor.start()
                 logger.error("App task executor has stopped")
@@ -226,53 +220,11 @@ class ThreemaApplication : Application() {
         LinuxSecureRandom()
     }
 
-    private suspend fun monitorRemoteSecret(
-        masterKeyManager: MasterKeyManagerImpl,
-        appStartupMonitor: AppStartupMonitorImpl,
-    ) = coroutineScope {
-        while (isActive) {
-            try {
-                masterKeyManager.monitorRemoteSecret()
-            } catch (_: BlockedByAdminException) {
-                logger.info("User is blocked by admin")
-                masterKeyManager.lockPermanently()
-                appStartupMonitor.reportAppStartupError(AppStartupError.BlockedByAdmin)
-            } catch (e: RemoteSecretMonitorException) {
-                logger.warn("Fetching/monitoring remote secret failed", e)
-                masterKeyManager.lockWithRemoteSecret()
-                appStartupMonitor.reportAppStartupError(AppStartupError.FailedToFetchRemoteSecret)
-            } catch (e: CancellationException) {
-                throw e
-            } catch (e: Exception) {
-                logger.error("Fetching/monitoring remote secret failed unexpectedly", e)
-                masterKeyManager.lockPermanently()
-                appStartupMonitor.reportAppStartupError(AppStartupError.Unexpected("RS-MONITOR"))
-            }
-
-            // Wait for the user to request a retry
-            RemoteSecretMonitorRetryController.awaitRetryRequest()
-            appStartupMonitor.clearTemporaryStartupErrors()
-        }
-    }
-
     private suspend fun monitorMasterKey(
         masterKeyManager: MasterKeyManagerImpl,
-        appStartupMonitor: AppStartupMonitorImpl,
     ) = coroutineScope {
         val masterKeyProvider = masterKeyManager.masterKeyProvider
         while (isActive) {
-            if (masterKeyManager.isLockedWithRemoteSecret()) {
-                try {
-                    appStartupMonitor.whileFetchingRemoteSecret {
-                        masterKeyManager.unlockWithRemoteSecret()
-                    }
-                } catch (e: Exception) {
-                    logger.warn("Failed to unlock with remote secret", e)
-                    appStartupMonitor.reportUnexpectedAppStartupError("RS-UNLOCK")
-                    cancel()
-                }
-            }
-
             val masterKey = masterKeyProvider.awaitUnlocked()
             onMasterKeyUnlocked(masterKey)
 
@@ -323,14 +275,18 @@ class ThreemaApplication : Application() {
 
             try {
                 val preferenceStore: PreferenceStore = get()
+                val identityProvider: IdentityProvider = get()
                 val mutableIdentityProvider: MutableIdentityProvider = get()
                 val encryptedPreferenceStore: EncryptedPreferenceStore = get()
 
                 setUpSqlCipher()
-                val databaseService = createDatabaseService(appContext, masterKey)
+                val databaseProvider: DatabaseProviderImpl = get()
                 coroutineScope.launch {
                     try {
-                        databaseService.migrateIfNeeded()
+                        val time = measureTime {
+                            databaseProvider.open(masterKey)
+                        }
+                        logger.info("Database is ready after {}", time)
                     } catch (e: DatabaseUpdateException) {
                         appStartupMonitor.reportUnexpectedAppStartupError("DB-${e.failedDatabaseUpdateVersion}")
                     } catch (e: DatabaseDowngradeException) {
@@ -341,49 +297,52 @@ class ThreemaApplication : Application() {
                 val identityStore = IdentityStoreImpl(mutableIdentityProvider, preferenceStore, encryptedPreferenceStore)
 
                 // Since the DB updates are kicked off on a different thread, we have to wait for them to start before we continue.
-                // Otherwise we might get race-conditions with other threads that might access the DB before the migration thread.
-                databaseService.databaseState.first { it != DatabaseState.INIT }
+                // Otherwise, we might get race-conditions with other threads that might access the DB before the migration thread.
+                databaseProvider.databaseState.first { it != DatabaseState.INIT }
 
                 // Note: the task manager should only be used to schedule tasks once the service manager is set
                 val coreServiceManager = createCoreServiceManager(
                     appContext,
-                    databaseService,
+                    databaseProvider,
                     preferenceStore,
                     encryptedPreferenceStore,
                     identityStore,
                 )
 
-                val modelRepositories = ModelRepositories(coreServiceManager)
+                val modelRepositories = ModelRepositories(
+                    coreServiceManager = coreServiceManager,
+                    identityProvider = identityProvider,
+                )
 
                 val systemUpdater = SystemUpdater(sharedPreferences)
                 val serviceManager = try {
                     ServiceManager(
+                        appContext,
                         modelRepositories,
                         dhSessionStore,
                         masterKeyManager.masterKeyProvider,
                         coreServiceManager,
-                        get(qualifier = Qualifiers.okHttpBase),
-                        getOrNull(),
                     )
                 } catch (e: ThreemaException) {
                     logger.error("Could not instantiate service manager", e)
                     appStartupMonitor.reportUnexpectedAppStartupError("SM-0")
                     return
                 }
-                runSystemUpdatesIfNeeded(
-                    appContext,
-                    systemUpdater,
-                    serviceManager,
-                    databaseService,
-                    appStartupMonitor,
-                )
 
                 masterKeyLockStateChangeHandler.onMasterKeyUnlocked(
                     serviceManager,
-                    databaseService.databaseState,
+                    databaseProvider.databaseState,
                     systemUpdater.systemUpdateState,
                 )
 
+                // The system updates must only be started after the service manager is set up,
+                // as some system updates may (indirectly) depend on it
+                runSystemUpdatesIfNeeded(
+                    systemUpdater,
+                    databaseProvider,
+                    appStartupMonitor,
+                )
+
                 startThreemaPushIfNeeded(appContext)
 
                 setDefaultPreferences(appContext, sharedPreferences)
@@ -408,7 +367,7 @@ class ThreemaApplication : Application() {
                 coroutineScope.launch {
                     appStartupMonitor.awaitSystem(AppSystem.DATABASE_UPDATES)
 
-                    markUploadingFilesAsFailed(databaseService)
+                    markUploadingFilesAsFailed(databaseService = get())
                     SessionWakeUpServiceImpl.getInstance().processPendingWakeupsAsync()
                     serviceManager.threemaSafeService.schedulePeriodicUpload()
                     scheduleWorkers(appContext, serviceManager.preferenceService, preferenceStore)
@@ -417,13 +376,6 @@ class ThreemaApplication : Application() {
                 coroutineScope.launch {
                     get<DraftManagerImpl>().init()
                 }
-
-                coroutineScope.launch {
-                    appStartupMonitor.awaitAll()
-
-                    // Unless the user specifically enabled it, we disable logging into the debug log file once the app has successfully started up
-                    get<DebugLogHelper>().disableDebugLogFileIfNeeded()
-                }
             } catch (e: MasterKeyLockedException) {
                 logger.error("Master key was unexpectedly locked during onMasterKeyUnlocked", e)
                 appStartupMonitor.reportUnexpectedAppStartupError("MK-L")
@@ -438,15 +390,13 @@ class ThreemaApplication : Application() {
         }
 
         private fun runSystemUpdatesIfNeeded(
-            context: Context,
             systemUpdater: SystemUpdater,
-            serviceManager: ServiceManager,
-            databaseService: DatabaseService,
+            databaseProvider: DatabaseProviderImpl,
             appStartupMonitor: AppStartupMonitorImpl,
         ) {
             val hasUpdates = systemUpdater.checkForUpdates(
-                systemUpdateProvider = SystemUpdateProvider(context, serviceManager),
-                initialVersion = getInitialSystemUpdateVersion(databaseService),
+                systemUpdateProvider = SystemUpdateProvider(),
+                initialVersion = getInitialSystemUpdateVersion(databaseProvider),
             )
             if (hasUpdates) {
                 coroutineScope.launch {
@@ -459,11 +409,11 @@ class ThreemaApplication : Application() {
             }
         }
 
-        private fun getInitialSystemUpdateVersion(databaseService: DatabaseService): Int? {
+        private fun getInitialSystemUpdateVersion(databaseProvider: DatabaseProviderImpl): Int? {
             // Until DB version 109, the system updates and database updates were treated as the same thing and as such shared a version number.
             // Now they are split up, with both update types having their own version number which is incremented independently and thus will
             // diverge over time.
-            return databaseService.oldVersion?.coerceAtMost(109)
+            return databaseProvider.oldVersion?.coerceAtMost(109)
         }
 
         private fun setUpDayNightMode(context: Context) {
@@ -477,14 +427,14 @@ class ThreemaApplication : Application() {
 
         private fun createCoreServiceManager(
             appContext: Context,
-            databaseService: DatabaseService,
+            databaseProvider: DatabaseProvider,
             preferenceStore: PreferenceStore,
             encryptedPreferenceStore: EncryptedPreferenceStore,
             identityStore: IdentityStoreImpl,
         ) =
             CoreServiceManagerImpl(
                 appVersion,
-                databaseService,
+                databaseProvider,
                 preferenceStore,
                 encryptedPreferenceStore,
                 identityStore,
@@ -530,25 +480,11 @@ class ThreemaApplication : Application() {
             if (preferenceStore.getBoolean(context.getString(R.string.preferences__direct_share))) {
                 ShareTargetUpdateWorker.scheduleShareTargetShortcutUpdate(context)
             }
-            AutoDeleteWorker.scheduleAutoDelete(context)
+            get<AutoDeleteWorker.Scheduler>().scheduleAutoDelete()
+            get<DebugLogHelper>().updateDebugLogFileDeletionSchedule()
             GatewayProfilePicturesWorker.schedulePeriodicSync(context)
         }
 
-        private fun createDatabaseService(
-            context: Context,
-            masterKey: MasterKey,
-        ): DatabaseService {
-            val databaseService = DatabaseService(
-                context = context,
-                password = masterKey.deriveDatabasePassword(),
-                onDatabaseCorrupted = {
-                    context.showToast("Database corrupted. Please restart your device and try again.", duration = LONG)
-                    exitProcess(2)
-                },
-            )
-            return databaseService
-        }
-
         private fun resolveMasterKeyDeactivationRaceCondition(
             context: Context,
             masterKeyManager: MasterKeyManager,

+ 47 - 45
app/src/main/java/ch/threema/app/activities/AddContactActivity.java

@@ -49,7 +49,9 @@ import ch.threema.app.qrcodes.ContactUrlUtil;
 import ch.threema.app.webclient.services.WebSessionQRCodeParser;
 import ch.threema.app.webclient.services.WebSessionQRCodeParserImpl;
 import ch.threema.base.utils.Base64;
+
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+
 import ch.threema.storage.models.ContactModel;
 
 import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
@@ -206,56 +208,56 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
             return;
         }
 
-        backgroundExecutor.execute(new BasicAddOrUpdateContactBackgroundTask(
-            identity,
-            ContactModel.AcquaintanceLevel.DIRECT,
-            dependencies.getUserService().getIdentity(),
-            dependencies.getApiConnector(),
-            dependencies.getContactModelRepository(),
-            AddContactRestrictionPolicy.CHECK,
-            this,
-            publicKey
-        ) {
-            @Override
-            public void onBefore() {
-                GenericProgressDialog.newInstance(R.string.creating_contact, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS);
-            }
-
-            @Override
-            public void onFinished(@NonNull ContactResult result) {
-                if (isDestroyed()) {
-                    return;
+        backgroundExecutor.execute(
+            new BasicAddOrUpdateContactBackgroundTask(
+                identity,
+                ContactModel.AcquaintanceLevel.DIRECT,
+                dependencies.getUserService().getIdentity(),
+                dependencies.getApiConnector(),
+                dependencies.getContactModelRepository(),
+                AddContactRestrictionPolicy.CHECK,
+                dependencies.getAppRestrictions(),
+                publicKey
+            ) {
+                @Override
+                public void onBefore() {
+                    GenericProgressDialog.newInstance(R.string.creating_contact, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS);
                 }
 
-                DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS, true);
+                @Override
+                public void onFinished(@NonNull ContactResult result) {
+                    if (isDestroyed()) {
+                        return;
+                    }
+
+                    DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS, true);
 
-                if (result instanceof ContactCreated) {
-                    showContactAndFinish(identity, R.string.creating_contact_successful);
-                } else if (result instanceof ContactModified) {
-                    if (((ContactModified) result).getAcquaintanceLevelChanged()) {
+                    if (result instanceof ContactCreated) {
                         showContactAndFinish(identity, R.string.creating_contact_successful);
-                    } else {
-                        showContactAndFinish(identity, R.string.scan_successful);
+                    } else if (result instanceof ContactModified) {
+                        if (((ContactModified) result).getAcquaintanceLevelChanged()) {
+                            showContactAndFinish(identity, R.string.creating_contact_successful);
+                        } else {
+                            showContactAndFinish(identity, R.string.scan_successful);
+                        }
+                    } else if (result instanceof AlreadyVerified) {
+                        showContactAndFinish(identity, R.string.scan_duplicate);
+                    } else if (result instanceof ContactExists) {
+                        showContactAndFinish(identity, R.string.identity_already_exists);
+                    } else if (result instanceof PolicyViolation) {
+                        Toast.makeText(AddContactActivity.this, R.string.disabled_by_policy_short, Toast.LENGTH_SHORT).show();
+                        finish();
+                    } else if (result instanceof Failed) {
+                        GenericAlertDialog.newInstance(
+                            ConfigUtils.isOnPremBuild() ? R.string.invalid_onprem_id_title : R.string.title_adduser,
+                            ((Failed) result).message,
+                            R.string.close,
+                            0
+                        ).show(getSupportFragmentManager(), DIALOG_TAG_ADD_ERROR);
                     }
-                } else if (result instanceof AlreadyVerified) {
-                    showContactAndFinish(identity, R.string.scan_duplicate);
-                } else if (result instanceof ContactExists) {
-                    showContactAndFinish(identity, R.string.identity_already_exists);
-                } else if (result instanceof PolicyViolation) {
-                    Toast.makeText(AddContactActivity.this, R.string.disabled_by_policy_short, Toast.LENGTH_SHORT).show();
-                    finish();
-                } else if (result instanceof Failed) {
-                    GenericAlertDialog.newInstance(
-                        ConfigUtils.isOnPremBuild() ?
-                            R.string.invalid_onprem_id_title :
-                            R.string.title_adduser,
-                        ((Failed) result).getMessage(),
-                        R.string.close,
-                        0
-                    ).show(getSupportFragmentManager(), DIALOG_TAG_ADD_ERROR);
                 }
             }
-        });
+        );
     }
 
     private void showContactDetail(String id) {
@@ -351,12 +353,12 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
     }
 
     @Override
-    public void onYes(String tag, Object data) {
+    public void onYes(@Nullable String tag, @Nullable Object data) {
         finish();
     }
 
     @Override
-    public void onNo(String tag, Object data) {
+    public void onNo(@Nullable String tag, @Nullable Object data) {
         finish();
     }
 

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

@@ -3,27 +3,31 @@ package ch.threema.app.activities;
 import android.content.Intent;
 import android.net.Uri;
 import android.os.Bundle;
-import android.widget.Toast;
 
 import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
+import androidx.activity.result.ActivityResultLauncher;
 import androidx.annotation.NonNull;
+import ch.threema.android.ToastDuration;
 import ch.threema.app.AppConstants;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
+import ch.threema.app.applock.CheckAppLockContract;
 import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
 import ch.threema.app.asynctasks.BasicAddOrUpdateContactBackgroundTask;
 import ch.threema.app.asynctasks.ContactAvailable;
 import ch.threema.app.asynctasks.ContactResult;
 import ch.threema.app.contactdetails.ContactDetailActivity;
 import ch.threema.app.di.DependencyContainer;
-import ch.threema.app.utils.HiddenChatUtil;
 import ch.threema.app.utils.executor.BackgroundExecutor;
+
+import static ch.threema.android.ToastKt.showToast;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 import ch.threema.storage.models.ContactModel;
 import kotlin.Lazy;
+import kotlin.Unit;
 
 import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
@@ -38,6 +42,16 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
     @NonNull
     private final Lazy<BackgroundExecutor> backgroundExecutor = lazy(BackgroundExecutor::new);
 
+    private final ActivityResultLauncher<Unit> checkLockToHandleIntentLauncher = registerForActivityResult(new CheckAppLockContract(), unlocked -> {
+        if (unlocked) {
+            dependencies.getLockAppService().unlock(null);
+            handleIntent();
+        } else {
+            showToast(this, R.string.pin_locked_cannot_send, ToastDuration.LONG);
+            finish();
+        }
+    });
+
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -63,7 +77,7 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
 
     private void checkLock() {
         if (dependencies.getLockAppService().isLocked()) {
-            HiddenChatUtil.launchLockCheckDialog(this, dependencies.getPreferenceService());
+            checkLockToHandleIntentLauncher.launch(Unit.INSTANCE);
         } else {
             handleIntent();
         }
@@ -89,10 +103,10 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
             } else if (threemaId.length() == ProtocolDefines.IDENTITY_LEN) {
                 addNewContactAndOpenChat(threemaId, appLinkData);
             } else {
-                Toast.makeText(this, R.string.invalid_input, Toast.LENGTH_LONG).show();
+                showToast(this, R.string.invalid_input, ToastDuration.LONG);
             }
         } else {
-            Toast.makeText(this, R.string.invalid_input, Toast.LENGTH_LONG).show();
+            showToast(this, R.string.invalid_input, ToastDuration.LONG);
         }
     }
 
@@ -102,21 +116,6 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
         overridePendingTransition(0, 0);
     }
 
-    @Override
-    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
-        if (requestCode == ThreemaActivity.ACTIVITY_ID_CHECK_LOCK) {
-            if (resultCode == RESULT_OK) {
-                dependencies.getLockAppService().unlock(null);
-                handleIntent();
-            } else {
-                Toast.makeText(this, R.string.pin_locked_cannot_send, Toast.LENGTH_LONG).show();
-                finish();
-            }
-        } else {
-            super.onActivityResult(requestCode, resultCode, data);
-        }
-    }
-
     private void addNewContactAndOpenChat(@NonNull String identity, @NonNull Uri appLinkData) {
         backgroundExecutor.getValue().execute(
             new BasicAddOrUpdateContactBackgroundTask(
@@ -126,7 +125,7 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
                 dependencies.getApiConnector(),
                 dependencies.getContactModelRepository(),
                 AddContactRestrictionPolicy.CHECK,
-                AppLinksActivity.this,
+                dependencies.getAppRestrictions(),
                 null
             ) {
                 @Override

+ 24 - 23
app/src/main/java/ch/threema/app/activities/BackupAdminActivity.java

@@ -1,15 +1,17 @@
 package ch.threema.app.activities;
 
-import static ch.threema.app.preference.service.PreferenceService.LockingMech_NONE;
+import static ch.threema.app.preference.service.PreferenceService.LOCKING_MECH_NONE;
 import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
 
+import android.content.Context;
 import android.content.Intent;
 import android.os.Bundle;
 import android.view.MenuItem;
 import android.view.View;
 import android.widget.TextView;
 
+import androidx.activity.result.ActivityResultLauncher;
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.ActionBar;
 import androidx.fragment.app.Fragment;
@@ -25,14 +27,15 @@ import org.slf4j.Logger;
 import java.time.Instant;
 
 import ch.threema.app.R;
+import ch.threema.app.applock.CheckAppLockContract;
 import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.fragments.BackupDataFragment;
 import ch.threema.app.threemasafe.BackupThreemaSafeFragment;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.utils.AnimationUtil;
-import ch.threema.app.restrictions.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.HiddenChatUtil;
+import kotlin.Unit;
+
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 public class BackupAdminActivity extends ThreemaToolbarActivity {
@@ -46,6 +49,14 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
     private boolean isUnlocked;
     private ThreemaSafeMDMConfig safeConfig;
 
+    private final ActivityResultLauncher<Unit> checkLockLauncher = registerForActivityResult(new CheckAppLockContract(), unlocked -> {
+        if (unlocked) {
+            isUnlocked = true;
+        } else {
+            finish();
+        }
+    });
+
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -57,12 +68,12 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
         isUnlocked = false;
         safeConfig = ThreemaSafeMDMConfig.getInstance();
 
-        if (AppRestrictionUtil.isBackupsDisabled(this)) {
+        if (dependencies.getAppRestrictions().isBackupsDisabled()) {
             this.finish();
             return;
         }
 
-        if (AppRestrictionUtil.isDataBackupsDisabled(this) && threemaSafeUIDisabled()) {
+        if (dependencies.getAppRestrictions().isDataBackupsDisabled() && threemaSafeUIDisabled()) {
             this.finish();
             return;
         }
@@ -107,10 +118,8 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
     protected void onResume() {
         super.onResume();
 
-        if (!isUnlocked) {
-            if (!dependencies.getPreferenceService().getLockMechanism().equals(LockingMech_NONE)) {
-                HiddenChatUtil.launchLockCheckDialog(this, dependencies.getPreferenceService());
-            }
+        if (!isUnlocked && !dependencies.getPreferenceService().getLockMechanism().equals(LOCKING_MECH_NONE)) {
+            checkLockLauncher.launch(Unit.INSTANCE);
         }
     }
 
@@ -118,19 +127,6 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
         return R.layout.activity_backup_admin;
     }
 
-    @Override
-    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
-        super.onActivityResult(requestCode, resultCode, data);
-
-        if (requestCode == ThreemaActivity.ACTIVITY_ID_CHECK_LOCK) {
-            if (resultCode == RESULT_OK) {
-                isUnlocked = true;
-            } else {
-                finish();
-            }
-        }
-    }
-
     @Override
     public boolean onOptionsItemSelected(@NonNull MenuItem item) {
         if (item.getItemId() == android.R.id.home) {
@@ -144,7 +140,7 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
     }
 
     private boolean dataBackupUIDisabled() {
-        return AppRestrictionUtil.isDataBackupsDisabled(this);
+        return dependencies.getAppRestrictions().isDataBackupsDisabled();
     }
 
     @Override
@@ -187,4 +183,9 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
             return null;
         }
     }
+
+    @NonNull
+    public static Intent createIntent(@NonNull Context context) {
+        return new Intent(context, BackupAdminActivity.class);
+    }
 }

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

@@ -6,7 +6,7 @@ import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.utils.logScreenVisibility
 import ch.threema.base.utils.getThreemaLogger
-import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
 
 private val logger = getThreemaLogger("BlockedIdentitiesActivity")
 
@@ -24,11 +24,11 @@ class BlockedIdentitiesActivity : IdentityListActivity() {
                 return blockedIdentitiesService.getAllBlockedIdentities()
             }
 
-            override fun addIdentity(identity: Identity) {
+            override fun addIdentity(identity: IdentityString) {
                 blockedIdentitiesService.blockIdentity(identity)
             }
 
-            override fun removeIdentity(identity: Identity) {
+            override fun removeIdentity(identity: IdentityString) {
                 blockedIdentitiesService.unblockIdentity(identity)
             }
         }

+ 0 - 281
app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java

@@ -1,281 +0,0 @@
-package ch.threema.app.activities;
-
-import android.content.Intent;
-import android.content.res.Configuration;
-import android.os.Bundle;
-import android.view.WindowManager;
-import android.widget.FrameLayout;
-
-import androidx.annotation.NonNull;
-import androidx.fragment.app.FragmentManager;
-
-import org.koin.java.KoinJavaComponent;
-import org.slf4j.Logger;
-
-import ch.threema.app.R;
-import ch.threema.app.di.DependencyContainer;
-import ch.threema.app.dialogs.GenericAlertDialog;
-import ch.threema.app.fragments.ComposeMessageFragment;
-import ch.threema.app.fragments.ConversationsFragment;
-import ch.threema.app.messagereceiver.MessageReceiver;
-import ch.threema.app.preference.SettingsActivity;
-import ch.threema.app.ui.InsetSides;
-import ch.threema.app.ui.ViewExtensionsKt;
-import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.HiddenChatUtil;
-import ch.threema.app.utils.IntentDataUtil;
-import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
-
-import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
-import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
-
-public class ComposeMessageActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener {
-    private static final Logger logger = getThreemaLogger("ComposeMessageActivity");
-
-    private static final int ID_HIDDEN_CHECK_ON_NEW_INTENT = 9291;
-    private static final int ID_HIDDEN_CHECK_ON_CREATE = 9292;
-    private static final String DIALOG_TAG_HIDDEN_NOTICE = "hidden";
-
-    @NonNull
-    private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
-
-    private ComposeMessageFragment composeMessageFragment;
-    private ConversationsFragment conversationsFragment;
-
-    private Intent currentIntent;
-    private int savedSoftInputMode;
-
-    private final String COMPOSE_FRAGMENT_TAG = "compose_message_fragment";
-    private final String MESSAGES_FRAGMENT_TAG = "message_section_fragment";
-
-    @Override
-    public void onCreate(Bundle savedInstanceState) {
-        logger.info("onCreate");
-
-        getWindow().setAllowEnterTransitionOverlap(true);
-        getWindow().setAllowReturnTransitionOverlap(true);
-        this.currentIntent = getIntent();
-        super.onCreate(savedInstanceState);
-        logScreenVisibility(this, logger);
-        if (finishAndRestartLaterIfNotReady(this)) {
-            return;
-        }
-
-        ViewExtensionsKt.applyDeviceInsetsAsPadding(
-            findViewById(R.id.appbar),
-            InsetSides.ltr()
-        );
-    }
-
-    @Override
-    protected boolean initActivity(Bundle savedInstanceState) {
-        if (!super.initActivity(savedInstanceState)) {
-            return false;
-        }
-
-        logger.info("initActivity");
-
-        this.getFragments();
-
-        if (findViewById(R.id.messages) != null) {
-            // add messages fragment in tablet layout
-            if (conversationsFragment == null) {
-                conversationsFragment = new ConversationsFragment();
-                getSupportFragmentManager().beginTransaction().add(R.id.messages, conversationsFragment, MESSAGES_FRAGMENT_TAG).commit();
-            }
-        }
-
-        boolean isHidden = checkHiddenChatLock(getIntent(), ID_HIDDEN_CHECK_ON_CREATE);
-        if (composeMessageFragment == null) {
-            // fragment no longer around
-            composeMessageFragment = new ComposeMessageFragment();
-            if (isHidden) {
-                getSupportFragmentManager().beginTransaction().add(R.id.compose, composeMessageFragment, COMPOSE_FRAGMENT_TAG).hide(composeMessageFragment).commit();
-            } else {
-                getSupportFragmentManager().beginTransaction().add(R.id.compose, composeMessageFragment, COMPOSE_FRAGMENT_TAG).commit();
-            }
-        } else {
-            if (!isHidden) {
-                getSupportFragmentManager().beginTransaction().show(composeMessageFragment).commit();
-            }
-        }
-        return true;
-    }
-
-    @Override
-    public int getLayoutResource() {
-        return ConfigUtils.isTabletLayout(this) ? R.layout.activity_compose_message_tablet : R.layout.activity_compose_message;
-    }
-
-    private void getFragments() {
-        FragmentManager fragmentManager = getSupportFragmentManager();
-        composeMessageFragment = (ComposeMessageFragment) fragmentManager.findFragmentByTag(COMPOSE_FRAGMENT_TAG);
-        conversationsFragment = (ConversationsFragment) fragmentManager.findFragmentByTag(MESSAGES_FRAGMENT_TAG);
-    }
-
-    @Override
-    public void onNewIntent(@NonNull Intent intent) {
-        logger.info("onNewIntent");
-
-        super.onNewIntent(intent);
-
-        this.currentIntent = intent;
-
-        this.getFragments();
-
-        if (composeMessageFragment != null) {
-            if (!checkHiddenChatLock(intent, ID_HIDDEN_CHECK_ON_NEW_INTENT)) {
-                getSupportFragmentManager().beginTransaction().show(composeMessageFragment).commit();
-                composeMessageFragment.onNewIntent(intent);
-            }
-        }
-    }
-
-    @Override
-    protected boolean enableOnBackPressedCallback() {
-        return true;
-    }
-
-    @Override
-    protected void handleOnBackPressed() {
-        logger.info("handleOnBackPressed");
-        if (ConfigUtils.isTabletLayout()) {
-            if (conversationsFragment != null) {
-                if (conversationsFragment.onBackPressed()) {
-                    return;
-                }
-            }
-        }
-        if (composeMessageFragment != null) {
-            if (!composeMessageFragment.onBackPressed()) {
-                finish();
-                if (ConfigUtils.isTabletLayout()) {
-                    overridePendingTransition(0, 0);
-                }
-            }
-            return;
-        }
-        finish();
-    }
-
-    @Override
-    public void onDestroy() {
-        logger.debug("onDestroy");
-        super.onDestroy();
-    }
-
-    @Override
-    public void onStop() {
-        logger.info("onStop");
-        super.onStop();
-    }
-
-    @Override
-    public void onResume() {
-        logger.info("onResume");
-        super.onResume();
-
-        // Set the soft input mode to resize when activity resumes because it is set to adjust nothing while it is paused
-        savedSoftInputMode = getWindow().getAttributes().softInputMode;
-        getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_UNCHANGED | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
-    }
-
-    @Override
-    public void onPause() {
-        logger.info("onPause");
-        super.onPause();
-
-        // Set the soft input mode to adjust nothing while paused. This is needed when the keyboard is opened to edit the contact before sending.
-        if (savedSoftInputMode > 0) {
-            getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_UNCHANGED | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING);
-        }
-    }
-
-    @Override
-    public void onActivityResult(int requestCode, int resultCode, final Intent intent) {
-        switch (requestCode) {
-            case ID_HIDDEN_CHECK_ON_CREATE:
-                super.onActivityResult(requestCode, resultCode, intent);
-
-                if (resultCode == RESULT_OK) {
-                    if (composeMessageFragment != null) {
-                        getSupportFragmentManager().beginTransaction().show(composeMessageFragment).commit();
-                        // mark conversation as read as soon as it's unhidden
-                        composeMessageFragment.markAsRead();
-                    }
-                } else {
-                    finish();
-                }
-                break;
-            case ID_HIDDEN_CHECK_ON_NEW_INTENT:
-                super.onActivityResult(requestCode, resultCode, intent);
-
-                if (resultCode == RESULT_OK) {
-                    if (composeMessageFragment != null) {
-                        getSupportFragmentManager().beginTransaction().show(composeMessageFragment).commit();
-                        composeMessageFragment.onNewIntent(this.currentIntent);
-                    }
-                } else {
-                    if (!ConfigUtils.isTabletLayout()) {
-                        finish();
-                    }
-                }
-                break;
-            default:
-                super.onActivityResult(requestCode, resultCode, intent);
-
-                // required for result of qr code scanner
-                if (composeMessageFragment != null) {
-                    composeMessageFragment.onActivityResult(requestCode, resultCode, intent);
-                }
-        }
-    }
-
-    private boolean checkHiddenChatLock(Intent intent, int requestCode) {
-        MessageReceiver<?> messageReceiver = IntentDataUtil.getMessageReceiverFromIntent(getApplicationContext(), intent);
-
-        if (messageReceiver == null) {
-            logger.info("Intent does not have any extras. Check \"Don't keep activities\" option in developer settings.");
-            return false;
-        }
-
-        if (dependencies.getConversationCategoryService().isPrivateChat(messageReceiver.getUniqueIdString())) {
-            if (ConfigUtils.hasProtection(dependencies.getPreferenceService())) {
-                HiddenChatUtil.launchLockCheckDialog(this, null, dependencies.getPreferenceService(), requestCode);
-            } else {
-                GenericAlertDialog.newInstance(R.string.hide_chat, R.string.hide_chat_enter_message_explain, R.string.set_lock, R.string.cancel).show(getSupportFragmentManager(), DIALOG_TAG_HIDDEN_NOTICE);
-            }
-            return true;
-        }
-        return false;
-    }
-
-    @Override
-    public void onConfigurationChanged(@NonNull Configuration newConfig) {
-        super.onConfigurationChanged(newConfig);
-
-        ConfigUtils.adjustToolbar(this, getToolbar());
-
-        FrameLayout messagesLayout = findViewById(R.id.messages);
-
-        if (messagesLayout != null) {
-            // adjust width of messages fragment in tablet layout
-            FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) messagesLayout.getLayoutParams();
-            layoutParams.width = getResources().getDimensionPixelSize(R.dimen.message_fragment_width);
-            messagesLayout.setLayoutParams(layoutParams);
-        }
-    }
-
-    @Override
-    public void onYes(String tag, Object data) {
-        Intent intent = new Intent(this, SettingsActivity.class);
-        intent.putExtra(SettingsActivity.EXTRA_SHOW_SECURITY_FRAGMENT, true);
-        startActivity(intent);
-        finish();
-    }
-
-    @Override
-    public void onNo(String tag, Object data) {
-        finish();
-    }
-}

+ 288 - 0
app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.kt

@@ -0,0 +1,288 @@
+package ch.threema.app.activities
+
+import android.content.Context
+import android.content.Intent
+import android.content.res.Configuration
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import android.widget.FrameLayout
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.launch
+import ch.threema.android.buildActivityIntent
+import ch.threema.android.buildBundle
+import ch.threema.android.disableExitTransition
+import ch.threema.android.runTransaction
+import ch.threema.app.AppConstants
+import ch.threema.app.R
+import ch.threema.app.applock.CheckAppLockContract
+import ch.threema.app.dialogs.GenericAlertDialog
+import ch.threema.app.dialogs.GenericAlertDialog.DialogClickListener
+import ch.threema.app.fragments.composemessage.ComposeMessageFragment
+import ch.threema.app.fragments.conversations.ConversationsFragment
+import ch.threema.app.preference.SettingsActivity
+import ch.threema.app.preference.service.PreferenceService
+import ch.threema.app.services.ConversationCategoryService
+import ch.threema.app.startup.AppStartupAware
+import ch.threema.app.startup.waitUntilReady
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.ConversationUtil.getContactConversationUid
+import ch.threema.app.utils.ConversationUtil.getDistributionListConversationUid
+import ch.threema.app.utils.ConversationUtil.getGroupConversationUid
+import ch.threema.app.utils.IntentDataUtil
+import ch.threema.app.utils.logScreenVisibility
+import ch.threema.base.utils.getThreemaLogger
+import ch.threema.domain.models.ContactReceiverIdentifier
+import ch.threema.domain.models.DistributionListReceiverIdentifier
+import ch.threema.domain.models.GroupReceiverIdentifier
+import ch.threema.domain.models.ReceiverIdentifier
+import org.koin.android.ext.android.inject
+
+private val logger = getThreemaLogger("ComposeMessageActivity")
+
+class ComposeMessageActivity : ThreemaToolbarActivity(), DialogClickListener, AppStartupAware {
+    init {
+        logScreenVisibility(logger)
+    }
+
+    private val conversationCategoryService: ConversationCategoryService by inject()
+    private val preferenceService: PreferenceService by inject()
+
+    private var composeMessageFragment: ComposeMessageFragment? = null
+    private var conversationsFragment: ConversationsFragment? = null
+
+    private var currentIntent: Intent? = null
+    private var savedSoftInputMode = 0
+
+    private val checkLockOnCreateLauncher = registerForActivityResult(CheckAppLockContract()) { unlocked ->
+        if (unlocked) {
+            composeMessageFragment?.let { fragment ->
+                supportFragmentManager.runTransaction {
+                    show(fragment)
+                }
+                // mark conversation as read as soon as it's unhidden
+                fragment.markAsRead()
+            }
+        } else {
+            finish()
+        }
+    }
+    private val checkLockOnNewIntentLauncher = registerForActivityResult(CheckAppLockContract()) { unlocked ->
+        if (unlocked) {
+            composeMessageFragment?.let { fragment ->
+                supportFragmentManager.runTransaction {
+                    show(fragment)
+                }
+                fragment.onNewIntent(this.currentIntent)
+            }
+        } else if (!ConfigUtils.isTabletLayout()) {
+            finish()
+        }
+    }
+
+    public override fun onCreate(savedInstanceState: Bundle?) {
+        logger.info("onCreate")
+        window.setAllowEnterTransitionOverlap(true)
+        window.setAllowReturnTransitionOverlap(true)
+        currentIntent = intent
+
+        super.onCreate(savedInstanceState)
+
+        // TODO(ANDR-4389): Improve the waiting mechanism
+        waitUntilReady {
+            initActivity(savedInstanceState)
+            handleDeviceInsets()
+        }
+    }
+
+    override fun initActivity(savedInstanceState: Bundle?): Boolean {
+        if (!super.initActivity(savedInstanceState)) {
+            return false
+        }
+        logger.info("initActivity")
+
+        findExistingFragments()
+
+        if (findViewById<View>(R.id.messages) != null && conversationsFragment == null) {
+            // add messages fragment in tablet layout
+            conversationsFragment = ConversationsFragment()
+            getConversationUidFromIntent(intent)?.let { conversationUID ->
+                conversationsFragment!!.setArguments(
+                    buildBundle {
+                        putString(ConversationsFragment.OPENED_CONVERSATION_UID, conversationUID)
+                    },
+                )
+            }
+            supportFragmentManager.runTransaction {
+                add(R.id.messages, conversationsFragment!!, MESSAGES_FRAGMENT_TAG)
+            }
+        }
+
+        val isHidden = checkHiddenChatLock(intent, checkLockOnCreateLauncher)
+        if (composeMessageFragment == null) {
+            composeMessageFragment = ComposeMessageFragment()
+            if (isHidden) {
+                supportFragmentManager.runTransaction {
+                    add(R.id.compose, composeMessageFragment!!, COMPOSE_FRAGMENT_TAG)
+                    hide(composeMessageFragment!!)
+                }
+            } else {
+                supportFragmentManager.runTransaction {
+                    add(R.id.compose, composeMessageFragment!!, COMPOSE_FRAGMENT_TAG)
+                }
+            }
+        } else if (!isHidden) {
+            supportFragmentManager.runTransaction {
+                show(composeMessageFragment!!)
+            }
+        }
+        return true
+    }
+
+    private fun getConversationUidFromIntent(intent: Intent?): String? {
+        val identity = IntentDataUtil.getIdentity(intent)
+        if (identity != null) {
+            return getContactConversationUid(identity)
+        }
+        val groupDbId = IntentDataUtil.getGroupId(intent)
+        if (groupDbId != -1L) {
+            return getGroupConversationUid(groupDbId)
+        }
+        val distributionListId = IntentDataUtil.getDistributionListId(intent)
+        if (distributionListId != -1L) {
+            return getDistributionListConversationUid(distributionListId)
+        }
+        return null
+    }
+
+    override fun getLayoutResource() = if (ConfigUtils.isTabletLayout(this)) {
+        R.layout.activity_compose_message_tablet
+    } else {
+        R.layout.activity_compose_message
+    }
+
+    private fun findExistingFragments() {
+        composeMessageFragment = supportFragmentManager.findFragmentByTag(COMPOSE_FRAGMENT_TAG) as ComposeMessageFragment?
+        conversationsFragment = supportFragmentManager.findFragmentByTag(MESSAGES_FRAGMENT_TAG) as ConversationsFragment?
+    }
+
+    public override fun onNewIntent(intent: Intent) {
+        logger.info("onNewIntent")
+
+        super.onNewIntent(intent)
+
+        this.currentIntent = intent
+
+        findExistingFragments()
+
+        composeMessageFragment?.let { composeMessageFragment ->
+            if (!checkHiddenChatLock(intent, checkLockOnNewIntentLauncher)) {
+                supportFragmentManager.runTransaction {
+                    show(composeMessageFragment)
+                }
+                composeMessageFragment.onNewIntent(intent)
+            }
+        }
+    }
+
+    override fun enableOnBackPressedCallback() = true
+
+    override fun handleOnBackPressed() {
+        logger.info("handleOnBackPressed")
+        if (ConfigUtils.isTabletLayout() && conversationsFragment?.onBackPressed() == true) {
+            return
+        }
+        composeMessageFragment?.let { composeMessageFragment ->
+            if (!composeMessageFragment.onBackPressed()) {
+                finish()
+                if (ConfigUtils.isTabletLayout()) {
+                    disableExitTransition()
+                }
+            }
+            return
+        }
+        finish()
+    }
+
+    public override fun onResume() {
+        super.onResume()
+
+        // Set the soft input mode to resize when activity resumes because it is set to adjust nothing while it is paused
+        savedSoftInputMode = window.attributes.softInputMode
+        window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_UNCHANGED or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
+    }
+
+    public override fun onPause() {
+        super.onPause()
+
+        // Set the soft input mode to adjust nothing while paused. This is needed when the keyboard is opened to edit the contact before sending.
+        if (savedSoftInputMode > 0) {
+            window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_UNCHANGED or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
+        }
+    }
+
+    private fun checkHiddenChatLock(intent: Intent?, launcher: ActivityResultLauncher<Unit>): Boolean {
+        val messageReceiver = IntentDataUtil.getMessageReceiverFromIntent(applicationContext, intent)
+
+        if (messageReceiver == null) {
+            logger.info("Intent does not have any extras. Check \"Don't keep activities\" option in developer settings.")
+            return false
+        }
+
+        if (conversationCategoryService.isPrivateChat(messageReceiver.uniqueIdString)) {
+            if (ConfigUtils.hasProtection(preferenceService)) {
+                launcher.launch()
+            } else {
+                GenericAlertDialog.newInstance(R.string.hide_chat, R.string.hide_chat_enter_message_explain, R.string.set_lock, R.string.cancel)
+                    .show(supportFragmentManager, DIALOG_TAG_HIDDEN_NOTICE)
+            }
+            return true
+        }
+        return false
+    }
+
+    override fun onConfigurationChanged(newConfig: Configuration) {
+        super.onConfigurationChanged(newConfig)
+
+        ConfigUtils.adjustToolbar(this, toolbar)
+
+        val messagesLayout = findViewById<FrameLayout?>(R.id.messages)
+
+        if (messagesLayout != null) {
+            // adjust width of messages fragment in tablet layout
+            val layoutParams = messagesLayout.layoutParams as FrameLayout.LayoutParams
+            layoutParams.width = resources.getDimensionPixelSize(R.dimen.message_fragment_width)
+            messagesLayout.setLayoutParams(layoutParams)
+        }
+    }
+
+    override fun onYes(tag: String?, data: Any?) {
+        startActivity(SettingsActivity.createIntent(this, SettingsActivity.InitialScreen.SECURITY))
+        finish()
+    }
+
+    override fun onNo(tag: String?, data: Any?) {
+        finish()
+    }
+
+    companion object {
+        private const val COMPOSE_FRAGMENT_TAG = "compose_message_fragment"
+        private const val MESSAGES_FRAGMENT_TAG = "message_section_fragment"
+
+        private const val DIALOG_TAG_HIDDEN_NOTICE = "hidden"
+
+        fun createIntent(context: Context, receiverIdentifier: ReceiverIdentifier) = buildActivityIntent<ComposeMessageActivity>(context) {
+            when (receiverIdentifier) {
+                is GroupReceiverIdentifier -> {
+                    putExtra(AppConstants.INTENT_DATA_GROUP_DATABASE_ID, receiverIdentifier.groupDatabaseId)
+                }
+                is DistributionListReceiverIdentifier -> {
+                    putExtra(AppConstants.INTENT_DATA_DISTRIBUTION_LIST_ID, receiverIdentifier.id)
+                }
+                is ContactReceiverIdentifier -> {
+                    putExtra(AppConstants.INTENT_DATA_CONTACT, receiverIdentifier.identity)
+                }
+            }
+        }
+    }
+}

+ 21 - 13
app/src/main/java/ch/threema/app/activities/DirectoryActivity.java

@@ -2,6 +2,7 @@ package ch.threema.app.activities;
 
 import android.animation.LayoutTransition;
 import android.annotation.SuppressLint;
+import android.content.Context;
 import android.content.Intent;
 import android.content.res.Configuration;
 import android.net.Uri;
@@ -56,9 +57,9 @@ import ch.threema.app.ui.ThreemaSearchView;
 import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.executor.BackgroundExecutor;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+
 import ch.threema.data.models.ContactModel;
 import ch.threema.domain.protocol.api.work.WorkDirectoryCategory;
 import ch.threema.domain.protocol.api.work.WorkDirectoryContact;
@@ -238,7 +239,13 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 
         categoryList = dependencies.getPreferenceService().getWorkDirectoryCategories();
 
-        directoryAdapter = new DirectoryAdapter(this, dependencies.getPreferenceService(), dependencies.getContactService(), categoryList);
+        directoryAdapter = new DirectoryAdapter(
+            this,
+            dependencies.getPreferenceService(),
+            dependencies.getContactService(),
+            dependencies.getUserService(),
+            categoryList
+        );
         directoryAdapter.setOnClickItemListener(new DirectoryAdapter.OnClickItemListener() {
             @Override
             public void onClick(WorkDirectoryContact workDirectoryContact, int position) {
@@ -390,19 +397,15 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
     }
 
     private void launchContact(@NonNull final WorkDirectoryContact workDirectoryContact, final int position) {
-        if (workDirectoryContact.threemaId != null) {
-            if (dependencies.getContactService().getByIdentity(workDirectoryContact.threemaId) == null) {
-                addContact(workDirectoryContact, () -> {
-                    openContact(workDirectoryContact.threemaId);
-                    directoryAdapter.notifyItemChanged(position);
-                });
-            } else if (workDirectoryContact.threemaId.equalsIgnoreCase(dependencies.getContactService().getMe().getIdentity())) {
-                Toast.makeText(this, R.string.me_myself_and_i, Toast.LENGTH_LONG).show();
-            } else {
+        if (dependencies.getContactService().getByIdentity(workDirectoryContact.threemaId) == null) {
+            addContact(workDirectoryContact, () -> {
                 openContact(workDirectoryContact.threemaId);
-            }
+                directoryAdapter.notifyItemChanged(position);
+            });
+        } else if (workDirectoryContact.threemaId.equalsIgnoreCase(dependencies.getUserService().getIdentity())) {
+            Toast.makeText(this, R.string.me_myself_and_i, Toast.LENGTH_LONG).show();
         } else {
-            Toast.makeText(this, R.string.contact_not_found, Toast.LENGTH_LONG).show();
+            openContact(workDirectoryContact.threemaId);
         }
     }
 
@@ -626,4 +629,9 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
             overridePendingTransition(R.anim.slide_in_left_short, R.anim.slide_out_right_short);
         }
     }
+
+    @NonNull
+    public static Intent createIntent(@NonNull Context context) {
+        return new Intent(context, DirectoryActivity.class);
+    }
 }

+ 15 - 4
app/src/main/java/ch/threema/app/activities/DisableBatteryOptimizationsActivity.java

@@ -1,5 +1,6 @@
 package ch.threema.app.activities;
 
+import android.content.Context;
 import android.content.Intent;
 import android.content.res.Configuration;
 import android.net.Uri;
@@ -23,7 +24,6 @@ import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.utils.ConfigUtils;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
-import static ch.threema.app.fragments.BackupDataFragment.REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS;
 import static ch.threema.app.utils.PowermanagerUtil.isIgnoringBatteryOptimizations;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
 
@@ -36,6 +36,7 @@ import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
 public class DisableBatteryOptimizationsActivity extends ThreemaActivity implements GenericAlertDialog.DialogClickListener {
     private static final Logger logger = getThreemaLogger("DisableBatteryOptimizationsActivity");
 
+    private static final int REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS = 441;
     private static final int REQUEST_CODE_IGNORE_BATTERY_OPTIMIZATIONS = 778;
     private static final String DIALOG_TAG_DISABLE_BATTERY_OPTIMIZATIONS = "des";
     private static final String DIALOG_TAG_MIUI_WARNING = "miui";
@@ -123,7 +124,10 @@ public class DisableBatteryOptimizationsActivity extends ThreemaActivity impleme
     }
 
     @Override
-    public void onYes(String tag, Object data) {
+    public void onYes(@Nullable String tag, @Nullable Object data) {
+        if (tag == null) {
+            return;
+        }
         switch (tag) {
             case DIALOG_TAG_DISABLE_BATTERY_OPTIMIZATIONS:
                 launchDisableFlow();
@@ -187,8 +191,8 @@ public class DisableBatteryOptimizationsActivity extends ThreemaActivity impleme
     }
 
     @Override
-    public void onNo(String tag, Object data) {
-        if (tag.equals(DIALOG_TAG_DISABLE_BATTERY_OPTIMIZATIONS)) {
+    public void onNo(@Nullable String tag, @Nullable Object data) {
+        if (tag != null && tag.equals(DIALOG_TAG_DISABLE_BATTERY_OPTIMIZATIONS)) {
             // The user wants to continue without disabling battery optimizations
             setResult(RESULT_OK);
             finish();
@@ -239,4 +243,11 @@ public class DisableBatteryOptimizationsActivity extends ThreemaActivity impleme
         super.finish();
         overridePendingTransition(0, 0);
     }
+
+    @NonNull
+    public static Intent createIntent(@NonNull Context context, @NonNull String name) {
+        Intent intent = new Intent(context, DisableBatteryOptimizationsActivity.class);
+        intent.putExtra(EXTRA_NAME, name);
+        return intent;
+    }
 }

+ 13 - 11
app/src/main/java/ch/threema/app/activities/DistributionListAddActivity.kt

@@ -6,17 +6,17 @@ import android.os.Bundle
 import androidx.annotation.MainThread
 import androidx.annotation.StringRes
 import ch.threema.android.buildActivityIntent
+import ch.threema.android.showToast
 import ch.threema.app.AppConstants
 import ch.threema.app.R
 import ch.threema.app.dialogs.TextEntryDialog
 import ch.threema.app.dialogs.TextEntryDialog.TextEntryDialogClickListener
 import ch.threema.app.services.DistributionListService
 import ch.threema.app.ui.SingleToast
-import ch.threema.app.utils.LogUtil
 import ch.threema.app.utils.RuntimeUtil
 import ch.threema.app.utils.logScreenVisibility
 import ch.threema.base.utils.getThreemaLogger
-import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
 import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.DistributionListModel
 import org.koin.android.ext.android.inject
@@ -97,23 +97,24 @@ class DistributionListAddActivity : MemberChooseActivity(), TextEntryDialogClick
             distributionListName,
             0,
             TextEntryDialog.INPUT_FILTER_TYPE_NONE,
-            DistributionListModel.DISTRIBUTIONLIST_NAME_MAX_LENGTH_BYTES,
+            DistributionListModel.DISTRIBUTION_LIST_NAME_MAX_LENGTH_BYTES,
         ).show(supportFragmentManager, DIALOG_TAG_ENTER_NAME)
     }
 
     private fun launchComposeActivity() {
-        val intent = Intent(this@DistributionListAddActivity, ComposeMessageActivity::class.java)
-        intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
-        distributionListService.createReceiver(distributionListModel).prepareIntent(intent)
-
-        startActivity(intent)
-        finish()
+        distributionListModel?.let { distributionListModelNotNull ->
+            val intent = Intent(this@DistributionListAddActivity, ComposeMessageActivity::class.java)
+            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+            distributionListService.createReceiver(distributionListModelNotNull).prepareIntent(intent)
+            startActivity(intent)
+            finish()
+        }
     }
 
     // Callback from dialog "Edit distribution list - Choose a name for this list"
     override fun onYes(tag: String, text: String) {
         try {
-            val selectedContactIdentities: Array<Identity> = selectedContacts.map(ContactModel::identity).toTypedArray()
+            val selectedContactIdentities: Array<IdentityString> = selectedContacts.map(ContactModel::identity).toTypedArray()
 
             if (isEdit) {
                 if (selectedContactIdentities.isNotEmpty()) {
@@ -133,7 +134,8 @@ class DistributionListAddActivity : MemberChooseActivity(), TextEntryDialogClick
                 this.launchComposeActivity()
             }
         } catch (e: Exception) {
-            LogUtil.exception(e, this@DistributionListAddActivity)
+            logger.error("Failed to edit distribution list", e)
+            showToast(R.string.an_error_occurred)
         }
     }
 

+ 7 - 8
app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java

@@ -38,7 +38,6 @@ import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.services.license.LicenseServiceUser;
 import ch.threema.domain.models.SerialCredentials;
 import ch.threema.domain.models.UserCredentials;
-import ch.threema.app.restrictions.AppRestrictionUtil;
 import ch.threema.app.ui.InsetSides;
 import ch.threema.app.ui.SimpleTextWatcher;
 import ch.threema.app.ui.SpacingValues;
@@ -440,11 +439,11 @@ public class EnterSerialActivity extends ThreemaActivity {
     @SuppressLint("StaticFieldLeak")
     private void check(final LicenseCredentials credentials, String onPremServer) {
         if (ConfigUtils.isOnPremBuild()) {
-            if (onPremServer != null) {
-                onPremServer = getUrlToOppf(onPremServer);
-            }
+            var oppfUrl = onPremServer != null
+                ? getUrlToOppf(onPremServer)
+                : null;
             var preferenceService = dependencies.getPreferenceService();
-            preferenceService.setOnPremServer(onPremServer);
+            preferenceService.setOppfUrl(oppfUrl);
             preferenceService.setLicenseUsername(((UserCredentials) credentials).username);
             preferenceService.setLicensePassword(((UserCredentials) credentials).password);
         }
@@ -612,7 +611,7 @@ public class EnterSerialActivity extends ThreemaActivity {
      */
     @Nullable
     private String getConfiguredUsername() {
-        return AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__license_username));
+        return dependencies.getAppRestrictions().getLicenseUsername();
     }
 
     /**
@@ -620,7 +619,7 @@ public class EnterSerialActivity extends ThreemaActivity {
      */
     @Nullable
     private String getConfiguredPassword() {
-        return AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__license_password));
+        return dependencies.getAppRestrictions().getLicensePassword();
     }
 
     /**
@@ -636,7 +635,7 @@ public class EnterSerialActivity extends ThreemaActivity {
 
         final String serverUrl = ConfigUtils.isWhitelabelOnPremBuild(this)
             ? getPresetOnPremServerUrlIfWhiteLabeled()
-            : AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__onprem_server));
+            : dependencies.getAppRestrictions().getOnPremServer();
 
         return serverUrl != null ? getUrlToOppf(serverUrl) : null;
     }

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

@@ -7,7 +7,7 @@ import ch.threema.app.ThreemaApplication
 import ch.threema.app.utils.logScreenVisibility
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.taskmanager.TriggerSource
-import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
 
 private val logger = getThreemaLogger("ExcludedSyncIdentitiesActivity")
 
@@ -21,15 +21,15 @@ class ExcludedSyncIdentitiesActivity : IdentityListActivity() {
             ?: return@lazy null
 
         object : IdentityList {
-            override fun getAll(): Set<Identity> {
+            override fun getAll(): Set<IdentityString> {
                 return excludedSyncIdentitiesService.getExcludedIdentities()
             }
 
-            override fun addIdentity(identity: Identity) {
+            override fun addIdentity(identity: IdentityString) {
                 excludedSyncIdentitiesService.excludeFromSync(identity, TriggerSource.LOCAL)
             }
 
-            override fun removeIdentity(identity: Identity) {
+            override fun removeIdentity(identity: IdentityString) {
                 excludedSyncIdentitiesService.removeExcludedIdentity(identity, TriggerSource.LOCAL)
             }
         }

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

@@ -68,13 +68,13 @@ class ExportIDActivity : AppCompatActivity(), PasswordEntryDialogClickListener {
         dialogFragment.show(supportFragmentManager, DIALOG_TAG_SET_ID_BACKUP_PW)
     }
 
-    override fun onYes(tag: String, text: String, isChecked: Boolean, data: Any?) {
+    override fun onYes(tag: String?, text: String, isChecked: Boolean, data: Any?) {
         when (tag) {
             DIALOG_TAG_SET_ID_BACKUP_PW -> createIDBackup(text)
         }
     }
 
-    override fun onNo(tag: String) {
+    override fun onNo(tag: String?) {
         finish()
     }
 

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

@@ -239,14 +239,21 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
 
     @Override
     public boolean onOptionsItemSelected(@NonNull MenuItem item) {
-        if (item.getItemId() == android.R.id.home) {
-            done();
-        } else if (item.getItemId() == R.id.menu_print) {
-            printBitmap(originalQrCode);
-        } else if (item.getItemId() == R.id.menu_backup_share) {
-            shareId();
+        switch (item.getItemId()) {
+            case android.R.id.home:
+                done();
+                return true;
+            case R.id.menu_print:
+                if (originalQrCode != null) {
+                    printBitmap(originalQrCode);
+                }
+                return true;
+            case R.id.menu_backup_share:
+                shareId();
+                return true;
+            default:
+                return super.onOptionsItemSelected(item);
         }
-        return super.onOptionsItemSelected(item);
     }
 
     private void shareId() {
@@ -279,9 +286,9 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
     }
 
     @Override
-    public void onYes(String tag, Object data) {
-        Intent upIntent = new Intent(ExportIDResultActivity.this, HomeActivity.class);
-        NavUtils.navigateUpTo(ExportIDResultActivity.this, upIntent);
+    public void onYes(@Nullable String tag, @Nullable Object data) {
+        Intent upIntent = HomeActivity.createIntent(ExportIDResultActivity.this);
+        navigateUpTo(upIntent);
 
         finish();
     }

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

@@ -96,7 +96,7 @@ public class GroupAdd2Activity extends GroupEditActivity implements ContactEditD
         );
 
         Deferred<GroupFlowResult> createGroupFlowResultDeferred = dependencies.getGroupFlowDispatcher().runCreateGroupFlow(
-            this,
+            dependencies.getAppRestrictions(),
             new GroupCreateProperties(
                 groupName,
                 CheckedProfilePicture.getOrConvertFromFile(avatarFile),

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

@@ -1,10 +1,10 @@
 package ch.threema.app.activities;
 
+import android.content.Context;
 import android.content.Intent;
 import android.os.Bundle;
 import android.widget.Toast;
 
-import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
 import androidx.annotation.NonNull;
@@ -18,15 +18,14 @@ import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
-import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.ShowOnceDialog;
-import ch.threema.app.restrictions.AppRestrictionUtil;
 import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.app.utils.LogUtil;
+
+import static ch.threema.android.ToastKt.showToast;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.storage.models.ContactModel;
-import ch.threema.storage.models.GroupModel;
+import ch.threema.storage.models.group.GroupModelOld;
 
 import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
@@ -38,10 +37,7 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
     private static final String DIALOG_TAG_NO_MEMBERS = "NoMem";
     private static final String DIALOG_TAG_NOTE_GROUP_HOWTO = "note_group_hint";
 
-    @NonNull
-    private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
-
-    private GroupModel groupModel;
+    private GroupModelOld groupModel;
     private boolean appendMembers;
 
     @Override
@@ -59,7 +55,7 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
             return false;
         }
 
-        if (AppRestrictionUtil.isCreateGroupDisabled(this)) {
+        if (dependencies.getAppRestrictions().isCreateGroupDisabled()) {
             Toast.makeText(this, R.string.disabled_by_policy_short, Toast.LENGTH_LONG).show();
             return false;
         }
@@ -95,7 +91,8 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
                 }
             }
         } catch (Exception e) {
-            LogUtil.exception(e, this);
+            logger.error("Failed to init data", e);
+            showToast(this, R.string.an_error_occurred);
             return;
         }
 
@@ -160,7 +157,12 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
     }
 
     @Override
-    public void onYes(String tag, Object data) {
+    public void onYes(@Nullable String tag, @Nullable Object data) {
         createOrUpdateGroup(new ArrayList<>());
     }
+
+    @NonNull
+    public static Intent createIntent(@NonNull Context context) {
+        return new Intent(context, GroupAddActivity.class);
+    }
 }

+ 172 - 143
app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java

@@ -28,7 +28,6 @@ import java.io.File;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
@@ -77,15 +76,17 @@ import ch.threema.app.listeners.GroupListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.mediagallery.MediaGalleryActivity;
 import ch.threema.app.profilepicture.CheckedProfilePicture;
+import ch.threema.app.services.ContactService;
 import ch.threema.app.ui.AvatarEditView;
 import ch.threema.app.ui.GroupDetailViewModel;
 import ch.threema.app.ui.ResumePauseHandler;
 import ch.threema.app.ui.SelectorDialogItem;
-import ch.threema.app.restrictions.AppRestrictionUtil;
 import ch.threema.app.ui.SimpleTextWatcher;
 import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ContactUtil;
+import ch.threema.app.utils.DisplayableGroupParticipant;
+import ch.threema.app.utils.DisplayableGroupParticipants;
 import ch.threema.app.utils.GroupCallUtil;
 import ch.threema.app.utils.GroupUtil;
 import ch.threema.app.utils.IntentDataUtil;
@@ -95,12 +96,15 @@ import ch.threema.app.utils.TestUtil;
 import ch.threema.app.voip.groupcall.GroupCallDescription;
 import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.base.utils.CoroutinesExtensionKt;
+
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+
 import ch.threema.data.models.GroupIdentity;
+import ch.threema.data.models.GroupModel;
 import ch.threema.data.models.GroupModelData;
 import ch.threema.localcrypto.exceptions.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
-import ch.threema.storage.models.GroupModel;
+import ch.threema.storage.models.group.GroupModelOld;
 import kotlin.Unit;
 import kotlinx.coroutines.Deferred;
 
@@ -153,9 +157,15 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     private final ResumePauseHandler.RunIfActive runIfActiveUpdate = new ResumePauseHandler.RunIfActive() {
         @Override
         public void runOnUiThread() {
-            groupDetailViewModel.setGroupName(groupModel.getName());
+            GroupModelData groupModelData = groupModel.getData();
+            if (groupModelData == null) {
+                logger.warn("Group has been deleted");
+                finish();
+                return;
+            }
 
-            groupDetailViewModel.setGroupIdentities(dependencies.getGroupService().getGroupMemberIdentities(groupModel));
+            groupDetailViewModel.setGroupName(groupModelData.name);
+            groupDetailViewModel.setGroupIdentities(groupModelData.getAllMembers(myIdentity).toArray(new String[]{}));
             sortGroupMembers();
         }
     };
@@ -172,7 +182,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         public void onAvatarRemoved() {
             groupDetailViewModel.setAvatarFile(null);
             groupDetailViewModel.setIsAvatarRemoved(true);
-            avatarEditView.setDefaultAvatar(null, groupModel);
+            avatarEditView.setDefaultGroupAvatar();
             updateFloatingActionButtonAndMenu();
         }
     };
@@ -184,8 +194,14 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     private final ContactSettingsListener contactSettingsListener = new ContactSettingsListener() {
+
         @Override
-        public void onAvatarSettingChanged() {
+        public void onIsDefaultContactPictureColoredChanged(boolean isColored) {
+            resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
+        }
+
+        @Override
+        public void onShowContactDefinedAvatarsChanged(boolean shouldShow) {
             resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
         }
     };
@@ -314,16 +330,42 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         groupId = getIntent().getLongExtra(AppConstants.INTENT_DATA_GROUP_DATABASE_ID, 0L);
         if (this.groupId == 0) {
             finish();
+            return;
+        }
+        groupModel = dependencies.getGroupModelRepository().getByLocalGroupDbId(groupId);
+        if (groupModel == null) {
+            logger.warn("Group model couldn't be found");
+            finish();
+            return;
+        }
+        GroupModelData groupModelData = groupModel.getData();
+        if (groupModelData == null) {
+            logger.warn("Group model has been deleted");
+            finish();
+            return;
+        }
+        DisplayableGroupParticipants displayableGroupParticipants = DisplayableGroupParticipants.getDisplayableGroupParticipantsOfGroup(
+            groupModel,
+            dependencies.getContactModelRepository(),
+            dependencies.getUserService(),
+            dependencies.getPreferenceService()
+        );
+        if (displayableGroupParticipants == null) {
+            logger.error("Cannot get displayable group participants as group seems to have been deleted in the meantime.");
+            finish();
+            return;
         }
-        this.groupModel = dependencies.getGroupService().getById(groupId);
+        List<DisplayableGroupParticipant> displayableGroupParticipantList = new ArrayList<>(displayableGroupParticipants.getMembersWithoutCreator().size() + 1);
+        displayableGroupParticipantList.add(displayableGroupParticipants.getCreator());
+        displayableGroupParticipantList.addAll(displayableGroupParticipants.getMembersWithoutCreator());
 
         observeNewGroupModel();
 
         if (savedInstanceState == null) {
             // new instance
-            this.groupDetailViewModel.setGroupContacts(dependencies.getContactService().getByIdentities(dependencies.getGroupService().getGroupMemberIdentities(this.groupModel)));
-            this.groupDetailViewModel.setGroupName(this.groupModel.getName());
-            String groupDesc = this.groupModel.getGroupDesc();
+            this.groupDetailViewModel.setGroupMembers(displayableGroupParticipantList);
+            this.groupDetailViewModel.setGroupName(groupModelData.name);
+            String groupDesc = groupModelData.groupDescription;
             if (groupDesc == null || groupDesc.isEmpty()) {
                 this.groupDetailViewModel.setGroupDesc(null);
                 this.groupDetailViewModel.setGroupDescState(NONE);
@@ -331,17 +373,17 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
                 this.groupDetailViewModel.setGroupDesc(groupDesc);
                 this.groupDetailViewModel.setGroupDescState(COLLAPSED);
             }
-            this.groupDetailViewModel.setGroupDescTimestamp(this.groupModel.getGroupDescTimestamp());
+            this.groupDetailViewModel.setGroupDescTimestamp(groupModelData.groupDescriptionChangedAt);
         }
 
         this.avatarEditView.setHires(true);
         if (groupDetailViewModel.getIsAvatarRemoved()) {
-            this.avatarEditView.setDefaultAvatar(null, groupModel);
+            this.avatarEditView.setDefaultGroupAvatar();
         } else {
             if (groupDetailViewModel.getAvatarFile() != null) {
                 this.avatarEditView.setAvatarFile(groupDetailViewModel.getAvatarFile());
             } else {
-                this.avatarEditView.loadAvatarForModel(null, groupModel);
+                this.avatarEditView.loadAvatarForGroupModel(groupModel);
             }
         }
         this.avatarEditView.setListener(this.avatarEditViewListener);
@@ -360,7 +402,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         setTitle();
         updateFloatingActionButtonAndMenu();
 
-        if (dependencies.getGroupService().isGroupCreator(groupModel) && dependencies.getGroupService().isGroupMember(groupModel)) {
+        if (groupModel.isCreator() && groupModel.isMember()) {
             operationMode = MODE_EDIT;
             actionBar.setHomeButtonEnabled(false);
             actionBar.setDisplayHomeAsUpEnabled(true);
@@ -369,7 +411,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
                 logger.info("FAB (save group settings) clicked");
                 saveGroupSettings();
             });
-            groupNameEditText.setMaxByteSize(GroupModel.GROUP_NAME_MAX_LENGTH_BYTES);
+            groupNameEditText.setMaxByteSize(GroupModelOld.GROUP_NAME_MAX_LENGTH_BYTES);
             groupNameEditText.addTextChangedListener(new SimpleTextWatcher() {
                 @Override
                 public void afterTextChanged(@NonNull Editable editable) {
@@ -391,7 +433,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
             // If the user is not a member of the group, then display the group name with strike
             // through style
-            if (!dependencies.getGroupService().isGroupMember(groupModel)) {
+            if (!groupModel.isMember()) {
                 // Get the paint flags and add the strike through flag
                 int paintFlags = groupNameEditText.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG;
                 groupNameEditText.setPaintFlags(paintFlags);
@@ -420,16 +462,15 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
         groupDetailRecyclerView.setAdapter(this.groupDetailAdapter);
 
-        final Observer<List<ContactModel>> groupMemberObserver = groupMembers -> {
+        final Observer<List<DisplayableGroupParticipant>> groupMemberObserver = groupParticipants ->
             // Update the UI
-            groupDetailAdapter.setContactModels(groupMembers);
-        };
+            groupDetailAdapter.setGroupMembers(groupParticipants);
 
         // Observe the LiveData, passing in this activity as the LifecycleOwner and the observer.
         groupDetailViewModel.getGroupMembers().observe(this, groupMemberObserver);
         groupDetailViewModel.onDataChanged();
 
-        @ColorInt int color = dependencies.getGroupService().getAvatarColor(groupModel);
+        @ColorInt int color = dependencies.getGroupService().getGroupProfilePictureColor(groupModel);
         collapsingToolbar.setContentScrimColor(color);
         collapsingToolbar.setStatusBarScrimColor(color);
 
@@ -445,7 +486,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     @Override
-    public void handleDeviceInsets(){
+    public void handleDeviceInsets() {
         // As the CollapsingToolbarLayout will consume the window insets internally, we have to apply the window insets to every our child views
         // with one inset listener from the root layout
         ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main_content), (view, windowInsets) -> {
@@ -492,7 +533,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
             this,
             this.groupModel,
             groupDetailViewModel,
-            dependencies.getServiceManager()
+            dependencies.getContactService()
         );
 
         this.groupDetailAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
@@ -525,35 +566,22 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     private void sortGroupMembers() {
-        final boolean isSortingFirstName = dependencies.getPreferenceService().isContactListSortingFirstName();
-        List<ContactModel> contactModels = groupDetailViewModel.getGroupContacts();
-        Collections.sort(contactModels, new Comparator<ContactModel>() {
-            @Override
-            public int compare(ContactModel model1, ContactModel model2) {
-                return ContactUtil.getSafeNameString(model1, isSortingFirstName).compareTo(
-                    ContactUtil.getSafeNameString(model2, isSortingFirstName)
-                );
+        List<DisplayableGroupParticipant> displayableGroupParticipants = groupDetailViewModel.getDisplayableGroupParticipants();
+        displayableGroupParticipants.sort((o1, o2) -> {
+            if (o1 instanceof DisplayableGroupParticipant.Creator) {
+                return -1;
+            } else if (o2 instanceof DisplayableGroupParticipant.Creator) {
+                return 1;
+            } else {
+                return o1.getDisplayableContactOrUser().getDisplayName().compareTo(o2.getDisplayableContactOrUser().getDisplayName());
             }
         });
-
-        if (contactModels.size() > 1 && groupModel.getCreatorIdentity() != null) {
-            for (ContactModel currentMember : contactModels) {
-                if (groupModel.getCreatorIdentity().equals(currentMember.getIdentity())) {
-                    contactModels.remove(currentMember);
-                    contactModels.add(0, currentMember);
-                    break;
-                }
-            }
-        }
-
-        groupDetailViewModel.setGroupContacts(contactModels);
+        groupDetailViewModel.setGroupMembers(displayableGroupParticipants);
     }
 
-    private void removeMemberFromGroup(final ContactModel contactModel) {
-        if (contactModel != null) {
-            this.groupDetailViewModel.removeGroupContact(contactModel);
-            updateFloatingActionButtonAndMenu();
-        }
+    private void removeMemberFromGroup(@NonNull String identity) {
+        this.groupDetailViewModel.removeGroupContact(identity);
+        updateFloatingActionButtonAndMenu();
     }
 
     @Override
@@ -582,18 +610,18 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         MenuItem mediaGalleryMenu = menu.findItem(R.id.menu_gallery);
         MenuItem groupCallMenu = menu.findItem(R.id.menu_group_call);
 
-        if (AppRestrictionUtil.isCreateGroupDisabled(this)) {
+        if (dependencies.getAppRestrictions().isCreateGroupDisabled()) {
             cloneMenu.setVisible(false);
         }
 
         if (groupModel != null) {
-            var groupService = dependencies.getGroupService();
             GroupCallDescription call = dependencies.getGroupCallManager().getCurrentChosenCall(groupModel);
-            groupCallMenu.setVisible(GroupCallUtil.qualifiesForGroupCalls(groupService, groupModel) && !hasChanges() && call == null);
+            groupCallMenu.setVisible(GroupCallUtil.qualifiesForGroupCalls(groupModel) && !hasChanges() && call == null);
 
-            boolean isMember = groupService.isGroupMember(groupModel);
-            boolean isCreator = groupService.isGroupCreator(groupModel);
-            boolean hasOtherMembers = groupService.countMembersWithoutUser(groupModel) > 0;
+            boolean isMember = groupModel.isMember();
+            boolean isCreator = groupModel.isCreator();
+            GroupModelData groupModelData = groupModel.getData();
+            boolean hasOtherMembers = groupModelData != null && !groupModel.getRecipients().isEmpty();
 
             // The clone menu only makes sense if at least one other member is present
             cloneMenu.setVisible(hasOtherMembers);
@@ -634,7 +662,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
+    public boolean onOptionsItemSelected(@NonNull MenuItem item) {
         int itemId = item.getItemId();
         if (itemId == android.R.id.home) {
             onBackPressed();
@@ -667,8 +695,8 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         } else if (itemId == R.id.menu_delete_group) {
             @StringRes int title;
             @StringRes int description;
-            boolean isGroupCreator = dependencies.getGroupService().isGroupCreator(groupModel);
-            boolean isGroupMember = dependencies.getGroupService().isGroupMember(groupModel);
+            boolean isGroupCreator = groupModel.isCreator();
+            boolean isGroupMember = groupModel.isMember();
             if (isGroupCreator && isGroupMember) {
                 // Group creator and still member
                 title = R.string.action_dissolve_and_delete_group;
@@ -703,7 +731,12 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
                 startActivity(mediaGalleryIntent);
             }
         } else if (itemId == R.id.menu_group_call) {
-            GroupCallUtil.initiateCall(this, groupModel);
+            GroupModelOld oldGroupModel = dependencies.getGroupService().getById(groupModel.getDatabaseId());
+            if (oldGroupModel != null) {
+                GroupCallUtil.initiateCall(this, oldGroupModel);
+            } else {
+                logger.warn("Could not find old group model");
+            }
         }
         return super.onOptionsItemSelected(item);
     }
@@ -717,9 +750,9 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     private void deleteGroupAndQuit() {
-        if (!dependencies.getGroupService().isGroupMember(groupModel)) {
+        if (!groupModel.isMember()) {
             removeGroupAndQuit();
-        } else if (dependencies.getGroupService().isGroupCreator(groupModel)) {
+        } else if (groupModel.isCreator()) {
             disbandOrDeleteGroupAndQuit(GroupDisbandIntent.DISBAND_AND_REMOVE);
         } else {
             leaveOrDeleteGroupAndQuit(GroupLeaveIntent.LEAVE_AND_REMOVE);
@@ -727,8 +760,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     private void leaveOrDeleteGroupAndQuit(GroupLeaveIntent intent) {
-        ch.threema.data.models.GroupModel newGroupModel = getNewGroupModel();
-        if (newGroupModel == null) {
+        if (groupModel == null) {
             logger.error("Could not leave or delete group: group model missing");
             SimpleStringAlertDialog
                 .newInstance(R.string.error, R.string.error_leaving_group_internal)
@@ -743,7 +775,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         loadingDialog.show(getSupportFragmentManager());
 
         Deferred<GroupFlowResult> leaveGroupFlowResultDeferred = dependencies.getGroupFlowDispatcher()
-            .runLeaveGroupFlow(intent, newGroupModel);
+            .runLeaveGroupFlow(intent, groupModel);
 
         CoroutinesExtensionKt.onCompleted(
             leaveGroupFlowResultDeferred,
@@ -791,8 +823,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     private void disbandOrDeleteGroupAndQuit(GroupDisbandIntent intent) {
-        ch.threema.data.models.GroupModel newGroupModel = getNewGroupModel();
-        if (newGroupModel == null) {
+        if (groupModel == null) {
             logger.error("Could not disband or delete group: group model missing");
             SimpleStringAlertDialog
                 .newInstance(R.string.error, R.string.error_disbanding_group_internal)
@@ -807,7 +838,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         loadingDialog.show(getSupportFragmentManager());
 
         Deferred<GroupFlowResult> disbandGroupFlowResultDeferred = dependencies.getGroupFlowDispatcher()
-            .runDisbandGroupFlow(intent, newGroupModel);
+            .runDisbandGroupFlow(intent, groupModel);
 
         CoroutinesExtensionKt.onCompleted(
             disbandGroupFlowResultDeferred,
@@ -847,8 +878,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     private void removeGroupAndQuit() {
-        ch.threema.data.models.GroupModel newGroupModel = getNewGroupModel();
-        if (newGroupModel == null) {
+        if (groupModel == null) {
             logger.error("Cannot remove group: group model is null");
             SimpleStringAlertDialog
                 .newInstance(R.string.error, R.string.error_removing_group_internal)
@@ -863,7 +893,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         loadingDialog.show(getSupportFragmentManager());
 
         Deferred<GroupFlowResult> removeGroupFlowResultDeferred = dependencies.getGroupFlowDispatcher()
-            .runRemoveGroupFlow(newGroupModel);
+            .runRemoveGroupFlow(groupModel);
 
         CoroutinesExtensionKt.onCompleted(
             removeGroupFlowResultDeferred,
@@ -905,20 +935,6 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         });
     }
 
-    @Nullable
-    private ch.threema.data.models.GroupModel getNewGroupModel() {
-        ch.threema.data.models.GroupModel newGroupModel = dependencies.getGroupModelRepository().getByCreatorIdentityAndId(
-            groupModel.getCreatorIdentity(),
-            groupModel.getApiGroupId()
-        );
-
-        if (newGroupModel == null) {
-            logger.error("New group model is null");
-        }
-
-        return newGroupModel;
-    }
-
     private void cloneGroup(final String newGroupName) {
         final @NonNull LoadingWithTimeoutDialogXml loadingDialog = LoadingWithTimeoutDialogXml.newInstance(
             GROUP_FLOWS_LOADING_DIALOG_TIMEOUT_SECONDS,
@@ -926,12 +942,18 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         );
         loadingDialog.show(getSupportFragmentManager(), null);
 
+        GroupModelOld oldGroupModel = dependencies.getGroupService().getById(groupModel.getDatabaseId());
+        if (oldGroupModel == null) {
+            logger.error("Old group model is null");
+            return;
+        }
+
         Deferred<GroupFlowResult> cloneGroupFlowResultDeferred = dependencies.getGroupFlowDispatcher().runCreateGroupFlow(
-            this,
+            dependencies.getAppRestrictions(),
             new GroupCreateProperties(
                 newGroupName,
-                CheckedProfilePicture.getOrConvertFromBitmap(dependencies.getGroupService().getAvatar(groupModel, true, false)),
-                Set.of(dependencies.getGroupService().getGroupMemberIdentities(groupModel))
+                CheckedProfilePicture.getOrConvertFromBitmap(dependencies.getGroupService().getAvatar(oldGroupModel, true, false)),
+                Set.of(dependencies.getGroupService().getGroupMemberIdentities(oldGroupModel))
             )
         );
 
@@ -964,7 +986,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
     @AnyThread
     private void onGroupClonedSuccessfully(
-        @NonNull ch.threema.data.models.GroupModel groupModel,
+        @NonNull GroupModel groupModel,
         @NonNull LoadingWithTimeoutDialogXml loadingDialog
     ) {
         RuntimeUtil.runOnUiThread(() -> {
@@ -1006,16 +1028,21 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     private void addNewMembers() {
         Intent intent = new Intent(GroupDetailActivity.this, GroupAddActivity.class);
         IntentDataUtil.append(groupModel, intent);
-        IntentDataUtil.append(groupDetailViewModel.getGroupContacts(), intent);
+        List<DisplayableGroupParticipant> groupMembers = groupDetailViewModel.getDisplayableGroupParticipants();
+        List<ContactModel> contactModels = new ArrayList<>(groupMembers.size());
+        ContactService contactService = dependencies.getContactService();
+        for (DisplayableGroupParticipant displayableGroupMember : groupMembers) {
+            ContactModel contactModel = contactService.getByIdentity(displayableGroupMember.getDisplayableContactOrUser().getIdentity());
+            if (contactModel != null) {
+                contactModels.add(contactModel);
+            }
+        }
+        IntentDataUtil.append(contactModels, intent);
         startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_GROUP_ADD);
     }
 
     private void syncGroup() {
-        ch.threema.data.models.GroupModel newGroupModel = dependencies.getGroupModelRepository().getByCreatorIdentityAndId(
-            groupModel.getCreatorIdentity(),
-            groupModel.getApiGroupId()
-        );
-        if (newGroupModel == null) {
+        if (groupModel == null) {
             logger.error("Failed to resync group: New group model is null");
             SimpleStringAlertDialog
                 .newInstance(R.string.error, R.string.error_resyncing_group_internal)
@@ -1024,7 +1051,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         }
 
         Deferred<GroupFlowResult> resyncGroupFlowResultDeferred = dependencies.getGroupFlowDispatcher()
-            .runGroupResyncFlow(newGroupModel);
+            .runGroupResyncFlow(groupModel);
 
         final @NonNull LoadingWithTimeoutDialogXml loadingDialog = LoadingWithTimeoutDialogXml.newInstance(
             GROUP_FLOWS_LOADING_DIALOG_TIMEOUT_SECONDS,
@@ -1073,19 +1100,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     private void saveGroupSettings() {
-        ch.threema.data.models.GroupModel newGroupModel = dependencies.getGroupModelRepository().getByGroupIdentity(
-            new GroupIdentity(
-                groupModel.getCreatorIdentity(),
-                groupModel.getApiGroupId().toLong()
-            )
-        );
-        if (newGroupModel == null) {
-            logger.error("Group model does not exist");
-            showGroupUpdateErrorInternal();
-            return;
-        }
-
-        GroupModelData groupModelData = newGroupModel.getData();
+        GroupModelData groupModelData = groupModel.getData();
         if (groupModelData == null) {
             logger.warn("Group model data is null");
             showGroupUpdateErrorInternal();
@@ -1104,7 +1119,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
         GroupChanges groupChanges = new GroupChanges(
             getGroupNameChange(),
-            getProfilePictureChange(newGroupModel),
+            getProfilePictureChange(groupModel),
             updatedIdentities,
             groupModelData
         );
@@ -1115,7 +1130,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         );
         loadingDialog.show(getSupportFragmentManager());
 
-        Deferred<GroupFlowResult> updateResultDeferred = dependencies.getGroupFlowDispatcher().runUpdateGroupFlow(groupChanges, newGroupModel);
+        Deferred<GroupFlowResult> updateResultDeferred = dependencies.getGroupFlowDispatcher().runUpdateGroupFlow(groupChanges, groupModel);
 
         CoroutinesExtensionKt.onCompleted(
             updateResultDeferred,
@@ -1178,8 +1193,9 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     @Nullable
     private String getGroupNameChange() {
         @NonNull String newGroupName = groupDetailViewModel.getGroupName();
-        @NonNull String oldGroupName = groupModel.getName() != null ?
-            groupModel.getName() : "";
+        GroupModelData groupModelData = groupModel.getData();
+        @NonNull String oldGroupName = groupModelData != null ?
+            (groupModelData.name != null ? groupModelData.name : "") : "";
 
         if (!newGroupName.equals(oldGroupName)) {
             return newGroupName;
@@ -1189,7 +1205,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     @NonNull
-    private ProfilePictureChange getProfilePictureChange(ch.threema.data.models.GroupModel groupModel) {
+    private ProfilePictureChange getProfilePictureChange(GroupModel groupModel) {
         if (!groupDetailViewModel.hasAvatarChanges()) {
             return ProfilePictureChange.NoChange.INSTANCE;
         }
@@ -1215,12 +1231,18 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     private void showCloneDialog() {
+        GroupModelData groupModelData = groupModel.getData();
+        if (groupModelData == null) {
+            logger.error("Group model data is null");
+            return;
+        }
+
         TextEntryDialog.newInstance(
             R.string.action_clone_group,
             R.string.name,
             R.string.ok,
             R.string.cancel,
-            groupModel.getName(),
+            groupModelData.name,
             0,
             0
         ).show(getSupportFragmentManager(), DIALOG_TAG_CLONE_GROUP);
@@ -1278,7 +1300,9 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
                     break;
                 case SELECTOR_OPTION_REMOVE:
                     logger.info("Kick user button clicked");
-                    removeMemberFromGroup(selectorInfo.contactModel);
+                    if (selectorInfo.contactModel != null) {
+                        removeMemberFromGroup(selectorInfo.contactModel.getIdentity());
+                    }
                     break;
                 case SELECTOR_OPTION_CALL:
                     logger.info("Call button clicked");
@@ -1290,25 +1314,22 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         }
     }
 
-    @Override
-    public void onCancel(String tag) {
-    }
-
     @Override
     public void onYes(@NonNull String tag, @NonNull String text) {
         // text entry dialog
-        switch (tag) {
-            case DIALOG_TAG_CLONE_GROUP:
-                logger.info("Clone group dialog confirmed");
-                cloneGroup(text);
-                break;
-            default:
-                break;
+        if (tag.equals(DIALOG_TAG_CLONE_GROUP)) {
+            logger.info("Clone group dialog confirmed");
+            cloneGroup(text);
         }
     }
 
-    public void onGroupDescChange(String newGroupDesc) {
-        if (newGroupDesc.equals(groupModel.getGroupDesc())) {
+    public void onGroupDescChange(@NonNull String newGroupDesc) {
+        GroupModelData groupModelData = groupModel.getData();
+        if (groupModelData == null) {
+            return;
+        }
+
+        if (newGroupDesc.equals(groupModelData.groupDescription)) {
             return;
         }
         groupDetailViewModel.setGroupDescTimestamp(new Date());
@@ -1335,7 +1356,10 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     @Override
-    public void onYes(String tag, Object data) {
+    public void onYes(@Nullable String tag, @Nullable Object data) {
+        if (tag == null) {
+            return;
+        }
         switch (tag) {
             case DIALOG_TAG_LEAVE_GROUP:
                 logger.info("Leave group dialog confirmed");
@@ -1385,13 +1409,9 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     @Override
-    public void onNo(String tag, Object data) {
-        switch (tag) {
-            case DIALOG_TAG_QUIT:
-                finish();
-                break;
-            default:
-                break;
+    public void onNo(@Nullable String tag, @Nullable Object data) {
+        if (tag != null && tag.equals(DIALOG_TAG_QUIT)) {
+            finish();
         }
     }
 
@@ -1403,7 +1423,12 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         Editable groupNameEditable = groupNameEditText.getText();
         String editedGroupNameText = groupNameEditable != null ? groupNameEditable.toString() : "";
 
-        String currentGroupName = groupModel.getName() != null ? groupModel.getName() : "";
+        GroupModelData groupModelData = groupModel.getData();
+        if (groupModelData == null) {
+            return false;
+        }
+
+        String currentGroupName = groupModelData.name != null ? groupModelData.name : "";
 
         return !editedGroupNameText.equals(currentGroupName);
     }
@@ -1418,7 +1443,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
             return;
         }
 
-        if (dependencies.getGroupService().isGroupCreator(this.groupModel) && hasChanges()) {
+        if (groupModel.isCreator() && hasChanges()) {
             this.floatingActionButton.show();
         } else {
             this.floatingActionButton.hide();
@@ -1427,7 +1452,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     private void navigateHome() {
-        Intent intent = new Intent(GroupDetailActivity.this, HomeActivity.class);
+        Intent intent = HomeActivity.createIntent(GroupDetailActivity.this);
         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
         startActivity(intent);
         ActivityCompat.finishAffinity(GroupDetailActivity.this);
@@ -1444,10 +1469,14 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     }
 
     @Override
-    public void onGroupMemberClick(View v, @NonNull ContactModel contactModel) {
+    public void onGroupMemberClick(View v, @NonNull String identity) {
         logger.info("Group member clicked");
-        String identity = contactModel.getIdentity();
-        String shortName = NameUtil.getShortName(contactModel);
+        ContactModel contactModel = dependencies.getContactService().getByIdentity(identity);
+        if (contactModel == null) {
+            logger.error("Could not find contact model for the clicked identity");
+            return;
+        }
+        String shortName = NameUtil.getShortName(contactModel, dependencies.getPreferenceService().getContactNameFormat());
 
         ArrayList<SelectorDialogItem> items = new ArrayList<>();
         ArrayList<Integer> optionsMap = new ArrayList<>();
@@ -1467,7 +1496,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
             }
 
             if (operationMode == MODE_EDIT) {
-                if (groupModel != null && !TestUtil.compare(groupModel.getCreatorIdentity(), identity)) {
+                if (groupModel != null && !groupModel.getGroupIdentity().getCreatorIdentity().equals(identity)) {
                     items.add(new SelectorDialogItem(String.format(getString(R.string.kick_user_from_group), shortName), R.drawable.ic_person_remove_outline));
                     optionsMap.add(SELECTOR_OPTION_REMOVE);
                 }
@@ -1521,7 +1550,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     private void observeNewGroupModel() {
         LiveData<GroupModelData> groupModelDataLiveData = groupDetailViewModel.group;
         if (groupModelDataLiveData == null) {
-            ch.threema.data.models.GroupModel newGroupModel =
+            GroupModel newGroupModel =
                 dependencies.getGroupModelRepository().getByLocalGroupDbId(this.groupId);
 
             if (newGroupModel == null) {

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

@@ -3,12 +3,10 @@ package ch.threema.app.activities;
 import android.content.Intent;
 import android.text.InputType;
 
-import java.io.File;
-
 import androidx.fragment.app.Fragment;
 import ch.threema.app.R;
 import ch.threema.app.dialogs.ContactEditDialog;
-import ch.threema.storage.models.GroupModel;
+import ch.threema.storage.models.group.GroupModelOld;
 
 public abstract class GroupEditActivity extends ThreemaToolbarActivity {
     protected static final String DIALOG_TAG_GROUPNAME = "groupName";
@@ -27,7 +25,7 @@ public abstract class GroupEditActivity extends ThreemaToolbarActivity {
                 inputType,
                 null,
                 false,
-                GroupModel.GROUP_NAME_MAX_LENGTH_BYTES)
+                GroupModelOld.GROUP_NAME_MAX_LENGTH_BYTES)
             .show(getSupportFragmentManager(), DIALOG_TAG_GROUPNAME);
     }
 

+ 7 - 5
app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java

@@ -121,7 +121,9 @@ import ch.threema.app.utils.EditTextUtil;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
+
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+
 import ch.threema.data.models.GroupModel;
 
 public class ImagePaintActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener {
@@ -367,8 +369,8 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
                     }
                     break;
                 case REQUEST_CODE_ENTER_TEXT:
-                    final String text = data.getStringExtra(ImagePaintKeyboardActivity.INTENT_EXTRA_TEXT);
-                    if (!TestUtil.isEmptyOrNull(text)) {
+                    final @Nullable String text = data.getStringExtra(ImagePaintKeyboardActivity.INTENT_EXTRA_TEXT);
+                    if (text != null && !text.isBlank()) {
                         addText(text);
                     }
             }
@@ -700,7 +702,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
         ListeningExecutorService executorService = MoreExecutors.listeningDecorator(threadPoolExecutor);
         return executorService.submit(() -> {
             try {
-                int dimension = ConfigUtils.getPreferredImageDimensions(PreferenceService.ImageScale_MEDIUM);
+                int dimension = ConfigUtils.getPreferredImageDimensions(PreferenceService.IMAGE_SCALE_MEDIUM);
                 Bitmap bitmap = Bitmap.createBitmap(dimension, dimension, Bitmap.Config.RGB_565);
                 Canvas canvas = new Canvas(bitmap);
                 canvas.drawColor(color);
@@ -1392,7 +1394,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
     }
 
     private void initializeMentions() {
-        ch.threema.data.models.GroupModel groupModel = dependencies.getGroupModelRepository().getByLocalGroupDbId(groupId);
+        GroupModel groupModel = dependencies.getGroupModelRepository().getByLocalGroupDbId(groupId);
 
         if (groupModel == null) {
             logger.error("Cannot enable mention popup: no group model with id {} found", groupId);
@@ -1690,7 +1692,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
     }
 
     @Override
-    public void onYes(String tag, Object data) {
+    public void onYes(@Nullable String tag, @Nullable Object data) {
         finishWithoutChanges();
     }
 

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

@@ -1,6 +1,5 @@
 package ch.threema.app.activities
 
-import android.content.Intent
 import android.os.Bundle
 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
 import androidx.lifecycle.lifecycleScope
@@ -56,7 +55,7 @@ class MainActivity : ThreemaAppCompatActivity() {
     }
 
     private fun continueToHomeActivity() {
-        val intent = Intent(context, HomeActivity::class.java)
+        val intent = HomeActivity.createIntent(context)
         startActivity(intent)
         overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out)
         finish()

+ 167 - 92
app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java

@@ -2,6 +2,7 @@ package ch.threema.app.activities;
 
 import android.Manifest;
 import android.annotation.SuppressLint;
+import android.content.ComponentName;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.content.res.Configuration;
@@ -10,27 +11,30 @@ import android.net.Uri;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.Parcelable;
-import android.text.TextUtils;
 import android.util.SparseArray;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
+import android.widget.FrameLayout;
 import android.widget.TextView;
 import android.widget.Toast;
 
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.app.AppCompatDelegate;
 import androidx.appcompat.view.menu.MenuBuilder;
 import androidx.core.app.ActivityCompat;
-import androidx.core.graphics.Insets;
-import androidx.core.view.ViewCompat;
-import androidx.core.view.WindowInsetsCompat;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentStatePagerAdapter;
 import androidx.fragment.app.FragmentTransaction;
+import androidx.media3.session.MediaController;
+import androidx.media3.session.SessionToken;
 import androidx.viewpager.widget.PagerAdapter;
 
 import org.koin.java.KoinJavaComponent;
@@ -41,6 +45,7 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Consumer;
 
 import ch.threema.app.AppConstants;
 import ch.threema.app.R;
@@ -55,10 +60,10 @@ import ch.threema.app.fragments.mediaviews.MediaViewFragment;
 import ch.threema.app.fragments.mediaviews.VideoViewFragment;
 import ch.threema.app.mediagallery.MediaGalleryActivity;
 import ch.threema.app.messagereceiver.MessageReceiver;
+import ch.threema.app.services.AudioPlayerService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.ui.InsetSides;
 import ch.threema.app.ui.LockableViewPager;
-import ch.threema.app.restrictions.AppRestrictionUtil;
 import ch.threema.app.ui.SpacingValues;
 import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.ConfigUtils;
@@ -70,10 +75,12 @@ import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
+
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.DistributionListMessageModel;
-import ch.threema.storage.models.GroupMessageModel;
+import ch.threema.storage.models.group.GroupMessageModel;
 import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.data.MessageContentsType;
 
@@ -96,7 +103,8 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
     public static final String EXTRA_ID_IMMEDIATE_PLAY = "play";
     public static final String EXTRA_ID_REVERSE_ORDER = "reverse";
     public static final String EXTRA_FILTER = "filter";
-    public static final String EXTRA_IS_VOICEMESSAGE = "vm";
+    public static final String EXTRA_IS_VOICE_MESSAGE = "vm";
+    public static final String EXTRA_IS_PRIVATE_CHAT = "is_private_chat";
 
     private LockableViewPager pager;
     private File currentMediaFile;
@@ -115,11 +123,16 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
     private MediaViewFragment[] fragments;
     private File[] decryptedFileCache;
 
-    private View captionContainer;
+    private FrameLayout captionContainer;
     private TextView caption;
     private final Handler loadingFragmentHandler = new Handler();
     private MenuItem saveMenuItem, shareMenuItem, viewMenuItem;
 
+    private boolean isPrivateChat = false;
+
+    private @Nullable ListenableFuture<MediaController> mediaControllerFuture = null;
+    private volatile @Nullable MediaController mediaController = null;
+
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -134,8 +147,8 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         super.handleDeviceInsets();
         ViewExtensionsKt.applyDeviceInsetsAsMargin(
             findViewById(R.id.caption_container),
-            InsetSides.bottom(),
-            SpacingValues.bottom(R.dimen.mediaviewer_caption_border_bottom)
+            InsetSides.lbr(),
+            SpacingValues.horizontal(R.dimen.grid_unit_x2)
         );
     }
 
@@ -149,9 +162,9 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
 
         Intent intent = getIntent();
 
-        String t = IntentDataUtil.getAbstractMessageType(intent);
-        int i = IntentDataUtil.getAbstractMessageId(intent);
-        if (TestUtil.isEmptyOrNull(t) || i <= 0) {
+        final @Nullable String messageType = IntentDataUtil.getAbstractMessageType(intent);
+        final int messageId = IntentDataUtil.getAbstractMessageId(intent);
+        if (TestUtil.isEmptyOrNull(messageType) || messageId <= 0) {
             finish();
             return false;
         }
@@ -166,25 +179,8 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         this.actionBar.setDisplayHomeAsUpEnabled(true);
         this.actionBar.setTitle(" ");
 
-        this.caption = findViewById(R.id.caption);
-        ViewCompat.setOnApplyWindowInsetsListener(this.caption, (v, insets) -> {
-            Insets systemInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout());
-
-            // limit height so that caption doesn't overlap UI elements such as the play button
-            final int lineHeight = ((TextView) v).getLineHeight();
-            final int halfWindowHeight = ConfigUtils.getRealWindowHeight(getWindowManager()) / 2;
-            final int maxTextViewHeight = halfWindowHeight
-                - systemInsets.bottom
-                - getResources().getDimensionPixelSize(R.dimen.mediaviewer_play_button_radius)
-                - getResources().getDimensionPixelSize(R.dimen.mediaviewer_caption_border_bottom)
-                - (getResources().getDimensionPixelSize(R.dimen.mediaviewer_caption_container_padding_vertical) * 2);
-            ((TextView) v).setMaxLines(maxTextViewHeight / lineHeight);
-            ((TextView) v).setEllipsize(TextUtils.TruncateAt.END);
-
-            return insets;
-        });
-
         this.captionContainer = findViewById(R.id.caption_container);
+        this.caption = findViewById(R.id.caption);
 
         this.currentMessageModel = IntentDataUtil.getAbstractMessageModel(intent, dependencies.getMessageService());
         try {
@@ -195,15 +191,22 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
             return false;
         }
 
-        if (!TestUtil.required(this.currentMessageModel, this.currentReceiver)) {
+        if (currentMessageModel == null || currentReceiver == null) {
             finish();
             return false;
         }
 
+        this.isPrivateChat = dependencies.getConversationCategoryService().isPrivateChat(
+            this.currentReceiver.getUniqueIdString()
+        );
+
         final @MessageContentsType int[] filter = intent.hasExtra(EXTRA_FILTER)
             ? intent.getIntArrayExtra(EXTRA_FILTER)
             : null;
 
+        final @NonNull SessionToken sessionToken = new SessionToken(this, new ComponentName(this, AudioPlayerService.class));
+        this.mediaControllerFuture = new MediaController.Builder(this, sessionToken).buildAsync();
+
         //load all records of receiver to support list pager
         try {
             this.messageModels = this.currentReceiver.loadMessages(new MessageService.MessageFilter() {
@@ -315,12 +318,28 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
     }
 
     private void updateActionBarTitle(AbstractMessageModel messageModel) {
-        String title = NameUtil.getDisplayNameOrNickname(this, messageModel, dependencies.getContactService());
-        String subtitle = MessageUtil.getDisplayDate(this, messageModel, true);
+        String title = NameUtil.getContactDisplayNameOrNickname(
+            this,
+            messageModel,
+            dependencies.getContactService(),
+            dependencies.getUserService(),
+            dependencies.getPreferenceService().getContactNameFormat()
+        );
+
+        @Nullable String subtitle = null;
+        if (messageModel != null) {
+            subtitle = MessageUtil.getDisplayDate(
+                this,
+                messageModel.getPostedAt(),
+                messageModel.isOutbox(),
+                messageModel.getModifiedAt(),
+                true
+            );
+        }
 
         logger.debug("show updateActionBarTitle: '{}' '{}'", title, subtitle);
 
-        if (TestUtil.required(getToolbar(), title, subtitle)) {
+        if (getToolbar() != null && title != null) {
             getToolbar().setTitle(title);
             getToolbar().setSubtitle(subtitle);
         } else {
@@ -337,7 +356,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
     }
 
     private void updateMenus() {
-        boolean visibility = currentMediaFile != null && !AppRestrictionUtil.isShareMediaDisabled(this);
+        boolean visibility = currentMediaFile != null && !dependencies.getAppRestrictions().isShareMediaDisabled();
 
         if (saveMenuItem != null) {
             saveMenuItem.setVisible(visibility);
@@ -355,32 +374,36 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         }
     }
 
-    private void currentFragmentChanged(final int imagePos) {
+    private void currentFragmentChanged(final int index) {
         this.loadingFragmentHandler.removeCallbacksAndMessages(null);
-        this.loadingFragmentHandler.postDelayed(new Runnable() {
-            @Override
-            public void run() {
-                loadCurrentFrame(imagePos);
-            }
-        }, LOADING_DELAY);
+        this.loadingFragmentHandler.postDelayed(
+            () -> loadCurrentFrame(index),
+            LOADING_DELAY
+        );
     }
 
-    private void loadCurrentFrame(int imagePos) {
+    private void loadCurrentFrame(int index) {
         this.hideCurrentFragment();
 
-        if (imagePos >= 0 && imagePos < this.messageModels.size()) {
-            this.currentPosition = imagePos;
+        if (index >= 0 && index < this.messageModels.size()) {
+            this.currentPosition = index;
             this.currentMessageModel = this.messageModels.get(this.currentPosition);
 
             updateActionBarTitle(this.currentMessageModel);
 
-            final MediaViewFragment f = this.getCurrentFragment();
-            if (f != null) {
+            final @Nullable MediaViewFragment currentMediaViewFragment = this.getCurrentFragment();
+            for (@Nullable MediaViewFragment mediaViewFragment : fragments) {
+                if (mediaViewFragment != null) {
+                    mediaViewFragment.setIsCurrentlyInFocus(false);
+                }
+            }
+            if (currentMediaViewFragment != null) {
+                currentMediaViewFragment.setIsCurrentlyInFocus(true);
                 RuntimeUtil.runOnUiThread(() -> {
                     logger.debug("showUI - loadCurrentFrame");
                     showUi();
                 });
-                f.showDecrypted();
+                currentMediaViewFragment.showDecrypted();
             }
         }
     }
@@ -587,6 +610,16 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
 
     @Override
     protected void onDestroy() {
+        final @Nullable MediaController audioMediaController = this.mediaController;
+        if (audioMediaController != null) {
+            audioMediaController.stop();
+            audioMediaController.clearMediaItems();
+            audioMediaController.release();
+        }
+        if (mediaControllerFuture != null) {
+            MediaController.releaseFuture(mediaControllerFuture);
+        }
+
         //cleanup file cache
         loadingFragmentHandler.removeCallbacksAndMessages(null);
 
@@ -609,19 +642,61 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         return this.decryptedFileCache;
     }
 
+    /**
+     * Provides the {@code MediaController} after it was bound to the {@code AudioPlayerService}.
+     * If the {@code MediaController} failed to create or bind, {@code null} will be passed to {@code mediaControllerConsumer}.
+     *
+     * @see MediaViewerActivity#getAudioMediaController()
+     * @see AudioPlayerService#onGetSession
+     */
+    public void awaitAudioMediaController(@NonNull Consumer<MediaController> mediaControllerConsumer) {
+        if (mediaControllerFuture == null) {
+            mediaControllerConsumer.accept(null);
+            return;
+        }
+        if (mediaController != null) {
+            mediaControllerConsumer.accept(mediaController);
+            return;
+        }
+        mediaControllerFuture.addListener(
+            () -> {
+                try {
+                    mediaController = mediaControllerFuture.get();
+                } catch (InterruptedException e) {
+                    logger.error("Media Controller interrupted exception", e);
+                    Thread.currentThread().interrupt();
+                } catch (Exception e) {
+                    logger.error("Media Controller exception", e);
+                } finally {
+                    mediaControllerConsumer.accept(mediaController);
+                }
+            },
+            MoreExecutors.directExecutor()
+        );
+    }
+
+    /**
+     * @return The {@code MediaController} that is currently bound to {@code AudioPlayerService} or null.
+     * @see MediaViewerActivity#awaitAudioMediaController
+     */
+    @Nullable
+    public MediaController getAudioMediaController() {
+        return this.mediaController;
+    }
+
     /**
      * Page Adapter that instantiates ImageViewFragments
      */
     public static class ScreenSlidePagerAdapter extends FragmentStatePagerAdapter {
 
-        private final MediaViewerActivity a;
+        private final MediaViewerActivity mediaViewerActivity;
         private final FragmentManager fragmentManager;
         private final SparseArray<Fragment> fragments;
         private FragmentTransaction curTransaction;
 
-        public ScreenSlidePagerAdapter(MediaViewerActivity a, FragmentManager fm) {
+        public ScreenSlidePagerAdapter(MediaViewerActivity mediaViewerActivity, FragmentManager fm) {
             super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
-            this.a = a;
+            this.mediaViewerActivity = mediaViewerActivity;
             fragmentManager = fm;
             fragments = new SparseArray<>();
         }
@@ -647,69 +722,71 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
         @NonNull
         @Override
         public Fragment getItem(final int position) {
-            logger.debug("getItem " + position);
+            logger.debug("getItem {}", position);
 
-            if (a.fragments[position] == null) {
-                final AbstractMessageModel messageModel = a.messageModels.get(position);
-                MediaViewFragment f;
+            if (mediaViewerActivity.fragments[position] == null) {
+                final AbstractMessageModel messageModel = mediaViewerActivity.messageModels.get(position);
+                MediaViewFragment mediaViewFragment;
                 Bundle args = new Bundle();
 
                 // check if caller wants the item to be played immediately
-                Intent intent = a.getIntent();
-                if (intent.getExtras().getBoolean(EXTRA_ID_IMMEDIATE_PLAY, false)) {
+                final Intent intent = mediaViewerActivity.getIntent();
+                if (intent.getExtras() != null && intent.getExtras().getBoolean(EXTRA_ID_IMMEDIATE_PLAY, false)) {
                     args.putBoolean(EXTRA_ID_IMMEDIATE_PLAY, true);
                     intent.removeExtra(EXTRA_ID_IMMEDIATE_PLAY);
                 }
 
                 switch (messageModel.getType()) {
                     case VIDEO:
-                        f = new VideoViewFragment();
+                        mediaViewFragment = new VideoViewFragment();
                         break;
                     case FILE:
                         String mimeType = messageModel.getFileData().getMimeType();
                         if (MimeUtil.isSupportedImageFile(mimeType)) {
-                            f = new ImageViewFragment();
+                            mediaViewFragment = new ImageViewFragment();
                         } else if (MimeUtil.isVideoFile(mimeType)) {
-                            f = new VideoViewFragment();
+                            mediaViewFragment = new VideoViewFragment();
                         } else if (MimeUtil.isAudioFile(mimeType)) {
                             if (MimeUtil.isMidiFile(mimeType) || MimeUtil.isFlacFile(mimeType)) {
-                                f = new MediaPlayerViewFragment();
+                                mediaViewFragment = new MediaPlayerViewFragment();
                             } else {
-                                args.putBoolean(EXTRA_IS_VOICEMESSAGE, messageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE);
-                                f = new AudioViewFragment();
+                                args.putBoolean(EXTRA_IS_VOICE_MESSAGE, messageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE);
+                                args.putBoolean(EXTRA_IS_PRIVATE_CHAT, mediaViewerActivity.isPrivateChat);
+                                mediaViewFragment = new AudioViewFragment();
                             }
                         } else {
-                            f = new FileViewFragment();
+                            mediaViewFragment = new FileViewFragment();
                         }
                         break;
                     case VOICEMESSAGE:
-                        f = new AudioViewFragment();
+                        args.putBoolean(EXTRA_IS_PRIVATE_CHAT, mediaViewerActivity.isPrivateChat);
+                        mediaViewFragment = new AudioViewFragment();
                         break;
                     default:
-                        f = new ImageViewFragment();
+                        mediaViewFragment = new ImageViewFragment();
                 }
 
                 args.putInt("position", position);
-                f.setArguments(args);
+                mediaViewFragment.setArguments(args);
 
-                f.setOnImageLoaded(new MediaViewFragment.OnMediaLoadListener() {
+                mediaViewFragment.setOnImageLoaded(new MediaViewFragment.OnMediaLoadListener() {
                     @Override
                     public void decrypting() {
-                        a.currentMediaFile = null;
+                        mediaViewerActivity.currentMediaFile = null;
                     }
 
                     @Override
                     public void decrypted(boolean success) {
                         if (!success) {
-                            a.currentMediaFile = null;
-                            a.updateMenus();
+                            mediaViewerActivity.currentMediaFile = null;
+                            mediaViewerActivity.updateMenus();
                         }
                     }
 
                     @Override
                     public void loaded(File file) {
-                        a.currentMediaFile = file;
-                        a.updateMenus();
+                        mediaViewerActivity.currentMediaFile = file;
+                        mediaViewerActivity.updateMenus();
                     }
 
                     @Override
@@ -717,16 +794,16 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
                         //do nothing!
                     }
                 });
-                a.fragments[position] = f;
+                mediaViewerActivity.fragments[position] = mediaViewFragment;
             }
 
-            return a.fragments[position];
+            return mediaViewerActivity.fragments[position];
         }
 
         @SuppressLint("CommitTransaction")
         @Override
         public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
-            logger.debug("destroyItem " + position);
+            logger.debug("destroyItem {}", position);
 
             if (curTransaction == null) {
                 curTransaction = fragmentManager.beginTransaction();
@@ -734,13 +811,13 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
             curTransaction.detach(fragments.get(position));
             fragments.remove(position);
 
-            if (position >= 0 && position < a.fragments.length) {
-                if (a.fragments[position] != null) {
+            if (position >= 0 && position < mediaViewerActivity.fragments.length) {
+                if (mediaViewerActivity.fragments[position] != null) {
                     //free memory
-                    a.fragments[position].destroy();
+                    mediaViewerActivity.fragments[position].destroy();
 
                     //remove from array
-                    a.fragments[position] = null;
+                    mediaViewerActivity.fragments[position] = null;
                 }
             }
         }
@@ -756,7 +833,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
 
         @Override
         public int getCount() {
-            return a.messageModels.size();
+            return mediaViewerActivity.messageModels.size();
         }
 
         @Override
@@ -771,18 +848,16 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
     }
 
     @Override
-    public void onRequestPermissionsResult(int requestCode,
-                                           @NonNull String[] permissions, @NonNull int[] grantResults) {
+    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
         super.onRequestPermissionsResult(requestCode, permissions, grantResults);
-        switch (requestCode) {
-            case PERMISSION_REQUEST_SAVE_MESSAGE:
-                if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
-                    saveMedia();
-                } else {
-                    if (!ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_EXTERNAL_STORAGE)) {
-                        ConfigUtils.showPermissionRationale(this, findViewById(R.id.pager), R.string.permission_storage_required);
-                    }
+        if (requestCode == PERMISSION_REQUEST_SAVE_MESSAGE) {
+            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+                saveMedia();
+            } else {
+                if (!ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_EXTERNAL_STORAGE)) {
+                    ConfigUtils.showPermissionRationale(this, findViewById(R.id.pager), R.string.permission_storage_required);
                 }
+            }
         }
     }
 

+ 27 - 10
app/src/main/java/ch/threema/app/activities/MemberChooseActivity.java

@@ -33,6 +33,8 @@ import com.google.android.material.search.SearchBar;
 import com.google.android.material.snackbar.Snackbar;
 import com.google.android.material.tabs.TabLayout;
 
+import org.koin.java.KoinJavaComponent;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
@@ -43,6 +45,7 @@ import java.util.Set;
 import ch.threema.android.ActivityExtensionsKt;
 import ch.threema.app.R;
 import ch.threema.app.adapters.FilterableListAdapter;
+import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.fragments.MemberListFragment;
 import ch.threema.app.fragments.UserMemberListFragment;
 import ch.threema.app.fragments.WorkUserMemberListFragment;
@@ -56,6 +59,8 @@ import ch.threema.app.utils.SnackbarUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.ContactModel;
 
+import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
+
 abstract public class MemberChooseActivity extends ThreemaToolbarActivity implements SearchView.OnQueryTextListener, MemberListFragment.SelectionListener {
     private final static int FRAGMENT_USERS = 0;
     private final static int FRAGMENT_WORK_USERS = 1;
@@ -72,6 +77,9 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
     protected final static int MODE_PROFILE_PIC_RECIPIENTS = 4;
     private static final String BUNDLE_QUERY_TEXT = "query";
 
+    @NonNull
+    protected final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
+
     private MemberChoosePagerAdapter memberChoosePagerAdapter;
     private MenuItem searchMenuItem;
     private ThreemaSearchView searchView;
@@ -87,6 +95,18 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
     private ExtendedFloatingActionButton floatingActionButton;
     private String queryText;
 
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        if (!isSessionScopeReady()) {
+            finish();
+            return;
+        }
+        if (savedInstanceState != null) {
+            queryText = savedInstanceState.getString(BUNDLE_QUERY_TEXT, null);
+        }
+    }
+
     @Override
     public boolean onQueryTextSubmit(String query) {
         // Do something
@@ -260,15 +280,6 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
         });
     }
 
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-
-        if (savedInstanceState != null) {
-            queryText = savedInstanceState.getString(BUNDLE_QUERY_TEXT, null);
-        }
-    }
-
     @Override
     public boolean onCreateOptionsMenu(Menu menu) {
         super.onCreateOptionsMenu(menu);
@@ -482,7 +493,13 @@ abstract public class MemberChooseActivity extends ThreemaToolbarActivity implem
             if (builder.length() > 0) {
                 builder.append(", ");
             }
-            builder.append(NameUtil.getDisplayNameOrNickname(contactModel, true));
+            builder.append(
+                NameUtil.getContactDisplayNameOrNickname(
+                    contactModel,
+                    true,
+                    dependencies.getPreferenceService().getContactNameFormat()
+                )
+            );
         }
         return builder.toString();
     }

+ 0 - 222
app/src/main/java/ch/threema/app/activities/PinLockActivity.kt

@@ -1,222 +0,0 @@
-package ch.threema.app.activities
-
-import android.content.Context
-import android.os.Bundle
-import android.text.InputFilter
-import android.text.InputFilter.LengthFilter
-import android.text.InputType
-import android.view.KeyEvent
-import android.view.View
-import android.view.WindowManager
-import android.view.inputmethod.EditorInfo
-import android.widget.Button
-import android.widget.TextView
-import androidx.lifecycle.lifecycleScope
-import ch.threema.android.buildActivityIntent
-import ch.threema.app.AppConstants
-import ch.threema.app.R
-import ch.threema.app.ThreemaApplication.Companion.getServiceManager
-import ch.threema.app.preference.service.PreferenceService
-import ch.threema.app.services.LockAppService
-import ch.threema.app.ui.InsetSides.Companion.all
-import ch.threema.app.ui.applyDeviceInsetsAsPadding
-import ch.threema.app.utils.EditTextUtil
-import ch.threema.app.utils.NavigationUtil
-import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.getThreemaLogger
-import ch.threema.common.TimeProvider
-import ch.threema.common.consume
-import ch.threema.common.minus
-import ch.threema.common.plus
-import java.time.Instant
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.seconds
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import org.koin.android.ext.android.inject
-
-private val logger = getThreemaLogger("PinLockActivity")
-
-class PinLockActivity : ThreemaActivity() {
-    init {
-        logScreenVisibility(logger)
-    }
-
-    private val lockAppService: LockAppService by inject()
-    private val preferenceService: PreferenceService by inject()
-    private val timeProvider: TimeProvider by inject()
-
-    private lateinit var passwordEntry: TextView
-    private lateinit var errorTextView: TextView
-
-    private val isCheckOnly by lazy(LazyThreadSafetyMode.NONE) {
-        intent.getBooleanExtra(INTENT_DATA_CHECK_ONLY, false)
-    }
-
-    private var failedAttempts: Int
-        get() = preferenceService.lockoutAttempts
-        set(value) {
-            preferenceService.lockoutAttempts = value
-        }
-    private var lockoutDeadline: Instant?
-        get() = (preferenceService.lockoutDeadline)
-            ?.let { deadline ->
-                val now = timeProvider.get()
-                deadline
-                    .takeIf { it > now }
-                    ?.coerceAtMost(now + LOCKOUT_TIMEOUT)
-            }
-        set(value) {
-            preferenceService.lockoutDeadline = value
-        }
-
-    private var errorResetJob: Job? = null
-    private var countdownJob: Job? = null
-
-    public override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
-
-        if (getServiceManager() == null) {
-            finish()
-            return
-        }
-
-        if (!lockAppService.isLocked && !isCheckOnly) {
-            finish()
-            return
-        }
-
-        setContentView(R.layout.activity_pin_lock)
-        findViewById<View>(R.id.topFrame).applyDeviceInsetsAsPadding(all())
-        passwordEntry = findViewById(R.id.password_entry)
-        errorTextView = findViewById(R.id.errorText)
-
-        passwordEntry.setOnEditorActionListener { _: TextView?, actionId: Int, _: KeyEvent? ->
-            when (actionId) {
-                EditorInfo.IME_NULL, EditorInfo.IME_ACTION_DONE, EditorInfo.IME_ACTION_NEXT -> consume {
-                    onSubmit()
-                }
-                else -> false
-            }
-        }
-        passwordEntry.setFilters(arrayOf<InputFilter>(LengthFilter(AppConstants.MAX_PIN_LENGTH)))
-        passwordEntry.setInputType(InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD)
-
-        findViewById<Button>(R.id.cancelButton).setOnClickListener { quit() }
-    }
-
-    private fun onSubmit() {
-        val pin = passwordEntry.text.toString()
-        if (lockAppService.unlock(pin)) {
-            EditTextUtil.hideSoftKeyboard(passwordEntry)
-
-            setResult(RESULT_OK)
-            failedAttempts = 0
-            lockoutDeadline = null
-            finish()
-        } else {
-            failedAttempts++
-
-            if (failedAttempts > MAX_FAILED_ATTEMPTS) {
-                lockoutDeadline = timeProvider.get() + LOCKOUT_TIMEOUT
-                handleAttemptLockout()
-            } else {
-                showError(getString(R.string.pinentry_wrong_pin), resetTimeout = ERROR_MESSAGE_TIMEOUT)
-            }
-
-            if (isCheckOnly) {
-                passwordEntry.isEnabled = false
-
-                lifecycleScope.launch {
-                    delay(1.seconds)
-                    quit()
-                }
-            }
-        }
-    }
-
-    public override fun onResume() {
-        super.onResume()
-
-        if (!lockAppService.isLocked && !isCheckOnly) {
-            finish()
-            return
-        }
-
-        handleAttemptLockout()
-    }
-    public override fun onPause() {
-        super.onPause()
-        countdownJob?.cancel()
-        countdownJob = null
-        overridePendingTransition(0, 0)
-    }
-
-    private fun handleAttemptLockout() {
-        val deadline = lockoutDeadline ?: return
-        passwordEntry.isEnabled = false
-
-        countdownJob?.cancel()
-        countdownJob = lifecycleScope.launch {
-            var seconds = (deadline - timeProvider.get()).inWholeSeconds
-            while (seconds > 0) {
-                showError(getString(R.string.too_many_incorrect_attempts, seconds.toString()))
-                delay(1.seconds)
-                seconds--
-            }
-
-            passwordEntry.isEnabled = true
-            errorTextView.text = ""
-            failedAttempts = 0
-        }
-    }
-
-    private fun showError(errorMessage: CharSequence, resetTimeout: Duration? = null) {
-        errorTextView.text = errorMessage
-        errorTextView.announceForAccessibility(errorTextView.text)
-        passwordEntry.text = null
-
-        errorResetJob?.cancel()
-        resetTimeout?.let {
-            errorResetJob = lifecycleScope.launch {
-                delay(resetTimeout)
-                errorTextView.text = ""
-            }
-        }
-    }
-
-    override fun isPinLockable() = false
-
-    override fun enableOnBackPressedCallback() = true
-
-    override fun handleOnBackPressed() {
-        quit()
-    }
-
-    private fun quit() {
-        EditTextUtil.hideSoftKeyboard(passwordEntry)
-
-        if (isCheckOnly) {
-            setResult(RESULT_CANCELED)
-            finish()
-        } else {
-            NavigationUtil.navigateToLauncher(this)
-        }
-    }
-
-    companion object {
-        private const val MAX_FAILED_ATTEMPTS = 3
-        private val ERROR_MESSAGE_TIMEOUT = 3.seconds
-        private val LOCKOUT_TIMEOUT = 30.seconds
-
-        private const val INTENT_DATA_CHECK_ONLY = "check"
-
-        @JvmStatic
-        @JvmOverloads
-        fun createIntent(context: Context, checkOnly: Boolean = false) = buildActivityIntent<PinLockActivity>(context) {
-            putExtra(INTENT_DATA_CHECK_ONLY, checkOnly)
-        }
-    }
-}

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

@@ -5,14 +5,13 @@ import android.os.Bundle
 import androidx.annotation.StringRes
 import ch.threema.android.buildActivityIntent
 import ch.threema.app.R
-import ch.threema.app.ThreemaApplication
 import ch.threema.app.services.ProfilePictureRecipientsService
 import ch.threema.app.tasks.ReflectUserProfileShareWithAllowListSyncTask
 import ch.threema.app.utils.logScreenVisibility
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.common.equalsIgnoreOrder
 import ch.threema.domain.taskmanager.TaskManager
-import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
 import ch.threema.storage.models.ContactModel
 import org.koin.android.ext.android.inject
 
@@ -38,7 +37,7 @@ class ProfilePicRecipientsActivity : MemberChooseActivity() {
 
     override fun initData(savedInstanceState: Bundle?) {
         if (savedInstanceState == null) {
-            val selectedIdentities: Array<Identity>? = profilePictureRecipientsService.all
+            val selectedIdentities: Array<IdentityString>? = profilePictureRecipientsService.all
             if (!selectedIdentities.isNullOrEmpty()) {
                 preselectedIdentities = ArrayList(listOf(*selectedIdentities))
             }
@@ -48,8 +47,8 @@ class ProfilePicRecipientsActivity : MemberChooseActivity() {
     }
 
     override fun menuNext(selectedContacts: List<ContactModel?>) {
-        val oldAllowedIdentities: Array<Identity> = profilePictureRecipientsService.all
-        val newAllowedIdentities: Array<Identity> = selectedContacts
+        val oldAllowedIdentities: Array<IdentityString> = profilePictureRecipientsService.all
+        val newAllowedIdentities: Array<IdentityString> = selectedContacts
             .mapNotNull { contactModel -> contactModel?.identity }
             .toTypedArray<String>()
         profilePictureRecipientsService.replaceAll(newAllowedIdentities)
@@ -60,7 +59,6 @@ class ProfilePicRecipientsActivity : MemberChooseActivity() {
             taskManager.schedule(
                 ReflectUserProfileShareWithAllowListSyncTask(
                     allowedIdentities = newAllowedIdentities.toSet(),
-                    serviceManager = ThreemaApplication.requireServiceManager(),
                 ),
             )
         }

+ 47 - 40
app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java

@@ -85,6 +85,7 @@ import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.messagereceiver.SendingPermissionValidationResult;
 import ch.threema.app.services.FileService;
 import ch.threema.app.preference.service.PreferenceService;
+import ch.threema.app.startup.AppStartupAware;
 import ch.threema.app.ui.LongToast;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.ui.SingleToast;
@@ -103,22 +104,24 @@ import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.ShortcutUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.executor.BackgroundExecutor;
+
+import static ch.threema.app.startup.AppStartupUtilKt.waitUntilReady;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.domain.protocol.csp.messages.location.Poi;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.DistributionListModel;
-import ch.threema.storage.models.GroupModel;
+import ch.threema.storage.models.group.GroupModelOld;
 import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.data.LocationDataModel;
 import ch.threema.storage.models.data.MessageContentsType;
-import java8.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletableFuture;
 import kotlin.Lazy;
+import kotlin.Unit;
 
 import static ch.threema.app.activities.SendMediaActivity.MAX_EDITABLE_FILES;
-import static ch.threema.app.fragments.ComposeMessageFragment.MAX_FORWARDABLE_ITEMS;
-import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
+import static ch.threema.app.fragments.composemessage.ComposeMessageFragment.MAX_FORWARDABLE_ITEMS;
 import static ch.threema.app.ui.MediaItem.TYPE_IMAGE;
 import static ch.threema.app.ui.MediaItem.TYPE_LOCATION;
 import static ch.threema.app.ui.MediaItem.TYPE_TEXT;
@@ -129,7 +132,8 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     CancelableHorizontalProgressDialog.ProgressDialogClickListener,
     ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener,
     TextWithCheckboxDialog.TextWithCheckboxDialogClickListener,
-    SearchView.OnQueryTextListener {
+    SearchView.OnQueryTextListener,
+    AppStartupAware {
 
     private static final Logger logger = getThreemaLogger("RecipientListBaseActivity");
 
@@ -587,11 +591,10 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 
                         if (contactModel != null) {
                             prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)), false);
-                            return;
                         } else {
                             finish();
-                            return;
                         }
+                        return;
                     }
                 } else if (action.equals(Intent.ACTION_VIEW)) {
                     // called from action URL
@@ -792,7 +795,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
                     dependencies.getApiConnector(),
                     dependencies.getContactModelRepository(),
                     AddContactRestrictionPolicy.CHECK,
-                    RecipientListBaseActivity.this,
+                    dependencies.getAppRestrictions(),
                     null
                 ) {
                     @Override
@@ -862,8 +865,8 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 
         if (model instanceof ContactModel) {
             messageReceiver = dependencies.getContactService().createReceiver((ContactModel) model);
-        } else if (model instanceof GroupModel) {
-            messageReceiver = dependencies.getGroupService().createReceiver((GroupModel) model);
+        } else if (model instanceof GroupModelOld) {
+            messageReceiver = dependencies.getGroupService().createReceiver((GroupModelOld) model);
         } else if (model instanceof DistributionListModel) {
             messageReceiver = dependencies.getDistributionListService().createReceiver((DistributionListModel) model);
         }
@@ -991,7 +994,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             finishAffinity();
         } else {
             // we have to clear the backstack to prevent users from coming back here with the return key
-            Intent upIntent = new Intent(this, HomeActivity.class);
+            Intent upIntent = HomeActivity.createIntent(this);
             TaskStackBuilder.create(this)
                 .addNextIntent(upIntent)
                 .addNextIntent(intent)
@@ -1101,11 +1104,11 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 
                                 if (originalMessageModel.getMessageContentsType() == MessageContentsType.IMAGE) {
                                     if (originalMessageModel.getFileData().getRenderingType() == FileData.RENDERING_DEFAULT) {
-                                        mediaItem.setImageScale(PreferenceService.ImageScale_SEND_AS_FILE);
+                                        mediaItem.setImageScale(PreferenceService.IMAGE_SCALE_SEND_AS_FILE);
                                     }
                                 } else if (originalMessageModel.getMessageContentsType() == MessageContentsType.VIDEO) {
                                     if (originalMessageModel.getFileData().getRenderingType() == FileData.RENDERING_DEFAULT) {
-                                        mediaItem.setVideoSize(PreferenceService.VideoSize_SEND_AS_FILE);
+                                        mediaItem.setVideoSize(PreferenceService.VIDEO_SIZE_SEND_AS_FILE);
                                     }
                                 } else if (originalMessageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE) {
                                     mediaItem.setDurationMs(
@@ -1172,11 +1175,29 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
                 recipientNameBuilder.append(", ");
             }
             if (recipientModel instanceof ContactModel) {
-                recipientNameBuilder.append(NameUtil.getDisplayNameOrNickname((ContactModel) recipientModel, true));
-            } else if (recipientModel instanceof GroupModel) {
-                recipientNameBuilder.append(NameUtil.getDisplayName((GroupModel) recipientModel, dependencies.getGroupService()));
+                recipientNameBuilder.append(
+                    NameUtil.getContactDisplayNameOrNickname(
+                        (ContactModel) recipientModel,
+                        true,
+                        dependencies.getPreferenceService().getContactNameFormat()
+                    )
+                );
+            } else if (recipientModel instanceof GroupModelOld) {
+                recipientNameBuilder.append(
+                    NameUtil.getGroupDisplayName(
+                        (GroupModelOld) recipientModel,
+                        dependencies.getGroupService(),
+                        dependencies.getPreferenceService().getContactNameFormat()
+                    )
+                );
             } else if (recipientModel instanceof DistributionListModel) {
-                recipientNameBuilder.append(NameUtil.getDisplayName((DistributionListModel) recipientModel, dependencies.getDistributionListService()));
+                recipientNameBuilder.append(
+                    NameUtil.getDistributionListDisplayName(
+                        (DistributionListModel) recipientModel,
+                        dependencies.getDistributionListService(),
+                        dependencies.getPreferenceService().getContactNameFormat()
+                    )
+                );
             }
         }
         return recipientNameBuilder.toString();
@@ -1348,37 +1369,23 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
         return super.onOptionsItemSelected(item);
     }
 
-    @Override
-    protected void onResume() {
-        logger.debug("onResume");
-        super.onResume();
-    }
-
-    @Override
-    protected void onPause() {
-        logger.debug("onPause");
-        super.onPause();
-    }
-
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
-        if (finishAndRestartLaterIfNotReady(this)) {
-            return;
-        }
+
+        // TODO(ANDR-4389): Improve the waiting mechanism
+        waitUntilReady(this, () -> {
+            initActivity(savedInstanceState);
+            handleDeviceInsets();
+            return Unit.INSTANCE;
+        });
 
         if (savedInstanceState != null) {
             queryText = savedInstanceState.getString(BUNDLE_QUERY_TEXT, null);
         }
     }
 
-    @Override
-    public void onUserInteraction() {
-        logger.debug("onUserInteraction");
-        super.onUserInteraction();
-    }
-
     @Override
     protected void onActivityResult(int requestCode, int resultCode, Intent data) {
         if (requestCode == ACTIVITY_ID_SEND_MEDIA) {
@@ -1519,10 +1526,10 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
         if (renderingType == FileData.RENDERING_MEDIA) {
             if (type == MediaItem.TYPE_VIDEO) {
                 // do not re-transcode forwarded videos
-                mediaItem.setVideoSize(PreferenceService.VideoSize_ORIGINAL);
+                mediaItem.setVideoSize(PreferenceService.VIDEO_SIZE_ORIGINAL);
             } else if (type == TYPE_IMAGE) {
                 // do not scale forwarded images
-                mediaItem.setImageScale(PreferenceService.ImageScale_ORIGINAL);
+                mediaItem.setImageScale(PreferenceService.IMAGE_SCALE_ORIGINAL);
             } else if (type == MediaItem.TYPE_VOICEMESSAGE) {
                 mediaItem.setDurationMs(durationMs);
             }

+ 0 - 86
app/src/main/java/ch/threema/app/activities/SMSVerificationLinkActivity.java

@@ -1,86 +0,0 @@
-package ch.threema.app.activities;
-
-import android.content.Intent;
-import android.net.Uri;
-import android.os.AsyncTask;
-import android.os.Bundle;
-import android.widget.Toast;
-
-import org.koin.java.KoinJavaComponent;
-import org.slf4j.Logger;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.StringRes;
-import androidx.appcompat.app.AppCompatActivity;
-import ch.threema.app.R;
-import ch.threema.app.di.DependencyContainer;
-import ch.threema.app.services.UserService;
-import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
-import ch.threema.domain.taskmanager.TriggerSource;
-
-import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
-import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
-
-public class SMSVerificationLinkActivity extends AppCompatActivity {
-    private static final Logger logger = getThreemaLogger("SMSVerificationLinkActivity");
-
-    @NonNull
-    private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
-
-    public void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        logScreenVisibility(this, logger);
-        if (finishAndRestartLaterIfNotReady(this)) {
-            return;
-        }
-
-        Integer resultText = R.string.verify_failed_summary;
-
-        var userService = dependencies.getUserService();
-        if (userService.getMobileLinkingState() == UserService.LinkingState_PENDING) {
-            Intent intent = getIntent();
-            Uri data = intent.getData();
-            if (data != null) {
-                final String code = data.getQueryParameter("code");
-
-                if (code != null && !code.isEmpty()) {
-                    resultText = null;
-
-                    new AsyncTask<Void, Void, Boolean>() {
-                        @Override
-                        protected Boolean doInBackground(Void... params) {
-                            try {
-                                userService.verifyMobileNumber(code, TriggerSource.LOCAL);
-                                return true;
-                            } catch (Exception e) {
-                                logger.error("Failed to verify mobile number", e);
-                            }
-                            return false;
-                        }
-
-                        @Override
-                        protected void onPostExecute(Boolean result) {
-                            showConfirmation(result ? R.string.verify_success_text : R.string.verify_failed_summary);
-                        }
-                    }.execute();
-                }
-            }
-        } else if (userService.getMobileLinkingState() == UserService.LinkingState_LINKED) {
-            // already linked
-            resultText = R.string.verify_success_text;
-        } else if (userService.getMobileLinkingState() == UserService.LinkingState_NONE) {
-            resultText = R.string.verify_failed_not_linked;
-        }
-
-        showConfirmation(resultText);
-        finish();
-    }
-
-    private void showConfirmation(Integer resultText) {
-        if (resultText != null) {
-            @StringRes int resId = resultText;
-
-            Toast.makeText(getApplicationContext(), resId, Toast.LENGTH_LONG).show();
-        }
-    }
-}

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

@@ -0,0 +1,67 @@
+package ch.threema.app.activities
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import ch.threema.android.ToastDuration
+import ch.threema.android.showToast
+import ch.threema.app.R
+import ch.threema.app.services.UserService
+import ch.threema.app.startup.finishAndRestartLaterIfNotReady
+import ch.threema.app.utils.DispatcherProvider
+import ch.threema.app.utils.logScreenVisibility
+import ch.threema.base.utils.getThreemaLogger
+import ch.threema.domain.taskmanager.TriggerSource
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.koin.android.ext.android.inject
+
+private val logger = getThreemaLogger("SMSVerificationLinkActivity")
+
+class SMSVerificationLinkActivity : AppCompatActivity() {
+    init {
+        logScreenVisibility(logger)
+    }
+
+    private val userService: UserService by inject()
+    private val dispatcherProvider: DispatcherProvider by inject()
+
+    public override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        if (finishAndRestartLaterIfNotReady()) {
+            return
+        }
+
+        lifecycleScope.launch {
+            verifyMobileNumber()
+            finish()
+        }
+    }
+
+    private suspend fun verifyMobileNumber() {
+        when (userService.getMobileLinkingState()) {
+            UserService.LinkingState_PENDING -> {
+                val code = intent.data?.getQueryParameter("code")
+                if (code.isNullOrEmpty()) {
+                    showToast(R.string.verify_failed_summary, ToastDuration.LONG)
+                } else {
+                    try {
+                        withContext(dispatcherProvider.worker) {
+                            userService.verifyMobileNumber(code, TriggerSource.LOCAL)
+                        }
+                        showToast(R.string.verify_success_text, ToastDuration.LONG)
+                    } catch (e: Exception) {
+                        logger.error("Failed to verify mobile number", e)
+                        showToast(R.string.verify_failed_summary, ToastDuration.LONG)
+                    }
+                }
+            }
+            UserService.LinkingState_LINKED -> {
+                showToast(R.string.verify_success_text, ToastDuration.LONG)
+            }
+            UserService.LinkingState_NONE -> {
+                showToast(R.string.verify_failed_not_linked, ToastDuration.LONG)
+            }
+        }
+    }
+}

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

@@ -1,13 +1,14 @@
 package ch.threema.app.activities;
 
+import static ch.threema.android.ToastKt.showToast;
 import static ch.threema.app.adapters.SendMediaPreviewAdapter.VIEW_TYPE_NORMAL;
 import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
-import static ch.threema.app.preference.service.PreferenceService.ImageScale_SEND_AS_FILE;
-import static ch.threema.app.preference.service.PreferenceService.VideoSize_DEFAULT;
-import static ch.threema.app.preference.service.PreferenceService.VideoSize_MEDIUM;
-import static ch.threema.app.preference.service.PreferenceService.VideoSize_ORIGINAL;
-import static ch.threema.app.preference.service.PreferenceService.VideoSize_SEND_AS_FILE;
-import static ch.threema.app.preference.service.PreferenceService.VideoSize_SMALL;
+import static ch.threema.app.preference.service.PreferenceService.IMAGE_SCALE_SEND_AS_FILE;
+import static ch.threema.app.preference.service.PreferenceService.VIDEO_SIZE_DEFAULT;
+import static ch.threema.app.preference.service.PreferenceService.VIDEO_SIZE_MEDIUM;
+import static ch.threema.app.preference.service.PreferenceService.VIDEO_SIZE_ORIGINAL;
+import static ch.threema.app.preference.service.PreferenceService.VIDEO_SIZE_SEND_AS_FILE;
+import static ch.threema.app.preference.service.PreferenceService.VIDEO_SIZE_SMALL;
 import static ch.threema.app.ui.MediaItem.TYPE_IMAGE;
 import static ch.threema.app.ui.MediaItem.TYPE_IMAGE_CAM;
 import static ch.threema.app.ui.MediaItem.TYPE_VIDEO;
@@ -44,7 +45,6 @@ import android.view.inputmethod.EditorInfo;
 import android.widget.ImageButton;
 import android.widget.LinearLayout;
 import android.widget.TextView;
-import android.widget.Toast;
 
 import androidx.annotation.IdRes;
 import androidx.annotation.NonNull;
@@ -78,13 +78,13 @@ import java.util.List;
 import java.util.Objects;
 
 import ch.threema.android.ActivityExtensionsKt;
+import ch.threema.android.ToastDuration;
 import ch.threema.app.AppConstants;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.SendMediaAdapter;
 import ch.threema.app.adapters.SendMediaPreviewAdapter;
 import ch.threema.app.camera.CameraActivity;
-import ch.threema.app.camera.CameraUtil;
 import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.dialogs.CallbackTextEntryDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
@@ -263,7 +263,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
                 messageReceivers.remove(messageReceiver);
                 @Nullable Integer errorStringRes = ((SendingPermissionValidationResult.Denied) validationResult).getErrorResId();
                 if (errorStringRes != null) {
-                    Toast.makeText(getApplicationContext(), errorStringRes, Toast.LENGTH_LONG).show();
+                    showToast(getApplicationContext(), errorStringRes);
                 }
             }
             if (allReceiverChatsAreHidden && !dependencies.getConversationCategoryService().isPrivateChat(messageReceiver.getUniqueIdString())) {
@@ -476,7 +476,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
             }
         });
 
-        if (dependencies.getPreferenceService().getEmojiStyle() != PreferenceService.EmojiStyle_ANDROID) {
+        if (dependencies.getPreferenceService().getEmojiStyle() != PreferenceService.EMOJI_STYLE_ANDROID) {
             addOnSoftKeyboardChangedListener(this);
         }
 
@@ -629,7 +629,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
             final @PreferenceService.ImageScale int oldSetting = mediaItem.getImageScale();
             final @PreferenceService.ImageScale int newSetting = item.getOrder();
             mediaItem.setImageScale(newSetting);
-            if (oldSetting != newSetting && (oldSetting == ImageScale_SEND_AS_FILE || newSetting == ImageScale_SEND_AS_FILE)) {
+            if (oldSetting != newSetting && (oldSetting == IMAGE_SCALE_SEND_AS_FILE || newSetting == IMAGE_SCALE_SEND_AS_FILE)) {
                 mediaAdapterManager.updateSendAsFileState(NOTIFY_BOTH_ADAPTERS);
                 updateMenu();
             }
@@ -642,7 +642,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
         }
 
         @PreferenceService.ImageScale int currentScale = mediaItem.getImageScale();
-        if (currentScale == PreferenceService.ImageScale_DEFAULT) {
+        if (currentScale == PreferenceService.IMAGE_SCALE_DEFAULT) {
             currentScale = dependencies.getPreferenceService().getImageScale();
         }
 
@@ -658,10 +658,10 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
             final @PreferenceService.VideoSize int newVideoSize = getVideoSize(item.getItemId());
             final @PreferenceService.VideoSize int oldVideoSize = mediaItem.getVideoSize();
-            if (newVideoSize != VideoSize_DEFAULT && oldVideoSize != newVideoSize) {
+            if (newVideoSize != VIDEO_SIZE_DEFAULT && oldVideoSize != newVideoSize) {
                 mediaItem.setVideoSize(newVideoSize);
-                mediaItem.setRenderingType(newVideoSize == VideoSize_SEND_AS_FILE ? FileData.RENDERING_DEFAULT : FileData.RENDERING_MEDIA);
-                if (oldVideoSize == VideoSize_SEND_AS_FILE || newVideoSize == VideoSize_SEND_AS_FILE) {
+                mediaItem.setRenderingType(newVideoSize == VIDEO_SIZE_SEND_AS_FILE ? FileData.RENDERING_DEFAULT : FileData.RENDERING_MEDIA);
+                if (oldVideoSize == VIDEO_SIZE_SEND_AS_FILE || newVideoSize == VIDEO_SIZE_SEND_AS_FILE) {
                     mediaAdapterManager.updateSendAsFileState(NOTIFY_BOTH_ADAPTERS);
                     updateMenu();
                 }
@@ -679,13 +679,13 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
         // Set video size item checked
         @PreferenceService.VideoSize int currentSize = mediaItem.getVideoSize();
-        if (currentSize == PreferenceService.VideoSize_DEFAULT) {
+        if (currentSize == PreferenceService.VIDEO_SIZE_DEFAULT) {
             currentSize = dependencies.getPreferenceService().getVideoSize();
         }
         popup.getMenu().findItem(getMenuItemId(currentSize)).setChecked(true);
 
         // Update mute option
-        if (mediaItem.getVideoSize() == VideoSize_SEND_AS_FILE) {
+        if (mediaItem.getVideoSize() == VIDEO_SIZE_SEND_AS_FILE) {
             popup.getMenu().removeItem(R.id.mute_item);
         } else {
             popup.getMenu().findItem(R.id.mute_item).setChecked(mediaItem.isMuted());
@@ -715,7 +715,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
         final Intent cameraIntent;
         final int requestCode;
-        if (CameraUtil.isInternalCameraSupported() && !useExternalCamera) {
+        if (!useExternalCamera) {
             // use internal camera
             cameraIntent = new Intent(this, CameraActivity.class);
             cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, cameraFilePath);
@@ -727,7 +727,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
             if (packageManager == null || !(packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA) ||
                 packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY))) {
                 logger.info("No camera found, closing");
-                Toast.makeText(getApplicationContext(), R.string.no_camera_installed, Toast.LENGTH_LONG).show();
+                showToast(getApplicationContext(), R.string.no_camera_installed, ToastDuration.LONG);
                 finish();
                 return;
             }
@@ -955,7 +955,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
                     break;
                 case ThreemaActivity.ACTIVITY_ID_PICK_CAMERA_EXTERNAL:
                 case ThreemaActivity.ACTIVITY_ID_PICK_CAMERA_INTERNAL:
-                    if (ConfigUtils.supportsVideoCapture() && intent != null && intent.getBooleanExtra(CameraActivity.EXTRA_VIDEO_RESULT, false)) {
+                    if (intent != null && intent.getBooleanExtra(CameraActivity.EXTRA_VIDEO_RESULT, false)) {
                         // it's a video file
                         if (!TestUtil.isEmptyOrNull(this.videoFilePath)) {
                             File videoFile = new File(this.videoFilePath);
@@ -999,7 +999,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
                 logger.warn("Received result with resultCode={} for requestCode={}", resultCode, requestCode);
 
                 if (requestCode == ACTIVITY_ID_PICK_CAMERA_INTERNAL || requestCode == ACTIVITY_ID_PICK_CAMERA_EXTERNAL) {
-                    Toast.makeText(this, R.string.error, Toast.LENGTH_SHORT).show();
+                    showToast(this, R.string.an_error_occurred);
                 }
             }
 
@@ -1166,7 +1166,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
             }
 
             @MediaItem.MediaType int type = current.getType();
-            boolean showImageEdit = (type == TYPE_IMAGE || type == TYPE_IMAGE_CAM) && current.getImageScale() != ImageScale_SEND_AS_FILE;
+            boolean showImageEdit = (type == TYPE_IMAGE || type == TYPE_IMAGE_CAM) && current.getImageScale() != IMAGE_SCALE_SEND_AS_FILE;
             boolean showFilenameEdit = current.sendAsFile();
             boolean showSettings = current.getType() == TYPE_IMAGE || current.getType() == TYPE_VIDEO;
 
@@ -1264,7 +1264,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
     }
 
     @Override
-    public void onYes(String tag, Object data) {
+    public void onYes(@Nullable String tag, @Nullable Object data) {
         finish();
     }
 
@@ -1308,30 +1308,30 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
     @PreferenceService.VideoSize
     private int getVideoSize(@IdRes int itemId) {
         if (itemId == R.id.menu_video_size_small) {
-            return VideoSize_SMALL;
+            return VIDEO_SIZE_SMALL;
         } else if (itemId == R.id.menu_video_size_medium) {
-            return VideoSize_MEDIUM;
+            return VIDEO_SIZE_MEDIUM;
         } else if (itemId == R.id.menu_video_size_original) {
-            return VideoSize_ORIGINAL;
+            return VIDEO_SIZE_ORIGINAL;
         } else if (itemId == R.id.menu_video_send_as_file) {
-            return VideoSize_SEND_AS_FILE;
+            return VIDEO_SIZE_SEND_AS_FILE;
         } else {
-            return VideoSize_DEFAULT;
+            return VIDEO_SIZE_DEFAULT;
         }
     }
 
     @IdRes
     private int getMenuItemId(@PreferenceService.VideoSize int videoSize) {
         switch (videoSize) {
-            case VideoSize_SMALL:
+            case VIDEO_SIZE_SMALL:
                 return R.id.menu_video_size_small;
-            case VideoSize_MEDIUM:
+            case VIDEO_SIZE_MEDIUM:
                 return R.id.menu_video_size_medium;
-            case VideoSize_ORIGINAL:
+            case VIDEO_SIZE_ORIGINAL:
                 return R.id.menu_video_size_original;
-            case VideoSize_SEND_AS_FILE:
+            case VIDEO_SIZE_SEND_AS_FILE:
                 return R.id.menu_video_send_as_file;
-            case VideoSize_DEFAULT:
+            case VIDEO_SIZE_DEFAULT:
             default:
                 logger.error("No menu item for video size {}", videoSize);
                 throw new IllegalArgumentException(String.format("No menu item for video size %d", videoSize));

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

@@ -1,5 +1,7 @@
 package ch.threema.app.activities;
 
+import android.content.Context;
+import android.content.Intent;
 import android.os.Bundle;
 import android.text.method.LinkMovementMethod;
 import android.view.MenuItem;
@@ -113,4 +115,9 @@ public class ServerMessageActivity extends ThreemaActivity {
     private void cancelServerMessageNotification() {
         dependencies.getNotificationService().cancel(NotificationIDs.SERVER_MESSAGE_NOTIFICATION_ID);
     }
+
+    @NonNull
+    public static Intent createIntent(@NonNull Context context) {
+        return new Intent(context, ServerMessageActivity.class);
+    }
 }

+ 0 - 391
app/src/main/java/ch/threema/app/activities/StarredMessagesActivity.kt

@@ -1,391 +0,0 @@
-package ch.threema.app.activities
-
-import android.content.Context
-import android.content.res.Configuration
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.view.ViewGroup
-import androidx.activity.result.ActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.view.ActionMode
-import androidx.appcompat.widget.SearchView
-import androidx.core.view.WindowCompat
-import androidx.core.view.WindowInsetsCompat
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.DefaultItemAnimator
-import androidx.recyclerview.widget.LinearLayoutManager
-import ch.threema.android.buildActivityIntent
-import ch.threema.app.R
-import ch.threema.app.dialogs.GenericAlertDialog
-import ch.threema.app.dialogs.SelectorDialog
-import ch.threema.app.fragments.ComposeMessageFragment.EXTRA_OVERRIDE_BACK_TO_HOME_BEHAVIOR
-import ch.threema.app.globalsearch.GlobalSearchAdapter
-import ch.threema.app.globalsearch.GlobalSearchViewModel
-import ch.threema.app.managers.ListenerManager
-import ch.threema.app.preference.service.PreferenceService
-import ch.threema.app.services.MessageService
-import ch.threema.app.services.MessageServiceImpl.FILTER_CHATS
-import ch.threema.app.services.MessageServiceImpl.FILTER_GROUPS
-import ch.threema.app.services.MessageServiceImpl.FILTER_INCLUDE_ARCHIVED
-import ch.threema.app.services.MessageServiceImpl.FILTER_STARRED_ONLY
-import ch.threema.app.ui.EmptyRecyclerView
-import ch.threema.app.ui.EmptyView
-import ch.threema.app.ui.InsetSides
-import ch.threema.app.ui.SelectorDialogItem
-import ch.threema.app.ui.SpacingValues
-import ch.threema.app.ui.ThreemaSearchView
-import ch.threema.app.ui.applyDeviceInsetsAsPadding
-import ch.threema.app.utils.ConfigUtils
-import ch.threema.app.utils.IntentDataUtil
-import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.getThreemaLogger
-import ch.threema.storage.models.AbstractMessageModel
-import ch.threema.storage.models.data.DisplayTag.DISPLAY_TAG_NONE
-import com.bumptech.glide.Glide
-import com.google.android.material.search.SearchBar
-import kotlin.time.Duration.Companion.milliseconds
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.koin.android.ext.android.inject
-import org.koin.androidx.viewmodel.ext.android.viewModel
-
-private val logger = getThreemaLogger("StarredMessagesActivity")
-
-class StarredMessagesActivity :
-    ThreemaToolbarActivity(),
-    SearchView.OnQueryTextListener,
-    SelectorDialog.SelectorDialogClickListener,
-    GenericAlertDialog.DialogClickListener {
-    init {
-        logScreenVisibility(logger)
-    }
-
-    private val preferenceService: PreferenceService by inject()
-    private val messageService: MessageService by inject()
-    private val globalSearchViewModel: GlobalSearchViewModel by viewModel()
-
-    private val starredMessagesSearchQueryTimeout = 500.milliseconds
-    private var chatsAdapter: GlobalSearchAdapter? = null
-    private var searchView: ThreemaSearchView? = null
-    private var searchBar: SearchBar? = null
-    private var sortMenuItem: MenuItem? = null
-    private var removeStarsMenuItem: MenuItem? = null
-    private var actionMode: ActionMode? = null
-    private var sortOrder = PreferenceService.StarredMessagesSortOrder_DATE_DESCENDING
-    private var queryText: String? = null
-    private val queryHandler = Handler(Looper.getMainLooper())
-    private val queryTask = Runnable {
-        globalSearchViewModel.onQueryChanged(
-            queryText,
-            FILTER_FLAGS,
-            true,
-            sortOrder == PreferenceService.StarredMessagesSortOrder_DATE_ASCENDING,
-        )
-        chatsAdapter?.onQueryChanged(queryText)
-    }
-    private val showMessageLauncher =
-        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _: ActivityResult ->
-            // starred status may have changed when returning from ComposeMessageFragment
-            globalSearchViewModel.onDataChanged()
-        }
-
-    override fun onQueryTextSubmit(query: String): Boolean {
-        return true
-    }
-
-    override fun onQueryTextChange(newText: String?): Boolean {
-        queryText = newText
-        queryHandler.removeCallbacksAndMessages(null)
-        if (queryText?.isNotEmpty() == true) {
-            queryHandler.postDelayed(queryTask, starredMessagesSearchQueryTimeout.inWholeMilliseconds)
-        } else {
-            globalSearchViewModel.onQueryChanged(
-                null,
-                FILTER_FLAGS,
-                true,
-                sortOrder == PreferenceService.StarredMessagesSortOrder_DATE_ASCENDING,
-            )
-            chatsAdapter?.onQueryChanged(null)
-        }
-        return true
-    }
-
-    override fun getLayoutResource() = R.layout.activity_starred_messages
-
-    override fun handleDeviceInsets() {
-        super.handleDeviceInsets()
-        findViewById<EmptyRecyclerView>(R.id.recycler_chats).applyDeviceInsetsAsPadding(
-            insetSides = InsetSides.lbr(),
-        )
-    }
-
-    override fun initActivity(savedInstanceState: Bundle?): Boolean {
-        if (!super.initActivity(savedInstanceState)) {
-            return false
-        }
-
-        sortOrder = preferenceService.starredMessagesSortOrder
-
-        if (supportActionBar != null) {
-            searchBar = toolbar as SearchBar
-            searchBar?.let { bar ->
-                bar.setNavigationOnClickListener {
-                    searchView?.let {
-                        if (it.isIconified) {
-                            finish()
-                        } else {
-                            it.isIconified = true
-                        }
-                    }
-                }
-                bar.setOnClickListener { searchView?.isIconified = false }
-            }
-        }
-
-        chatsAdapter = GlobalSearchAdapter(
-            this,
-            Glide.with(this),
-            R.layout.item_starred_messages,
-            50,
-        )
-        chatsAdapter?.setOnClickItemListener(
-            object : GlobalSearchAdapter.OnClickItemListener {
-                override fun onClick(
-                    messageModel: AbstractMessageModel?,
-                    itemView: View,
-                    position: Int,
-                ) {
-                    if (actionMode != null) {
-                        logger.info("Starred message selection toggled")
-                        chatsAdapter?.toggleChecked(position)
-                        if ((chatsAdapter?.checkedItemsCount ?: 0) > 0) {
-                            actionMode?.invalidate()
-                        } else {
-                            actionMode?.finish()
-                        }
-                    } else {
-                        logger.info("Starred message clicked")
-                        showMessage(messageModel)
-                    }
-                }
-
-                override fun onLongClick(
-                    messageModel: AbstractMessageModel?,
-                    itemView: View,
-                    position: Int,
-                ): Boolean {
-                    actionMode?.finish()
-                    chatsAdapter?.toggleChecked(position)
-                    if ((chatsAdapter?.checkedItemsCount ?: 0) > 0) {
-                        actionMode = startSupportActionMode(actionModeCallback)
-                    }
-                    return true
-                }
-            },
-        )
-        val recyclerView = findViewById<EmptyRecyclerView>(R.id.recycler_chats)
-        recyclerView.layoutManager = LinearLayoutManager(this)
-        recyclerView.itemAnimator = DefaultItemAnimator()
-        val emptyView = EmptyView(this, ConfigUtils.getActionBarSize(this))
-        emptyView.setup(
-            R.string.no_starred_messages,
-            R.drawable.ic_star_filled,
-        )
-        (recyclerView.parent.parent as ViewGroup).addView(emptyView)
-        recyclerView.emptyView = emptyView
-        emptyView.setLoading(true)
-        recyclerView.adapter = chatsAdapter
-
-        emptyView.applyDeviceInsetsAsPadding(
-            insetSides = InsetSides.lbr(),
-            ownPadding = SpacingValues.all(R.dimen.grid_unit_x2),
-        )
-
-        globalSearchViewModel.messageModels.observe(this) { messages ->
-            emptyView.setLoading(false)
-            chatsAdapter?.setMessageModels(messages)
-            removeStarsMenuItem?.isVisible =
-                messages.isNotEmpty() && (searchView?.isIconified ?: false)
-        }
-
-        onQueryTextChange(null)
-        return true
-    }
-
-    override fun onCreateOptionsMenu(menu: Menu): Boolean {
-        super.onCreateOptionsMenu(menu)
-        menuInflater.inflate(R.menu.action_starred_messages_search, menu)
-        val searchMenuItem = menu.findItem(R.id.menu_action_search)
-        searchView = searchMenuItem.actionView as ThreemaSearchView?
-        searchView?.let {
-            if (ConfigUtils.isLandscape(this)) {
-                it.maxWidth = Int.MAX_VALUE
-            }
-
-            ConfigUtils.adjustSearchViewPadding(it)
-            it.queryHint = getString(R.string.hint_filter_list)
-            it.setOnQueryTextListener(this)
-            it.setOnSearchClickListener {
-                searchBar?.hint = ""
-                sortMenuItem?.isVisible = false
-                removeStarsMenuItem?.isVisible = false
-            }
-            // Show the hint of the search bar again when the search view is closed
-            it.setOnCloseListener {
-                searchBar?.setHint(R.string.starred_messages)
-                sortMenuItem?.isVisible = true
-                removeStarsMenuItem?.isVisible = (chatsAdapter?.itemCount ?: 0) > 0
-                false
-            }
-        }
-        if (searchView == null) {
-            searchMenuItem.isVisible = false
-        }
-        sortMenuItem = menu.findItem(R.id.menu_action_sort)
-        sortMenuItem?.setOnMenuItemClickListener {
-            showSortingSelector()
-            false
-        }
-
-        removeStarsMenuItem = menu.findItem(R.id.menu_remove_stars)
-        removeStarsMenuItem?.setOnMenuItemClickListener {
-            GenericAlertDialog.newInstance(
-                R.string.remove_all_stars,
-                R.string.really_remove_all_stars,
-                R.string.yes,
-                R.string.no,
-            )
-                .show(supportFragmentManager, "rem")
-            false
-        }
-        removeStarsMenuItem?.isVisible = (chatsAdapter?.itemCount ?: 0) > 0
-        return true
-    }
-
-    private fun showSortingSelector() {
-        val selectorDialog = SelectorDialog.newInstance(
-            getString(R.string.sort_by),
-            arrayListOf(
-                SelectorDialogItem(getString(R.string.newest_first), R.drawable.ic_arrow_downward),
-                SelectorDialogItem(getString(R.string.oldest_first), R.drawable.ic_arrow_upward),
-            ),
-            getString(R.string.cancel),
-        )
-        try {
-            selectorDialog.show(supportFragmentManager, DIALOG_TAG_SORT_BY)
-        } catch (e: IllegalStateException) {
-            logger.error("Exception", e)
-        }
-    }
-
-    private fun showMessage(messageModel: AbstractMessageModel?) {
-        if (messageModel == null) {
-            return
-        }
-        hideKeyboard()
-        val intent = IntentDataUtil.getJumpToMessageIntent(this, messageModel)
-        intent.putExtra(EXTRA_OVERRIDE_BACK_TO_HOME_BEHAVIOR, true)
-        showMessageLauncher.launch(intent)
-    }
-
-    private fun removeStar(checkedItems: MutableList<AbstractMessageModel>?) {
-        if (checkedItems != null) {
-            lifecycleScope.launch(Dispatchers.IO) {
-                checkedItems.forEach {
-                    it.displayTags = DISPLAY_TAG_NONE
-                    messageService.save(it)
-                }
-
-                ListenerManager.messageListeners.handle { listener ->
-                    listener.onModified(
-                        checkedItems,
-                    )
-                }
-                checkedItems.clear()
-
-                withContext(Dispatchers.Main) {
-                    actionMode?.finish()
-                    globalSearchViewModel.onDataChanged()
-                }
-            }
-        }
-    }
-
-    private fun removeAllStars() {
-        lifecycleScope.launch(Dispatchers.IO) {
-            messageService.unstarAllMessages()
-            withContext(Dispatchers.Main) {
-                globalSearchViewModel.onDataChanged()
-            }
-        }
-    }
-
-    private val actionModeCallback = object : ActionMode.Callback {
-        override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
-            mode?.menuInflater?.inflate(R.menu.action_starred_messages, menu)
-
-            return true
-        }
-
-        override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
-            val checked: Int = chatsAdapter?.checkedItemsCount ?: 0
-            if (checked > 0) {
-                mode?.title = checked.toString()
-                return true
-            }
-            return false
-        }
-
-        override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
-            return if (R.id.menu_remove_star == item?.itemId) {
-                removeStar(chatsAdapter?.checkedItems)
-                true
-            } else {
-                false
-            }
-        }
-
-        override fun onDestroyActionMode(mode: ActionMode?) {
-            chatsAdapter?.clearCheckedItems()
-            actionMode = null
-        }
-    }
-
-    override fun onClick(tag: String, which: Int, data: Any?) {
-        if (DIALOG_TAG_SORT_BY == tag) {
-            logger.info("Sorting order for starred messages changed")
-            sortOrder = which
-            preferenceService.starredMessagesSortOrder = sortOrder
-            onQueryTextChange(queryText)
-        }
-    }
-
-    override fun onYes(tag: String?, data: Any?) {
-        removeAllStars()
-    }
-
-    override fun onConfigurationChanged(newConfig: Configuration) {
-        hideKeyboard()
-        super.onConfigurationChanged(newConfig)
-    }
-
-    private fun hideKeyboard() {
-        searchView?.let {
-            WindowCompat.getInsetsController(window, it).hide(WindowInsetsCompat.Type.ime())
-        }
-    }
-
-    companion object {
-        private const val DIALOG_TAG_SORT_BY = "sortBy"
-        private const val FILTER_FLAGS =
-            FILTER_STARRED_ONLY or FILTER_GROUPS or FILTER_CHATS or FILTER_INCLUDE_ARCHIVED
-
-        @JvmStatic
-        fun createIntent(context: Context) = buildActivityIntent<StarredMessagesActivity>(context)
-    }
-}

+ 0 - 553
app/src/main/java/ch/threema/app/activities/StorageManagementActivity.java

@@ -1,553 +0,0 @@
-package ch.threema.app.activities;
-
-import android.annotation.SuppressLint;
-import android.os.AsyncTask;
-import android.os.Bundle;
-import android.text.format.Formatter;
-import android.view.Gravity;
-import android.view.MenuItem;
-import android.view.View;
-import android.widget.ArrayAdapter;
-import android.widget.Button;
-import android.widget.FrameLayout;
-import android.widget.ImageButton;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.ActionBar;
-import androidx.coordinatorlayout.widget.CoordinatorLayout;
-
-import com.google.android.material.progressindicator.CircularProgressIndicator;
-import com.google.android.material.snackbar.Snackbar;
-import com.google.android.material.textfield.MaterialAutoCompleteTextView;
-
-import org.koin.java.KoinJavaComponent;
-import org.slf4j.Logger;
-
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.Iterator;
-import java.util.List;
-import java.util.concurrent.CopyOnWriteArrayList;
-
-import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.asynctasks.DeleteIdentityAsyncTask;
-import ch.threema.app.di.DependencyContainer;
-import ch.threema.app.dialogs.CancelableHorizontalProgressDialog;
-import ch.threema.app.dialogs.GenericAlertDialog;
-import ch.threema.app.dialogs.SimpleStringAlertDialog;
-import ch.threema.app.listeners.ConversationListener;
-import ch.threema.app.managers.ListenerManager;
-import ch.threema.app.services.MessageService;
-import ch.threema.app.ui.InsetSides;
-import ch.threema.app.ui.LongToast;
-import ch.threema.app.restrictions.AppRestrictionUtil;
-import ch.threema.app.ui.ViewExtensionsKt;
-import ch.threema.app.utils.AutoDeleteUtil;
-import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.DialogUtil;
-import ch.threema.app.workers.AutoDeleteWorker;
-import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
-import ch.threema.storage.models.AbstractMessageModel;
-import ch.threema.storage.models.ConversationModel;
-import ch.threema.storage.models.MessageType;
-
-import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
-import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
-import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
-
-public class StorageManagementActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener, CancelableHorizontalProgressDialog.ProgressDialogClickListener {
-    private static final Logger logger = getThreemaLogger("StorageManagementActivity");
-
-    private final static String DELETE_ALL_APP_DATA_TAG = "delallappdata";
-    private final static String DELETE_CONFIRM_TAG = "delconf";
-    private final static String DELETE_PROGRESS_TAG = "delprog";
-    private static final String DELETE_MESSAGES_CONFIRM_TAG = "delmsgsconf";
-    private static final String DELETE_MESSAGES_PROGRESS_TAG = "delmsgs";
-    private static final String DIALOG_TAG_DELETE_ID = "delid";
-    private static final String DIALOG_TAG_REALLY_DELETE = "rlydelete";
-    private static final String DIALOG_TAG_SET_AUTO_DELETE = "autodelete";
-
-    @NonNull
-    private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
-
-    private TextView totalView, usageView, freeView, messageView, inuseView;
-    private CircularProgressIndicator progressBar;
-    private boolean isCancelled, isMessageDeleteCancelled;
-    private int selectedSpinnerItem, selectedMessageSpinnerItem, selectedKeepMessageSpinnerItem;
-    private MaterialAutoCompleteTextView keepMessagesSpinner;
-    private FrameLayout storageFull, storageThreema, storageEmpty;
-    private CoordinatorLayout coordinatorLayout;
-    private final int[] dayValues = {730, 365, 183, 92, 31, 7, 0};
-    private final int[] keepMessagesValues = {0, 365, 183, 92, 31, 7};
-
-    @Override
-    public void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        logScreenVisibility(this, logger);
-        if (finishAndRestartLaterIfNotReady(this)) {
-            return;
-        }
-
-        ActionBar actionBar = getSupportActionBar();
-        if (actionBar != null) {
-            actionBar.setDisplayHomeAsUpEnabled(true);
-            actionBar.setTitle(R.string.storage_management);
-        }
-
-        if (!dependencies.getUserService().hasIdentity()) {
-            GenericAlertDialog.newInstance(
-                R.string.delete_data,
-                R.string.delete_all_data_prompt,
-                R.string.delete_data,
-                R.string.cancel
-            ).show(getSupportFragmentManager(), DELETE_ALL_APP_DATA_TAG);
-            return;
-        }
-
-        coordinatorLayout = findViewById(R.id.content);
-        totalView = findViewById(R.id.total_view);
-        usageView = findViewById(R.id.usage_view);
-        freeView = findViewById(R.id.free_view);
-        inuseView = findViewById(R.id.in_use_view);
-        messageView = findViewById(R.id.num_messages_view);
-        MaterialAutoCompleteTextView timeSpinner = findViewById(R.id.time_spinner);
-        keepMessagesSpinner = findViewById(R.id.keep_messages_spinner);
-        MaterialAutoCompleteTextView messageTimeSpinner = findViewById(R.id.time_spinner_messages);
-        Button deleteButton = findViewById(R.id.delete_button);
-        Button messageDeleteButton = findViewById(R.id.delete_button_messages);
-        storageFull = findViewById(R.id.storage_full);
-        storageThreema = findViewById(R.id.storage_threema);
-        storageEmpty = findViewById(R.id.storage_empty);
-        progressBar = findViewById(R.id.progressbar);
-        ImageButton autoDeleteInfo = findViewById(R.id.auto_delete_info);
-        autoDeleteInfo.setOnClickListener(v -> SimpleStringAlertDialog.newInstance(R.string.delete_automatically, R.string.autodelete_explain).show(getSupportFragmentManager(), "autoDel"));
-
-        selectedSpinnerItem = 0;
-        selectedMessageSpinnerItem = 0;
-        ((TextView) findViewById(R.id.used_by_threema)).setText(getString(R.string.storage_threema, getString(R.string.app_name)));
-
-        if (deleteButton == null) {
-            logger.info("deleteButton is null");
-            finish();
-            return;
-        }
-
-        deleteButton.setOnClickListener(new View.OnClickListener() {
-            @Override
-            public void onClick(View v) {
-                GenericAlertDialog.newInstance(R.string.delete_data, R.string.delete_date_confirm_message, R.string.delete_data, R.string.cancel).show(getSupportFragmentManager(), DELETE_CONFIRM_TAG);
-            }
-        });
-
-        messageDeleteButton.setOnClickListener(new View.OnClickListener() {
-            @Override
-            public void onClick(View v) {
-                GenericAlertDialog.newInstance(R.string.delete_message, R.string.really_delete_messages, R.string.delete_message, R.string.cancel).show(getSupportFragmentManager(), DELETE_MESSAGES_CONFIRM_TAG);
-            }
-        });
-
-        Button deleteAllButton = findViewById(R.id.delete_everything_button);
-
-        if (ConfigUtils.isWorkBuild() && AppRestrictionUtil.isReadonlyProfile(this)) {
-            // In readonly profile the user should not be able to delete its ID
-            deleteAllButton.setVisibility(View.GONE);
-        } else {
-            deleteAllButton.setOnClickListener(v -> GenericAlertDialog.newInstance(
-                R.string.delete_id_title,
-                R.string.delete_id_message,
-                R.string.delete_everything,
-                R.string.cancel
-            ).show(getSupportFragmentManager(), DIALOG_TAG_DELETE_ID));
-        }
-
-        final ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(this, R.array.storagemanager_timeout, android.R.layout.simple_spinner_dropdown_item);
-        timeSpinner.setAdapter(adapter);
-        timeSpinner.setText(adapter.getItem(selectedSpinnerItem), false);
-        timeSpinner.setOnItemClickListener((parent, view, position, id) -> {
-            selectedSpinnerItem = position;
-        });
-
-        final ArrayAdapter<CharSequence> messageCleanupAdapter = ArrayAdapter.createFromResource(this, R.array.storagemanager_timeout, android.R.layout.simple_spinner_dropdown_item);
-        messageTimeSpinner.setAdapter(messageCleanupAdapter);
-        messageTimeSpinner.setText(messageCleanupAdapter.getItem(selectedMessageSpinnerItem), false);
-        messageTimeSpinner.setOnItemClickListener((parent, view, position, id) -> {
-            selectedMessageSpinnerItem = position;
-        });
-
-        Integer days = ConfigUtils.isWorkRestricted()
-            ? AppRestrictionUtil.getKeepMessagesDays(this)
-            : null;
-        if (days != null) {
-            findViewById(R.id.keep_messages_spinner_layout).setEnabled(false);
-            keepMessagesSpinner.setEnabled(false);
-            findViewById(R.id.disabled_by_policy).setVisibility(View.VISIBLE);
-            if (days <= 0) {
-                keepMessagesSpinner.setText(getString(R.string.forever));
-            } else {
-                keepMessagesSpinner.setText(getString(R.string.number_of_days, days));
-            }
-        } else {
-            selectedKeepMessageSpinnerItem = 0;
-            days = dependencies.getPreferenceService().getAutoDeleteDays();
-            for (int i = keepMessagesValues.length - 1; i > 0; i--) {
-                if (keepMessagesValues[i] <= days) {
-                    selectedKeepMessageSpinnerItem = i;
-                } else {
-                    break;
-                }
-            }
-
-            final ArrayAdapter<CharSequence> keepMessagesAdapter = ArrayAdapter.createFromResource(this, R.array.keep_messages_timeout, android.R.layout.simple_spinner_dropdown_item);
-            keepMessagesSpinner.setAdapter(keepMessagesAdapter);
-            keepMessagesSpinner.setText(keepMessagesAdapter.getItem(selectedKeepMessageSpinnerItem), false);
-            keepMessagesSpinner.setOnItemClickListener((parent, view, position, id) -> {
-                if (position != selectedKeepMessageSpinnerItem) {
-                    int selectedDays = keepMessagesValues[position];
-                    if (selectedDays > 0) {
-                        GenericAlertDialog dialog = GenericAlertDialog.newInstance(
-                            R.string.delete_automatically,
-                            getString(R.string.autodelete_confirm, keepMessagesSpinner.getText()),
-                            R.string.yes,
-                            R.string.no);
-                        dialog.setData(position);
-                        dialog.show(getSupportFragmentManager(), DIALOG_TAG_SET_AUTO_DELETE);
-                    } else {
-                        selectedKeepMessageSpinnerItem = position;
-                        dependencies.getPreferenceService().setAutoDeleteDays(selectedDays);
-                        LongToast.makeText(StorageManagementActivity.this, R.string.autodelete_disabled, Toast.LENGTH_LONG).show();
-                        AutoDeleteWorker.Companion.cancelAutoDelete(ThreemaApplication.getAppContext());
-                    }
-                }
-            });
-        }
-
-        storageFull.post(this::updateStorageDisplay);
-    }
-
-    @Override
-    protected void handleDeviceInsets() {
-        super.handleDeviceInsets();
-        ViewExtensionsKt.applyDeviceInsetsAsPadding(
-            findViewById(R.id.scroll_container),
-            InsetSides.lbr()
-        );
-    }
-
-    @SuppressLint("StaticFieldLeak")
-    private void updateStorageDisplay() {
-        new AsyncTask<Void, Void, Void>() {
-            long total, usage, free, messages;
-
-            @Override
-            protected void onPreExecute() {
-                progressBar.setVisibility(View.VISIBLE);
-            }
-
-            @Override
-            protected Void doInBackground(Void... params) {
-                var fileService = dependencies.getFileService();
-                total = fileService.getInternalStorageSize();
-                usage = fileService.getInternalStorageUsage();
-                free = fileService.getInternalStorageFree();
-                messages = dependencies.getMessageService().getTotalMessageCount();
-
-                return null;
-            }
-
-            @Override
-            protected void onPostExecute(Void result) {
-                messageView.setText(String.valueOf(messages));
-                progressBar.setVisibility(View.GONE);
-
-                totalView.setText(Formatter.formatFileSize(StorageManagementActivity.this, total));
-                usageView.setText(Formatter.formatFileSize(StorageManagementActivity.this, usage));
-                freeView.setText(Formatter.formatFileSize(StorageManagementActivity.this, free));
-
-                if (total > 0) {
-                    inuseView.setText(Formatter.formatFileSize(StorageManagementActivity.this, total - free));
-
-                    int fullWidth = storageFull.getWidth();
-                    storageThreema.setLayoutParams(new FrameLayout.LayoutParams((int) (fullWidth * usage / total), FrameLayout.LayoutParams.MATCH_PARENT));
-                    FrameLayout.LayoutParams params = new FrameLayout.LayoutParams((int) (fullWidth * free / total), FrameLayout.LayoutParams.MATCH_PARENT);
-                    params.gravity = Gravity.RIGHT;
-                    storageEmpty.setLayoutParams(params);
-                } else {
-                    inuseView.setText(Formatter.formatFileSize(StorageManagementActivity.this, 0));
-
-                    storageFull.setVisibility(View.GONE);
-                    storageThreema.setVisibility(View.GONE);
-                    storageEmpty.setVisibility(View.GONE);
-                }
-            }
-        }.execute();
-    }
-
-    @Override
-    public int getLayoutResource() {
-        if (!isSessionScopeReady() || !dependencies.getUserService().hasIdentity()) {
-            return R.layout.activity_storagemanagement_empty;
-        }
-        return R.layout.activity_storagemanagement;
-    }
-
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        if (item.getItemId() == android.R.id.home) {
-            finish();
-            return true;
-        }
-        return super.onOptionsItemSelected(item);
-    }
-
-    @SuppressLint("StaticFieldLeak")
-    private void deleteMessages(final int days) {
-        final Date today = new Date();
-
-        new AsyncTask<Void, Integer, Void>() {
-            int delCount = 0;
-
-            @Override
-            protected void onPreExecute() {
-                isMessageDeleteCancelled = false;
-                CancelableHorizontalProgressDialog.newInstance(R.string.delete_message, R.string.cancel, 100).show(getSupportFragmentManager(), DELETE_MESSAGES_PROGRESS_TAG);
-            }
-
-            @Override
-            protected void onProgressUpdate(Integer... values) {
-                DialogUtil.updateProgress(getSupportFragmentManager(), DELETE_MESSAGES_PROGRESS_TAG, values[0]);
-            }
-
-            @Override
-            protected Void doInBackground(Void... params) {
-                final List<ConversationModel> conversations = new CopyOnWriteArrayList<>(dependencies.getConversationService().getAll(true));
-                final int numConversations = conversations.size();
-                int i = 0;
-
-                for (Iterator<ConversationModel> iterator = conversations.iterator(); iterator.hasNext(); ) {
-                    ConversationModel conversationModel = iterator.next();
-
-                    if (isMessageDeleteCancelled) {
-                        // cancel task if aborted by user
-                        break;
-                    }
-                    publishProgress(i++ * 100 / numConversations);
-
-                    final List<AbstractMessageModel> messageModels = dependencies.getMessageService().getMessagesForReceiver(conversationModel.messageReceiver, null);
-
-                    for (AbstractMessageModel messageModel : messageModels) {
-                        if (isMessageDeleteCancelled) {
-                            // cancel task if aborted by user
-                            break;
-                        }
-
-                        Date postedDate = messageModel.getPostedAt();
-                        if (postedDate == null) {
-                            postedDate = messageModel.getCreatedAt();
-                        }
-
-                        if (days == 0 || (postedDate != null && AutoDeleteUtil.getDifferenceDays(postedDate, today) > days)) {
-                            dependencies.getMessageService().remove(messageModel, true);
-                            delCount++;
-                        }
-                    }
-                }
-                return null;
-            }
-
-            @Override
-            protected void onPostExecute(Void result) {
-                DialogUtil.dismissDialog(getSupportFragmentManager(), DELETE_MESSAGES_PROGRESS_TAG, true);
-
-                Snackbar.make(coordinatorLayout, ConfigUtils.getSafeQuantityString(StorageManagementActivity.this, R.plurals.message_deleted, delCount, delCount), Snackbar.LENGTH_LONG).show();
-
-                updateStorageDisplay();
-
-                dependencies.getConversationService().reset();
-
-                ListenerManager.conversationListeners.handle(new ListenerManager.HandleListener<ConversationListener>() {
-                    @Override
-                    public void handle(ConversationListener listener) {
-                        listener.onModifiedAll();
-                    }
-                });
-            }
-        }.execute();
-
-    }
-
-    @SuppressLint("StaticFieldLeak")
-    private void deleteMediaFiles(final int days) {
-        final Date today = new Date();
-        final MessageService.MessageFilter messageFilter = new MessageService.MessageFilter() {
-            @Override
-            public long getPageSize() {
-                return 0;
-            }
-
-            @Override
-            public Integer getPageReferenceId() {
-                return null;
-            }
-
-            @Override
-            public boolean withStatusMessages() {
-                return false;
-            }
-
-            @Override
-            public boolean withUnsaved() {
-                return true;
-            }
-
-            @Override
-            public boolean onlyUnread() {
-                return false;
-            }
-
-            @Override
-            public boolean onlyDownloaded() {
-                return true;
-            }
-
-            @Override
-            public MessageType[] types() {
-                return new MessageType[]{MessageType.IMAGE, MessageType.VIDEO, MessageType.VOICEMESSAGE, MessageType.FILE};
-            }
-
-            @Override
-            public int[] contentTypes() {
-                return null;
-            }
-
-            @Override
-            public int[] displayTags() {
-                return null;
-            }
-        };
-
-        new AsyncTask<Void, Integer, Void>() {
-            int delCount = 0;
-
-            @Override
-            protected void onPreExecute() {
-                isCancelled = false;
-                CancelableHorizontalProgressDialog.newInstance(R.string.delete_data, R.string.cancel, 100).show(getSupportFragmentManager(), DELETE_PROGRESS_TAG);
-            }
-
-            @Override
-            protected void onProgressUpdate(Integer... values) {
-                DialogUtil.updateProgress(getSupportFragmentManager(), DELETE_PROGRESS_TAG, values[0]);
-            }
-
-            @Override
-            protected Void doInBackground(Void... params) {
-                final List<ConversationModel> conversations = new ArrayList<>(dependencies.getConversationService().getAll(true));
-                final int numConversations = conversations.size();
-                int i = 0;
-
-                for (ConversationModel conversationModel : conversations) {
-                    if (isCancelled) {
-                        // cancel task if aborted by user
-                        break;
-                    }
-                    publishProgress(i++ * 100 / numConversations);
-
-                    final List<AbstractMessageModel> messageModels = dependencies.getMessageService().getMessagesForReceiver(conversationModel.messageReceiver, messageFilter);
-
-                    for (AbstractMessageModel messageModel : messageModels) {
-                        if (isCancelled) {
-                            // cancel task if aborted by user
-                            break;
-                        }
-
-                        Date postedDate = messageModel.getPostedAt();
-                        if (postedDate == null) {
-                            postedDate = messageModel.getCreatedAt();
-                        }
-
-                        if (days == 0 || (postedDate != null && AutoDeleteUtil.getDifferenceDays(postedDate, today) > days)) {
-                            if (dependencies.getFileService().removeMessageFiles(messageModel, false)) {
-                                delCount++;
-                            }
-                        }
-                    }
-                }
-                return null;
-            }
-
-            @Override
-            protected void onPostExecute(Void result) {
-                DialogUtil.dismissDialog(getSupportFragmentManager(), DELETE_PROGRESS_TAG, true);
-
-                Snackbar.make(coordinatorLayout, ConfigUtils.getSafeQuantityString(StorageManagementActivity.this, R.plurals.media_files_deleted, delCount, delCount), Snackbar.LENGTH_LONG).show();
-
-                updateStorageDisplay();
-
-                dependencies.getConversationService().reset();
-
-                ListenerManager.conversationListeners.handle(new ListenerManager.HandleListener<ConversationListener>() {
-                    @Override
-                    public void handle(ConversationListener listener) {
-                        listener.onModifiedAll();
-                    }
-                });
-            }
-        }.execute();
-    }
-
-    @Override
-    public void onYes(String tag, Object data) {
-        if (tag.equals(DELETE_CONFIRM_TAG)) {
-            deleteMediaFiles(dayValues[selectedSpinnerItem]);
-        } else if (tag.equals(DELETE_MESSAGES_CONFIRM_TAG)) {
-            deleteMessages(dayValues[selectedMessageSpinnerItem]);
-        } else if (DIALOG_TAG_DELETE_ID.equals(tag)) {
-            GenericAlertDialog.newInstance(
-                R.string.delete_id_title,
-                R.string.delete_id_message2,
-                R.string.delete_everything,
-                R.string.cancel).show(getSupportFragmentManager(), DIALOG_TAG_REALLY_DELETE);
-        } else if (DIALOG_TAG_REALLY_DELETE.equals(tag)) {
-            new DeleteIdentityAsyncTask(getSupportFragmentManager(), new Runnable() {
-                @Override
-                public void run() {
-                    ConfigUtils.clearAppData(ThreemaApplication.getAppContext());
-                    finishAffinity();
-                    System.exit(0);
-                }
-            }).execute();
-        } else if (DIALOG_TAG_SET_AUTO_DELETE.equals(tag)) {
-            if (data != null) {
-                int spinnerItemPosition = (int) data;
-                int selectedDays = keepMessagesValues[spinnerItemPosition];
-                selectedKeepMessageSpinnerItem = spinnerItemPosition;
-                LongToast.makeText(StorageManagementActivity.this, R.string.autodelete_activated, Toast.LENGTH_LONG).show();
-                dependencies.getPreferenceService().setAutoDeleteDays(selectedDays);
-                AutoDeleteWorker.Companion.scheduleAutoDelete(ThreemaApplication.getAppContext());
-            }
-        } else if (DELETE_ALL_APP_DATA_TAG.equals(tag)) {
-            ConfigUtils.clearAppData(this);
-        }
-    }
-
-    @Override
-    public void onNo(String tag, Object data) {
-        if (DIALOG_TAG_SET_AUTO_DELETE.equals(tag)) {
-            keepMessagesSpinner.setText(((ArrayAdapter<CharSequence>) keepMessagesSpinner.getAdapter()).getItem(selectedKeepMessageSpinnerItem), false);
-        } else if (DELETE_ALL_APP_DATA_TAG.equals(tag)) {
-            finish();
-        }
-    }
-
-    @Override
-    public void onCancel(String tag, Object object) {
-        if (tag.equals(DELETE_PROGRESS_TAG)) {
-            isCancelled = true;
-        } else if (tag.equals(DELETE_MESSAGES_PROGRESS_TAG)) {
-            isMessageDeleteCancelled = true;
-        }
-    }
-}

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

@@ -8,33 +8,25 @@ import ch.threema.app.services.ActivityService;
 
 public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
 
-    final static public int ACTIVITY_ID_WIZARDFIRST = 20001;
-    final static public int ACTIVITY_ID_SETTINGS = 20002;
     final static public int ACTIVITY_ID_COMPOSE_MESSAGE = 20003;
     final static public int ACTIVITY_ID_ADD_CONTACT = 20004;
-    final static public int ACTIVITY_ID_VERIFY_MOBILE = 20005;
     final static public int ACTIVITY_ID_CONTACT_DETAIL = 20007;
     final static public int ACTIVITY_ID_PICK_CAMERA_EXTERNAL = 20011;
     final static public int ACTIVITY_ID_PICK_CAMERA_INTERNAL = 20012;
     final static public int ACTIVITY_ID_RESTORE_KEY = 20016;
-    final static public int ACTIVITY_ID_ENTER_SERIAL = 20017;
-    final static public int ACTIVITY_ID_SHARE_CHAT = 20018;
+    final static public int ACTIVITY_ID_SHARE_CONVERSATION = 20018;
     final static public int ACTIVITY_ID_SEND_MEDIA = 20019;
     final static public int ACTIVITY_ID_ATTACH_MEDIA = 20020;
     final static public int ACTIVITY_ID_GROUP_ADD = 20028;
     final static public int ACTIVITY_ID_GROUP_DETAIL = 20029;
     final static public int ACTIVITY_ID_MEDIA_VIEWER = 20035;
     public static final int ACTIVITY_ID_CREATE_BALLOT = 20037;
-    final static public int ACTIVITY_ID_ID_SECTION = 20041;
     final static public int ACTIVITY_ID_BACKUP_PICKER = 20042;
     final static public int ACTIVITY_ID_COPY_BALLOT = 20043;
-    public static final int ACTIVITY_ID_CHECK_LOCK = 20046;
     public static final int ACTIVITY_ID_PAINT = 20049;
     public static final int ACTIVITY_ID_PICK_MEDIA = 20050;
     public static final int ACTIVITY_ID_CROP_IMAGE = 20051;
 
-    public static final int RESULT_RESTART = 40005;
-
     private boolean isResumed;
 
     @Override

+ 14 - 43
app/src/main/java/ch/threema/app/activities/ThreemaToolbarActivity.java

@@ -6,7 +6,6 @@ import android.os.Bundle;
 import android.view.View;
 import android.widget.EditText;
 
-import androidx.activity.result.ActivityResultLauncher;
 import androidx.annotation.CallSuper;
 import androidx.annotation.LayoutRes;
 import androidx.annotation.NonNull;
@@ -17,7 +16,6 @@ import androidx.preference.PreferenceManager;
 
 import com.google.android.material.appbar.AppBarLayout;
 import com.google.android.material.appbar.MaterialToolbar;
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 
 import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
@@ -28,7 +26,7 @@ import java.util.Set;
 import ch.threema.app.R;
 import ch.threema.app.activities.wizard.WizardIntroActivity;
 import ch.threema.app.di.DependencyContainer;
-import ch.threema.app.passphrase.PassphraseUnlockContract;
+import ch.threema.app.startup.AppStartupAware;
 import ch.threema.app.ui.InsetSides;
 import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.ConfigUtils;
@@ -38,11 +36,8 @@ import ch.threema.app.utils.RuntimeUtil;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.domain.protocol.connection.ConnectionState;
 import ch.threema.domain.protocol.connection.ConnectionStateListener;
-import kotlin.Unit;
 
-import static ch.threema.app.di.DIJavaCompat.getMasterKeyManager;
 import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
-import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
 
 /**
  * Helper class for activities that use the new toolbar
@@ -57,27 +52,6 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
     @NonNull
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
 
-    private final ActivityResultLauncher<Unit> masterKeyUnlockLauncher = registerForActivityResult(
-        PassphraseUnlockContract.INSTANCE,
-        unlocked -> {
-            if (unlocked) {
-                new MaterialAlertDialogBuilder(this)
-                    .setTitle(R.string.master_key_locked)
-                    .setMessage(R.string.master_key_locked_want_exit)
-                    .setPositiveButton(R.string.try_again, (dialog, whichButton) -> launchMasterKeyUnlocker())
-                    .setNegativeButton(R.string.cancel, (dialog, which) -> finish()).show();
-            } else {
-                if (!finishAndRestartLaterIfNotReady(this)) {
-                    recreate();
-                }
-            }
-        }
-    );
-
-    private void launchMasterKeyUnlocker() {
-        masterKeyUnlockLauncher.launch(Unit.INSTANCE);
-    }
-
     @Override
     protected void onResume() {
         if (isSessionScopeReady()) {
@@ -97,24 +71,21 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
     }
 
     @Override
-    protected void onCreate(Bundle savedInstanceState) {
-        logger.debug("onCreate");
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
         resetKeyboard();
 
         super.onCreate(savedInstanceState);
 
-        if (getMasterKeyManager().isLockedWithPassphrase()) {
-            launchMasterKeyUnlocker();
+        // The license can not be checked if the session scope is not ready, so we potentially skip the check here.
+        // This isn't ideal, but at the latest in the next ThreemaToolbarActivity, another check will be made.
+        if (isSessionScopeReady() && ConfigUtils.isSerialLicensed() && !ConfigUtils.isSerialLicenseValid()) {
+            startActivity(new Intent(this, EnterSerialActivity.class));
+            finish();
             return;
-        } else {
-            if (ConfigUtils.isSerialLicensed() && !ConfigUtils.isSerialLicenseValid()) {
-                startActivity(new Intent(this, EnterSerialActivity.class));
-                finish();
-                return;
-            }
         }
 
-        if (!initActivity(savedInstanceState)) {
+        // TODO(ANDR-4389): Improve app-startup behavior
+        if (!(this instanceof AppStartupAware) && !initActivity(savedInstanceState)) {
             finish();
             return;
         }
@@ -142,8 +113,6 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
      */
     @CallSuper
     protected boolean initActivity(@Nullable Bundle savedInstanceState) {
-        logger.debug("initActivity");
-
         if (!isSessionScopeReady()) {
             return false;
         }
@@ -151,12 +120,14 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
         @LayoutRes int layoutResource = getLayoutResource();
 
         if (dependencies.getNotificationPreferenceService().getWizardRunning()) {
-            startActivity(new Intent(this, WizardIntroActivity.class));
+            startActivity(WizardIntroActivity.createIntent(this));
             return false;
         }
 
-        // hide contents in app switcher and inhibit screenshots
-        ConfigUtils.setScreenshotsAllowed(this, dependencies.getPreferenceService(), dependencies.getLockAppService());
+        ConfigUtils.applyScreenshotPolicy(this,
+            dependencies.getSynchronizedSettingsService(),
+            dependencies.getLockAppService()
+        );
 
         if (layoutResource != 0) {
             logger.debug("setContentView");

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

@@ -8,11 +8,11 @@ import android.widget.LinearLayout
 import android.widget.TextView
 import androidx.core.view.isVisible
 import ch.threema.android.buildActivityIntent
+import ch.threema.android.postDelayed
 import ch.threema.app.BuildConfig
 import ch.threema.app.R
 import ch.threema.app.ui.InsetSides.Companion.all
 import ch.threema.app.ui.applyDeviceInsetsAsPadding
-import ch.threema.app.ui.postDelayed
 import ch.threema.app.utils.AnimationUtil
 import ch.threema.app.utils.logScreenVisibility
 import ch.threema.base.utils.getThreemaLogger

+ 3 - 3
app/src/main/java/ch/threema/app/activities/WorkIntroActivity.kt

@@ -100,7 +100,7 @@ class WorkIntroActivity : ThreemaActivity() {
                 else -> R.string.threema_work_url
             },
         )
-        LinkifyUtil.getInstance().openLink(workInfoLink.toUri(), null, this)
+        LinkifyUtil.getInstance().openLink(workInfoLink.toUri(), this, null)
     }
 
     private fun onClickedDownload() {
@@ -126,9 +126,9 @@ class WorkIntroActivity : ThreemaActivity() {
     }
 
     private fun openConsumerAppInHuaweiAppGallery() {
-        logger.info("Opening Huawai App Gallery")
+        logger.info("Opening Huawei App Gallery")
         val intent = Intent(Intent.ACTION_VIEW)
-        val uri = ("market://details?id=" + this.packageName).toUri()
+        val uri = "market://details?id=$packageName".toUri()
         intent.setData(uri)
         intent.setPackage("com.huawei.appmarket")
         startActivity(intent)

+ 2 - 0
app/src/main/java/ch/threema/app/activities/ballot/BallotChooserActivity.java

@@ -183,6 +183,8 @@ public class BallotChooserActivity extends ThreemaToolbarActivity implements Lis
                     null,
                     dependencies.getBallotService(),
                     dependencies.getContactService(),
+                    dependencies.getUserService(),
+                    dependencies.getPreferenceService(),
                     Glide.with(this)
                 );
 

+ 11 - 6
app/src/main/java/ch/threema/app/activities/ballot/BallotMatrixActivity.java

@@ -34,12 +34,12 @@ import ch.threema.app.ui.InsetSides;
 import ch.threema.app.ui.SpacingValues;
 import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.ViewUtil;
 import ch.threema.base.ThreemaException;
+
+import static ch.threema.android.ToastKt.showToast;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ballot.BallotModel;
@@ -138,7 +138,8 @@ public class BallotMatrixActivity extends BallotDetailActivity {
 
                 this.setBallotModel(ballotModel);
             } catch (ThreemaException e) {
-                LogUtil.exception(e, this);
+                logger.error("Failed to init activity", e);
+                showToast(this, R.string.an_error_occurred);
                 finish();
                 return false;
             }
@@ -156,7 +157,7 @@ public class BallotMatrixActivity extends BallotDetailActivity {
         }
 
         TextView textView = findViewById(R.id.text_view);
-        if (TestUtil.required(textView, this.getBallotModel().getName())) {
+        if (textView != null && getBallotModel().getName() != null) {
             textView.setText(this.getBallotModel().getName());
         }
 
@@ -283,7 +284,11 @@ public class BallotMatrixActivity extends BallotDetailActivity {
                 if (!userList.isEmpty()) {
                     userList += ", ";
                 }
-                userList += NameUtil.getDisplayNameOrNickname(p.getIdentity(), dependencies.getContactService());
+                userList += NameUtil.getContactDisplayNameOrNickname(
+                    p.getIdentity(),
+                    dependencies.getContactService(),
+                    dependencies.getPreferenceService().getContactNameFormat()
+                );
             }
             notVotedTextView.setText(getString(R.string.not_voted_user_list, userList));
         } else {
@@ -319,7 +324,7 @@ public class BallotMatrixActivity extends BallotDetailActivity {
             final ContactModel contactModel = dependencies.getContactService().getByIdentity(p.getIdentity());
 
             View nameCell = getLayoutInflater().inflate(R.layout.row_cell_ballot_matrix_name, null);
-            String name = NameUtil.getDisplayNameOrNickname(contactModel, true);
+            String name = NameUtil.getContactDisplayNameOrNickname(contactModel, true, dependencies.getPreferenceService().getContactNameFormat());
 
             HintedImageView hintedImageView = nameCell.findViewById(R.id.avatar_view);
             if (hintedImageView != null) {

+ 10 - 3
app/src/main/java/ch/threema/app/activities/ballot/BallotOverviewActivity.java

@@ -45,8 +45,9 @@ import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.BallotUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.RuntimeUtil;
+
+import static ch.threema.android.ToastKt.showToast;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.domain.models.MessageId;
 import ch.threema.domain.taskmanager.TriggerSource;
@@ -262,6 +263,8 @@ public class BallotOverviewActivity extends ThreemaToolbarActivity implements Li
                     messageReceiver,
                     dependencies.getBallotService(),
                     dependencies.getContactService(),
+                    dependencies.getUserService(),
+                    dependencies.getPreferenceService(),
                     Glide.with(this)
                 );
 
@@ -387,7 +390,8 @@ public class BallotOverviewActivity extends ThreemaToolbarActivity implements Li
                         try {
                             dependencies.getBallotService().remove(this.ballots.get(index));
                         } catch (NotAllowedException e) {
-                            LogUtil.exception(e, this);
+                            logger.error("Failed to delete ballot", e);
+                            showToast(this, R.string.an_error_occurred);
                             return;
                         }
                     }
@@ -406,7 +410,10 @@ public class BallotOverviewActivity extends ThreemaToolbarActivity implements Li
     }
 
     @Override
-    public void onYes(String tag, Object data) {
+    public void onYes(@Nullable String tag, @Nullable Object data) {
+        if (tag == null) {
+            return;
+        }
         if (tag.equals(DIALOG_TAG_BALLOT_DELETE)) {
             removeSelectedBallotsDo((SparseBooleanArray) data);
         } else if (tag.equals(AppConstants.CONFIRM_TAG_CLOSE_BALLOT)) {

+ 0 - 2
app/src/main/java/ch/threema/app/activities/ballot/BallotWizardFragment.java

@@ -13,8 +13,6 @@ abstract class BallotWizardFragment extends Fragment {
 
     /**
      * cast activity to ballotActivity
-     *
-     * @return
      */
     public BallotWizardActivity getBallotActivity() {
         if (this.ballotWizardActivity == null) {

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

@@ -9,7 +9,7 @@ import ch.threema.app.utils.logScreenVisibility
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.data.models.ContactModel
 import ch.threema.data.repositories.ContactModelRepository
-import ch.threema.domain.types.Identity
+import ch.threema.domain.types.IdentityString
 import org.koin.android.ext.android.inject
 
 private val logger = getThreemaLogger("ContactNotificationsActivity")
@@ -22,7 +22,7 @@ class ContactNotificationsActivity : NotificationsActivity() {
     private val ringtoneService: RingtoneService by inject()
     private val contactModelRepository: ContactModelRepository by inject()
 
-    private val contactIdentity: Identity? by lazy {
+    private val contactIdentity: IdentityString? by lazy {
         intent.getStringExtra(AppConstants.INTENT_DATA_CONTACT)
     }
 

+ 2 - 3
app/src/main/java/ch/threema/app/activities/notificationpolicy/NotificationsActivity.java

@@ -321,7 +321,7 @@ public abstract class NotificationsActivity extends ThreemaActivity implements S
             notificationSettingsSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
                 if (isChecked) {
                     individualSettingsText.setEnabled(true);
-                    if (ShowOnceDialog.shouldNotShowAnymore(DIALOG_TAG_INDIVIDUAL_CONFIRM)) {
+                    if (dependencies.getPreferenceService().isOneTimeDialogShown(DIALOG_TAG_INDIVIDUAL_CONFIRM)) {
                         onYes(DIALOG_TAG_INDIVIDUAL_CONFIRM);
                     } else {
                         ShowOnceDialog showOnceDialog = ShowOnceDialog.newInstance(R.string.individual_notification_settings, R.string.individual_notification_settings_warn);
@@ -396,8 +396,7 @@ public abstract class NotificationsActivity extends ThreemaActivity implements S
         });
 
         parentLayout.findViewById(R.id.prefs_button).setOnClickListener(v -> {
-            Intent intent = new Intent(this, SettingsActivity.class);
-            intent.putExtra(SettingsActivity.EXTRA_SHOW_NOTIFICATION_FRAGMENT, true);
+            Intent intent = SettingsActivity.createIntent(this, SettingsActivity.InitialScreen.NOTIFICATIONS);
             ringtoneSettingsLauncher.launch(intent);
             refreshSettings();
         });

+ 6 - 6
app/src/main/java/ch/threema/app/activities/referral/ReferralActivity.kt

@@ -76,7 +76,7 @@ class ReferralActivity : ThreemaActivity() {
         logScreenVisibility(logger)
     }
 
-    val viewModel by viewModel<ReferralViewModel>()
+    private val viewModel by viewModel<ReferralViewModel>()
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -91,7 +91,7 @@ class ReferralActivity : ThreemaActivity() {
 
             ThreemaTheme {
                 ReferralScreenContent(
-                    onClickedBack = {
+                    onClickBack = {
                         finish()
                     },
                     onClickShareInvitationLink = {
@@ -132,7 +132,7 @@ class ReferralActivity : ThreemaActivity() {
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 private fun ReferralScreenContent(
-    onClickedBack: () -> Unit,
+    onClickBack: () -> Unit,
     onClickShareInvitationLink: () -> Unit,
     onClickViewTos: () -> Unit,
 ) {
@@ -157,7 +157,7 @@ private fun ReferralScreenContent(
                 },
                 navigationIcon = {
                     IconButton(
-                        onClick = onClickedBack,
+                        onClick = onClickBack,
                     ) {
                         Icon(
                             painter = painterResource(R.drawable.ic_arrow_back_24),
@@ -353,7 +353,7 @@ private fun ExplanationItem(
 private fun ReferralScreenContent_Preview() {
     ThreemaThemePreview {
         ReferralScreenContent(
-            onClickedBack = {},
+            onClickBack = {},
             onClickShareInvitationLink = {},
             onClickViewTos = {},
         )
@@ -367,7 +367,7 @@ private fun ReferralScreenContent_Preview_DynamicColors() {
         shouldUseDynamicColors = true,
     ) {
         ReferralScreenContent(
-            onClickedBack = {},
+            onClickBack = {},
             onClickShareInvitationLink = {},
             onClickViewTos = {},
         )

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