瀏覽代碼

Version 6.3.0-1113

Threema 2 月之前
父節點
當前提交
5b439c14bc
共有 100 個文件被更改,包括 825 次插入1784 次删除
  1. 17 4
      README.md
  2. 0 7
      app/assets/license.html
  3. 34 9
      app/build.gradle.kts
  4. 0 77
      app/src/androidTest/java/ch/threema/app/TestApplication.java
  5. 1 1
      app/src/androidTest/java/ch/threema/app/ThreemaTestRunner.java
  6. 0 389
      app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java
  7. 1 1
      app/src/androidTest/java/ch/threema/app/contacts/MarkContactAsDeletedBackgroundTaskTest.kt
  8. 1 1
      app/src/androidTest/java/ch/threema/app/contacts/ReflectedContactSyncTaskTest.kt
  9. 1 1
      app/src/androidTest/java/ch/threema/app/groupmanagement/CreateGroupFlowTest.kt
  10. 1 1
      app/src/androidTest/java/ch/threema/app/groupmanagement/DisbandGroupFlowTest.kt
  11. 1 1
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupFlowTest.kt
  12. 1 1
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupResyncFlowTest.kt
  13. 6 6
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt
  14. 3 3
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupTextTest.kt
  15. 1 1
      app/src/androidTest/java/ch/threema/app/groupmanagement/LeaveGroupFlowTest.kt
  16. 1 1
      app/src/androidTest/java/ch/threema/app/groupmanagement/RemoveGroupFlowTest.kt
  17. 14 13
      app/src/androidTest/java/ch/threema/app/groupmanagement/UpdateGroupFlowTest.kt
  18. 9 3
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt
  19. 4 4
      app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt
  20. 67 0
      app/src/androidTest/java/ch/threema/app/qrcodes/QrCodeGeneratorTest.kt
  21. 16 4
      app/src/androidTest/java/ch/threema/app/stores/EncryptedPreferenceStoreImplTest.kt
  22. 10 42
      app/src/androidTest/java/ch/threema/app/tasks/GroupCreateTaskTest.kt
  23. 114 11
      app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt
  24. 11 8
      app/src/androidTest/java/ch/threema/app/testutils/AndroidTestUtils.kt
  25. 13 10
      app/src/androidTest/java/ch/threema/app/utils/BackgroundExecutorTest.kt
  26. 1 1
      app/src/androidTest/java/ch/threema/app/utils/LinkifyUtilTest.kt
  27. 4 3
      app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt
  28. 2 2
      app/src/androidTest/java/ch/threema/data/repositories/EditHistoryRepositoryTest.kt
  29. 11 11
      app/src/androidTest/java/ch/threema/data/repositories/EmojiReactionsRepositoryTest.kt
  30. 23 0
      app/src/androidTest/java/ch/threema/logging/backend/DebugLogFileBackendTest.kt
  31. 3 1
      app/src/androidTest/java/ch/threema/storage/DatabaseNonceStoreTest.kt
  32. 33 0
      app/src/androidTest/resources/qr-code.txt
  33. 2 2
      app/src/blue/java/ch/threema/app/activities/DownloadApkActivity.java
  34. 0 7
      app/src/foss_based/assets/license.html
  35. 2 2
      app/src/google_services_based/java/ch/threema/app/licensing/StoreLicenseCheck.java
  36. 2 2
      app/src/google_services_based/java/ch/threema/app/push/PushRegistrationWorker.java
  37. 2 2
      app/src/google_services_based/java/ch/threema/app/push/PushService.java
  38. 2 2
      app/src/google_services_based/java/ch/threema/app/services/VoiceActionService.java
  39. 2 2
      app/src/green/java/ch/threema/app/activities/DownloadApkActivity.java
  40. 2 2
      app/src/hms/java/ch/threema/app/activities/DownloadApkActivity.java
  41. 2 2
      app/src/hms_services_based/java/ch/threema/app/licensing/StoreLicenseCheck.java
  42. 2 2
      app/src/hms_services_based/java/ch/threema/app/push/HmsTokenUtil.kt
  43. 2 2
      app/src/hms_services_based/java/ch/threema/app/push/PushRegistrationWorker.java
  44. 2 2
      app/src/hms_services_based/java/ch/threema/app/push/PushService.java
  45. 2 2
      app/src/hms_work/java/ch/threema/app/activities/DownloadApkActivity.java
  46. 2 2
      app/src/libre/java/ch/threema/app/activities/DownloadApkActivity.java
  47. 3 3
      app/src/libre/play/release-notes/de/default.txt
  48. 3 3
      app/src/libre/play/release-notes/en-US/default.txt
  49. 7 2
      app/src/main/AndroidManifest.xml
  50. 0 3
      app/src/main/java/ch/threema/app/AppConstants.kt
  51. 0 128
      app/src/main/java/ch/threema/app/AppLogging.kt
  52. 2 15
      app/src/main/java/ch/threema/app/GlobalBroadcastReceivers.kt
  53. 28 22
      app/src/main/java/ch/threema/app/GlobalListeners.java
  54. 0 185
      app/src/main/java/ch/threema/app/MasterKeyManagerFactory.kt
  55. 42 36
      app/src/main/java/ch/threema/app/ThreemaApplication.kt
  56. 2 2
      app/src/main/java/ch/threema/app/actions/LocationMessageSendAction.java
  57. 2 2
      app/src/main/java/ch/threema/app/actions/TextMessageSendAction.java
  58. 22 19
      app/src/main/java/ch/threema/app/activities/AddContactActivity.java
  59. 6 5
      app/src/main/java/ch/threema/app/activities/AppLinksActivity.java
  60. 6 4
      app/src/main/java/ch/threema/app/activities/BackupAdminActivity.java
  61. 3 3
      app/src/main/java/ch/threema/app/activities/BackupRestoreProgressActivity.kt
  62. 0 239
      app/src/main/java/ch/threema/app/activities/BiometricLockActivity.java
  63. 3 3
      app/src/main/java/ch/threema/app/activities/BlockedIdentitiesActivity.kt
  64. 2 4
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java
  65. 6 6
      app/src/main/java/ch/threema/app/activities/CropImageActivity.kt
  66. 9 7
      app/src/main/java/ch/threema/app/activities/DirectoryActivity.java
  67. 2 2
      app/src/main/java/ch/threema/app/activities/DisableBatteryOptimizationsActivity.java
  68. 3 3
      app/src/main/java/ch/threema/app/activities/DistributionListAddActivity.kt
  69. 3 3
      app/src/main/java/ch/threema/app/activities/EditSendContactActivity.kt
  70. 6 5
      app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java
  71. 3 3
      app/src/main/java/ch/threema/app/activities/ExcludedSyncIdentitiesActivity.kt
  72. 4 4
      app/src/main/java/ch/threema/app/activities/ExportIDActivity.kt
  73. 23 9
      app/src/main/java/ch/threema/app/activities/ExportIDResultActivity.java
  74. 4 3
      app/src/main/java/ch/threema/app/activities/GroupAdd2Activity.java
  75. 4 3
      app/src/main/java/ch/threema/app/activities/GroupAddActivity.java
  76. 12 14
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  77. 4 2
      app/src/main/java/ch/threema/app/activities/IdentityListActivity.java
  78. 5 4
      app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java
  79. 2 2
      app/src/main/java/ch/threema/app/activities/ImagePaintKeyboardActivity.java
  80. 3 3
      app/src/main/java/ch/threema/app/activities/MainActivity.kt
  81. 4 3
      app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java
  82. 1 1
      app/src/main/java/ch/threema/app/activities/MemberChooseActivity.java
  83. 3 3
      app/src/main/java/ch/threema/app/activities/PermissionRequestActivity.kt
  84. 10 5
      app/src/main/java/ch/threema/app/activities/PinLockActivity.kt
  85. 0 212
      app/src/main/java/ch/threema/app/activities/ProblemSolverActivity.kt
  86. 8 8
      app/src/main/java/ch/threema/app/activities/ProfilePicRecipientsActivity.kt
  87. 6 6
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  88. 2 2
      app/src/main/java/ch/threema/app/activities/SMSVerificationLinkActivity.java
  89. 31 37
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  90. 2 2
      app/src/main/java/ch/threema/app/activities/ServerMessageActivity.java
  91. 3 3
      app/src/main/java/ch/threema/app/activities/StarredMessagesActivity.kt
  92. 2 2
      app/src/main/java/ch/threema/app/activities/StickerSelectorActivity.java
  93. 4 3
      app/src/main/java/ch/threema/app/activities/StorageManagementActivity.java
  94. 0 1
      app/src/main/java/ch/threema/app/activities/ThreemaActivity.java
  95. 3 3
      app/src/main/java/ch/threema/app/activities/ThreemaPushNotificationInfoActivity.kt
  96. 6 5
      app/src/main/java/ch/threema/app/activities/ThreemaToolbarActivity.java
  97. 3 3
      app/src/main/java/ch/threema/app/activities/VerificationLevelActivity.kt
  98. 0 76
      app/src/main/java/ch/threema/app/activities/WhatsNewActivity.java
  99. 75 0
      app/src/main/java/ch/threema/app/activities/WhatsNewActivity.kt
  100. 8 8
      app/src/main/java/ch/threema/app/activities/WorkIntroActivity.kt

+ 17 - 4
README.md

@@ -1,15 +1,16 @@
-<!-- Centered README header hack -->
-<!-- Do not replace the obsolete align attribute with inline style, as GitHub may strip it. -->
 <div align="center">
+  <!-- Centered README header hack -->
+  <!-- Note: Do not replace the obsolete align attribute with inline style, as GitHub may strip it. -->
   <picture>
     <source media="(prefers-color-scheme: dark)" srcset="logo_dark.svg">
     <source media="(prefers-color-scheme: light)" srcset="logo_light.svg">
     <img width="500" src="logo_light.svg" alt="Threema Logo">
   </picture>
-  <br>
-  <br>
+  <br><br>
 </div>
 
+---
+
 # Threema for Android
 
 This repository contains the complete source code of
@@ -195,6 +196,18 @@ assistance for building on macOS, Windows, or other operating systems.
 The project can be imported into [Android Studio](https://developer.android.com/studio/).
 To build and deploy it to a device, click the green “Play” icon.
 
+### Troubleshooting
+
+**NDK is not installed**
+
+If you get the following error when building Threema:
+
+> Cause 1: org.gradle.api.InvalidUserDataException: NDK is not installed
+
+...then ensure that you have installed the correct version of NDK (as defined
+by `ndkVersion` in `app/build.gradle.kts`). In Android Studio: *Settings >
+Languages & Frameworks > Android SDK > SDK Tools*.
+
 ## <a name="testing"></a>Testing
 
 ### Via Command Line

+ 0 - 7
app/assets/license.html

@@ -401,13 +401,6 @@ POSSIBILITY OF SUCH DAMAGE.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
-<h2>ZoomableTextureView</h2>
-
-<p>Copyright (c) 2018 Volodya Polohalo</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>

+ 34 - 9
app/build.gradle.kts

@@ -51,7 +51,7 @@ if (gradle.startParameter.taskRequests.toString().contains("Hms")) {
 /**
  * Only use the scheme "<major>.<minor>.<patch>" for the appVersion
  */
-val appVersion = "6.2.1"
+val appVersion = "6.3.0"
 
 /**
  * betaSuffix with leading dash (e.g. `-beta1`).
@@ -60,7 +60,7 @@ val appVersion = "6.2.1"
  */
 val betaSuffix = ""
 
-val defaultVersionCode = 1098
+val defaultVersionCode = 1113
 
 /**
  * Map with keystore paths (if found).
@@ -97,6 +97,7 @@ android {
         setProductNames(
             appName = "Threema",
         )
+        intBuildConfigField("DEFAULT_VERSION_CODE", defaultVersionCode)
         // package name used for sync adapter - needs to match mime types below
         stringResValue("package_name", applicationId!!)
         stringResValue("contacts_mime_type", "vnd.android.cursor.item/vnd.$applicationId.profile")
@@ -115,6 +116,7 @@ android {
         byteArrayBuildConfigField("SERVER_PUBKEY", PublicKeys.prodServer)
         byteArrayBuildConfigField("SERVER_PUBKEY_ALT", PublicKeys.prodServerAlt)
         stringBuildConfigField("GIT_HASH", getGitHash())
+        stringBuildConfigField("GIT_BRANCH", getGitBranch())
         stringBuildConfigField("DIRECTORY_SERVER_URL", "https://apip.threema.ch/")
         stringBuildConfigField("DIRECTORY_SERVER_IPV6_URL", "https://ds-apip.threema.ch/")
         stringBuildConfigField("WORK_SERVER_URL", null)
@@ -147,7 +149,10 @@ android {
         stringArrayBuildConfigField("ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", emptyArray())
         booleanBuildConfigField("MD_SYNC_DISTRIBUTION_LISTS", false)
         booleanBuildConfigField("AVAILABILITY_STATUS_ENABLED", BuildFeatureFlags["availability_status"] ?: false)
-        booleanBuildConfigField("REMOTE_SECRETS_SUPPORTED", BuildFeatureFlags["remote_secrets"] ?: false)
+        booleanBuildConfigField("CRASH_REPORTING_SUPPORTED", BuildFeatureFlags["crash_reporting"] ?: false)
+
+        // TODO(ANDR-4376): Remove this build flag
+        booleanBuildConfigField("REFERRAL_PROGRAM_AVAILABLE", BuildFeatureFlags["referral_program_available"] ?: false)
 
         // config fields for action URLs / deep links
         stringBuildConfigField("uriScheme", "threema")
@@ -285,6 +290,10 @@ 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)
+
+            // TODO(ANDR-4376): Remove this build flag
+            booleanBuildConfigField("REFERRAL_PROGRAM_AVAILABLE", true)
         }
         create("sandbox_work") {
             versionName = "${appVersion}k$betaSuffix"
@@ -326,6 +335,8 @@ android {
             stringBuildConfigField("uriScheme", "threemawork")
             stringBuildConfigField("actionUrl", "work.test.threema.ch")
 
+            booleanBuildConfigField("CRASH_REPORTING_SUPPORTED", true)
+
             stringBuildConfigField("MD_CLIENT_DOWNLOAD_URL", "https://three.ma/mdw")
 
             with(manifestPlaceholders) {
@@ -414,6 +425,8 @@ android {
             stringBuildConfigField("LOG_TAG", "3mablue")
             stringBuildConfigField("BLOB_MIRROR_SERVER_URL", "https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
 
+            booleanBuildConfigField("CRASH_REPORTING_SUPPORTED", true)
+
             // config fields for action URLs / deep links
             stringBuildConfigField("uriScheme", "threemablue")
             stringBuildConfigField("actionUrl", "blue.threema.ch")
@@ -776,6 +789,7 @@ dependencies {
     coreLibraryDesugaring(libs.desugarJdkLibs)
 
     implementation(project(":domain"))
+    implementation(project("::commonAndroid"))
     implementation(project(":common"))
     lintChecks(project(":lint-rules"))
 
@@ -793,7 +807,6 @@ dependencies {
     implementation(libs.zip4j)
     implementation(libs.taptargetview)
     implementation(libs.commonsIo)
-    implementation(libs.commonsText)
     implementation(libs.slf4j.api)
     implementation(libs.androidImageCropper)
     implementation(libs.fastscroll)
@@ -840,7 +853,6 @@ dependencies {
     // Jetpack Compose
     implementation(platform(libs.compose.bom))
     implementation(libs.androidx.material3)
-    implementation(libs.androidx.materialIconsExtended)
     implementation(libs.androidx.ui.tooling.preview)
     implementation(libs.androidx.activity.compose)
     implementation(libs.androidx.lifecycle.viewmodel.compose)
@@ -873,8 +885,7 @@ dependencies {
 
     // Glide components
     implementation(libs.glide)
-    ksp(libs.glide.compiler)
-    annotationProcessor(libs.glide.compiler)
+    ksp(libs.glide.ksp)
 
     // Kotlin
     implementation(libs.kotlin.stdlib)
@@ -1024,10 +1035,24 @@ afterEvaluate {
 
 sonarqube {
     properties {
-        property("sonar.sources", "src/main/, ../scripts/, ../scripts-internal/")
+        property(
+            "sonar.sources",
+            listOf(
+                "src/main/",
+                "../scripts/",
+                "../scripts-internal/",
+            )
+                .joinToString(separator = ", "),
+        )
         property(
             "sonar.exclusions",
-            "src/main/java/ch/threema/localcrypto/**, src/test/java/ch/threema/localcrypto/**, src/*/res/, src/*/res-rendezvous/",
+            listOf(
+                "src/**/res/",
+                "src/**/res-rendezvous/",
+                "**/emojis/EmojiParser.kt",
+                "**/emojis/EmojiSpritemap.kt",
+            )
+                .joinToString(separator = ", "),
         )
         property("sonar.tests", "src/test/")
         property("sonar.sourceEncoding", "UTF-8")

+ 0 - 77
app/src/androidTest/java/ch/threema/app/TestApplication.java

@@ -1,77 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2017-2025 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app;
-
-import android.app.Activity;
-import android.app.Application;
-import android.os.Bundle;
-
-
-public class TestApplication extends ThreemaApplication implements Application.ActivityLifecycleCallbacks {
-
-    private Activity currentActivity;
-
-    @Override
-    public void onCreate() {
-        super.onCreate();
-        registerActivityLifecycleCallbacks(this);
-    }
-
-    @Override
-    public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
-        currentActivity = activity;
-    }
-
-    @Override
-    public void onActivityStarted(Activity activity) {
-        currentActivity = activity;
-    }
-
-    @Override
-    public void onActivityResumed(Activity activity) {
-        currentActivity = activity;
-    }
-
-    @Override
-    public void onActivityPaused(Activity activity) {
-
-    }
-
-    @Override
-    public void onActivityStopped(Activity activity) {
-
-    }
-
-    @Override
-    public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
-
-    }
-
-    @Override
-    public void onActivityDestroyed(Activity activity) {
-
-    }
-
-    public Activity getCurrentActivity() {
-        return currentActivity;
-    }
-}

+ 1 - 1
app/src/androidTest/java/ch/threema/app/ThreemaTestRunner.java

@@ -35,6 +35,6 @@ public class ThreemaTestRunner extends AndroidJUnitRunner {
 
     @Override
     public Application newApplication(ClassLoader cl, String className, Context context) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
-        return super.newApplication(cl, TestApplication.class.getName(), context);
+        return super.newApplication(cl, ThreemaApplication.class.getName(), context);
     }
 }

+ 0 - 389
app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java

@@ -1,389 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2020-2025 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.backuprestore.csv;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Build;
-import android.util.Log;
-
-import net.lingala.zip4j.ZipFile;
-import net.lingala.zip4j.io.inputstream.ZipInputStream;
-import net.lingala.zip4j.model.AbstractFileHeader;
-import net.lingala.zip4j.model.FileHeader;
-
-import org.apache.commons.io.IOUtils;
-import org.junit.Assert;
-import org.junit.Before;
-import org.junit.BeforeClass;
-import org.junit.Ignore;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import java.io.File;
-import java.io.InputStreamReader;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Objects;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.WorkerThread;
-import androidx.test.core.app.ApplicationProvider;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
-import androidx.test.filters.LargeTest;
-import androidx.test.rule.GrantPermissionRule;
-import ch.threema.app.DangerousTest;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
-import ch.threema.app.asynctasks.BasicAddOrUpdateContactBackgroundTask;
-import ch.threema.app.asynctasks.DeleteAllContactsBackgroundTask;
-import ch.threema.app.asynctasks.DeleteContactServices;
-import ch.threema.app.backuprestore.BackupRestoreDataConfig;
-import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.services.ContactService;
-import ch.threema.app.services.ConversationService;
-import ch.threema.app.services.DistributionListService;
-import ch.threema.app.services.FileService;
-import ch.threema.app.services.GroupService;
-import ch.threema.app.services.MessageService;
-import ch.threema.app.services.ballot.BallotService;
-import ch.threema.app.testutils.TestHelpers;
-import ch.threema.app.utils.CSVReader;
-import ch.threema.app.utils.CSVRow;
-import ch.threema.app.utils.executor.BackgroundExecutor;
-import ch.threema.base.ThreemaException;
-import ch.threema.data.repositories.ContactModelRepository;
-import ch.threema.domain.identitybackup.IdentityBackup;
-import ch.threema.domain.protocol.api.APIConnector;
-import ch.threema.storage.models.ContactModel;
-import ch.threema.storage.models.data.status.VoipStatusDataModel;
-import java8.util.stream.StreamSupport;
-
-import static ch.threema.app.PermissionRuleUtilsKt.getReadWriteExternalStoragePermissionRule;
-
-@RunWith(AndroidJUnit4.class)
-@LargeTest
-@DangerousTest(reason = "Deletes data and possibly identity")
-@Ignore("because this test broke with API version switch introduced in 7ed52bcfedd0bdcd2924ae14afe7ccb7bdc52c7a")
-// TODO(ANDR-1483)
-public class BackupServiceTest {
-    private final static String PASSWORD = "ubnpwrgujioasdfi0932";
-    private static final String TAG = "BackupServiceTest";
-
-    @SuppressWarnings("NotNullFieldNotInitialized")
-    private static @NonNull String TEST_IDENTITY;
-
-    // Services
-    private @NonNull ServiceManager serviceManager;
-    private @NonNull FileService fileService;
-    private @NonNull MessageService messageService;
-    private @NonNull ConversationService conversationService;
-    private @NonNull GroupService groupService;
-    private @NonNull ContactService contactService;
-    private @NonNull DistributionListService distributionListService;
-    private @NonNull BallotService ballotService;
-    private @NonNull APIConnector apiConnector;
-    private @NonNull ContactModelRepository contactModelRepository;
-
-    private final @NonNull BackgroundExecutor backgroundExecutor = new BackgroundExecutor();
-
-    @Rule
-    public GrantPermissionRule permissionRule = getReadWriteExternalStoragePermissionRule();
-
-    /**
-     * Ensure that an identity is set up, initialize static {@link #TEST_IDENTITY} variable.
-     */
-    @BeforeClass
-    public static void ensureIdentityExists() throws Exception {
-        // Set up identity
-        final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
-        TEST_IDENTITY = TestHelpers.ensureIdentity(Objects.requireNonNull(serviceManager));
-    }
-
-    /**
-     * Load Threema services.
-     */
-    @Before
-    public void loadServices() throws Exception {
-        this.serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
-        this.fileService = serviceManager.getFileService();
-        this.messageService = serviceManager.getMessageService();
-        this.conversationService = serviceManager.getConversationService();
-        this.groupService = serviceManager.getGroupService();
-        this.contactService = serviceManager.getContactService();
-        this.distributionListService = serviceManager.getDistributionListService();
-        this.ballotService = serviceManager.getBallotService();
-        this.apiConnector = serviceManager.getAPIConnector();
-        this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
-    }
-
-    /**
-     * Return the list of backups for the TEST_IDENTITY identity.
-     */
-    private @NonNull List<File> getUserBackups(@NonNull File backupPath) {
-        if (backupPath.exists() && backupPath.isDirectory()) {
-            final File[] files = backupPath.listFiles(
-                (dir, name) -> name.startsWith("threema-backup_" + TEST_IDENTITY)
-            );
-            return files == null ? new ArrayList<>() : Arrays.asList(files);
-        } else {
-            return new ArrayList<>();
-        }
-    }
-
-    /**
-     * Helper method: Create a backup with the specified config, return backup file.
-     */
-    private @NonNull File doBackup(BackupRestoreDataConfig config) {
-        // List old backups
-        final File backupPath = this.fileService.getBackupPath();
-        final List<File> initialBackupFiles = this.getUserBackups(backupPath);
-
-
-        // Prepare service intent
-        final Context appContext = ApplicationProvider.getApplicationContext();
-        final Intent intent = new Intent(appContext, BackupService.class);
-        intent.putExtra(BackupService.EXTRA_BACKUP_RESTORE_DATA_CONFIG, config);
-
-        // Start service
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-            appContext.startForegroundService(intent);
-        }
-
-        appContext.startService(intent);
-        Assert.assertTrue(TestHelpers.iServiceRunning(appContext, BackupService.class));
-
-        // Wait for service to stop
-        while (TestHelpers.iServiceRunning(appContext, BackupService.class)) {
-            try {
-                Thread.sleep(100);
-            } catch (InterruptedException e) {
-                // ignore
-            }
-        }
-
-        // Check that a backup file has been created
-        Assert.assertTrue(backupPath.exists());
-        Assert.assertTrue(backupPath.isDirectory());
-        File backupFile = null;
-        for (File file : getUserBackups(backupPath)) {
-            if (!initialBackupFiles.contains(file)) {
-                if (backupFile != null) {
-                    Assert.fail("Found more than one new backup: " + backupFile + " and " + file);
-                }
-                backupFile = file;
-            }
-        }
-        Assert.assertNotNull("New backup file not found", backupFile);
-        Assert.assertTrue(backupFile.exists());
-        Assert.assertTrue(backupFile.isFile());
-
-        return backupFile;
-    }
-
-    /**
-     * Unpack the backup from the specified backup file and ensure
-     * that the specified files are contained.
-     */
-    private ZipFile openBackupFile(
-        @NonNull File backupFile,
-        @NonNull String[] expectedFiles
-    ) throws Exception {
-        // Open ZIP
-        final ZipFile zipFile = new ZipFile(backupFile, PASSWORD.toCharArray());
-        Assert.assertTrue("Generated backup ZIP is invalid", zipFile.isValidZipFile());
-
-        // Ensure list of files is correct
-        final List<FileHeader> headers = zipFile.getFileHeaders();
-        Log.d(TAG, "File headers: " + Arrays.toString(headers.toArray()));
-        final Object[] actualFiles = StreamSupport.stream(headers)
-            .map(AbstractFileHeader::getFileName)
-            .toArray();
-        Assert.assertArrayEquals(
-            "Array is " + Arrays.toString(actualFiles),
-            expectedFiles,
-            actualFiles
-        );
-
-        return zipFile;
-    }
-
-    @Test
-    public void testBackupIdentity() throws Exception {
-        // Do backup
-        final File backupFile = doBackup(new BackupRestoreDataConfig(PASSWORD)
-            .setBackupContactAndMessages(false)
-            .setBackupIdentity(true)
-            .setBackupAvatars(false)
-            .setBackupMedia(false)
-            .setBackupThumbnails(false)
-            .setBackupNonces(false));
-
-        try (final ZipFile zipFile = this.openBackupFile(backupFile, new String[]{"settings", "identity"})) {
-            // Read identity backup
-            final String identityBackup;
-            try (final ZipInputStream stream = zipFile.getInputStream(zipFile.getFileHeader("identity"))) {
-                identityBackup = IOUtils.toString(stream);
-            }
-
-            // Verify identity backup
-            IdentityBackup.PlainBackupData decryptedBackupData = IdentityBackup.decryptIdentityBackup(
-                PASSWORD,
-                new IdentityBackup.EncryptedIdentityBackup(identityBackup)
-            );
-            Assert.assertEquals(TEST_IDENTITY, decryptedBackupData.getThreemaId());
-        } finally {
-            //noinspection ResultOfMethodCallIgnored
-            backupFile.delete();
-        }
-    }
-
-    @Test
-    public void testBackupContactsAndMessages() throws Exception {
-        // Clear all data
-        this.messageService.removeAll();
-        this.conversationService.reset();
-        this.groupService.removeAll();
-        this.backgroundExecutor.execute(getContactDeleteTask());
-        this.distributionListService.removeAll();
-        this.ballotService.removeAll();
-
-        // Insert test data:
-        // Contacts
-        final ContactModel contact1 = createContact("CDXVZ5E4");
-        contact1.setFirstName("Fritzli");
-        contact1.setLastName("Bühler");
-        this.contactService.save(contact1);
-        final ContactModel contact2 = createContact("DRMWZP3H");
-        createContact("ECHOECHO");
-        // Messages contact 1
-        this.messageService.sendText("Bonjour!", this.contactService.createReceiver(contact1));
-        this.messageService.sendText("Phở?", this.contactService.createReceiver(contact1));
-        this.messageService.createVoipStatus(VoipStatusDataModel.createAborted(0), this.contactService.createReceiver(contact1), true, false);
-        // Messages contact 2
-        this.messageService.sendText("\uD83D\uDC4B", this.contactService.createReceiver(contact2));
-
-        // Do backup
-        final File backupFile = doBackup(new BackupRestoreDataConfig(PASSWORD)
-            .setBackupContactAndMessages(true)
-            .setBackupIdentity(false)
-            .setBackupAvatars(false)
-            .setBackupMedia(false)
-            .setBackupThumbnails(false)
-            .setBackupNonces(false));
-
-        try (
-            final ZipFile zipFile = this.openBackupFile(backupFile, new String[]{
-                "settings",
-                "message_CDXVZ5E4.csv",
-                "message_DRMWZP3H.csv",
-                "message_ECHOECHO.csv",
-                "contacts.csv",
-                "groups.csv",
-                "distribution_list.csv",
-                "ballot.csv",
-                "ballot_choice.csv",
-                "ballot_vote.csv",
-            })
-        ) {
-            // Read contacts
-            try (final ZipInputStream stream = zipFile.getInputStream(zipFile.getFileHeader("contacts.csv"))) {
-                final CSVReader csvReader = new CSVReader(new InputStreamReader(stream), true);
-                final CSVRow row1 = csvReader.readNextRow();
-                Assert.assertEquals("CDXVZ5E4", row1.getString("identity"));
-                Assert.assertEquals("Fritzli", row1.getString("firstname"));
-                Assert.assertEquals("Bühler", row1.getString("lastname"));
-                final CSVRow row2 = csvReader.readNextRow();
-                Assert.assertEquals("DRMWZP3H", row2.getString("identity"));
-                final CSVRow row3 = csvReader.readNextRow();
-                Assert.assertEquals("ECHOECHO", row3.getString("identity"));
-            }
-
-            // Read messages
-            try (final ZipInputStream stream = zipFile.getInputStream(zipFile.getFileHeader("message_CDXVZ5E4.csv"))) {
-                final CSVReader csvReader = new CSVReader(new InputStreamReader(stream), true);
-                // First, the two text messages
-                final CSVRow row1 = csvReader.readNextRow();
-                final CSVRow row2 = csvReader.readNextRow();
-                Assert.assertTrue(row1.getBoolean("isoutbox"));
-                Assert.assertTrue(row2.getBoolean("isoutbox"));
-                Assert.assertEquals("TEXT", row1.getString("type"));
-                Assert.assertEquals("TEXT", row2.getString("type"));
-                Assert.assertEquals("Bonjour!", row1.getString("body"));
-                Assert.assertEquals("Phở?", row2.getString("body"));
-                // …followed by the VoIPstatus message
-                final CSVRow row3 = csvReader.readNextRow();
-                Assert.assertEquals("VOIP_STATUS", row3.getString("type"));
-                Assert.assertEquals("[1,{\"status\":" + VoipStatusDataModel.ABORTED + "}]", row3.getString("body"));
-                Assert.assertNull(csvReader.readNextRow());
-            }
-        } finally {
-            //noinspection ResultOfMethodCallIgnored
-            backupFile.delete();
-        }
-    }
-
-    @NonNull
-    @WorkerThread
-    private ContactModel createContact(@NonNull String identity) {
-        new BasicAddOrUpdateContactBackgroundTask(
-            identity,
-            ContactModel.AcquaintanceLevel.DIRECT,
-            TEST_IDENTITY,
-            apiConnector,
-            contactModelRepository,
-            AddContactRestrictionPolicy.CHECK,
-            ApplicationProvider.getApplicationContext(),
-            null
-        ).runSynchronously();
-
-        ContactModel contactModel = contactService.getByIdentity(identity);
-        if (contactModel == null) {
-            throw new IllegalStateException("Contact is null after creating it");
-        }
-        return contactModel;
-    }
-
-    @NonNull
-    private DeleteAllContactsBackgroundTask getContactDeleteTask() throws ThreemaException {
-        return new DeleteAllContactsBackgroundTask(
-            serviceManager.getModelRepositories().getContacts(),
-            new DeleteContactServices(
-                serviceManager.getUserService(),
-                serviceManager.getContactService(),
-                serviceManager.getConversationService(),
-                serviceManager.getRingtoneService(),
-                serviceManager.getConversationCategoryService(),
-                serviceManager.getProfilePicRecipientsService(),
-                serviceManager.getWallpaperService(),
-                serviceManager.getFileService(),
-                serviceManager.getExcludedSyncIdentitiesService(),
-                serviceManager.getDHSessionStore(),
-                serviceManager.getNotificationService(),
-                serviceManager.getDatabaseService()
-            )
-        );
-    }
-
-}

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

@@ -176,7 +176,7 @@ class MarkContactAsDeletedBackgroundTaskTest {
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         isArchived = false,
-        androidContactLookupKey = null,
+        androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
         profilePictureBlobId = null,

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

@@ -93,7 +93,7 @@ class ReflectedContactSyncTaskTest {
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         isArchived = false,
-        androidContactLookupKey = null,
+        androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
         profilePictureBlobId = null,

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

@@ -83,7 +83,7 @@ class CreateGroupFlowTest : GroupFlowTest() {
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         isArchived = false,
         profilePictureBlobId = null,
-        androidContactLookupKey = null,
+        androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
         jobTitle = null,

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

@@ -78,7 +78,7 @@ class DisbandGroupFlowTest : GroupFlowTest() {
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         isArchived = false,
         profilePictureBlobId = null,
-        androidContactLookupKey = null,
+        androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
         jobTitle = null,

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

@@ -93,7 +93,7 @@ abstract class GroupFlowTest {
             SetupConfig.MULTI_DEVICE_ENABLED -> testMultiDeviceManagerEnabled
             SetupConfig.MULTI_DEVICE_DISABLED -> testMultiDeviceManagerDisabled
         },
-        serviceManager.apiService,
+        serviceManager.groupProfilePictureUploader,
         serviceManager.apiConnector,
         serviceManager.fileService,
         serviceManager.databaseService,

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

@@ -72,7 +72,7 @@ class GroupResyncFlowTest : GroupFlowTest() {
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         isArchived = false,
         profilePictureBlobId = null,
-        androidContactLookupKey = null,
+        androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
         jobTitle = null,

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

@@ -46,13 +46,13 @@ import java.util.Date
 import junit.framework.TestCase
 import kotlin.test.AfterTest
 import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
 import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Assert.fail
 import org.junit.runner.RunWith
 
 /**
@@ -566,7 +566,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
                 groupIdentity: GroupIdentity,
                 identityNew: String?,
             ) {
-                assertTrue("Did not expect member $identityNew", newMembers.contains(identityNew))
+                assertTrue(newMembers.contains(identityNew), "Did not expect member $identityNew")
                 newMembersAdded.add(identityNew!!)
             }
 
@@ -662,7 +662,7 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         isArchived = false,
-        androidContactLookupKey = null,
+        androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
         profilePictureBlobId = null,

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

@@ -26,8 +26,8 @@ import androidx.test.filters.LargeTest
 import ch.threema.app.DangerousTest
 import ch.threema.domain.protocol.csp.messages.GroupTextMessage
 import kotlin.test.Test
+import kotlin.test.assertTrue
 import kotlinx.coroutines.runBlocking
-import org.junit.Assert
 import org.junit.runner.RunWith
 
 /**
@@ -50,8 +50,8 @@ class IncomingGroupTextTest : GroupControlTest<GroupTextMessage>() {
         // listener. Therefore it is sufficient to test that processing a message succeeds.
         processMessage(firstMessage, contactA.identityStore)
 
-        Assert.assertTrue(sentMessagesInsideTask.isEmpty())
-        Assert.assertTrue(sentMessagesNewTask.isEmpty())
+        assertTrue(sentMessagesInsideTask.isEmpty())
+        assertTrue(sentMessagesNewTask.isEmpty())
     }
 
     override fun createMessageForGroup(): GroupTextMessage {

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

@@ -77,7 +77,7 @@ class LeaveGroupFlowTest : GroupFlowTest() {
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         isArchived = false,
         profilePictureBlobId = null,
-        androidContactLookupKey = null,
+        androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
         jobTitle = null,

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

@@ -73,7 +73,7 @@ class RemoveGroupFlowTest : GroupFlowTest() {
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         isArchived = false,
         profilePictureBlobId = null,
-        androidContactLookupKey = null,
+        androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
         jobTitle = null,

+ 14 - 13
app/src/androidTest/java/ch/threema/app/groupmanagement/UpdateGroupFlowTest.kt

@@ -23,6 +23,7 @@ package ch.threema.app.groupmanagement
 
 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.tasks.GroupUpdateTask
 import ch.threema.app.tasks.ReflectLocalGroupUpdate
@@ -81,7 +82,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         isArchived = false,
         profilePictureBlobId = null,
-        androidContactLookupKey = null,
+        androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
         jobTitle = null,
@@ -111,7 +112,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         isArchived = false,
         profilePictureBlobId = null,
-        androidContactLookupKey = null,
+        androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
         jobTitle = null,
@@ -171,7 +172,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         assertNotNull(groupModel)
         val groupChanges = GroupChanges(
             name = "NewGroupName",
-            profilePictureChange = null,
+            profilePictureChange = NoChange,
             updatedMembers = myInitialGroupModelData.otherMembers,
             groupModelData = myInitialGroupModelData,
         )
@@ -189,7 +190,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         assertNotNull(groupModel)
         val groupChanges = GroupChanges(
             name = "NewGroupName",
-            profilePictureChange = null,
+            profilePictureChange = NoChange,
             updatedMembers = myInitialGroupModelData.otherMembers,
             groupModelData = myInitialGroupModelData,
         )
@@ -213,7 +214,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
 
         val groupChanges = GroupChanges(
             name = "NewGroupName",
-            profilePictureChange = null,
+            profilePictureChange = NoChange,
             updatedMembers = myInitialGroupModelData.otherMembers + initialContactData.identity,
             groupModelData = myInitialGroupModelData,
         )
@@ -235,7 +236,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         assertNotNull(groupModel)
         val groupChanges = GroupChanges(
             name = "NewGroupName",
-            profilePictureChange = null,
+            profilePictureChange = NoChange,
             updatedMembers = myInitialGroupModelData.otherMembers + initialContactData.identity,
             groupModelData = myInitialGroupModelData,
         )
@@ -257,7 +258,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         assertNotNull(groupModel)
         val groupChanges = GroupChanges(
             name = "NewGroupName",
-            profilePictureChange = null,
+            profilePictureChange = NoChange,
             updatedMembers = emptySet(),
             groupModelData = myInitialGroupModelData,
         )
@@ -277,7 +278,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         assertNotNull(groupModel)
         val groupChanges = GroupChanges(
             name = "NewGroupName",
-            profilePictureChange = null,
+            profilePictureChange = NoChange,
             updatedMembers = emptySet(),
             groupModelData = myInitialGroupModelData,
         )
@@ -297,7 +298,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         assertNotNull(groupModel)
         val groupChanges = GroupChanges(
             name = "NewGroupName",
-            profilePictureChange = null,
+            profilePictureChange = NoChange,
             updatedMembers = emptySet(),
             groupModelData = myInitialGroupModelData,
         )
@@ -321,7 +322,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         assertNotNull(groupModel)
         val groupChanges = GroupChanges(
             name = "NewGroupName",
-            profilePictureChange = null,
+            profilePictureChange = NoChange,
             updatedMembers = emptySet(),
             groupModelData = myInitialGroupModelData,
         )
@@ -345,7 +346,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         assertNotNull(groupModel)
         val groupChanges = GroupChanges(
             name = "NewGroupName",
-            profilePictureChange = null,
+            profilePictureChange = NoChange,
             updatedMembers = setOf(initialGroupMemberData.identity),
             groupModelData = initialGroupModelData,
         )
@@ -367,7 +368,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         assertNotNull(groupModel)
         val groupChanges = GroupChanges(
             name = "NewGroupName",
-            profilePictureChange = null,
+            profilePictureChange = NoChange,
             updatedMembers = setOf(initialGroupMemberData.identity),
             groupModelData = initialGroupModelData,
         )
@@ -395,7 +396,7 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         )
         val groupChangesNewName = GroupChanges(
             name = "NewGroupName",
-            profilePictureChange = null,
+            profilePictureChange = NoChange,
             updatedMembers = myInitialGroupModelData.otherMembers,
             groupModelData = myInitialGroupModelData,
         )

+ 9 - 3
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt

@@ -32,7 +32,8 @@ import ch.threema.app.managers.ServiceManager
 import ch.threema.app.multidevice.MultiDeviceManagerImpl
 import ch.threema.app.services.FileService
 import ch.threema.app.services.LifetimeService
-import ch.threema.app.tasks.TaskArchiverImpl
+import ch.threema.app.tasks.archive.TaskArchiverImpl
+import ch.threema.app.tasks.archive.recovery.TaskRecoveryManagerImpl
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
@@ -77,6 +78,7 @@ import ch.threema.domain.taskmanager.toCspMessage
 import ch.threema.storage.DatabaseService
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.GroupMemberModel
+import java.io.ByteArrayInputStream
 import java.util.Queue
 import java.util.concurrent.ConcurrentLinkedQueue
 import junit.framework.TestCase.assertEquals
@@ -420,7 +422,7 @@ open class MessageProcessorProvider {
             serviceManager.databaseService,
             serviceManager.preferenceStore,
             serviceManager.encryptedPreferenceStore,
-            TaskArchiverImpl(serviceManager.databaseService.taskArchiveFactory),
+            TaskArchiverImpl(serviceManager.databaseService.taskArchiveFactory, TaskRecoveryManagerImpl()),
             serviceManager.deviceCookieManager,
             taskManager,
             serviceManager.multiDeviceManager as MultiDeviceManagerImpl,
@@ -507,7 +509,11 @@ open class MessageProcessorProvider {
             databaseService.groupMemberModelFactory.createOrUpdate(memberModel)
         }
         if (testGroup.profilePicture != null) {
-            fileService.writeGroupAvatar(groupModel, testGroup.profilePicture)
+            fileService.writeGroupProfilePicture(
+                groupModel.groupIdentity,
+                groupModel.id.toLong(),
+                ByteArrayInputStream(testGroup.profilePicture),
+            )
         }
 
         // We trigger the listeners to invalidate the cache of the new group model.

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

@@ -252,7 +252,7 @@ class IdentityBlockedStepsTest {
                 readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
                 typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
                 isArchived = false,
-                androidContactLookupKey = null,
+                androidContactLookupInfo = null,
                 localAvatarExpires = null,
                 isRestored = false,
                 profilePictureBlobId = null,
@@ -280,7 +280,7 @@ class IdentityBlockedStepsTest {
                 readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
                 typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
                 isArchived = false,
-                androidContactLookupKey = null,
+                androidContactLookupInfo = null,
                 localAvatarExpires = null,
                 isRestored = false,
                 profilePictureBlobId = null,
@@ -308,7 +308,7 @@ class IdentityBlockedStepsTest {
                 readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
                 typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
                 isArchived = false,
-                androidContactLookupKey = null,
+                androidContactLookupInfo = null,
                 localAvatarExpires = null,
                 isRestored = false,
                 profilePictureBlobId = null,
@@ -336,7 +336,7 @@ class IdentityBlockedStepsTest {
                 readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
                 typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
                 isArchived = false,
-                androidContactLookupKey = null,
+                androidContactLookupInfo = null,
                 localAvatarExpires = null,
                 isRestored = false,
                 profilePictureBlobId = null,

+ 67 - 0
app/src/androidTest/java/ch/threema/app/qrcodes/QrCodeGeneratorTest.kt

@@ -0,0 +1,67 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2025 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.qrcodes
+
+import android.graphics.Bitmap
+import ch.threema.testhelpers.loadResource
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+class QrCodeGeneratorTest {
+
+    @Test
+    fun generateQrCode() {
+        val bitmap = QrCodeGenerator().generateWithUnicodeSupport(content = "äbc-123 \uD83D\uDC08")
+        assertNotNull(bitmap)
+
+        try {
+            assertEquals(
+                loadResource("qr-code.txt"),
+                bitmap.toPixelString(),
+            )
+        } finally {
+            bitmap.recycle()
+        }
+    }
+
+    private fun Bitmap.toPixelString(): String {
+        val pixels = IntArray(width * height)
+        getPixels(pixels, 0, width, 0, 0, width, height)
+        return pixels.toList()
+            .chunked(width)
+            .joinToString(separator = "\n") { pixelRow ->
+                pixelRow.joinToString(separator = "") { pixelColor ->
+                    when (pixelColor) {
+                        BLACK -> "■"
+                        WHITE -> " "
+                        else -> "?"
+                    }
+                }
+            }
+    }
+
+    companion object {
+        private const val BLACK = 0xFF000000.toInt()
+        private const val WHITE = 0xFFFFFFFF.toInt()
+    }
+}

+ 16 - 4
app/src/androidTest/java/ch/threema/app/stores/EncryptedPreferenceStoreImplTest.kt

@@ -21,9 +21,12 @@
 
 package ch.threema.app.stores
 
-import ch.threema.app.ThreemaApplication
+import ch.threema.common.stateFlowOf
 import ch.threema.localcrypto.MasterKeyImpl
+import ch.threema.localcrypto.MasterKeyProvider
+import ch.threema.testhelpers.createTempDirectory
 import java.io.File
+import kotlin.test.AfterTest
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
@@ -39,20 +42,29 @@ import org.json.JSONObject
 class EncryptedPreferenceStoreImplTest {
 
     private var onChangedCalled = false
+    private lateinit var directory: File
     private lateinit var store: EncryptedPreferenceStore
 
     @BeforeTest
     fun setUp() {
         val masterKeyData = ByteArray(32) { it.toByte() }
+        directory = createTempDirectory()
         store = EncryptedPreferenceStoreImpl(
-            context = ThreemaApplication.getAppContext(),
-            masterKey = MasterKeyImpl(masterKeyData),
+            directory = directory,
+            masterKeyProvider = MasterKeyProvider(
+                masterKeyFlow = stateFlowOf(MasterKeyImpl(masterKeyData)),
+            ),
             onChanged = { _, _ -> onChangedCalled = true },
         )
         store.clear()
         onChangedCalled = false
     }
 
+    @AfterTest
+    fun tearDown() {
+        directory.deleteRecursively()
+    }
+
     @Test
     fun checkingForAndRemovingKeys() {
         assertFalse(store.containsKey("foo"))
@@ -171,7 +183,7 @@ class EncryptedPreferenceStoreImplTest {
 
     @Test
     fun restoringFromPreviouslyEncryptedFile() {
-        File(ThreemaApplication.getAppContext().filesDir, ".crs-test")
+        File(directory, ".crs-test")
             .writeBytes(
                 byteArrayOf(
                     -116, 41, -38, -100, 96, 67, -28, -11, -118, -59, -33, -25,

+ 10 - 42
app/src/androidTest/java/ch/threema/app/tasks/GroupCreateTaskTest.kt

@@ -24,9 +24,8 @@ package ch.threema.app.tasks
 import ch.threema.app.DangerousTest
 import ch.threema.app.TestMultiDeviceManager
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.protocol.ExpectedProfilePictureChange
 import ch.threema.app.protocol.PredefinedMessageIds
-import ch.threema.app.protocol.RemoveProfilePicture
-import ch.threema.app.services.ApiService
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.clearDatabaseAndCaches
@@ -43,16 +42,11 @@ import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
-import ch.threema.domain.protocol.blob.BlobLoader
-import ch.threema.domain.protocol.blob.BlobScope
-import ch.threema.domain.protocol.blob.BlobUploader
 import ch.threema.domain.protocol.connection.data.CspMessage
 import ch.threema.domain.protocol.connection.data.OutboundD2mMessage
-import ch.threema.domain.types.Identity
 import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.GroupModel
-import ch.threema.testhelpers.MUST_NOT_BE_CALLED
-import java.net.URL
+import io.mockk.mockk
 import java.util.Date
 import kotlin.test.BeforeTest
 import kotlin.test.Test
@@ -82,7 +76,7 @@ class GroupCreateTaskTest {
         typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
         isArchived = false,
         profilePictureBlobId = null,
-        androidContactLookupKey = null,
+        androidContactLookupInfo = null,
         localAvatarExpires = null,
         isRestored = false,
         jobTitle = null,
@@ -92,32 +86,6 @@ class GroupCreateTaskTest {
 
     private val serviceManager by lazy { ThreemaApplication.requireServiceManager() }
 
-    private val noopApiService = object : ApiService {
-        override fun createUploader(
-            data: ByteArray,
-            shouldPersist: Boolean,
-            blobScope: BlobScope,
-        ): BlobUploader {
-            MUST_NOT_BE_CALLED()
-        }
-
-        override fun createLoader(blobId: ByteArray): BlobLoader {
-            MUST_NOT_BE_CALLED()
-        }
-
-        override fun getAuthToken(): String {
-            MUST_NOT_BE_CALLED()
-        }
-
-        override fun invalidateAuthToken() {
-            MUST_NOT_BE_CALLED()
-        }
-
-        override fun getAvatarURL(identity: Identity): URL {
-            MUST_NOT_BE_CALLED()
-        }
-    }
-
     private val testMultiDeviceManagerMdEnabled by lazy {
         TestMultiDeviceManager(
             isMdDisabledOrSupportsFs = false,
@@ -159,24 +127,24 @@ class GroupCreateTaskTest {
                     notificationTriggerPolicyOverride = null,
                 ),
             )
-        } catch (e: GroupCreateException) {
+        } catch (_: GroupCreateException) {
             // Ignore
         }
     }
 
     @Test
     fun testSimpleGroupMd() = runTest {
-        val predefinedMessageIds = PredefinedMessageIds()
+        val predefinedMessageIds = PredefinedMessageIds.random()
         val groupCreateTask = GroupCreateTask(
             name = "My Group",
-            profilePictureChange = RemoveProfilePicture,
+            expectedProfilePictureChange = ExpectedProfilePictureChange.Remove,
             members = setOf(initialContactModelData.identity),
             groupIdentity = GroupIdentity(myContact.identity, 42),
             predefinedMessageIds = predefinedMessageIds,
             outgoingCspMessageServices = getOutgoingCspMessageServicesMd(),
             groupCallManager = serviceManager.groupCallManager,
             fileService = serviceManager.fileService,
-            apiService = noopApiService,
+            groupProfilePictureUploader = mockk(),
             groupModelRepository = serviceManager.modelRepositories.groups,
         )
 
@@ -209,17 +177,17 @@ class GroupCreateTaskTest {
 
     @Test
     fun testSimpleGroupNonMd() = runTest {
-        val predefinedMessageIds = PredefinedMessageIds()
+        val predefinedMessageIds = PredefinedMessageIds.random()
         val groupCreateTask = GroupCreateTask(
             name = "My Group",
-            profilePictureChange = RemoveProfilePicture,
+            expectedProfilePictureChange = ExpectedProfilePictureChange.Remove,
             members = setOf("12345678"),
             groupIdentity = GroupIdentity(myContact.identity, 42),
             predefinedMessageIds = predefinedMessageIds,
             outgoingCspMessageServices = getOutgoingCspMessageServicesNonMd(),
             groupCallManager = serviceManager.groupCallManager,
             fileService = serviceManager.fileService,
-            apiService = noopApiService,
+            groupProfilePictureUploader = mockk(),
             groupModelRepository = serviceManager.modelRepositories.groups,
         )
 

+ 114 - 11
app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt

@@ -640,11 +640,49 @@ class PersistableTasksTest {
     fun testGroupCreateTask() {
         assertValidEncoding(
             GroupCreateTask::class,
-            """{"type":"ch.threema.app.tasks.GroupCreateTask.GroupCreateTaskData","name":"Name","profilePictureChange":""" +
-                """{"type":"ch.threema.app.protocol.RemoveProfilePicture"},"members":["TESTTEST","01234567"],"groupIdentity":""" +
-                """{"creatorIdentity":"01234567","groupId":42},"predefinedMessageIds":{"messageIdBytes1":[-121,-57,86,-82,-126,8,80,89],""" +
-                """"messageIdBytes2":[54,-9,56,45,19,79,-33,80],"messageIdBytes3":[-62,57,-64,-73,-95,78,59,82],""" +
-                """"messageIdBytes4":[-59,-117,93,109,46,-10,-119,118]}}""",
+            """
+                {
+                    "type":"ch.threema.app.tasks.GroupCreateTask.GroupCreateTaskData",
+                    "name":"Name",
+                    "serializableExpectedProfilePictureChange":{
+                        "type":"ch.threema.app.tasks.GroupCreateTask.GroupCreateTaskData.Companion.SerializableExpectedProfilePictureChange.Set",
+                        "profilePictureBytes":[-1,-40,-1,-32,0,16,74,70,73,70,0,1,1,0,0,1,0,1,0,0,-1,-30,2,40,73,67,67,95,80,82,79,70,73,76,69,0],
+                        "blobId":[-111,-102,108,13,-47,127,-69,77,86,-9,80,-104,-19,52,2,96],
+                        "encryptionKey":[-42,-1,-3,3,-1,1,60,66,3,120,82,12,-82,-72,-1,120,-63,91,-119,-40,-112,89,2,-99,120,109,-1,-114,65,14,33,37],
+                        "size":1171
+                    },
+                    "members":["01234567"],
+                    "groupIdentity":{"creatorIdentity":"07654321","groupId":-8035334611319732223},
+                    "serializablePredefinedMessageIds":{
+                        "messageId1":4963550096699540653,
+                        "messageId2":3028276473780066008,
+                        "messageId3":-4433228351225673089,
+                        "messageId4":3269950967196984857
+                    }
+                }
+            """.trimIndent(),
+        )
+
+        assertValidEncoding(
+            GroupCreateTask::class,
+            """
+                {
+                    "type":"ch.threema.app.tasks.GroupCreateTask.GroupCreateTaskData",
+                    "name":"Name",
+                    "serializableExpectedProfilePictureChange":{
+                        "type":"ch.threema.app.tasks.GroupCreateTask.GroupCreateTaskData.Companion.SerializableExpectedProfilePictureChange.SetWithoutUpload",
+                        "profilePictureBytes":[-1,-40,-1,-32,0,16,74,70,73,70,0,1,1,0,0,1,0,1,0,0,-1,-30,2,40,73,67,67,95,80,82,79,70,73,76,69,0]
+                    },
+                    "members":["01234567"],
+                    "groupIdentity":{"creatorIdentity":"07654321","groupId":-8035334611319732223},
+                    "serializablePredefinedMessageIds":{
+                        "messageId1":4963550096699540653,
+                        "messageId2":3028276473780066008,
+                        "messageId3":-4433228351225673089,
+                        "messageId4":3269950967196984857
+                    }
+                }
+            """.trimIndent(),
         )
     }
 
@@ -652,11 +690,76 @@ class PersistableTasksTest {
     fun testGroupUpdateTask() {
         assertValidEncoding(
             GroupUpdateTask::class,
-            """{"type":"ch.threema.app.tasks.GroupUpdateTask.GroupUpdateTaskData","name":"Name","profilePictureChange":""" +
-                """{"type":"ch.threema.app.protocol.RemoveProfilePicture"},"updatedMembers":["01234567"],"addedMembers":["TESTTEST",""" +
-                """"01234567"],"removedMembers":["01234567"],"groupIdentity":{"creatorIdentity":"01234567","groupId":42},""" +
-                """"predefinedMessageIds":{"messageIdBytes1":[5,-23,34,43,-15,49,22,-42],"messageIdBytes2":[-62,55,62,-15,-110,-56,58,-103],""" +
-                """"messageIdBytes3":[-128,28,-10,110,14,-39,105,-105],"messageIdBytes4":[-9,115,118,38,-118,-73,99,89]}}""",
+            """
+                {
+                    "type":"ch.threema.app.tasks.GroupUpdateTask.GroupUpdateTaskData",
+                    "name":null,
+                    "serializableExpectedProfilePictureChange":{
+                        "type":"ch.threema.app.tasks.GroupUpdateTask.GroupUpdateTaskData.Companion.SerializableExpectedProfilePictureChange.Set",
+                        "profilePictureBytes":[-1,-40,-1,-32,0,16,74,70,73,70,0,1,1,0,0,1,0,1,0,0,-1,-30,2,40,73,67,67,95,80,82,79,70,73,76,69,0],
+                        "blobId":[-112,115,-35,44,75,-71,56,34,-35,-28,-18,-49,108,-6,17,-2],
+                        "encryptionKey":[124,-84,89,-29,99,-56,-80,45,4,124,43,99,81,-15,64,-55,25,-54,53,115,27,-6,23,55,39,-5,-50,14,-79,125,24,63],
+                        "size":1179
+                    },
+                    "updatedMembers":["01234567"],
+                    "addedMembers":[],
+                    "removedMembers":[],
+                    "groupIdentity":{"creatorIdentity":"07654321","groupId":5588186647911236286},
+                    "serializablePredefinedMessageIds":{
+                        "messageId1":139294365944476775,
+                        "messageId2":-413801300579649603,
+                        "messageId3":525071018028493227,
+                        "messageId4":-388653292512991891
+                    }
+                }
+            """.trimIndent(),
+        )
+
+        assertValidEncoding(
+            GroupUpdateTask::class,
+            """
+                {
+                    "type":"ch.threema.app.tasks.GroupUpdateTask.GroupUpdateTaskData",
+                    "name":null,
+                    "serializableExpectedProfilePictureChange":{
+                        "type":"ch.threema.app.tasks.GroupUpdateTask.GroupUpdateTaskData.Companion.SerializableExpectedProfilePictureChange.SetWithoutUpload",
+                        "profilePictureBytes":[-1,-40,-1,-32,0,16,74,70,73,70,0,1,1,0,0,1,0,1,0,0,-1,-30,2,40,73,67,67,95,80,82,79,70,73,76,69,0]
+                    },
+                    "updatedMembers":["01234567"],
+                    "addedMembers":[],
+                    "removedMembers":[],
+                    "groupIdentity":{"creatorIdentity":"07654321","groupId":5588186647911236286},
+                    "serializablePredefinedMessageIds":{
+                        "messageId1":139294365944476775,
+                        "messageId2":-413801300579649603,
+                        "messageId3":525071018028493227,
+                        "messageId4":-388653292512991891
+                    }
+                }
+            """.trimIndent(),
+        )
+
+        assertValidEncoding(
+            GroupUpdateTask::class,
+            """
+                {
+                    "type":"ch.threema.app.tasks.GroupUpdateTask.GroupUpdateTaskData",
+                    "name":null,
+                    "serializableExpectedProfilePictureChange":{
+                        "type":"ch.threema.app.tasks.GroupUpdateTask.GroupUpdateTaskData.Companion.SerializableExpectedProfilePictureChange.NoChange"
+                    },
+                    "updatedMembers":["01234567"],
+                    "addedMembers":[],
+                    "removedMembers":[],
+                    "groupIdentity":{"creatorIdentity":"07654321","groupId":5588186647911236286},
+                    "serializablePredefinedMessageIds":{
+                        "messageId1":139294365944476775,
+                        "messageId2":-413801300579649603,
+                        "messageId3":525071018028493227,
+                        "messageId4":-388653292512991891
+                    }
+                }
+            """.trimIndent(),
         )
     }
 
@@ -816,7 +919,7 @@ class PersistableTasksTest {
                 typingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
                 readReceiptPolicy = ReadReceiptPolicy.DEFAULT,
                 isArchived = false,
-                androidContactLookupKey = null,
+                androidContactLookupInfo = null,
                 localAvatarExpires = null,
                 isRestored = false,
                 profilePictureBlobId = null,

+ 11 - 8
app/src/androidTest/java/ch/threema/app/testutils/AndroidTestUtils.kt

@@ -24,6 +24,7 @@ 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.runTransaction
 
 fun clearDatabaseAndCaches(serviceManager: ServiceManager) {
     // First get all available contacts and groups
@@ -42,14 +43,16 @@ fun clearDatabaseAndCaches(serviceManager: ServiceManager) {
         }
 
     // Clear entire database
-    serviceManager.databaseService.writableDatabase.apply {
-        rawExecSQL("PRAGMA writable_schema = 1;")
-        rawExecSQL("DELETE FROM sqlite_master where type in ('table', 'index', 'trigger');")
-        rawExecSQL("PRAGMA writable_schema = 0;")
-        rawExecSQL("VACUUM;")
-        rawExecSQL("PRAGMA integrity_check;")
-        // Recreate the database
-        serviceManager.databaseService.onCreate(this)
+    serviceManager.databaseService.writableDatabase.runTransaction {
+        rawExecSQL("PRAGMA foreign_keys = OFF;")
+
+        query("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").use { cursor ->
+            while (cursor.moveToNext()) {
+                rawExecSQL("DELETE FROM ${cursor.getString(0)};")
+            }
+        }
+
+        rawExecSQL("PRAGMA foreign_keys = ON;")
     }
 
     // Clear caches in services and trigger listeners to refresh the new models from database

+ 13 - 10
app/src/androidTest/java/ch/threema/app/utils/BackgroundExecutorTest.kt

@@ -25,9 +25,12 @@ import android.os.Looper
 import ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.app.utils.executor.BackgroundTask
 import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
+import kotlin.test.assertNotEquals
+import kotlin.test.fail
 import kotlinx.coroutines.runBlocking
-import org.junit.Assert
 import org.junit.Rule
 import org.junit.rules.Timeout
 
@@ -44,17 +47,17 @@ class BackgroundExecutorTest {
 
         executor.execute(object : BackgroundTask<Unit> {
             override fun runBefore() {
-                Assert.assertEquals(initialThread.id, Thread.currentThread().id)
+                assertEquals(initialThread.id, Thread.currentThread().id)
             }
 
             override fun runInBackground() {
                 val currentThreadId = Thread.currentThread().id
-                Assert.assertNotEquals(initialThread.id, currentThreadId)
-                Assert.assertNotEquals(Looper.getMainLooper().thread.id, currentThreadId)
+                assertNotEquals(initialThread.id, currentThreadId)
+                assertNotEquals(Looper.getMainLooper().thread.id, currentThreadId)
             }
 
             override fun runAfter(result: Unit) {
-                Assert.assertEquals(Looper.getMainLooper().thread.id, Thread.currentThread().id)
+                assertEquals(Looper.getMainLooper().thread.id, Thread.currentThread().id)
             }
         })
     }
@@ -63,7 +66,7 @@ class BackgroundExecutorTest {
     fun testReturnValue() {
         executor.execute(object : BackgroundTask<Int> {
             override fun runInBackground() = 42
-            override fun runAfter(result: Int) = Assert.assertEquals(42, result)
+            override fun runAfter(result: Int) = assertEquals(42, result)
         })
     }
 
@@ -96,7 +99,7 @@ class BackgroundExecutorTest {
             }
         }).await()
 
-        Assert.assertArrayEquals(
+        assertContentEquals(
             expected,
             methodExecutionList.toTypedArray(),
         )
@@ -111,12 +114,12 @@ class BackgroundExecutorTest {
 
             override fun runInBackground() {
                 // This should never be executed as run before failed
-                Assert.fail()
+                fail()
             }
 
             override fun runAfter(result: Unit) {
                 // This should never be executed as run before failed
-                Assert.fail()
+                fail()
             }
         })
 
@@ -136,7 +139,7 @@ class BackgroundExecutorTest {
 
             override fun runAfter(result: Unit) {
                 // This should never be executed as run in background failed
-                Assert.fail()
+                fail()
             }
         })
 

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

@@ -26,7 +26,7 @@ import android.text.style.URLSpan
 import android.widget.TextView
 import androidx.test.platform.app.InstrumentationRegistry
 import kotlin.test.Test
-import org.junit.Assert.assertEquals
+import kotlin.test.assertEquals
 
 class LinkifyUtilTest {
     /**

+ 4 - 3
app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt

@@ -29,6 +29,7 @@ import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.utils.AppVersionProvider
 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
 import ch.threema.domain.helpers.TransactionAckTaskCodec
@@ -106,7 +107,7 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
             featureMask: ULong = 0u,
             readReceiptPolicy: ReadReceiptPolicy = ReadReceiptPolicy.DEFAULT,
             typingIndicatorPolicy: TypingIndicatorPolicy = TypingIndicatorPolicy.DEFAULT,
-            androidContactLookupKey: String? = null,
+            androidContactLookupInfo: AndroidContactLookupInfo? = null,
             localAvatarExpires: Date? = null,
             isRestored: Boolean = false,
             profilePictureBlobId: ByteArray? = null,
@@ -130,7 +131,7 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
             readReceiptPolicy = readReceiptPolicy,
             typingIndicatorPolicy = typingIndicatorPolicy,
             isArchived = false,
-            androidContactLookupKey = androidContactLookupKey,
+            androidContactLookupInfo = androidContactLookupInfo,
             localAvatarExpires = localAvatarExpires,
             isRestored = isRestored,
             profilePictureBlobId = profilePictureBlobId,
@@ -473,7 +474,7 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
             expected.notificationTriggerPolicyOverride,
             actual.notificationTriggerPolicyOverride,
         )
-        assertEquals(expected.androidContactLookupKey, actual.androidContactLookupKey)
+        assertEquals(expected.androidContactLookupInfo, actual.androidContactLookupInfo)
 
         // TODO(ANDR-2998): Assert that notification sound policy override are set correctly
 

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

@@ -37,8 +37,8 @@ import ch.threema.storage.models.MessageType
 import java.util.UUID
 import kotlin.test.BeforeTest
 import kotlin.test.Test
+import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
-import org.junit.Assert
 
 class EditHistoryRepositoryTest {
     private lateinit var databaseService: TestDatabaseService
@@ -123,7 +123,7 @@ class EditHistoryRepositoryTest {
     private fun AbstractMessageModel.assertEditHistorySize(expectedSize: Int) {
         val actualSize = editHistoryDao.findAllByMessageUid(uid!!).size
 
-        Assert.assertEquals(expectedSize, actualSize)
+        assertEquals(expectedSize, actualSize)
     }
 
     private fun <T : AbstractMessageModel> T.enrich(text: String = "Text"): T = apply {

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

@@ -43,11 +43,11 @@ import java.util.UUID
 import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.fail
-import org.junit.Assert
 
 class EmojiReactionsRepositoryTest {
     private lateinit var testCoreServiceManager: TestCoreServiceManager
@@ -139,10 +139,10 @@ class EmojiReactionsRepositoryTest {
         message.assertEmojiReactionSize(1)
 
         val reactions = emojiReactionsRepository.getReactionsByMessage(message)
-        Assert.assertNotNull(reactions)
+        assertNotNull(reactions)
 
-        val reaction = reactions!!.data!![0]
-        Assert.assertEquals("⚽", reaction.emojiSequence)
+        val reaction = reactions.data!![0]
+        assertEquals("⚽", reaction.emojiSequence)
 
         databaseService.messageModelFactory.delete(message)
     }
@@ -155,8 +155,8 @@ class EmojiReactionsRepositoryTest {
         databaseService.messageModelFactory.create(contactMessage)
         databaseService.groupMessageModelFactory.create(groupMessage)
 
-        Assert.assertEquals(1, contactMessage.id)
-        Assert.assertEquals(1, groupMessage.id)
+        assertEquals(1, contactMessage.id)
+        assertEquals(1, groupMessage.id)
 
         contactMessage.assertEmojiReactionSize(0)
         groupMessage.assertEmojiReactionSize(0)
@@ -176,8 +176,8 @@ class EmojiReactionsRepositoryTest {
         val groupReaction =
             emojiReactionsRepository.getReactionsByMessage(groupMessage)!!.data!![0]
 
-        Assert.assertEquals("⚾", contactReaction.emojiSequence)
-        Assert.assertEquals("⛵", groupReaction.emojiSequence)
+        assertEquals("⚾", contactReaction.emojiSequence)
+        assertEquals("⛵", groupReaction.emojiSequence)
     }
 
     @Test
@@ -190,8 +190,8 @@ class EmojiReactionsRepositoryTest {
         databaseService.messageModelFactory.create(contactMessage)
         databaseService.groupMessageModelFactory.create(groupMessage)
 
-        Assert.assertEquals(1, contactMessage.id)
-        Assert.assertEquals(1, groupMessage.id)
+        assertEquals(1, contactMessage.id)
+        assertEquals(1, groupMessage.id)
 
         contactMessage.assertEmojiReactionSize(0)
         groupMessage.assertEmojiReactionSize(0)
@@ -306,7 +306,7 @@ class EmojiReactionsRepositoryTest {
     private fun AbstractMessageModel.assertEmojiReactionSize(expectedSize: Int) {
         val actualSize = emojiReactionDao.findAllByMessage(this).size
 
-        Assert.assertEquals(expectedSize, actualSize)
+        assertEquals(expectedSize, actualSize)
     }
 
     private fun <T : AbstractMessageModel> T.enrich(text: String = "Text"): T {

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

@@ -26,6 +26,7 @@ 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
@@ -33,6 +34,8 @@ 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
 
@@ -125,4 +128,24 @@ class DebugLogFileBackendTest {
     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 - 1
app/src/androidTest/java/ch/threema/storage/DatabaseNonceStoreTest.kt

@@ -34,7 +34,9 @@ import javax.crypto.spec.SecretKeySpec
 import kotlin.test.AfterTest
 import kotlin.test.BeforeTest
 import kotlin.test.Test
-import org.junit.Assert.*
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
 
 class DatabaseNonceStoreTest {
     private lateinit var tempDbFileName: String

+ 33 - 0
app/src/androidTest/resources/qr-code.txt

@@ -0,0 +1,33 @@
+                                 
+                                 
+                                 
+                                 
+    ■■■■■■■  ■■    ■■ ■■■■■■■    
+    ■     ■  ■■  ■■ ■ ■     ■    
+    ■ ■■■ ■   ■   ■■■ ■ ■■■ ■    
+    ■ ■■■ ■ ■■■ ■ ■   ■ ■■■ ■    
+    ■ ■■■ ■  ■ ■ ■■■  ■ ■■■ ■    
+    ■     ■ ■■ ■■  ■  ■     ■    
+    ■■■■■■■ ■ ■ ■ ■ ■ ■■■■■■■    
+            ■     ■■■            
+     ■■   ■     ■■■ ■ ■■ ■       
+       ■■■    ■  ■■  ■■   ■■■    
+    ■  ■ ■■  ■■■■  ■■■■  ■ ■■    
+    ■ ■■ ■   ■ ■ ■    ■  ■       
+    ■■■ ■ ■■ ■■■ ■ ■■■■■ ■■      
+       ■ ■ ■■■           ■ ■     
+    ■■■■■ ■ ■  ■■■■ ■    ■       
+      ■■ ■ ■ ■■■  ■  ■■          
+    ■■ ■  ■ ■   ■■  ■■■■■■ ■■    
+            ■■ ■■■■ ■   ■■       
+    ■■■■■■■   ■ ■■■■■ ■ ■■  ■    
+    ■     ■  ■ ■    ■   ■   ■    
+    ■ ■■■ ■  ■■ ■ ■■■■■■■  ■     
+    ■ ■■■ ■   ■■■■ ■   ■   ■     
+    ■ ■■■ ■ ■■ ■ ■■■ ■■■■■ ■■    
+    ■     ■ ■■     ■■ ■■■■■■■    
+    ■■■■■■■  ■ ■■ ■   ■■■ ■ ■    
+                                 
+                                 
+                                 
+                                 

+ 2 - 2
app/src/blue/java/ch/threema/app/activities/DownloadApkActivity.java

@@ -27,13 +27,13 @@ import org.slf4j.Logger;
 
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 public class DownloadApkActivity extends AppCompatActivity {
     public static final String EXTRA_FORCE_UPDATE_DIALOG = "";
     // stub
 
-    private static final Logger logger = LoggingUtil.getThreemaLogger("DownloadApkActivity");
+    private static final Logger logger = getThreemaLogger("DownloadApkActivity");
 
     @Override
     protected void onCreate(@Nullable Bundle savedInstanceState) {

+ 0 - 7
app/src/foss_based/assets/license.html

@@ -361,13 +361,6 @@ POSSIBILITY OF SUCH DAMAGE.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
-<h2>ZoomableTextureView</h2>
-
-<p>Copyright (c) 2018 Volodya Polohalo</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>

+ 2 - 2
app/src/google_services_based/java/ch/threema/app/licensing/StoreLicenseCheck.java

@@ -31,10 +31,10 @@ import org.slf4j.Logger;
 
 import ch.threema.app.ThreemaLicensePolicy;
 import ch.threema.app.services.UserService;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 public class StoreLicenseCheck {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("StoreLicenseCheck");
+    private static final Logger logger = getThreemaLogger("StoreLicenseCheck");
 
     private static final String LICENSE_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqJArbOQT3Vi2KUEbyk+xq+DSsowwIYoudh3miXC7DmR6SVL6ji7XG8C+hmtR6t+Ytar64z87xgTPiEPiuyyg6/fp8ALRLAjM2FmZadSS4hSpvmJKb2ViFyUmcCJ8MoZ2QPxA+SVGZFdwIwwXdHPx2xUQw6ftyx0EF0hvF4nwHLvq89p03QtiPnIb0A3MOEXsq88xu2xAUge/BTvRWo0gWTtIJhTdZXY2CSib5d/G45xca0DKgOECAaMxVbFhE5jSyS+qZvUN4tABgDKBiEPuuzBBaHVt/m7MQoqoM6kcNrozACmIx6UdwWbkK3Isa9Xo9g3Yy6oc9Mp/9iKXwco4vwIDAQAB";
 

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

@@ -35,11 +35,11 @@ import org.slf4j.Logger;
 
 import ch.threema.app.utils.PushUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class PushRegistrationWorker extends Worker {
-    private final Logger logger = LoggingUtil.getThreemaLogger("PushRegistrationWorker");
+    private final Logger logger = getThreemaLogger("PushRegistrationWorker");
 
     /**
      * Constructor for the PushRegistrationWorker.

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

@@ -44,13 +44,13 @@ import ch.threema.app.utils.PushUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 import static ch.threema.common.TimeExtensionsKt.now;
 
 public class PushService extends FirebaseMessagingService {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("PushService");
+    private static final Logger logger = getThreemaLogger("PushService");
 
     public static final String EXTRA_CLEAR_TOKEN = "clear";
     public static final String EXTRA_WITH_CALLBACK = "cb";

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

@@ -47,13 +47,13 @@ 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 ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.storage.models.ContactModel;
 
 import static android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING;
 
 public class VoiceActionService extends SearchActionVerificationClientService {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("VoiceActionService");
+    private static final Logger logger = getThreemaLogger("VoiceActionService");
     private static final String TAG = "VoiceActionService";
 
     private MessageService messageService;

+ 2 - 2
app/src/green/java/ch/threema/app/activities/DownloadApkActivity.java

@@ -27,13 +27,13 @@ import org.slf4j.Logger;
 
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 public class DownloadApkActivity extends AppCompatActivity {
     public static final String EXTRA_FORCE_UPDATE_DIALOG = "";
     // stub
 
-    private static final Logger logger = LoggingUtil.getThreemaLogger("DownloadApkActivity");
+    private static final Logger logger = getThreemaLogger("DownloadApkActivity");
 
     @Override
     protected void onCreate(@Nullable Bundle savedInstanceState) {

+ 2 - 2
app/src/hms/java/ch/threema/app/activities/DownloadApkActivity.java

@@ -27,13 +27,13 @@ import org.slf4j.Logger;
 
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 public class DownloadApkActivity extends AppCompatActivity {
     public static final String EXTRA_FORCE_UPDATE_DIALOG = "";
     // stub
 
-    private static final Logger logger = LoggingUtil.getThreemaLogger("DownloadApkActivity");
+    private static final Logger logger = getThreemaLogger("DownloadApkActivity");
 
     @Override
     protected void onCreate(@Nullable Bundle savedInstanceState) {

+ 2 - 2
app/src/hms_services_based/java/ch/threema/app/licensing/StoreLicenseCheck.java

@@ -31,10 +31,10 @@ import org.slf4j.Logger;
 
 import ch.threema.app.routines.CheckLicenseRoutine;
 import ch.threema.app.services.UserService;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 public class StoreLicenseCheck {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("StoreLicenseCheck");
+    private static final Logger logger = getThreemaLogger("StoreLicenseCheck");
 
     private static final String HMS_ID = "5190041000024384032";
     private static final String HMS_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA26ccdC7mLHomHTnKvSRGg7Vuex19xD3qv8CEOUj5lcT5Z81ARby5CVhM/ZM9zKCQcrKmenn1aih6X+uZoNsvBziDUySkrzXPTX/NfoFDQlHgyXan/xsoIPlE1v0D9dLV7fgPOllHxmN8wiwF+woACo3ao/ra2VY38PCZTmfMX/V+hOLHsdRakgWVshzeYTtzMjlLrnYOp5AFXEjFhF0dB92ozAmLzjFJtwyMdpbVD+yRVr+fnLJ6ADhBpoKLjvpn8A7PhpT5wsvogovdr16u/uKhPy5an4DXE0bjWc76bE2SEse/bQTvPoGRw5TjHVWi7uDMFSz3OOGUqLSygucPdwIDAQAB";

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

@@ -23,10 +23,10 @@ package ch.threema.app.push
 
 import android.content.Context
 import android.content.pm.PackageManager
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 import com.huawei.agconnect.AGConnectOptionsBuilder
 
-private val logger = LoggingUtil.getThreemaLogger("HmsTokenUtil")
+private val logger = getThreemaLogger("HmsTokenUtil")
 
 object HmsTokenUtil {
     const val TOKEN_SCOPE = "HCM"

+ 2 - 2
app/src/hms_services_based/java/ch/threema/app/push/PushRegistrationWorker.java

@@ -35,11 +35,11 @@ import androidx.work.Data;
 import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 import ch.threema.app.utils.PushUtil;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class PushRegistrationWorker extends Worker {
-    private final Logger logger = LoggingUtil.getThreemaLogger("PushRegistrationWorker");
+    private final Logger logger = getThreemaLogger("PushRegistrationWorker");
 
     private final Context appContext;
 

+ 2 - 2
app/src/hms_services_based/java/ch/threema/app/push/PushService.java

@@ -43,11 +43,11 @@ import ch.threema.app.utils.PushUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class PushService extends HmsMessageService {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("PushService");
+    private static final Logger logger = getThreemaLogger("PushService");
 
     public static void deleteToken(Context context) {
         logger.info("Delete HMS push token");

+ 2 - 2
app/src/hms_work/java/ch/threema/app/activities/DownloadApkActivity.java

@@ -27,13 +27,13 @@ import org.slf4j.Logger;
 
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 public class DownloadApkActivity extends AppCompatActivity {
     public static final String EXTRA_FORCE_UPDATE_DIALOG = "";
     // stub
 
-    private static final Logger logger = LoggingUtil.getThreemaLogger("DownloadApkActivity");
+    private static final Logger logger = getThreemaLogger("DownloadApkActivity");
 
     @Override
     protected void onCreate(@Nullable Bundle savedInstanceState) {

+ 2 - 2
app/src/libre/java/ch/threema/app/activities/DownloadApkActivity.java

@@ -27,13 +27,13 @@ import org.slf4j.Logger;
 
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 public class DownloadApkActivity extends AppCompatActivity {
     public static final String EXTRA_FORCE_UPDATE_DIALOG = "";
     // stub, download happens through f-droid store
 
-    private static final Logger logger = LoggingUtil.getThreemaLogger("DownloadApkActivity");
+    private static final Logger logger = getThreemaLogger("DownloadApkActivity");
 
     @Override
     protected void onCreate(@Nullable Bundle savedInstanceState) {

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

@@ -1,3 +1,3 @@
-- Behebung eines Fehlers bei der Verwendung von Startbildschirm-Verknüpfungen
-- Behebung eines Fehlers, wodurch die App beim Öffnen des Archivs abstürzen konnte
-- Verschiedene UI-Verbesserungen und weitere kleine Fehlerbehebungen
+- Behebung eines Fehlers beim Zugriff auf gespeicherte Dateien
+- Nachrichtenentwürfe werden während des Tippens gespeichert
+- Verbesserungen und Behebung verschiedener Fehler

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

@@ -1,3 +1,3 @@
-- Fixed a bug that could occur when using home screen shortcuts
-- Fixed a bug that could cause the app to crash when opening the archive
-- Various UI improvements and small bug fixes
+- Fixed a bug in relation to access of stored files
+- Message drafts are saved while typing
+- Various improvements and bug fixes

+ 7 - 2
app/src/main/AndroidManifest.xml

@@ -750,8 +750,9 @@
             android:theme="@style/Theme.Threema.WithToolbar"
             android:windowSoftInputMode="adjustResize" />
         <activity
-            android:name=".activities.BiometricLockActivity"
+            android:name=".applock.AppLockActivity"
             android:autoRemoveFromRecents="true"
+            android:launchMode="singleTop"
             android:theme="@style/Theme.Threema.BiometricUnlock" />
         <activity
             android:name=".activities.AppLinksActivity"
@@ -803,6 +804,10 @@
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
             android:theme="@style/Theme.Threema.WithToolbar"
             android:windowSoftInputMode="adjustResize" />
+        <activity
+            android:name=".activities.referral.ReferralActivity"
+            android:configChanges="touchscreen|orientation|screenLayout|screenSize|uiMode"
+            android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
             android:name=".location.MapActivity"
             android:configChanges="keyboardHidden|screenSize|screenLayout|uiMode"
@@ -852,7 +857,7 @@
             android:launchMode="singleTask"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity
-            android:name=".activities.ProblemSolverActivity"
+            android:name=".problemsolving.ProblemSolverActivity"
             android:launchMode="singleTop"
             android:theme="@style/Theme.Threema.WithToolbar" />
         <activity

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

@@ -34,17 +34,14 @@ object AppConstants {
     const val INTENT_DATA_GROUP_DATABASE_ID = "group"
     const val INTENT_DATA_DISTRIBUTION_LIST_ID = "distribution_list"
     const val INTENT_DATA_ARCHIVE_FILTER = "archiveFilter"
-    const val INTENT_DATA_QRCODE = "qrcodestring"
     const val INTENT_DATA_MESSAGE_ID = "messageid"
     const val EXTRA_VOICE_REPLY = "voicereply"
     const val EXTRA_OUTPUT_FILE = "output"
-    const val INTENT_DATA_CHECK_ONLY = "check"
     const val INTENT_DATA_ANIM_CENTER = "itemPos"
     const val INTENT_DATA_PICK_FROM_CAMERA = "useCam"
     const val INTENT_PUSH_REGISTRATION_COMPLETE = "registrationComplete"
     const val INTENT_DATA_HIDE_RECENTS = "hiderec"
     const val INTENT_ACTION_FORWARD = "ch.threema.app.intent.FORWARD"
-    const val INTENT_ACTION_SHORTCUT_ADDED = BuildConfig.APPLICATION_ID + ".intent.SHORTCUT_ADDED"
 
     const val CONFIRM_TAG_CLOSE_BALLOT = "cb"
     const val ECHO_USER_IDENTITY = "ECHOECHO"

+ 0 - 128
app/src/main/java/ch/threema/app/AppLogging.kt

@@ -1,128 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2025 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app
-
-import android.app.ActivityManager
-import android.app.ApplicationExitInfo
-import android.content.Context
-import android.content.SharedPreferences
-import android.os.Build
-import android.os.Environment
-import androidx.core.content.edit
-import androidx.core.content.getSystemService
-import ch.threema.app.stores.PreferenceStore
-import ch.threema.app.utils.ApplicationExitInfoUtil.getReasonText
-import ch.threema.app.utils.ApplicationExitInfoUtil.getStatusText
-import ch.threema.app.utils.ConfigUtils
-import ch.threema.base.utils.LoggingUtil
-import ch.threema.logging.backend.DebugLogFileBackend
-import java.io.BufferedReader
-import java.io.File
-import java.io.IOException
-import java.io.InputStreamReader
-import java.text.SimpleDateFormat
-import java.util.Locale
-
-private val logger = LoggingUtil.getThreemaLogger("AppLogging")
-
-object AppLogging {
-
-    fun disableIfNeeded(context: Context, preferenceStore: PreferenceStore) {
-        if (!isDebugLogPreferenceEnabled(context, preferenceStore) && !forceDebugLog()) {
-            DebugLogFileBackend.setEnabled(false)
-        }
-    }
-
-    private fun isDebugLogPreferenceEnabled(context: Context, preferenceStore: PreferenceStore) =
-        preferenceStore.getBoolean(context.getString(R.string.preferences__message_log_switch))
-
-    private fun forceDebugLog(): Boolean {
-        val externalStorageDirectory = Environment.getExternalStorageDirectory()
-        val forceDebugLogFile = File(externalStorageDirectory, "ENABLE_THREEMA_DEBUG_LOG")
-        return forceDebugLogFile.exists()
-    }
-
-    @JvmStatic
-    fun logAppVersionInfo(context: Context) {
-        val buildInfo = buildString {
-            append(ConfigUtils.getBuildNumber(context))
-            if (BuildConfig.DEBUG) {
-                append(", Commit: ")
-                append(BuildConfig.GIT_HASH)
-            }
-        }
-        logger.info(
-            "*** App Version. Device/Android Version/Flavor: {} Version: {} Build: {}",
-            ConfigUtils.getDeviceInfo(false),
-            BuildConfig.VERSION_NAME,
-            buildInfo,
-        )
-    }
-
-    fun logExitReason(context: Context, sharedPreferences: SharedPreferences?) {
-        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
-            return
-        }
-        val activityManager = context.getSystemService<ActivityManager>() ?: return
-        try {
-            val applicationExitInfos = activityManager.getHistoricalProcessExitReasons(null, 0, 0)
-            if (applicationExitInfos.isEmpty()) {
-                return
-            }
-
-            val simpleDateFormat = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
-            for (exitInfo in applicationExitInfos) {
-                val timestampOfLastLog = sharedPreferences?.getLong(EXIT_REASON_LOGGING_TIMESTAMP, 0L) ?: 0L
-
-                if (exitInfo.timestamp > timestampOfLastLog) {
-                    logger.info(
-                        "*** App last exited at {} with reason: {}, description: {}, status: {}",
-                        simpleDateFormat.format(exitInfo.timestamp),
-                        getReasonText(exitInfo),
-                        exitInfo.description,
-                        getStatusText(exitInfo),
-                    )
-                    if (exitInfo.reason == ApplicationExitInfo.REASON_ANR) {
-                        try {
-                            exitInfo.traceInputStream?.use { traceInputStream ->
-                                val logMessage = BufferedReader(InputStreamReader(traceInputStream)).use { reader ->
-                                    reader.readText()
-                                }
-                                logger.info(logMessage)
-                            }
-                        } catch (e: IOException) {
-                            logger.error("Error getting ANR trace", e)
-                        }
-                    }
-                }
-            }
-
-            sharedPreferences?.edit {
-                putLong(EXIT_REASON_LOGGING_TIMESTAMP, System.currentTimeMillis())
-            }
-        } catch (e: IllegalArgumentException) {
-            logger.error("Unable to get reason of last exit", e)
-        }
-    }
-
-    private const val EXIT_REASON_LOGGING_TIMESTAMP = "exit_reason_timestamp"
-}

+ 2 - 15
app/src/main/java/ch/threema/app/GlobalBroadcastReceivers.kt

@@ -29,20 +29,18 @@ import android.content.IntentFilter
 import android.net.ConnectivityManager
 import android.os.Build
 import android.os.PowerManager
-import androidx.core.content.ContextCompat
 import androidx.core.content.getSystemService
 import ch.threema.app.backuprestore.csv.BackupService
 import ch.threema.app.receivers.ConnectivityChangeReceiver
-import ch.threema.app.receivers.ShortcutAddedReceiver
 import ch.threema.app.restrictions.AppRestrictionService
 import ch.threema.app.services.LifetimeService
 import ch.threema.app.utils.ConfigUtils
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 
-private val logger = LoggingUtil.getThreemaLogger("GlobalBroadcastReceivers")
+private val logger = getThreemaLogger("GlobalBroadcastReceivers")
 
 object GlobalBroadcastReceivers {
 
@@ -56,7 +54,6 @@ object GlobalBroadcastReceivers {
         registerDeviceIdleModeChangedReceiver(context)
         registerNotificationChannelGroupBlockStateChangedReceiver(context)
         registerAppRestrictionsChangeReceiver(context)
-        registerShortcutAddedReceiver(context)
     }
 
     private fun registerConnectivityChangeReceiver(context: Context) {
@@ -147,14 +144,4 @@ object GlobalBroadcastReceivers {
             )
         }
     }
-
-    private fun registerShortcutAddedReceiver(context: Context) {
-        // register a receiver for shortcuts that have been added to the launcher
-        ContextCompat.registerReceiver(
-            context,
-            ShortcutAddedReceiver(),
-            IntentFilter(AppConstants.INTENT_ACTION_SHORTCUT_ADDED),
-            ContextCompat.RECEIVER_NOT_EXPORTED,
-        )
-    }
 }

+ 28 - 22
app/src/main/java/ch/threema/app/GlobalListeners.java

@@ -71,8 +71,8 @@ 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.app.utils.Toaster;
-import ch.threema.app.utils.WidgetUtil;
+import ch.threema.android.Toaster;
+import ch.threema.app.widget.WidgetUtil;
 import ch.threema.app.voip.listeners.VoipCallEventListener;
 import ch.threema.app.voip.managers.VoipListenerManager;
 import ch.threema.app.webclient.listeners.WebClientServiceListener;
@@ -83,13 +83,15 @@ import ch.threema.app.webclient.services.SessionWakeUpServiceImpl;
 import ch.threema.app.webclient.services.instance.DisconnectContext;
 import ch.threema.app.webclient.state.WebClientSessionState;
 import ch.threema.base.ThreemaException;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+
+import ch.threema.data.models.ContactModel;
 import ch.threema.data.models.GroupIdentity;
+import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.domain.stores.IdentityStore;
 import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.localcrypto.exceptions.MasterKeyLockedException;
 import ch.threema.storage.models.AbstractMessageModel;
-import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.DistributionListModel;
 import ch.threema.storage.models.GroupModel;
@@ -103,10 +105,12 @@ import ch.threema.storage.models.ballot.LinkBallotModel;
 import ch.threema.storage.models.data.status.GroupStatusDataModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
 
+import static ch.threema.android.ToasterKt.showToast;
+
 // TODO(ANDR-3400) This code was moved out from ThreemaApplication and needs some heavy refactoring
 public class GlobalListeners {
 
-    private static final Logger logger = LoggingUtil.getThreemaLogger("GlobalListeners");
+    private static final Logger logger = getThreemaLogger("GlobalListeners");
 
     public static final Lock onAndroidContactChangeLock = new ReentrantLock();
 
@@ -116,6 +120,8 @@ public class GlobalListeners {
     ) {
         this.appContext = appContext;
         this.serviceManager = serviceManager;
+
+        webClientWakeUpListener = () -> showToast(appContext, R.string.webclient_protocol_version_to_old, Toaster.Duration.LONG);
     }
 
     @NonNull
@@ -581,11 +587,7 @@ public class GlobalListeners {
     private final ContactListener contactListener = new ContactListener() {
         @Override
         public void onModified(final @NonNull String identity) {
-            final ContactModel modifiedContact = serviceManager.getDatabaseService().getContactModelFactory().getByIdentity(identity);
-            if (modifiedContact == null) {
-                return;
-            }
-            final ch.threema.data.models.ContactModel modifiedContactModel = serviceManager.getModelRepositories().getContacts().getByIdentity(identity);
+            final ContactModel modifiedContactModel = serviceManager.getModelRepositories().getContacts().getByIdentity(identity);
             if (modifiedContactModel == null) {
                 return;
             }
@@ -596,7 +598,7 @@ public class GlobalListeners {
                     final ContactService contactService = serviceManager.getContactService();
 
                     // Refresh conversation cache
-                    conversationService.updateContactConversation(modifiedContact);
+                    conversationService.updateContactConversation(identity);
                     conversationService.refresh(modifiedContactModel);
 
                     ContactMessageReceiver messageReceiver = contactService.createReceiver(modifiedContactModel);
@@ -692,6 +694,7 @@ public class GlobalListeners {
                 try {
                     BallotService ballotService = s.getBallotService();
                     ContactService contactService = s.getContactService();
+                    ContactModelRepository contactModelRepository = s.getModelRepositories().getContacts();
                     GroupService groupService = s.getGroupService();
                     MessageService messageService = s.getMessageService();
                     UserService userService = s.getUserService();
@@ -717,10 +720,12 @@ public class GlobalListeners {
                             } else if (linkBallotModel instanceof IdentityBallotModel) {
                                 String identity = ((IdentityBallotModel) linkBallotModel).getIdentity();
 
-                                // not implemented
-                                receiver = contactService.createReceiver(contactService.getByIdentity(identity));
-                                // reset archived status
-                                contactService.setIsArchived(identity, false, TriggerSource.LOCAL);
+                                ContactModel contactModel = contactModelRepository.getByIdentity(identity);
+                                if (contactModel != null) {
+                                    receiver = contactService.createReceiver(contactModel);
+                                    // reset archived status
+                                    contactService.setIsArchived(identity, false, TriggerSource.LOCAL);
+                                }
                             }
 
                             if (ballotModel.getType() == BallotModel.Type.RESULT_ON_CLOSE) {
@@ -741,7 +746,7 @@ public class GlobalListeners {
                                 linkBallotModel instanceof GroupBallotModel
                                     && (type == GroupStatusDataModel.GroupStatusType.FIRST_VOTE
                                     || type == GroupStatusDataModel.GroupStatusType.MODIFIED_VOTE)
-                                    && !BallotUtil.isMine(ballotModel, userService)
+                                    && !BallotUtil.isMine(ballotModel, userService.getIdentity())
                             ) {
                                 // Only show votes (and vote changes) to the creator of the ballot in a group
                                 return;
@@ -917,7 +922,7 @@ public class GlobalListeners {
                 if (model.getLabel() != null) {
                     toastText += " (" + model.getLabel() + ")";
                 }
-                Toaster.Companion.showToast(toastText, Toaster.Duration.LONG);
+                showToast(appContext, toastText, Toaster.Duration.LONG);
 
                 final Intent intent = new Intent(appContext, SessionAndroidService.class);
 
@@ -986,13 +991,11 @@ public class GlobalListeners {
     };
 
     @NonNull
-    private final WebClientWakeUpListener webClientWakeUpListener =
-        () -> Toaster.Companion.showToast(R.string.webclient_protocol_version_to_old,
-            Toaster.Duration.LONG);
+    private final WebClientWakeUpListener webClientWakeUpListener;
 
     @NonNull
     private final VoipCallEventListener voipCallEventListener = new VoipCallEventListener() {
-        private final Logger logger = LoggingUtil.getThreemaLogger("VoipCallEventListener");
+        private final Logger logger = getThreemaLogger("VoipCallEventListener");
 
         @Override
         public void onRinging(String peerIdentity) {
@@ -1054,6 +1057,7 @@ public class GlobalListeners {
                 // Services
                 final IdentityStore identityStore = serviceManager.getIdentityStore();
                 final ContactService contactService = serviceManager.getContactService();
+                final ContactModelRepository contactModelRepository = serviceManager.getModelRepositories().getContacts();
                 final MessageService messageService = serviceManager.getMessageService();
 
                 // If an incoming status message is not targeted at our own identity, something's wrong
@@ -1064,9 +1068,11 @@ public class GlobalListeners {
                 }
 
                 // Create status message
-                final ContactModel contactModel = contactService.getByIdentity(identity);
+                final ContactModel contactModel = contactModelRepository.getByIdentity(identity);
+                if (contactModel != null) {
                 final ContactMessageReceiver receiver = contactService.createReceiver(contactModel);
                 messageService.createVoipStatus(status, receiver, isOutbox, isRead);
+                }
             } catch (ThreemaException e) {
                 logger.error("Exception", e);
             }

+ 0 - 185
app/src/main/java/ch/threema/app/MasterKeyManagerFactory.kt

@@ -1,185 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2025 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app
-
-import android.content.Context
-import android.os.Build
-import ch.threema.app.onprem.OnPremCertPinning
-import ch.threema.app.restrictions.AppRestrictionUtil
-import ch.threema.app.services.ServiceManagerProvider
-import ch.threema.app.stores.EncryptedPreferenceStoreImpl
-import ch.threema.app.stores.PreferenceStoreImpl
-import ch.threema.app.utils.ConfigUtils
-import ch.threema.app.utils.FileUtil
-import ch.threema.base.ThreemaException
-import ch.threema.base.utils.LoggingUtil
-import ch.threema.domain.libthreema.LibthreemaHttpClient
-import ch.threema.domain.models.WorkClientInfo
-import ch.threema.domain.onprem.OnPremConfigStore
-import ch.threema.localcrypto.MasterKeyFileProvider
-import ch.threema.localcrypto.MasterKeyManagerImpl
-import ch.threema.localcrypto.MasterKeyStorageManager
-import ch.threema.localcrypto.NoOpRemoteSecretManagerImpl
-import ch.threema.localcrypto.RemoteSecretClient
-import ch.threema.localcrypto.RemoteSecretManagerImpl
-import ch.threema.localcrypto.RemoteSecretMonitor
-import ch.threema.localcrypto.Version1MasterKeyFileManager
-import ch.threema.localcrypto.Version2MasterKeyFileManager
-import ch.threema.localcrypto.Version2MasterKeyStorageDecoder
-import ch.threema.localcrypto.Version2MasterKeyStorageEncoder
-import ch.threema.storage.DatabaseNonceStore
-import ch.threema.storage.DatabaseService
-import ch.threema.storage.SQLDHSessionStore
-import java.io.IOException
-import java.util.Locale
-import okhttp3.OkHttpClient
-
-private val logger = LoggingUtil.getThreemaLogger("MasterKeyManagerFactory")
-
-object MasterKeyManagerFactory {
-    fun createMasterKeyManager(
-        context: Context,
-        baseOkHttpClient: OkHttpClient,
-        onPremConfigStore: OnPremConfigStore?,
-        serviceManagerProvider: ServiceManagerProvider,
-    ): MasterKeyManagerImpl {
-        val version1MasterKeyFile = MasterKeyFileProvider.getVersion1MasterKeyFile(context)
-        val version2MasterKeyFile = MasterKeyFileProvider.getVersion2MasterKeyFile(context)
-        val masterKeyStorageEncoder = Version2MasterKeyStorageEncoder()
-        val masterKeyStorageDecoder = Version2MasterKeyStorageDecoder()
-        val masterKeyStorageManager = MasterKeyStorageManager(
-            version2KeyFileManager = Version2MasterKeyFileManager(version2MasterKeyFile, masterKeyStorageEncoder, masterKeyStorageDecoder),
-            version1KeyFileManager = Version1MasterKeyFileManager(version1MasterKeyFile),
-        )
-        if (!masterKeyStorageManager.keyExists()) {
-            onMasterKeyNotFound(context)
-        }
-        return MasterKeyManagerImpl(
-            keyStorageManager = masterKeyStorageManager,
-            remoteSecretManager = if (ConfigUtils.isRemoteSecretsSupported()) {
-                val remoteSecretClient = RemoteSecretClient(
-                    clientInfo = getClientInfo(),
-                    httpClientWithOnPremCertPinning = LibthreemaHttpClient(
-                        okHttpClient = getOkHttpClientWithCertificatePinning(
-                            baseOkHttpClient = baseOkHttpClient,
-                            onPremConfigStore = onPremConfigStore,
-                            serviceManagerProvider = serviceManagerProvider,
-                        ),
-                    ),
-                    httpClientWithoutOnPremCertPinning = LibthreemaHttpClient(
-                        okHttpClient = baseOkHttpClient,
-                    ),
-                )
-                RemoteSecretManagerImpl(
-                    remoteSecretClient = remoteSecretClient,
-                    shouldUseRemoteSecretProtection = {
-                        AppRestrictionUtil.shouldEnableRemoteSecret(context)
-                    },
-                    remoteSecretMonitor = RemoteSecretMonitor(
-                        remoteSecretClient = remoteSecretClient,
-                    ),
-                    getWorkServerBaseUrl = {
-                        getWorkServerBaseUrl(onPremConfigStore)
-                    },
-                )
-            } else {
-                NoOpRemoteSecretManagerImpl()
-            },
-        )
-    }
-
-    private fun getOkHttpClientWithCertificatePinning(
-        baseOkHttpClient: OkHttpClient,
-        onPremConfigStore: OnPremConfigStore?,
-        serviceManagerProvider: ServiceManagerProvider,
-    ): OkHttpClient =
-        if (onPremConfigStore != null) {
-            OnPremCertPinning.createClientWithCertPinning(
-                baseClient = baseOkHttpClient,
-                getOnPremConfigDomains = {
-                    val config = onPremConfigStore.get()
-                        ?: run {
-                            logger.warn("No stored OPPF found, trying to fetch it")
-                            val serviceManager = serviceManagerProvider.getServiceManagerOrNull()
-                                ?: throw IOException("cannot enforce certificate pinning, no service manager available to fetch OPPF")
-                            try {
-                                serviceManager.onPremConfigFetcherProvider.getOnPremConfigFetcher().fetch()
-                            } catch (e: ThreemaException) {
-                                throw IOException("cannot enforce certificate pinning, failed to fetch OPPF", e)
-                            }
-                        }
-                    config.domains
-                },
-            )
-        } else {
-            baseOkHttpClient
-        }
-
-    private fun getWorkServerBaseUrl(onPremConfigStore: OnPremConfigStore?): String =
-        onPremConfigStore?.get()?.work?.url
-            ?: throw IOException("cannot monitor remote secret, no stored OPPF found")
-
-    private fun getClientInfo(): WorkClientInfo =
-        WorkClientInfo(
-            appVersion = ConfigUtils.getAppVersion(),
-            appLocale = Locale.getDefault().toString(),
-            deviceModel = Build.MODEL,
-            osVersion = Build.VERSION.RELEASE,
-            workFlavor = when {
-                BuildFlavor.current.isOnPrem -> WorkClientInfo.WorkFlavor.ON_PREM
-                BuildFlavor.current.isWork -> WorkClientInfo.WorkFlavor.WORK
-                else -> error("Not a work build")
-            },
-        )
-
-    private fun onMasterKeyNotFound(context: Context) {
-        // If the MasterKey does not exist, remove every file that is encrypted with this non-existing MasterKey
-        logger.warn("master key is missing or does not match, deleting DB and preferences")
-        deleteDatabaseFiles(context)
-        deleteAllPreferences(context)
-    }
-
-    private fun deleteDatabaseFiles(context: Context) {
-        val defaultDatabaseFile = DatabaseService.getDatabaseFile(context)
-        if (defaultDatabaseFile.exists()) {
-            val databaseBackup = DatabaseService.getDatabaseBackupFile(context)
-            if (!defaultDatabaseFile.renameTo(databaseBackup)) {
-                FileUtil.deleteFileOrWarn(defaultDatabaseFile, "threema4 database", logger)
-            }
-        }
-
-        val nonceDatabaseFile = DatabaseNonceStore.getDatabaseFile(context)
-        if (nonceDatabaseFile.exists()) {
-            FileUtil.deleteFileOrWarn(nonceDatabaseFile, "nonce4 database", logger)
-        }
-
-        val sqldhSessionDatabaseFile = context.getDatabasePath(SQLDHSessionStore.DATABASE_NAME)
-        if (sqldhSessionDatabaseFile.exists()) {
-            FileUtil.deleteFileOrWarn(sqldhSessionDatabaseFile, "sql dh session database", logger)
-        }
-    }
-
-    private fun deleteAllPreferences(context: Context) {
-        PreferenceStoreImpl(context).clear()
-        EncryptedPreferenceStoreImpl.clear(context)
-    }
-}

+ 42 - 36
app/src/main/java/ch/threema/app/ThreemaApplication.kt

@@ -28,23 +28,26 @@ import android.content.SharedPreferences
 import android.database.sqlite.SQLiteException
 import android.os.Build
 import android.os.Process
-import androidx.annotation.OpenForTesting
 import androidx.annotation.WorkerThread
 import androidx.appcompat.app.AppCompatDelegate
 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.AppLogging.logAppVersionInfo
-import ch.threema.app.AppLogging.logExitReason
 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.DraftManager
+import ch.threema.app.drafts.DraftManagerImpl
+import ch.threema.app.logging.AppVersionLogger
+import ch.threema.app.logging.DebugLogHelper
+import ch.threema.app.logging.ExitReasonLogger
 import ch.threema.app.managers.CoreServiceManagerImpl
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.notifications.NotificationIDs
@@ -52,6 +55,7 @@ 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.startup.AppProcessLifecycleObserver
@@ -59,9 +63,9 @@ 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.deleteOrphanedUserData
 import ch.threema.app.startup.models.AppSystem
 import ch.threema.app.stores.EncryptedPreferenceStore
-import ch.threema.app.stores.EncryptedPreferenceStoreImpl
 import ch.threema.app.stores.IdentityProvider
 import ch.threema.app.stores.IdentityStoreImpl
 import ch.threema.app.stores.MutableIdentityProvider
@@ -76,20 +80,18 @@ import ch.threema.app.utils.ConnectionIndicatorUtil
 import ch.threema.app.utils.DispatcherProvider
 import ch.threema.app.utils.FileUtil
 import ch.threema.app.utils.LinuxSecureRandom
-import ch.threema.app.utils.LoggingUEH
 import ch.threema.app.utils.PushUtil
 import ch.threema.app.utils.StateBitmapUtil
-import ch.threema.app.utils.Toaster.Companion.showToast
-import ch.threema.app.utils.Toaster.Duration.LONG
 import ch.threema.app.voip.Config
 import ch.threema.app.webclient.services.SessionWakeUpServiceImpl
 import ch.threema.app.workers.AutoDeleteWorker
 import ch.threema.app.workers.ContactUpdateWorker
+import ch.threema.app.workers.GatewayProfilePicturesWorker
 import ch.threema.app.workers.ShareTargetUpdateWorker
 import ch.threema.app.workers.WorkSyncWorker
 import ch.threema.base.ThreemaException
 import ch.threema.base.crypto.NonceScope
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 import ch.threema.common.now
 import ch.threema.data.repositories.ModelRepositories
 import ch.threema.domain.protocol.connection.ConnectionState
@@ -103,6 +105,7 @@ 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.storage.DatabaseDowngradeException
 import ch.threema.storage.DatabaseNonceStore
@@ -127,10 +130,9 @@ import org.koin.core.component.KoinComponent
 import org.koin.core.component.get
 import org.koin.core.component.inject
 
-private val logger = LoggingUtil.getThreemaLogger("ThreemaApplication")
+private val logger = getThreemaLogger("ThreemaApplication")
 
-@OpenForTesting
-open class ThreemaApplication : Application() {
+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()
@@ -149,26 +151,29 @@ open class ThreemaApplication : Application() {
 
         super.onCreate()
 
+        setUpUnhandledExceptionLogger()
+
         DynamicColorsHelper.applyDynamicColorsIfEnabled(this)
 
         AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
 
         initLibthreema(LogLevel.TRACE, LibthreemaLogger())
 
-        setUpUnhandledExceptionLogger()
-
         setUpSecureRandom()
 
         setUpDayNightMode(this)
 
-        logger.info("*** App launched")
-        logAppVersionInfo(applicationContext)
-        logExitReason(applicationContext, PreferenceManager.getDefaultSharedPreferences(applicationContext))
-
         initDependencyInjection(this)
 
+        logger.info("*** App launched")
+
+        // TODO(ANDR-4310): consolidate this logging
+        with(get<AppVersionLogger>()) {
+            logAppVersionInfo()
+            updateAppVersionHistory()
+        }
+        get<ExitReasonLogger>().logExitReason()
         logger.info("Has identity: {}", identityProvider.getIdentity() != null)
-        logger.info("Remote secrets supported: {}", ConfigUtils.isRemoteSecretsSupported())
 
         ProcessLifecycleOwner.get().lifecycle.addObserver(get<AppProcessLifecycleObserver>())
 
@@ -183,7 +188,10 @@ open class ThreemaApplication : Application() {
         coroutineScope.launch {
             try {
                 withContext(dispatcherProvider.io) {
-                    masterKeyManager.readOrGenerateKey()
+                    val result = masterKeyManager.readOrGenerateKey()
+                    if (result == MasterKeyReadResult.NEWLY_GENERATED) {
+                        deleteOrphanedUserData(applicationContext)
+                    }
                 }
             } catch (e: Exception) {
                 logger.error("Failed to read or generate master key", e)
@@ -230,7 +238,7 @@ open class ThreemaApplication : Application() {
     }
 
     private fun setUpUnhandledExceptionLogger() {
-        Thread.setDefaultUncaughtExceptionHandler(LoggingUEH())
+        Thread.setDefaultUncaughtExceptionHandler(ThreemaUncaughtExceptionHandler(this))
     }
 
     private fun setUpSecureRandom() {
@@ -299,10 +307,7 @@ open class ThreemaApplication : Application() {
 
         super.onLowMemory()
         try {
-            get<ServiceManagerProvider>()
-                .getServiceManagerOrNull()
-                ?.avatarCacheService
-                ?.clear()
+            getOrNull<AvatarCacheService>()?.clear()
         } catch (e: Exception) {
             logger.error("Failed to clear avatar cache", e)
         }
@@ -339,7 +344,7 @@ open class ThreemaApplication : Application() {
             try {
                 val preferenceStore: PreferenceStore = get()
                 val mutableIdentityProvider: MutableIdentityProvider = get()
-                val encryptedPreferenceStore = EncryptedPreferenceStoreImpl(appContext, masterKey)
+                val encryptedPreferenceStore: EncryptedPreferenceStore = get()
 
                 setUpSqlCipher()
                 val databaseService = createDatabaseService(appContext, masterKey)
@@ -426,16 +431,18 @@ open class ThreemaApplication : Application() {
                     markUploadingFilesAsFailed(databaseService)
                     SessionWakeUpServiceImpl.getInstance().processPendingWakeupsAsync()
                     serviceManager.threemaSafeService.schedulePeriodicUpload()
-                    scheduleSync(appContext, serviceManager.preferenceService, preferenceStore)
+                    scheduleWorkers(appContext, serviceManager.preferenceService, preferenceStore)
                 }
 
                 coroutineScope.launch {
-                    loadDraftsFromStorage(serviceManager)
+                    get<DraftManagerImpl>().init()
                 }
 
                 coroutineScope.launch {
                     appStartupMonitor.awaitAll()
-                    AppLogging.disableIfNeeded(appContext, preferenceStore)
+
+                    // 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)
@@ -532,7 +539,7 @@ open class ThreemaApplication : Application() {
             }
         }
 
-        private fun scheduleSync(
+        private fun scheduleWorkers(
             context: Context,
             preferenceService: PreferenceService,
             preferenceStore: PreferenceStore,
@@ -543,6 +550,7 @@ open class ThreemaApplication : Application() {
                 ShareTargetUpdateWorker.scheduleShareTargetShortcutUpdate(context)
             }
             AutoDeleteWorker.scheduleAutoDelete(context)
+            GatewayProfilePicturesWorker.schedulePeriodicSync(context)
         }
 
         private fun createDatabaseService(
@@ -553,7 +561,7 @@ open class ThreemaApplication : Application() {
                 context = context,
                 password = masterKey.deriveDatabasePassword(),
                 onDatabaseCorrupted = {
-                    showToast("Database corrupted. Please restart your device and try again.", duration = LONG)
+                    context.showToast("Database corrupted. Please restart your device and try again.", duration = LONG)
                     exitProcess(2)
                 },
             )
@@ -653,19 +661,17 @@ open class ThreemaApplication : Application() {
             }
         }
 
-        @WorkerThread
-        private fun loadDraftsFromStorage(serviceManager: ServiceManager) {
-            DraftManager.retrieveMessageDraftsFromStorage(serviceManager.preferenceService)
-        }
-
         // TODO(ANDR-4187): Remove this static method
+        @Deprecated("Do not access service manager directly, use DI instead")
         @JvmStatic
         fun getServiceManager(): ServiceManager? = get<ServiceManagerProvider>().getServiceManagerOrNull()
 
         // TODO(ANDR-4187): Remove this static method
+        @Deprecated("Do not access service manager directly, use DI instead")
         @JvmStatic
         fun requireServiceManager(): ServiceManager = get<ServiceManager>()
 
+        @Deprecated("Use DI instead")
         @JvmStatic
         fun getAppContext(): Context = instance
     }

+ 2 - 2
app/src/main/java/ch/threema/app/actions/LocationMessageSendAction.java

@@ -33,11 +33,11 @@ import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.utils.MessageUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.storage.models.AbstractMessageModel;
 
 public class LocationMessageSendAction extends SendAction {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("LocationMessageSendAction");
+    private static final Logger logger = getThreemaLogger("LocationMessageSendAction");
 
     protected static volatile LocationMessageSendAction instance;
     private static final Object instanceLock = new Object();

+ 2 - 2
app/src/main/java/ch/threema/app/actions/TextMessageSendAction.java

@@ -31,11 +31,11 @@ import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TextUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class TextMessageSendAction extends SendAction {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("TextMessageSendAction");
+    private static final Logger logger = getThreemaLogger("TextMessageSendAction");
 
     protected static volatile TextMessageSendAction instance;
     private static final Object instanceLock = new Object();

+ 22 - 19
app/src/main/java/ch/threema/app/activities/AddContactActivity.java

@@ -37,7 +37,7 @@ import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
 import java.io.IOException;
-import java.util.Date;
+import java.time.Instant;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -55,22 +55,22 @@ import ch.threema.app.asynctasks.ContactExists;
 import ch.threema.app.asynctasks.ContactModified;
 import ch.threema.app.asynctasks.Failed;
 import ch.threema.app.asynctasks.PolicyViolation;
+import ch.threema.app.camera.QRScannerActivity;
 import ch.threema.app.contactdetails.ContactDetailActivity;
 import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.NewContactDialog;
-import ch.threema.app.services.QRCodeService;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.app.utils.QRScannerUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.executor.BackgroundExecutor;
+import ch.threema.app.qrcodes.ContactUrlResult;
+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 ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.storage.models.ContactModel;
 
 import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
@@ -81,11 +81,14 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
     private static final String DIALOG_TAG_ADD_PROGRESS = "ap";
     private static final String DIALOG_TAG_ADD_ERROR = "ae";
     private static final String DIALOG_TAG_ADD_BY_ID = "abi";
+
+    private static final int REQUEST_CODE_QR_SCANNER = 26657;
+
     public static final String EXTRA_ADD_BY_ID = "add_by_id";
     public static final String EXTRA_ADD_BY_QR = "add_by_qr";
     public static final String EXTRA_QR_RESULT = "qr_result";
 
-    private static final Logger logger = LoggingUtil.getThreemaLogger("AddContactActivity");
+    private static final Logger logger = getThreemaLogger("AddContactActivity");
 
     private static final int PERMISSION_REQUEST_CAMERA = 1;
 
@@ -154,7 +157,8 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 
     private void parseQrResult(String payload) {
         // first: try to parse as contact result (contact scan)
-        QRCodeService.QRCodeContentResult contactQRCode = dependencies.getQrCodeService().getResult(payload);
+        ContactUrlUtil contactUrlUtil = KoinJavaComponent.get(ContactUrlUtil.class);
+        ContactUrlResult contactQRCode = contactUrlUtil.parse(payload);
 
         if (contactQRCode != null) {
             addContactByQRResult(contactQRCode);
@@ -207,15 +211,14 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
     }
 
     @SuppressLint("StaticFieldLeak")
-    private void addContactByQRResult(@NonNull final QRCodeService.QRCodeContentResult qrResult) {
+    private void addContactByQRResult(@NonNull final ContactUrlResult contactUrlResult) {
         logger.info("Adding contact from QR code");
-        if (qrResult.getExpirationDate() != null
-            && qrResult.getExpirationDate().before(new Date())) {
+        if (contactUrlResult.isExpired(Instant.now())) {
             GenericAlertDialog.newInstance(R.string.title_adduser, getString(R.string.expired_barcode), R.string.ok, 0).show(getSupportFragmentManager(), "ex");
             return;
         }
 
-        addContactByIdentity(qrResult.getIdentity(), qrResult.getPublicKey());
+        addContactByIdentity(contactUrlResult.getIdentity(), contactUrlResult.getPublicKey());
     }
 
     private void addContactByIdentity(@NonNull String identity, @Nullable byte[] publicKey) {
@@ -291,7 +294,8 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 
     private void scanQR() {
         if (ConfigUtils.requestCameraPermissions(this, null, PERMISSION_REQUEST_CAMERA)) {
-            QRScannerUtil.getInstance().initiateScan(this, getString(R.string.qr_scanner_id_hint));
+            var intent = QRScannerActivity.createIntent(this, getString(R.string.qr_scanner_id_hint));
+            startActivityForResult(intent, REQUEST_CODE_QR_SCANNER);
         }
     }
 
@@ -308,18 +312,17 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
     public void onActivityResult(int requestCode, int resultCode, Intent intent) {
         super.onActivityResult(requestCode, resultCode, intent);
 
-        if (resultCode == RESULT_OK) {
-            String payload = QRScannerUtil.getInstance().parseActivityResult(this, requestCode, resultCode, intent);
-
-            if (!TestUtil.isEmptyOrNull(payload)) {
+        if (requestCode == REQUEST_CODE_QR_SCANNER && resultCode == RESULT_OK) {
+            String payload = QRScannerActivity.extractResult(intent);
 
+            if (payload != null) {
                 // first: try to parse as content result (contact scan)
-                QRCodeService.QRCodeContentResult contactQRCode = dependencies.getQrCodeService().getResult(payload);
+                ContactUrlUtil contactUrlUtil = KoinJavaComponent.get(ContactUrlUtil.class);
+                ContactUrlResult contactQRCode = contactUrlUtil.parse(payload);
 
                 if (contactQRCode != null) {
                     // ok, try to add contact
-                    if (contactQRCode.getExpirationDate() != null
-                        && contactQRCode.getExpirationDate().before(new Date())) {
+                    if (contactQRCode.isExpired(Instant.now())) {
                         GenericAlertDialog.newInstance(R.string.title_adduser, getString(R.string.expired_barcode), R.string.ok, 0).show(getSupportFragmentManager(), "ex");
                     } else {
                         addContactByQRResult(contactQRCode);

+ 6 - 5
app/src/main/java/ch/threema/app/activities/AppLinksActivity.java

@@ -40,23 +40,24 @@ 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.LazyProperty;
 import ch.threema.app.utils.executor.BackgroundExecutor;
-import ch.threema.base.utils.LoggingUtil;
+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 static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
+import static ch.threema.common.LazyKt.lazy;
 
 public class AppLinksActivity extends ThreemaToolbarActivity {
-    private final static Logger logger = LoggingUtil.getThreemaLogger("AppLinksActivity");
+    private final static Logger logger = getThreemaLogger("AppLinksActivity");
 
     @NonNull
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
 
     @NonNull
-    private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
+    private final Lazy<BackgroundExecutor> backgroundExecutor = lazy(BackgroundExecutor::new);
 
     @Override
     public void onCreate(Bundle savedInstanceState) {
@@ -138,7 +139,7 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
     }
 
     private void addNewContactAndOpenChat(@NonNull String identity, @NonNull Uri appLinkData) {
-        backgroundExecutor.get().execute(
+        backgroundExecutor.getValue().execute(
             new BasicAddOrUpdateContactBackgroundTask(
                 identity,
                 ContactModel.AcquaintanceLevel.DIRECT,

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

@@ -43,6 +43,8 @@ import com.google.android.material.tabs.TabLayout;
 import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
+import java.time.Instant;
+
 import ch.threema.app.R;
 import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.fragments.BackupDataFragment;
@@ -52,10 +54,10 @@ 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 ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 public class BackupAdminActivity extends ThreemaToolbarActivity {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("BackupAdminActivity");
+    private static final Logger logger = getThreemaLogger("BackupAdminActivity");
 
     private static final String BUNDLE_IS_UNLOCKED = "biu";
 
@@ -104,12 +106,12 @@ public class BackupAdminActivity extends ThreemaToolbarActivity {
         viewPager.setAdapter(new BackupAdminPagerAdapter(getSupportFragmentManager()));
         tabLayout.setupWithViewPager(viewPager);
 
-        if (dependencies.getPreferenceService().getBackupWarningDismissedTime() == 0L) {
+        if (dependencies.getPreferenceService().getBackupWarningDismissedTime() == null) {
             ((TextView) findViewById(R.id.notice_text)).setText(R.string.backup_explain_text);
             final View noticeLayout = findViewById(R.id.notice_layout);
             noticeLayout.setVisibility(View.VISIBLE);
             findViewById(R.id.close_button).setOnClickListener(v -> {
-                dependencies.getPreferenceService().setBackupWarningDismissedTime(System.currentTimeMillis());
+                dependencies.getPreferenceService().setBackupWarningDismissedTime(Instant.now());
                 AnimationUtil.collapse(noticeLayout, null, true);
             });
         } else {

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

@@ -33,6 +33,7 @@ import androidx.activity.enableEdgeToEdge
 import androidx.appcompat.app.AppCompatActivity
 import androidx.constraintlayout.widget.ConstraintLayout
 import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import ch.threema.android.buildActivityIntent
 import ch.threema.app.R
 import ch.threema.app.backuprestore.csv.BackupService
 import ch.threema.app.backuprestore.csv.RestoreService
@@ -41,13 +42,12 @@ import ch.threema.app.ui.InsetSides
 import ch.threema.app.ui.SpacingValues
 import ch.threema.app.ui.applyDeviceInsetsAsPadding
 import ch.threema.app.utils.ConfigUtils
-import ch.threema.app.utils.buildActivityIntent
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 import com.google.android.material.progressindicator.LinearProgressIndicator
 import org.koin.android.ext.android.inject
 
-private val logger = LoggingUtil.getThreemaLogger("BackupRestoreProgressActivity")
+private val logger = getThreemaLogger("BackupRestoreProgressActivity")
 
 /**
  * This activity is shown when the user opens Threema while a backup is being created or restored.

+ 0 - 239
app/src/main/java/ch/threema/app/activities/BiometricLockActivity.java

@@ -1,239 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2019-2025 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.activities;
-
-import android.app.Activity;
-import android.app.KeyguardManager;
-import android.content.Context;
-import android.content.Intent;
-import android.os.Build;
-import android.os.Bundle;
-import android.os.SystemClock;
-import android.view.View;
-import android.view.WindowManager;
-import android.widget.Toast;
-
-import org.koin.java.KoinJavaComponent;
-import org.slf4j.Logger;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.biometric.BiometricPrompt;
-import ch.threema.app.AppConstants;
-import ch.threema.app.R;
-import ch.threema.app.di.DependencyContainer;
-import ch.threema.app.preference.service.PreferenceService;
-import ch.threema.app.utils.BiometricUtil;
-import ch.threema.app.utils.NavigationUtil;
-import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.base.utils.LoggingUtil;
-
-import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
-
-public class BiometricLockActivity extends ThreemaAppCompatActivity {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("BiometricLockActivity");
-
-    private static final int REQUEST_CODE_SYSTEM_SCREENLOCK_CHECK = 551;
-    public static final String INTENT_DATA_AUTHENTICATION_TYPE = "auth_type";
-
-    @NonNull
-    private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
-
-    private boolean isCheckOnly = false;
-    private String authenticationType = null;
-
-    @Override
-    public void onCreate(Bundle savedInstanceState) {
-        logger.debug("onCreate");
-
-        super.onCreate(savedInstanceState);
-        logScreenVisibility(this, logger);
-
-        if (!dependencies.isAvailable()) {
-            finish();
-            return;
-        }
-
-        setContentView(R.layout.activity_biometric_lock);
-
-        getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
-
-        isCheckOnly = getIntent().getBooleanExtra(AppConstants.INTENT_DATA_CHECK_ONLY, false);
-        if (getIntent().hasExtra(INTENT_DATA_AUTHENTICATION_TYPE)) {
-            authenticationType = getIntent().getStringExtra(INTENT_DATA_AUTHENTICATION_TYPE);
-        }
-
-        if (authenticationType == null) {
-            authenticationType = dependencies.getPreferenceService().getLockMechanism();
-        }
-
-        if (!dependencies.getLockAppService().isLocked() && !isCheckOnly) {
-            finish();
-        }
-
-        switch (authenticationType) {
-            case PreferenceService.LockingMech_SYSTEM:
-                showSystemScreenLock();
-                break;
-            case PreferenceService.LockingMech_BIOMETRIC:
-                if (BiometricUtil.isBiometricsSupported(this)) {
-                    showBiometricPrompt();
-                } else {
-                    // no enrolled fingerprints - try system screen lock
-                    showSystemScreenLock();
-                }
-                break;
-            default:
-                break;
-        }
-    }
-
-    @Override
-    public void finish() {
-        logger.debug("finish");
-        try {
-            super.finish();
-            overridePendingTransition(0, 0);
-        } catch (Exception ignored) {
-        }
-    }
-
-    private void showBiometricPrompt() {
-        KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
-
-        BiometricPrompt.PromptInfo.Builder promptInfoBuilder = new BiometricPrompt.PromptInfo.Builder()
-            .setTitle(getString(R.string.prefs_title_access_protection))
-            .setSubtitle(getString(R.string.biometric_enter_authentication))
-            .setConfirmationRequired(false);
-
-        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P && keyguardManager != null && keyguardManager.isDeviceSecure()) {
-            // allow fallback to device credentials such as PIN, passphrase or pattern
-            promptInfoBuilder.setDeviceCredentialAllowed(true);
-        } else {
-            promptInfoBuilder.setNegativeButtonText(getString(R.string.cancel));
-        }
-
-        BiometricPrompt.PromptInfo promptInfo = promptInfoBuilder.build();
-        BiometricPrompt biometricPrompt = new BiometricPrompt(this, new RuntimeUtil.MainThreadExecutor(), new BiometricPrompt.AuthenticationCallback() {
-            @Override
-            public void onAuthenticationError(int errorCode, @NonNull CharSequence errString) {
-                super.onAuthenticationError(errorCode, errString);
-                logger.error("Authentication error: (errorCode={}, errString={})", errorCode, errString);
-                if (errorCode != BiometricPrompt.ERROR_USER_CANCELED && errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
-                    Toast.makeText(BiometricLockActivity.this, errString + " (" + errorCode + ")", Toast.LENGTH_LONG).show();
-                }
-                BiometricLockActivity.this.onAuthenticationError(errorCode);
-            }
-
-            @Override
-            public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
-                super.onAuthenticationSucceeded(result);
-                logger.info("Authentication succeeded");
-                BiometricLockActivity.this.onAuthenticationSuccess();
-            }
-
-            @Override
-            public void onAuthenticationFailed() {
-                super.onAuthenticationFailed();
-                BiometricLockActivity.this.onAuthenticationFailed();
-            }
-        });
-        biometricPrompt.authenticate(promptInfo);
-    }
-
-    private void showSystemScreenLock() {
-        logger.debug("showSystemScreenLock");
-        if (isCheckOnly) {
-            if (dependencies.getSystemScreenLockService().tryEncrypt(this, REQUEST_CODE_SYSTEM_SCREENLOCK_CHECK)) {
-                onAuthenticationSuccess();
-            }
-        } else {
-            if (dependencies.getSystemScreenLockService().systemUnlock(this)) {
-                onAuthenticationSuccess();
-            }
-        }
-    }
-
-    @Override
-    public void onWindowFocusChanged(boolean hasFocus) {
-        super.onWindowFocusChanged(hasFocus);
-        if (hasFocus) {
-            getWindow().getDecorView().setSystemUiVisibility(
-                // Set the content to appear under the system bars so that the
-                // content doesn't resize when the system bars hide and show.
-                View.SYSTEM_UI_FLAG_LAYOUT_STABLE
-                    | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
-                    | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
-                    | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
-                    | View.SYSTEM_UI_FLAG_FULLSCREEN
-            );
-        }
-    }
-
-    @Override
-    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
-        logger.info("onActivityResult (requestCode={}, resultCode={})", requestCode, resultCode);
-
-        super.onActivityResult(requestCode, resultCode, data);
-
-        if (requestCode == ThreemaActivity.ACTIVITY_ID_CONFIRM_DEVICE_CREDENTIALS || requestCode == REQUEST_CODE_SYSTEM_SCREENLOCK_CHECK) {
-            // Challenge completed, proceed with using cipher
-            if (resultCode != Activity.RESULT_CANCELED) {
-                onAuthenticationSuccess();
-            } else {
-                // The user canceled or didn’t complete the lock screen
-                onAuthenticationFailed();
-            }
-        }
-    }
-
-    private void onAuthenticationSuccess() {
-        logger.debug("Authentication successful");
-        if (!isCheckOnly) {
-            dependencies.getLockAppService().unlock(null);
-        }
-        this.setResult(RESULT_OK);
-        this.finish();
-    }
-
-    private void onAuthenticationFailed() {
-        logger.debug("Authentication failed");
-        if (!isCheckOnly) {
-            NavigationUtil.navigateToLauncher(this);
-        }
-        this.setResult(RESULT_CANCELED);
-        this.finish();
-    }
-
-    private void onAuthenticationError(int errorCode) {
-        logger.debug("Authentication error");
-        if (!isCheckOnly) {
-            NavigationUtil.navigateToLauncher(this);
-            if (errorCode == BiometricPrompt.ERROR_USER_CANCELED && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
-                // ugly hack for leaking content in app switcher
-                SystemClock.sleep(2000);
-            }
-        }
-        this.setResult(RESULT_CANCELED);
-        this.finish();
-    }
-}

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

@@ -22,14 +22,14 @@
 package ch.threema.app.activities
 
 import android.content.Context
+import ch.threema.android.buildActivityIntent
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
-import ch.threema.app.utils.buildActivityIntent
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.types.Identity
 
-private val logger = LoggingUtil.getThreemaLogger("BlockedIdentitiesActivity")
+private val logger = getThreemaLogger("BlockedIdentitiesActivity")
 
 class BlockedIdentitiesActivity : IdentityListActivity() {
     init {

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

@@ -45,13 +45,13 @@ 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 ch.threema.base.utils.LoggingUtil;
+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 = LoggingUtil.getThreemaLogger("ComposeMessageActivity");
+    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;
@@ -219,7 +219,6 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
                 super.onActivityResult(requestCode, resultCode, intent);
 
                 if (resultCode == RESULT_OK) {
-                    dependencies.getSystemScreenLockService().setAuthenticated(true);
                     if (composeMessageFragment != null) {
                         getSupportFragmentManager().beginTransaction().show(composeMessageFragment).commit();
                         // mark conversation as read as soon as it's unhidden
@@ -233,7 +232,6 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
                 super.onActivityResult(requestCode, resultCode, intent);
 
                 if (resultCode == RESULT_OK) {
-                    dependencies.getSystemScreenLockService().setAuthenticated(true);
                     if (composeMessageFragment != null) {
                         getSupportFragmentManager().beginTransaction().show(composeMessageFragment).commit();
                         composeMessageFragment.onNewIntent(this.currentIntent);

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

@@ -34,16 +34,16 @@ import android.view.View
 import android.view.ViewTreeObserver.OnGlobalLayoutListener
 import androidx.appcompat.app.AppCompatDelegate
 import androidx.core.view.ViewCompat
+import ch.threema.android.buildActivityIntent
+import ch.threema.android.getIntOrNull
+import ch.threema.android.getParcelable
+import ch.threema.android.getSerializable
 import ch.threema.app.R
 import ch.threema.app.ui.InsetSides.Companion.lbr
 import ch.threema.app.ui.applyDeviceInsetsAsPadding
 import ch.threema.app.utils.BitmapUtil
-import ch.threema.app.utils.buildActivityIntent
-import ch.threema.app.utils.getIntOrNull
-import ch.threema.app.utils.getParcelable
-import ch.threema.app.utils.getSerializable
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import com.canhub.cropper.CropImageView
 import com.canhub.cropper.CropImageView.CropShape
@@ -52,7 +52,7 @@ import com.google.android.material.appbar.MaterialToolbar
 import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
 import java.util.concurrent.atomic.AtomicBoolean
 
-private val logger = LoggingUtil.getThreemaLogger("CropImageActivity")
+private val logger = getThreemaLogger("CropImageActivity")
 
 class CropImageActivity : ThreemaToolbarActivity() {
     init {

+ 9 - 7
app/src/main/java/ch/threema/app/activities/DirectoryActivity.java

@@ -77,20 +77,22 @@ 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.LazyProperty;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.executor.BackgroundExecutor;
-import ch.threema.base.utils.LoggingUtil;
+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;
 import ch.threema.domain.protocol.api.work.WorkOrganization;
+import kotlin.Lazy;
 
+import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
 import static ch.threema.app.ui.DirectoryDataSource.MIN_SEARCH_STRING_LENGTH;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
+import static ch.threema.common.LazyKt.lazy;
 
 public class DirectoryActivity extends ThreemaToolbarActivity implements ThreemaSearchView.OnQueryTextListener, MultiChoiceSelectorDialog.SelectorDialogClickListener {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("DirectoryActivity");
+    private static final Logger logger = getThreemaLogger("DirectoryActivity");
 
     private static final String EXTRA_QUERY_TEXT = "queryText";
     private static final String EXTRA_CHECKED_CATEGORIES = "checkedCategories";
@@ -113,7 +115,7 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
 
     @NonNull
-    private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
+    private final Lazy<BackgroundExecutor> backgroundExecutor = lazy(BackgroundExecutor::new);
 
     private boolean sortByFirstName;
 
@@ -152,7 +154,7 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
-        if (!dependencies.isAvailable()) {
+        if (!isSessionScopeReady()) {
             finish();
         }
     }
@@ -229,7 +231,7 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
         }
 
         WorkOrganization workOrganization = dependencies.getPreferenceService().getWorkOrganization();
-        if (workOrganization != null && !TestUtil.isEmptyOrNull(workOrganization.getName())) {
+        if (workOrganization != null && workOrganization.getName() != null) {
             logger.info("Organization: {}", workOrganization.getName());
             updateToolbarTitle(workOrganization.getName());
         }
@@ -427,7 +429,7 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 
     private void addContact(final WorkDirectoryContact workDirectoryContact, Runnable runAfter) {
         logger.info("Add new work contact");
-        backgroundExecutor.get().execute(
+        backgroundExecutor.getValue().execute(
             new AddOrUpdateWorkContactBackgroundTask(
                 workDirectoryContact,
                 dependencies.getUserService().getIdentity(),

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

@@ -42,7 +42,7 @@ import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.utils.ConfigUtils;
-import ch.threema.base.utils.LoggingUtil;
+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;
@@ -55,7 +55,7 @@ import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
  * ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS intent is used instead.
  */
 public class DisableBatteryOptimizationsActivity extends ThreemaActivity implements GenericAlertDialog.DialogClickListener {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("DisableBatteryOptimizationsActivity");
+    private static final Logger logger = getThreemaLogger("DisableBatteryOptimizationsActivity");
 
     private static final int REQUEST_CODE_IGNORE_BATTERY_OPTIMIZATIONS = 778;
     private static final String DIALOG_TAG_DISABLE_BATTERY_OPTIMIZATIONS = "des";

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

@@ -26,6 +26,7 @@ import android.content.Intent
 import android.os.Bundle
 import androidx.annotation.MainThread
 import androidx.annotation.StringRes
+import ch.threema.android.buildActivityIntent
 import ch.threema.app.AppConstants
 import ch.threema.app.R
 import ch.threema.app.dialogs.TextEntryDialog
@@ -34,15 +35,14 @@ 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.buildActivityIntent
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.types.Identity
 import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.DistributionListModel
 import org.koin.android.ext.android.inject
 
-private val logger = LoggingUtil.getThreemaLogger("DistributionListAddActivity")
+private val logger = getThreemaLogger("DistributionListAddActivity")
 
 class DistributionListAddActivity : MemberChooseActivity(), TextEntryDialogClickListener {
     init {

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

@@ -45,14 +45,14 @@ import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.updatePadding
 import androidx.core.widget.NestedScrollView
 import androidx.core.widget.addTextChangedListener
+import ch.threema.android.buildActivityIntent
 import ch.threema.app.R
 import ch.threema.app.mediaattacher.EditSendContactViewModel
 import ch.threema.app.ui.VCardPropertyView
 import ch.threema.app.ui.setMargin
 import ch.threema.app.utils.VCardExtractor
-import ch.threema.app.utils.buildActivityIntent
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 import com.google.android.material.appbar.AppBarLayout
 import com.google.android.material.appbar.MaterialToolbar
 import com.google.android.material.bottomsheet.BottomSheetBehavior
@@ -62,7 +62,7 @@ import com.google.android.material.progressindicator.CircularProgressIndicator
 import ezvcard.property.StructuredName
 import org.koin.androidx.viewmodel.ext.android.viewModel
 
-private val logger = LoggingUtil.getThreemaLogger("EditSendContactActivity")
+private val logger = getThreemaLogger("EditSendContactActivity")
 
 /**
  * This activity lets the user select which properties of contact should be included before sending

+ 6 - 5
app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java

@@ -67,19 +67,20 @@ import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.EditTextUtil;
-import ch.threema.app.utils.LazyProperty;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.app.utils.executor.BackgroundTask;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.domain.models.LicenseCredentials;
+import kotlin.Lazy;
 
 import static ch.threema.app.startup.AppStartupUtilKt.finishAndRestartLaterIfNotReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
+import static ch.threema.common.LazyKt.lazy;
 
 // this should NOT extend ThreemaToolbarActivity
 public class EnterSerialActivity extends ThreemaActivity {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("EnterSerialActivity");
+    private static final Logger logger = getThreemaLogger("EnterSerialActivity");
 
     private static final String BUNDLE_PASSWORD = "bupw";
     private static final String BUNDLE_LICENSE_KEY = "bulk";
@@ -93,7 +94,7 @@ public class EnterSerialActivity extends ThreemaActivity {
     @NonNull
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
 
-    private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
+    private final Lazy<BackgroundExecutor> backgroundExecutor = lazy(BackgroundExecutor::new);
 
     // We need to use getResources().getIdentifier(...) because of flavor specific layout files for this fragment
     @SuppressLint("DiscouragedApi")
@@ -153,7 +154,7 @@ public class EnterSerialActivity extends ThreemaActivity {
         // In case there are credentials, we can validate them and skip this activity so that the
         // user does not have to enter them again.
         if (dependencies.getLicenseService().hasCredentials()) {
-            backgroundExecutor.get().execute(new BackgroundTask<Boolean>() {
+            backgroundExecutor.getValue().execute(new BackgroundTask<Boolean>() {
                 @Override
                 public void runBefore() {
                     // Nothing to do

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

@@ -22,15 +22,15 @@
 package ch.threema.app.activities
 
 import android.content.Context
+import ch.threema.android.buildActivityIntent
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
-import ch.threema.app.utils.buildActivityIntent
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.taskmanager.TriggerSource
 import ch.threema.domain.types.Identity
 
-private val logger = LoggingUtil.getThreemaLogger("ExcludedSyncIdentitiesActivity")
+private val logger = getThreemaLogger("ExcludedSyncIdentitiesActivity")
 
 class ExcludedSyncIdentitiesActivity : IdentityListActivity() {
     init {

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

@@ -26,6 +26,8 @@ import android.content.Intent
 import android.os.Bundle
 import androidx.appcompat.app.AppCompatActivity
 import androidx.lifecycle.lifecycleScope
+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.GenericProgressDialog
@@ -36,16 +38,14 @@ import ch.threema.app.services.UserService
 import ch.threema.app.startup.finishAndRestartLaterIfNotReady
 import ch.threema.app.utils.DialogUtil
 import ch.threema.app.utils.DispatcherProvider
-import ch.threema.app.utils.buildActivityIntent
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.app.utils.showToast
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.identitybackup.IdentityBackup
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import org.koin.android.ext.android.inject
 
-private val logger = LoggingUtil.getThreemaLogger("ExportIDActivity")
+private val logger = getThreemaLogger("ExportIDActivity")
 
 class ExportIDActivity : AppCompatActivity(), PasswordEntryDialogClickListener {
     init {

+ 23 - 9
app/src/main/java/ch/threema/app/activities/ExportIDResultActivity.java

@@ -43,6 +43,7 @@ import android.widget.ScrollView;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.content.res.AppCompatResources;
 import androidx.core.app.NavUtils;
@@ -67,12 +68,13 @@ import ch.threema.app.ui.TooltipPopup;
 import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
+import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
 
 public class ExportIDResultActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener, LifecycleOwner {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("ExportIDResultActivity");
+    private static final Logger logger = getThreemaLogger("ExportIDResultActivity");
 
     private static final String DIALOG_TAG_QUIT_CONFIRM = "qconf";
     private static final int QRCODE_SMALL_DIMENSION_PIXEL = 200;
@@ -80,7 +82,8 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
     @NonNull
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
 
-    private Bitmap qrcodeBitmap;
+    private @Nullable Bitmap originalQrCode;
+    private @Nullable Bitmap scaledQrCode;
     private TooltipPopup tooltipPopup;
 
     // Keeping this reference on purpose
@@ -93,7 +96,7 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
 
-        if (!dependencies.isAvailable()) {
+        if (!isSessionScopeReady()) {
             finish();
             return;
         }
@@ -148,12 +151,12 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
         textView.setText(backupData);
 
         final ImageView imageView = findViewById(R.id.qrcode_backup);
-        this.qrcodeBitmap = dependencies.getQrCodeService().getRawQR(backupData, false);
+        originalQrCode = dependencies.getQrCodeGenerator().generate(backupData);
 
         final int px = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, QRCODE_SMALL_DIMENSION_PIXEL, getResources().getDisplayMetrics());
-        Bitmap bmpScaled = Bitmap.createScaledBitmap(qrcodeBitmap, px, px, false);
-        bmpScaled.setDensity(Bitmap.DENSITY_NONE);
-        imageView.setImageBitmap(bmpScaled);
+        scaledQrCode = Bitmap.createScaledBitmap(originalQrCode, px, px, false);
+        scaledQrCode.setDensity(Bitmap.DENSITY_NONE);
+        imageView.setImageBitmap(scaledQrCode);
         if (ConfigUtils.isTheDarkSide(this)) {
             ConfigUtils.invertColors(imageView);
         }
@@ -260,7 +263,7 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
         if (item.getItemId() == android.R.id.home) {
             done();
         } else if (item.getItemId() == R.id.menu_print) {
-            printBitmap(qrcodeBitmap);
+            printBitmap(originalQrCode);
         } else if (item.getItemId() == R.id.menu_backup_share) {
             shareId();
         }
@@ -303,4 +306,15 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
 
         finish();
     }
+
+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        if (scaledQrCode != null) {
+            scaledQrCode.recycle();
+        }
+        if (originalQrCode != null) {
+            originalQrCode.recycle();
+        }
+    }
 }

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

@@ -50,16 +50,17 @@ import ch.threema.app.profilepicture.CheckedProfilePicture;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.utils.CoroutinesExtensionKt;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.data.models.GroupModel;
 import kotlin.Unit;
 import kotlinx.coroutines.Deferred;
 
+import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
 import static ch.threema.app.groupflows.GroupFlowResultKt.GROUP_FLOWS_LOADING_DIALOG_TIMEOUT_SECONDS;
 
 public class GroupAdd2Activity extends GroupEditActivity implements ContactEditDialog.ContactEditDialogClickListener {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("GroupAdd2Activity");
+    private static final Logger logger = getThreemaLogger("GroupAdd2Activity");
 
     private static final String BUNDLE_GROUP_IDENTITIES = "grId";
 
@@ -79,7 +80,7 @@ public class GroupAdd2Activity extends GroupEditActivity implements ContactEditD
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
 
-        if (!dependencies.isAvailable()) {
+        if (!isSessionScopeReady()) {
             finish();
             return;
         }

+ 4 - 3
app/src/main/java/ch/threema/app/activities/GroupAddActivity.java

@@ -45,14 +45,15 @@ 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 ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
+import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
 
 public class GroupAddActivity extends MemberChooseActivity implements GenericAlertDialog.DialogClickListener {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("GroupAddActivity");
+    private static final Logger logger = getThreemaLogger("GroupAddActivity");
 
     private static final String BUNDLE_EXISTING_MEMBERS = "ExMem";
     private static final String DIALOG_TAG_NO_MEMBERS = "NoMem";
@@ -68,7 +69,7 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
-        if (!dependencies.isAvailable()) {
+        if (!isSessionScopeReady()) {
             finish();
         }
     }

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

@@ -75,7 +75,6 @@ import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.AppConstants;
 import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.GroupDetailAdapter;
 import ch.threema.app.contactdetails.ContactDetailActivity;
 import ch.threema.app.di.DependencyContainer;
@@ -86,6 +85,7 @@ import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.dialogs.TextEntryDialog;
 import ch.threema.app.dialogs.loadingtimeout.LoadingWithTimeoutDialogXml;
 import ch.threema.app.emojis.EmojiEditText;
+import ch.threema.app.groupflows.GroupChanges.ProfilePictureChange;
 import ch.threema.app.groupflows.GroupFlowResult;
 import ch.threema.app.groupflows.GroupChanges;
 import ch.threema.app.groupflows.GroupCreateProperties;
@@ -98,9 +98,6 @@ 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.protocol.ProfilePictureChange;
-import ch.threema.app.protocol.RemoveProfilePicture;
-import ch.threema.app.protocol.SetProfilePicture;
 import ch.threema.app.ui.AvatarEditView;
 import ch.threema.app.ui.GroupDetailViewModel;
 import ch.threema.app.ui.ResumePauseHandler;
@@ -119,7 +116,7 @@ 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 ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.data.models.GroupIdentity;
 import ch.threema.data.models.GroupModelData;
 import ch.threema.localcrypto.exceptions.MasterKeyLockedException;
@@ -130,6 +127,7 @@ import kotlinx.coroutines.Deferred;
 
 import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.COLLAPSED;
 import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.NONE;
+import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
 import static ch.threema.app.groupflows.GroupFlowResultKt.GROUP_FLOWS_LOADING_DIALOG_TIMEOUT_SECONDS;
 
@@ -137,7 +135,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
     GenericAlertDialog.DialogClickListener,
     TextEntryDialog.TextEntryDialogClickListener,
     GroupDetailAdapter.OnGroupDetailsClickListener {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("GroupDetailActivity");
+    private static final Logger logger = getThreemaLogger("GroupDetailActivity");
     // static values
     private final int MODE_EDIT = 1;
     private final int MODE_READONLY = 2;
@@ -283,7 +281,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
 
-        if (!dependencies.isAvailable()) {
+        if (!isSessionScopeReady()) {
             finish();
             return;
         }
@@ -1211,29 +1209,29 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
         }
     }
 
-    @Nullable
+    @NonNull
     private ProfilePictureChange getProfilePictureChange(ch.threema.data.models.GroupModel groupModel) {
         if (!groupDetailViewModel.hasAvatarChanges()) {
-            return null;
+            return ProfilePictureChange.NoChange.INSTANCE;
         }
 
         CheckedProfilePicture profilePicture = CheckedProfilePicture.getOrConvertFromFile(groupDetailViewModel.getAvatarFile());
-        byte[] newAvatarBytes = profilePicture != null ? profilePicture.getProfilePictureBytes() : null;
+        byte[] newAvatarBytes = profilePicture != null ? profilePicture.getBytes() : null;
 
         byte[] oldAvatarBytes;
         try {
-            oldAvatarBytes = dependencies.getFileService().getGroupAvatarBytes(groupModel);
+            oldAvatarBytes = dependencies.getFileService().getGroupProfilePictureBytes(groupModel);
         } catch (Exception e) {
             logger.error("Could not get group avatar", e);
             oldAvatarBytes = null;
         }
 
         if (Arrays.equals(newAvatarBytes, oldAvatarBytes)) {
-            return null;
+            return ProfilePictureChange.NoChange.INSTANCE;
         } else if (newAvatarBytes != null) {
-            return new SetProfilePicture(profilePicture, null);
+            return new ProfilePictureChange.Set(profilePicture);
         } else {
-            return RemoveProfilePicture.INSTANCE;
+            return ProfilePictureChange.Remove.INSTANCE;
         }
     }
 

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

@@ -57,6 +57,8 @@ import ch.threema.app.ui.SpacingValues;
 import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 
+import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
+
 abstract public class IdentityListActivity extends ThreemaToolbarActivity implements TextEntryDialog.TextEntryDialogClickListener {
     private static final String BUNDLE_RECYCLER_LAYOUT = "recycler";
     private static final String BUNDLE_SELECTED_ITEM = "item";
@@ -92,7 +94,7 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
 
-        if (!dependencies.isAvailable()) {
+        if (!isSessionScopeReady()) {
             finish();
             return;
         }
@@ -322,7 +324,7 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
     }
 
     private void fireOnModifiedContact(final String identity) {
-        if (dependencies.isAvailable()) {
+        if (isSessionScopeReady()) {
             ListenerManager.contactListeners.handle(listener -> listener.onModified(identity));
         }
     }

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

@@ -21,6 +21,7 @@
 
 package ch.threema.app.activities;
 
+import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
 import static ch.threema.app.utils.BitmapUtil.FLIP_HORIZONTAL;
 import static ch.threema.app.utils.BitmapUtil.FLIP_NONE;
 import static ch.threema.app.utils.BitmapUtil.FLIP_VERTICAL;
@@ -141,11 +142,11 @@ 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 ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.data.models.GroupModel;
 
 public class ImagePaintActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("ImagePaintActivity");
+    private static final Logger logger = getThreemaLogger("ImagePaintActivity");
 
     {
         // Always use night mode for this activity. Note that setting it here avoids the activity being recreated.
@@ -465,7 +466,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
 
-        if (!dependencies.isAvailable()) {
+        if (!isSessionScopeReady()) {
             finish();
             return;
         }
@@ -1239,7 +1240,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
                 // If soft keyboard is open then add its height to the image frame
                 imageFrame.setMinimumHeight(bottom - top + softKeyboardHeight);
 
-                // If the image frame is larger than it's parent (scroll view), we need to wait for another relayout.
+                // If the image frame is larger than its parent (scroll view), we need to wait for another relayout.
                 // Otherwise we can remove this listener and load the image
                 if (imageFrame.getMinimumHeight() <= scrollView.getHeight()) {
                     scrollView.removeOnLayoutChangeListener(this);

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

@@ -50,12 +50,12 @@ import ch.threema.app.motionviews.widget.TextEntity;
 import ch.threema.app.ui.SimpleTextWatcher;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.EditTextUtil;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
 
 public class ImagePaintKeyboardActivity extends ThreemaToolbarActivity {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("ImagePaintKeyboardActivity");
+    private static final Logger logger = getThreemaLogger("ImagePaintKeyboardActivity");
 
     public final static String INTENT_EXTRA_TEXT = "text";
     public final static String INTENT_EXTRA_COLOR = "color"; // resolved color

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

@@ -25,12 +25,12 @@ import android.content.Intent
 import android.os.Bundle
 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
 import androidx.lifecycle.lifecycleScope
+import ch.threema.android.context
 import ch.threema.app.R
 import ch.threema.app.home.HomeActivity
 import ch.threema.app.startup.AppStartupActivity
 import ch.threema.app.startup.AppStartupMonitor
 import ch.threema.app.startup.models.AppSystem
-import ch.threema.app.utils.context
 import ch.threema.common.waitAtMost
 import ch.threema.localcrypto.MasterKeyManager
 import kotlin.time.Duration.Companion.seconds
@@ -61,7 +61,7 @@ class MainActivity : ThreemaAppCompatActivity() {
      * unnecessarily finishing and recreating [HomeActivity], we wait here for a few seconds while
      * displaying the splash screen. In the unlikely event that the service manager does not become ready
      * within this time, we continue normally and let [HomeActivity] handle the waiting.
-     * We only wait for the [AppSystem.SERVICE_MANAGER], not [AppSystem.DATABASE_UPDATES]
+     * We only wait for the [AppSystem.UNLOCKED_MASTER_KEY], not [AppSystem.DATABASE_UPDATES]
      * or [AppSystem.SYSTEM_UPDATES], as those might take significantly longer
      * and we want to display [AppStartupActivity] for those.
      */
@@ -71,7 +71,7 @@ class MainActivity : ThreemaAppCompatActivity() {
         }
         waitAtMost(3.seconds) {
             if (!masterKeyManager.isProtected()) {
-                appStartupMonitor.awaitSystem(AppSystem.SERVICE_MANAGER)
+                appStartupMonitor.awaitSystem(AppSystem.UNLOCKED_MASTER_KEY)
             }
         }
     }

+ 4 - 3
app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java

@@ -91,18 +91,19 @@ import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.base.utils.LoggingUtil;
+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.MessageType;
 import ch.threema.storage.models.data.MessageContentsType;
 
+import static ch.threema.app.di.DIJavaCompat.isSessionScopeReady;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
 
 public class MediaViewerActivity extends ThreemaToolbarActivity implements ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener {
 
-    private static final Logger logger = LoggingUtil.getThreemaLogger("MediaViewerActivity");
+    private static final Logger logger = getThreemaLogger("MediaViewerActivity");
 
     {
         // Always use night mode for this activity. Note that setting it here avoids the activity being recreated.
@@ -144,7 +145,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements Expan
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
-        if (!dependencies.isAvailable()) {
+        if (!isSessionScopeReady()) {
             finish();
         }
     }

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

@@ -61,6 +61,7 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 
+import ch.threema.android.ActivityExtensionsKt;
 import ch.threema.app.R;
 import ch.threema.app.adapters.FilterableListAdapter;
 import ch.threema.app.fragments.MemberListFragment;
@@ -68,7 +69,6 @@ import ch.threema.app.fragments.UserMemberListFragment;
 import ch.threema.app.fragments.WorkUserMemberListFragment;
 import ch.threema.app.ui.ThreemaSearchView;
 import ch.threema.app.ui.ViewExtensionsKt;
-import ch.threema.app.utils.ActivityExtensionsKt;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.NameUtil;

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

@@ -42,6 +42,7 @@ import androidx.core.content.ContextCompat
 import androidx.core.content.edit
 import androidx.core.net.toUri
 import androidx.preference.PreferenceManager
+import ch.threema.android.buildActivityIntent
 import ch.threema.app.BuildConfig
 import ch.threema.app.R
 import ch.threema.app.activities.PermissionRequestActivity.Companion.INTENT_PERMISSION_REQUESTS
@@ -50,11 +51,10 @@ import ch.threema.app.ui.PermissionIconView
 import ch.threema.app.ui.PermissionIconView.PermissionIconState
 import ch.threema.app.ui.applyDeviceInsetsAsPadding
 import ch.threema.app.utils.PermissionRequest
-import ch.threema.app.utils.buildActivityIntent
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 
-private val logger = LoggingUtil.getThreemaLogger("PermissionRequestActivity")
+private val logger = getThreemaLogger("PermissionRequestActivity")
 
 /**
  * This activity guides the user through the permission requests. This activity finishes with

+ 10 - 5
app/src/main/java/ch/threema/app/activities/PinLockActivity.kt

@@ -33,6 +33,7 @@ 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
@@ -42,9 +43,8 @@ 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.buildActivityIntent
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 import ch.threema.common.TimeProvider
 import ch.threema.common.consume
 import ch.threema.common.minus
@@ -57,7 +57,7 @@ import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
 import org.koin.android.ext.android.inject
 
-private val logger = LoggingUtil.getThreemaLogger("PinLockActivity")
+private val logger = getThreemaLogger("PinLockActivity")
 
 class PinLockActivity : ThreemaActivity() {
     init {
@@ -72,7 +72,7 @@ class PinLockActivity : ThreemaActivity() {
     private lateinit var errorTextView: TextView
 
     private val isCheckOnly by lazy(LazyThreadSafetyMode.NONE) {
-        intent.getBooleanExtra(AppConstants.INTENT_DATA_CHECK_ONLY, false)
+        intent.getBooleanExtra(INTENT_DATA_CHECK_ONLY, false)
     }
 
     private var failedAttempts: Int
@@ -232,7 +232,12 @@ class PinLockActivity : ThreemaActivity() {
         private val ERROR_MESSAGE_TIMEOUT = 3.seconds
         private val LOCKOUT_TIMEOUT = 30.seconds
 
+        private const val INTENT_DATA_CHECK_ONLY = "check"
+
         @JvmStatic
-        fun createIntent(context: Context) = buildActivityIntent<PinLockActivity>(context)
+        @JvmOverloads
+        fun createIntent(context: Context, checkOnly: Boolean = false) = buildActivityIntent<PinLockActivity>(context) {
+            putExtra(INTENT_DATA_CHECK_ONLY, checkOnly)
+        }
     }
 }

+ 0 - 212
app/src/main/java/ch/threema/app/activities/ProblemSolverActivity.kt

@@ -1,212 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2013-2025 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.activities
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.content.Intent
-import android.os.Build
-import android.os.Bundle
-import android.provider.Settings
-import android.view.LayoutInflater
-import android.view.View
-import android.widget.LinearLayout
-import android.widget.TextView
-import androidx.activity.result.ActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.core.net.toUri
-import androidx.core.widget.NestedScrollView
-import ch.threema.app.R
-import ch.threema.app.ThreemaApplication
-import ch.threema.app.ui.InsetSides
-import ch.threema.app.ui.SpacingValues
-import ch.threema.app.ui.applyDeviceInsetsAsPadding
-import ch.threema.app.utils.ConfigUtils
-import ch.threema.app.utils.PowermanagerUtil
-import ch.threema.app.utils.buildActivityIntent
-import ch.threema.app.utils.logScreenVisibility
-import ch.threema.app.webclient.services.SessionService
-import ch.threema.base.utils.LoggingUtil
-import com.google.android.material.appbar.MaterialToolbar
-import com.google.android.material.button.MaterialButton
-import org.koin.android.ext.android.inject
-
-private val logger = LoggingUtil.getThreemaLogger("ProblemSolverActivity")
-
-class ProblemSolverActivity : ThreemaToolbarActivity() {
-    private val sessionService: SessionService by inject()
-
-    init {
-        logScreenVisibility(logger)
-    }
-
-    private val settingsLauncher = registerForActivityResult(
-        ActivityResultContracts.StartActivityForResult(),
-    ) { _: ActivityResult? -> recreate() }
-
-    private class Problem(
-        // title text used in problem solver box
-        val title: Int,
-        // explanation text used in problem solver box
-        val explanation: String,
-        // the action to call for fixing the problem
-        val intentAction: String,
-        // the check to determine if the problem exists
-        val check: Boolean,
-    )
-
-    private val problems by lazy {
-        arrayOf(
-            Problem(
-                title = R.string.problemsolver_title_background,
-                explanation = getString(R.string.problemsolver_explain_background),
-                intentAction = Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
-                check = ConfigUtils.isBackgroundRestricted(this),
-            ),
-            Problem(
-                title = R.string.problemsolver_title_background_data,
-                explanation = getString(R.string.problemsolver_explain_background_data),
-                intentAction = Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS,
-                check = ConfigUtils.isBackgroundDataRestricted(this),
-            ),
-            @SuppressLint("InlinedApi")
-            Problem(
-                title = R.string.problemsolver_title_notifications,
-                explanation = getString(R.string.problemsolver_explain_notifications),
-                intentAction = Settings.ACTION_APP_NOTIFICATION_SETTINGS,
-                check = ConfigUtils.isNotificationsDisabled(this),
-            ),
-            @SuppressLint("InlinedApi")
-            Problem(
-                title = R.string.problemsolver_title_fullscreen_notifications,
-                explanation = getString(R.string.problemsolver_explain_fullscreen_notifications),
-                intentAction = Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
-                check = ConfigUtils.isFullScreenNotificationsDisabled(this),
-            ),
-            Problem(
-                title = R.string.problemsolver_title_app_battery_usgae_optimized,
-                explanation = getString(R.string.problemsolver_explain_app_battery_usgae_optimized),
-                intentAction = Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
-                check = ThreemaApplication.getServiceManager()?.preferenceService?.useThreemaPush() ?: false &&
-                    !PowermanagerUtil.isIgnoringBatteryOptimizations(this),
-            ),
-            Problem(
-                title = R.string.problemsolver_title_app_battery_usgae_optimized,
-                explanation = getString(
-                    R.string.battery_optimizations_explain,
-                    getString(R.string.webclient),
-                    getString(R.string.app_name),
-                ),
-                intentAction = Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
-                check = sessionService.hasRunningSessions() && !PowermanagerUtil.isIgnoringBatteryOptimizations(this),
-            ),
-        )
-    }
-
-    override fun getLayoutResource(): Int {
-        return R.layout.activity_problemsolver
-    }
-
-    override fun initActivity(savedInstanceState: Bundle?): Boolean {
-        if (!super.initActivity(savedInstanceState)) {
-            return false
-        }
-
-        findViewById<TextView>(R.id.intro_text).text =
-            getString(R.string.problemsolver_intro, getString(R.string.app_name))
-
-        val toolbar = findViewById<MaterialToolbar>(R.id.material_toolbar)
-        toolbar.setNavigationOnClickListener { finish() }
-
-        showProblems()
-
-        return true
-    }
-
-    override fun handleDeviceInsets() {
-        super.handleDeviceInsets()
-
-        findViewById<NestedScrollView>(R.id.scroll_container).applyDeviceInsetsAsPadding(
-            insetSides = InsetSides.lbr(),
-            ownPadding = SpacingValues.all(R.dimen.grid_unit_x2),
-        )
-    }
-
-    private fun showProblems() {
-        val problemsParentLayout = findViewById<LinearLayout>(R.id.problems_parent)
-        val problems = problems.filter { it.check }
-        for (problem in problems) {
-            val itemLayout: View = LayoutInflater.from(this).inflate(
-                /* resource = */
-                R.layout.item_problemsolver,
-                /* root = */
-                problemsParentLayout,
-                /* attachToRoot = */
-                false,
-            )
-            itemLayout.findViewById<TextView>(R.id.item_title).text = getString(problem.title)
-            itemLayout.findViewById<TextView>(R.id.item_explain).text = problem.explanation
-            val settingsButton = itemLayout.findViewById<MaterialButton>(R.id.item_button)
-            settingsButton.setOnClickListener { onProblemClick(problem) }
-            problemsParentLayout.addView(itemLayout)
-        }
-
-        if (problems.isEmpty()) {
-            finish()
-        }
-    }
-
-    private fun onProblemClick(problem: Problem) {
-        val action = problem.intentAction
-        val intent: Intent
-
-        if (problem.title == R.string.problemsolver_title_app_battery_usgae_optimized &&
-            Build.VERSION.SDK_INT < Build.VERSION_CODES.S
-        ) {
-            intent = Intent(this, DisableBatteryOptimizationsActivity::class.java)
-            intent.putExtra(
-                DisableBatteryOptimizationsActivity.EXTRA_NAME,
-                getString(R.string.threema_push),
-            )
-            intent.putExtra(DisableBatteryOptimizationsActivity.EXTRA_CANCEL_LABEL, R.string.cancel)
-            intent.putExtra(DisableBatteryOptimizationsActivity.EXTRA_DISABLE_RATIONALE, true)
-        } else {
-            intent = Intent(action)
-            if (problem.title == R.string.problemsolver_title_notifications) {
-                // for Android 7
-                intent.putExtra("app_package", packageName)
-                intent.putExtra("app_uid", applicationInfo.uid)
-                // for Android O
-                intent.putExtra("android.provider.extra.APP_PACKAGE", packageName)
-            } else {
-                intent.data = "package:$packageName".toUri()
-            }
-        }
-
-        settingsLauncher.launch(intent)
-    }
-
-    companion object {
-        @JvmStatic
-        fun createIntent(context: Context) = buildActivityIntent<ProblemSolverActivity>(context)
-    }
-}

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

@@ -24,27 +24,27 @@ package ch.threema.app.activities
 import android.content.Context
 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.IdListService
+import ch.threema.app.services.ProfilePictureRecipientsService
 import ch.threema.app.tasks.ReflectUserProfileShareWithAllowListSyncTask
-import ch.threema.app.utils.buildActivityIntent
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.LoggingUtil
+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.storage.models.ContactModel
 import org.koin.android.ext.android.inject
 
-private val logger = LoggingUtil.getThreemaLogger("ProfilePicRecipientsActivity")
+private val logger = getThreemaLogger("ProfilePicRecipientsActivity")
 
 class ProfilePicRecipientsActivity : MemberChooseActivity() {
     init {
         logScreenVisibility(logger)
     }
 
-    private val profilePicRecipientsService: IdListService by inject()
+    private val profilePictureRecipientsService: ProfilePictureRecipientsService by inject()
     private val taskManager: TaskManager by inject()
 
     override fun initActivity(savedInstanceState: Bundle?): Boolean {
@@ -59,7 +59,7 @@ class ProfilePicRecipientsActivity : MemberChooseActivity() {
 
     override fun initData(savedInstanceState: Bundle?) {
         if (savedInstanceState == null) {
-            val selectedIdentities: Array<Identity>? = profilePicRecipientsService.all
+            val selectedIdentities: Array<Identity>? = profilePictureRecipientsService.all
             if (!selectedIdentities.isNullOrEmpty()) {
                 preselectedIdentities = ArrayList(listOf(*selectedIdentities))
             }
@@ -69,11 +69,11 @@ class ProfilePicRecipientsActivity : MemberChooseActivity() {
     }
 
     override fun menuNext(selectedContacts: List<ContactModel?>) {
-        val oldAllowedIdentities: Array<Identity> = profilePicRecipientsService.all
+        val oldAllowedIdentities: Array<Identity> = profilePictureRecipientsService.all
         val newAllowedIdentities: Array<Identity> = selectedContacts
             .mapNotNull { contactModel -> contactModel?.identity }
             .toTypedArray<String>()
-        profilePicRecipientsService.replaceAll(newAllowedIdentities)
+        profilePictureRecipientsService.replaceAll(newAllowedIdentities)
 
         // If data changed:
         // sync new policy setting with newly set allow list values into device group (if md is active)

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

@@ -82,7 +82,6 @@ import androidx.viewpager.widget.ViewPager;
 import ch.threema.app.AppConstants;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
 import ch.threema.app.actions.LocationMessageSendAction;
 import ch.threema.app.actions.SendAction;
 import ch.threema.app.actions.TextMessageSendAction;
@@ -117,7 +116,6 @@ import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.GeoLocationUtil;
 import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.app.utils.LazyProperty;
 import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.NameUtil;
@@ -126,7 +124,7 @@ 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 ch.threema.base.utils.LoggingUtil;
+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;
@@ -137,6 +135,7 @@ 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 kotlin.Lazy;
 
 import static ch.threema.app.activities.SendMediaActivity.MAX_EDITABLE_FILES;
 import static ch.threema.app.fragments.ComposeMessageFragment.MAX_FORWARDABLE_ITEMS;
@@ -145,6 +144,7 @@ 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;
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
+import static ch.threema.common.LazyKt.lazy;
 
 public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     CancelableHorizontalProgressDialog.ProgressDialogClickListener,
@@ -152,7 +152,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     TextWithCheckboxDialog.TextWithCheckboxDialogClickListener,
     SearchView.OnQueryTextListener {
 
-    private static final Logger logger = LoggingUtil.getThreemaLogger("RecipientListBaseActivity");
+    private static final Logger logger = getThreemaLogger("RecipientListBaseActivity");
 
     private final static int FRAGMENT_RECENT = 0;
     private final static int FRAGMENT_USERS = 1;
@@ -189,7 +189,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
 
     @NonNull
-    private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
+    private final Lazy<BackgroundExecutor> backgroundExecutor = lazy(BackgroundExecutor::new);
 
     private final Runnable copyExternalFilesRunnable = new Runnable() {
         @Override
@@ -805,7 +805,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
         if (contactModel == null) {
             GenericProgressDialog.newInstance(R.string.creating_contact, R.string.please_wait).show(getSupportFragmentManager(), "pro");
 
-            backgroundExecutor.get().execute(
+            backgroundExecutor.getValue().execute(
                 new BasicAddOrUpdateContactBackgroundTask(
                     identity,
                     ContactModel.AcquaintanceLevel.DIRECT,

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

@@ -36,14 +36,14 @@ import androidx.appcompat.app.AppCompatActivity;
 import ch.threema.app.R;
 import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.services.UserService;
-import ch.threema.base.utils.LoggingUtil;
+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 = LoggingUtil.getThreemaLogger("SMSVerificationLinkActivity");
+    private static final Logger logger = getThreemaLogger("SMSVerificationLinkActivity");
 
     @NonNull
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);

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

@@ -22,6 +22,7 @@
 package ch.threema.app.activities;
 
 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;
@@ -88,7 +89,6 @@ import androidx.viewpager2.widget.ViewPager2;
 import com.google.android.material.snackbar.BaseTransientBottomBar;
 import com.google.android.material.snackbar.Snackbar;
 
-import org.apache.commons.text.similarity.JaroWinklerSimilarity;
 import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
@@ -98,6 +98,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
 
+import ch.threema.android.ActivityExtensionsKt;
 import ch.threema.app.AppConstants;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -108,7 +109,7 @@ import ch.threema.app.camera.CameraUtil;
 import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.dialogs.CallbackTextEntryDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
-import ch.threema.app.drafts.DraftManager;
+import ch.threema.app.drafts.DraftUpdateTextWatcher;
 import ch.threema.app.emojis.EmojiButton;
 import ch.threema.app.emojis.EmojiPicker;
 import ch.threema.app.mediaattacher.MediaFilterQuery;
@@ -127,7 +128,6 @@ import ch.threema.app.ui.SendButton;
 import ch.threema.app.ui.SimpleTextWatcher;
 import ch.threema.app.ui.TranslateDeferringInsetsAnimationCallback;
 import ch.threema.app.ui.ViewExtensionsKt;
-import ch.threema.app.utils.ActivityExtensionsKt;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.BitmapUtil;
 import ch.threema.app.utils.ConfigUtils;
@@ -138,7 +138,7 @@ import ch.threema.app.utils.MediaAdapterManager;
 import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.video.VideoTimelineCache;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 import ch.threema.app.messagereceiver.SendingPermissionValidationResult;
 import ch.threema.data.models.GroupModel;
 import ch.threema.domain.protocol.csp.messages.file.FileData;
@@ -147,7 +147,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
     GenericAlertDialog.DialogClickListener,
     ThreemaToolbarActivity.OnSoftKeyboardChangedListener,
     MediaAdapterListener {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("SendMediaActivity");
+    private static final Logger logger = getThreemaLogger("SendMediaActivity");
 
     {
         // Always use night mode for this activity. Note that setting it here avoids the activity being recreated.
@@ -178,6 +178,8 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
     private RecyclerView recyclerView;
     private ViewPager2 viewPager;
     private ArrayList<MessageReceiver> messageReceivers;
+    @Nullable
+    private DraftUpdateTextWatcher draftUpdateTextWatcher;
     private File tempFile = null;
     private ComposeEditText captionEditText;
     private LinearLayout activityParentLayout;
@@ -227,7 +229,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
-        if (!dependencies.isAvailable()) {
+        if (!isSessionScopeReady()) {
             finish();
         }
     }
@@ -442,6 +444,19 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
             findViewById(R.id.recipient_container).setVisibility(View.GONE);
         }
 
+        if (messageReceivers.size() == 1) {
+            var conversationUid = messageReceivers.get(0).getUniqueIdString();
+            draftUpdateTextWatcher = new DraftUpdateTextWatcher(
+                dependencies.getDraftManager(),
+                conversationUid,
+                () -> {
+                    var mediaItems = mediaAdapterManager.getItems();
+                    return !mediaItems.isEmpty() ? mediaItems.get(0).getCaption() : null;
+                }
+            );
+            captionEditText.addTextChangedListener(draftUpdateTextWatcher);
+        }
+
         SendButton sendButton = findViewById(R.id.send_button);
         sendButton.setOnClickListener(new DebouncedOnClickListener(500) {
             @Override
@@ -709,10 +724,10 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
         File cameraFile = null;
         File videoFile;
         try {
-            cameraFile = dependencies.getFileService().createTempFile(".camera", ".jpg", false);
+            cameraFile = dependencies.getFileService().createTempFile(".camera", ".jpg");
             this.cameraFilePath = cameraFile.getCanonicalPath();
 
-            videoFile = dependencies.getFileService().createTempFile(".video", ".mp4", false);
+            videoFile = dependencies.getFileService().createTempFile(".video", ".mp4");
             this.videoFilePath = videoFile.getCanonicalPath();
         } catch (IOException e) {
             logger.error("Exception", e);
@@ -1025,20 +1040,11 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
         dependencies.getMessageService().sendMediaAsync(mediaAdapterManager.getItems(), messageReceivers, null);
 
         if (messageReceivers.size() == 1) {
-            String messageDraft = DraftManager.getMessageDraft(messageReceivers.get(0).getUniqueIdString());
-            if (!TestUtil.isEmptyOrNull(messageDraft)) {
-                for (MediaItem mediaItem : mediaAdapterManager.getItems()) {
-                    try {
-                        double similarity = new JaroWinklerSimilarity().apply(mediaItem.getCaption(), messageDraft);
-                        if (similarity > 0.8D) {
-                            DraftManager.putMessageDraft(messageReceivers.get(0).getUniqueIdString(), null, null);
-                            break;
-                        }
-                    } catch (IllegalArgumentException ignore) {
-                        // one argument is probably null
-                    }
-                }
+            if (draftUpdateTextWatcher != null) {
+                captionEditText.removeTextChangedListener(draftUpdateTextWatcher);
+                draftUpdateTextWatcher.stop();
             }
+            dependencies.getDraftManager().remove(messageReceivers.get(0).getUniqueIdString());
         }
 
         // return last media filter to chat via intermediate hop through MediaAttachActivity
@@ -1270,6 +1276,10 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
         }
         this.sendMediaPreviewAdapter = null;
 
+        if (draftUpdateTextWatcher != null) {
+            draftUpdateTextWatcher.stop();
+        }
+
         super.onDestroy();
     }
 
@@ -1347,20 +1357,4 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
                 throw new IllegalArgumentException(String.format("No menu item for video size %d", videoSize));
         }
     }
-
-    @Override
-    protected void onPause() {
-        super.onPause();
-
-        // Normally, the activity's state is stored and recreated when the activity is recreated.
-        // However, in the event of a crash, we might lose this state and with that we lose user data.
-        // To mitigate this, we store the caption of the first media item as a draft, so that we can at least restore that.
-        // We only do this if we can uniquely identify the chat that the draft should be stored in.
-        if (!isFinishing()) {
-            var mediaItems = mediaAdapterManager.getItems();
-            if (messageReceivers.size() == 1 && !mediaItems.isEmpty()) {
-                DraftManager.putMessageDraft(messageReceivers.get(0).getUniqueIdString(), mediaItems.get(0).getCaption(), null);
-            }
-        }
-    }
 }

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

@@ -41,13 +41,13 @@ import ch.threema.app.ui.InsetSides;
 import ch.threema.app.ui.ServerMessageViewModel;
 import ch.threema.app.ui.SpacingValues;
 import ch.threema.app.ui.ViewExtensionsKt;
-import ch.threema.base.utils.LoggingUtil;
+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 ServerMessageActivity extends ThreemaActivity {
-    private final static Logger logger = LoggingUtil.getThreemaLogger("ServerMessageActivity");
+    private final static Logger logger = getThreemaLogger("ServerMessageActivity");
 
     @NonNull
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);

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

@@ -39,6 +39,7 @@ 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
@@ -61,9 +62,8 @@ 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.buildActivityIntent
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.LoggingUtil
+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
@@ -75,7 +75,7 @@ import kotlinx.coroutines.withContext
 import org.koin.android.ext.android.inject
 import org.koin.androidx.viewmodel.ext.android.viewModel
 
-private val logger = LoggingUtil.getThreemaLogger("StarredMessagesActivity")
+private val logger = getThreemaLogger("StarredMessagesActivity")
 
 class StarredMessagesActivity :
     ThreemaToolbarActivity(),

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

@@ -44,12 +44,12 @@ import ch.threema.app.adapters.StickerSelectorAdapter;
 import ch.threema.app.ui.InsetSides;
 import ch.threema.app.ui.SpacingValues;
 import ch.threema.app.ui.ViewExtensionsKt;
-import ch.threema.base.utils.LoggingUtil;
+import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
 
 public class StickerSelectorActivity extends ThreemaToolbarActivity implements LoaderManager.LoaderCallbacks<String[]> {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("StickerSelectorActivity");
+    private static final Logger logger = getThreemaLogger("StickerSelectorActivity");
 
     private static final String STICKER_DIRECTORY = "emojione";
     private static final String STICKER_INDEX = STICKER_DIRECTORY + "/contents.txt";

+ 4 - 3
app/src/main/java/ch/threema/app/activities/StorageManagementActivity.java

@@ -70,16 +70,17 @@ 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 ch.threema.base.utils.LoggingUtil;
+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 = LoggingUtil.getThreemaLogger("StorageManagementActivity");
+    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";
@@ -307,7 +308,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 
     @Override
     public int getLayoutResource() {
-        if (!dependencies.isAvailable() || !dependencies.getUserService().hasIdentity()) {
+        if (!isSessionScopeReady() || !dependencies.getUserService().hasIdentity()) {
             return R.layout.activity_storagemanagement_empty;
         }
         return R.layout.activity_storagemanagement;

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

@@ -42,7 +42,6 @@ public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
     final static public int ACTIVITY_ID_SHARE_CHAT = 20018;
     final static public int ACTIVITY_ID_SEND_MEDIA = 20019;
     final static public int ACTIVITY_ID_ATTACH_MEDIA = 20020;
-    public static final int ACTIVITY_ID_CONFIRM_DEVICE_CREDENTIALS = 20021;
     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;

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

@@ -25,17 +25,17 @@ import android.content.Context
 import android.os.Bundle
 import android.view.View
 import android.widget.ScrollView
+import ch.threema.android.buildActivityIntent
 import ch.threema.app.R
 import ch.threema.app.ui.InsetSides
 import ch.threema.app.ui.SpacingValues
 import ch.threema.app.ui.applyDeviceInsetsAsMargin
 import ch.threema.app.ui.applyDeviceInsetsAsPadding
-import ch.threema.app.utils.buildActivityIntent
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 import com.google.android.material.button.MaterialButton
 
-private val logger = LoggingUtil.getThreemaLogger("ThreemaPushNotificationInfoActivity")
+private val logger = getThreemaLogger("ThreemaPushNotificationInfoActivity")
 
 /**
  * Activity that is shown when the user taps on the persistent Threema Push notification.

+ 6 - 5
app/src/main/java/ch/threema/app/activities/ThreemaToolbarActivity.java

@@ -56,19 +56,20 @@ import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConnectionIndicatorUtil;
 import ch.threema.app.utils.EditTextUtil;
 import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.base.utils.LoggingUtil;
+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
  */
 public abstract class ThreemaToolbarActivity extends ThreemaActivity implements ConnectionStateListener {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("ThreemaToolbarActivity");
+    private static final Logger logger = getThreemaLogger("ThreemaToolbarActivity");
 
     private AppBarLayout appBarLayout;
     private Toolbar toolbar;
@@ -100,7 +101,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 
     @Override
     protected void onResume() {
-        if (dependencies.isAvailable()) {
+        if (isSessionScopeReady()) {
             dependencies.getServerConnection().addConnectionStateListener(this);
             ConnectionState connectionState = dependencies.getServerConnection().getConnectionState();
             ConnectionIndicatorUtil.getInstance().updateConnectionIndicator(connectionIndicator, connectionState);
@@ -110,7 +111,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 
     @Override
     protected void onPause() {
-        if (dependencies.isAvailable()) {
+        if (isSessionScopeReady()) {
             dependencies.getServerConnection().removeConnectionStateListener(this);
         }
         super.onPause();
@@ -164,7 +165,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
     protected boolean initActivity(@Nullable Bundle savedInstanceState) {
         logger.debug("initActivity");
 
-        if (!dependencies.isAvailable()) {
+        if (!isSessionScopeReady()) {
             return false;
         }
 

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

@@ -25,14 +25,14 @@ import android.content.Context
 import android.os.Bundle
 import android.view.MenuItem
 import android.view.View
+import ch.threema.android.buildActivityIntent
 import ch.threema.app.R
 import ch.threema.app.ui.InsetSides
 import ch.threema.app.ui.applyDeviceInsetsAsPadding
-import ch.threema.app.utils.buildActivityIntent
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 
-private val logger = LoggingUtil.getThreemaLogger("VerificationLevelActivity")
+private val logger = getThreemaLogger("VerificationLevelActivity")
 
 class VerificationLevelActivity : ThreemaToolbarActivity() {
 

+ 0 - 76
app/src/main/java/ch/threema/app/activities/WhatsNewActivity.java

@@ -1,76 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2017-2025 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.activities;
-
-import android.os.Bundle;
-import android.text.Html;
-import android.view.View;
-import android.widget.LinearLayout;
-import android.widget.TextView;
-
-import org.slf4j.Logger;
-
-import ch.threema.app.BuildConfig;
-import ch.threema.app.R;
-import ch.threema.app.ui.InsetSides;
-import ch.threema.app.ui.ViewExtensionsKt;
-import ch.threema.app.utils.AnimationUtil;
-import ch.threema.app.utils.ConfigUtils;
-import ch.threema.base.utils.LoggingUtil;
-
-import static ch.threema.app.utils.ActiveScreenLoggerKt.logScreenVisibility;
-
-public class WhatsNewActivity extends ThreemaAppCompatActivity {
-    private static final Logger logger = LoggingUtil.getThreemaLogger("WhatsNewActivity");
-
-    public static final String EXTRA_NO_ANIMATION = "noanim";
-
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-
-        super.onCreate(savedInstanceState);
-        logScreenVisibility(this, logger);
-
-        setContentView(R.layout.activity_whatsnew);
-
-        ViewExtensionsKt.applyDeviceInsetsAsPadding(
-            findViewById(R.id.content_container),
-            InsetSides.all()
-        );
-
-        String title = getString(R.string.whatsnew_title, BuildConfig.VERSION_NAME);
-        CharSequence body = Html.fromHtml(getString(R.string.whatsnew_headline));
-
-        ((TextView) findViewById(R.id.whatsnew_title)).setText(title);
-        ((TextView) findViewById(R.id.whatsnew_body)).setText(body);
-
-        findViewById(R.id.next_text).setOnClickListener(v -> finish());
-
-        if (!getIntent().getBooleanExtra(EXTRA_NO_ANIMATION, false)) {
-            LinearLayout buttonLayout = findViewById(R.id.button_layout);
-            if (savedInstanceState == null) {
-                buttonLayout.setVisibility(View.GONE);
-                buttonLayout.postDelayed(() -> AnimationUtil.slideInFromBottomOvershoot(buttonLayout), 200);
-            }
-        }
-    }
-}

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

@@ -0,0 +1,75 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2017-2025 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.activities
+
+import android.content.Context
+import android.os.Bundle
+import android.text.Html
+import android.view.View
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.core.view.isVisible
+import ch.threema.android.buildActivityIntent
+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
+import kotlin.time.Duration.Companion.milliseconds
+
+private val logger = getThreemaLogger("WhatsNewActivity")
+
+class WhatsNewActivity : ThreemaAppCompatActivity() {
+    init {
+        logScreenVisibility(logger)
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.activity_whatsnew)
+        findViewById<View?>(R.id.content_container).applyDeviceInsetsAsPadding(all())
+
+        val title = getString(R.string.whatsnew_title, BuildConfig.VERSION_NAME)
+        val body = Html.fromHtml(getString(R.string.whatsnew_headline), Html.FROM_HTML_MODE_LEGACY)
+
+        findViewById<TextView>(R.id.whatsnew_title).text = title
+        findViewById<TextView>(R.id.whatsnew_body).text = body
+
+        findViewById<View?>(R.id.next_text)?.setOnClickListener { finish() }
+
+        if (savedInstanceState == null) {
+            val buttonLayout = findViewById<LinearLayout>(R.id.button_layout)
+            buttonLayout.isVisible = false
+            buttonLayout.postDelayed(200.milliseconds) {
+                AnimationUtil.slideInFromBottomOvershoot(buttonLayout)
+            }
+        }
+    }
+
+    companion object {
+        @JvmStatic
+        fun createIntent(context: Context) = buildActivityIntent<WhatsNewActivity>(context)
+    }
+}

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

@@ -57,6 +57,9 @@ import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.tooling.preview.PreviewFontScale
 import androidx.compose.ui.unit.dp
 import androidx.core.net.toUri
+import ch.threema.android.buildActivityIntent
+import ch.threema.android.context
+import ch.threema.android.showToast
 import ch.threema.app.BuildFlavor
 import ch.threema.app.R
 import ch.threema.app.compose.common.SpacerVertical
@@ -72,14 +75,11 @@ import ch.threema.app.compose.theme.dimens.GridUnit
 import ch.threema.app.compose.theme.dimens.responsive
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.LinkifyUtil
-import ch.threema.app.utils.buildActivityIntent
 import ch.threema.app.utils.compose.currentLocaleOrDefault
-import ch.threema.app.utils.context
 import ch.threema.app.utils.logScreenVisibility
-import ch.threema.app.utils.showToast
-import ch.threema.base.utils.LoggingUtil
+import ch.threema.base.utils.getThreemaLogger
 
-private val logger = LoggingUtil.getThreemaLogger("WorkIntroActivity")
+private val logger = getThreemaLogger("WorkIntroActivity")
 
 /**
  *  Used to limit only certain ui elements in their width for a good look on tablets
@@ -249,7 +249,7 @@ private fun WorkIntroContent(
                     ).uppercase(
                         locale = currentLocaleOrDefault(),
                     ),
-                    iconLeading = ButtonIconInfo(
+                    leadingIcon = ButtonIconInfo(
                         icon = R.drawable.ic_arrow_right,
                         contentDescription = null,
                     ),
@@ -274,7 +274,7 @@ private fun WorkIntroContent(
                     ).uppercase(
                         locale = currentLocaleOrDefault(),
                     ),
-                    iconLeading = ButtonIconInfo(
+                    leadingIcon = ButtonIconInfo(
                         icon = R.drawable.ic_arrow_right,
                         contentDescription = null,
                     ),
@@ -314,7 +314,7 @@ private fun WorkIntroContent(
                 ).uppercase(
                     locale = currentLocaleOrDefault(),
                 ),
-                iconLeading = ButtonIconInfo(
+                leadingIcon = ButtonIconInfo(
                     icon = R.drawable.ic_arrow_right,
                     contentDescription = null,
                 ),

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