Explorar o código

Version 6.4.2-1142

Threema hai 1 mes
pai
achega
e2defbb87a
Modificáronse 100 ficheiros con 3039 adicións e 819 borrados
  1. 18 11
      app/build.gradle.kts
  2. 4 1
      app/src/androidTest/java/ch/threema/app/contacts/AddOrUpdateContactBackgroundTaskTest.kt
  3. 7 1
      app/src/androidTest/java/ch/threema/app/contacts/MarkContactAsDeletedBackgroundTaskTest.kt
  4. 80 75
      app/src/androidTest/java/ch/threema/app/contacts/ReflectedContactSyncTaskTest.kt
  5. 3 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/CreateGroupFlowTest.kt
  6. 3 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/DisbandGroupFlowTest.kt
  7. 15 14
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupConversationListTest.kt
  8. 3 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/GroupResyncFlowTest.kt
  9. 6 1
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt
  10. 3 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/LeaveGroupFlowTest.kt
  11. 3 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/RemoveGroupFlowTest.kt
  12. 5 0
      app/src/androidTest/java/ch/threema/app/groupmanagement/UpdateGroupFlowTest.kt
  13. 2 0
      app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt
  14. 3 0
      app/src/androidTest/java/ch/threema/app/tasks/GroupCreateTaskTest.kt
  15. 40 0
      app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt
  16. 11 2
      app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt
  17. 4 2
      app/src/androidTest/java/ch/threema/data/repositories/EditHistoryRepositoryTest.kt
  18. 4 1
      app/src/androidTest/java/ch/threema/data/repositories/EmojiReactionsRepositoryTest.kt
  19. 4 1
      app/src/androidTest/java/ch/threema/data/repositories/GroupModelRepositoryTest.kt
  20. 1 1
      app/src/libre/play/release-notes/de/default.txt
  21. 1 1
      app/src/libre/play/release-notes/en-US/default.txt
  22. 2 2
      app/src/main/AndroidManifest.xml
  23. 1 0
      app/src/main/java/ch/threema/app/AppConstants.kt
  24. 11 97
      app/src/main/java/ch/threema/app/GlobalListeners.java
  25. 11 6
      app/src/main/java/ch/threema/app/ThreemaApplication.kt
  26. 4 0
      app/src/main/java/ch/threema/app/activities/AddContactActivity.java
  27. 4 0
      app/src/main/java/ch/threema/app/activities/AppLinksActivity.java
  28. 5 2
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  29. 0 67
      app/src/main/java/ch/threema/app/activities/SMSVerificationLinkActivity.kt
  30. 1 1
      app/src/main/java/ch/threema/app/activities/WorkIntroActivity.kt
  31. 33 26
      app/src/main/java/ch/threema/app/activities/directory/DirectoryActivity.java
  32. 8 0
      app/src/main/java/ch/threema/app/activities/directory/DirectoryFeatureModule.kt
  33. 17 0
      app/src/main/java/ch/threema/app/activities/directory/DirectoryScreenEvent.kt
  34. 59 0
      app/src/main/java/ch/threema/app/activities/directory/DirectoryViewModel.kt
  35. 1 1
      app/src/main/java/ch/threema/app/activities/referral/ReferralActivity.kt
  36. 4 3
      app/src/main/java/ch/threema/app/activities/starred/StarredMessageListItem.kt
  37. 5 5
      app/src/main/java/ch/threema/app/activities/wizard/components/WizardButton.kt
  38. 88 45
      app/src/main/java/ch/threema/app/adapters/DirectoryAdapter.java
  39. 133 0
      app/src/main/java/ch/threema/app/androidcontactsync/AndroidContactChangeMonitor.kt
  40. 1 0
      app/src/main/java/ch/threema/app/androidcontactsync/AndroidContactFeatureModule.kt
  41. 1 1
      app/src/main/java/ch/threema/app/apptaskexecutor/tasks/RemoteSecretDeleteStepsTask.kt
  42. 2 0
      app/src/main/java/ch/threema/app/archive/ArchiveActivity.kt
  43. 3 0
      app/src/main/java/ch/threema/app/archive/ArchiveViewModel.kt
  44. 4 1
      app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateContactBackgroundTask.kt
  45. 29 75
      app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateWorkContactBackgroundTask.kt
  46. 109 0
      app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusContactBannerView.kt
  47. 65 0
      app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusExtensions.kt
  48. 17 0
      app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusFeatureModule.kt
  49. 76 0
      app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusIconElevatedView.kt
  50. 100 0
      app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusLabelView.kt
  51. 155 0
      app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusOwnBanner.kt
  52. 506 0
      app/src/main/java/ch/threema/app/availabilitystatus/edit/EditAvailabilityStatusBottomSheetDialog.kt
  53. 126 0
      app/src/main/java/ch/threema/app/availabilitystatus/edit/EditAvailabilityStatusViewModel.kt
  54. 39 0
      app/src/main/java/ch/threema/app/compose/common/avatar/Avatar.kt
  55. 6 0
      app/src/main/java/ch/threema/app/compose/common/avatar/AvatarAsync.kt
  56. 1 1
      app/src/main/java/ch/threema/app/compose/common/buttons/ButtonOutlined.kt
  57. 1 1
      app/src/main/java/ch/threema/app/compose/common/buttons/TextButton.kt
  58. 7 6
      app/src/main/java/ch/threema/app/compose/common/buttons/primary/ButtonPrimary.kt
  59. 424 0
      app/src/main/java/ch/threema/app/compose/common/buttons/primary/ButtonPrimaryRounded.kt
  60. 2 1
      app/src/main/java/ch/threema/app/compose/common/buttons/primary/FloatingActionButtonPrimary.kt
  61. 4 2
      app/src/main/java/ch/threema/app/compose/common/text/conversation/ConversationText.kt
  62. 5 0
      app/src/main/java/ch/threema/app/compose/conversation/AvatarAsyncCheckable.kt
  63. 7 1
      app/src/main/java/ch/threema/app/compose/conversation/ConversationListItem.kt
  64. 2 0
      app/src/main/java/ch/threema/app/compose/conversation/models/ConversationUiModel.kt
  65. 1 2
      app/src/main/java/ch/threema/app/compose/theme/color/AlphaValues.kt
  66. 40 0
      app/src/main/java/ch/threema/app/compose/theme/color/CustomColors.kt
  67. 0 6
      app/src/main/java/ch/threema/app/contactdetails/AvailabilityStatus.kt
  68. 0 101
      app/src/main/java/ch/threema/app/contactdetails/ContactAvailabilityView.kt
  69. 6 4
      app/src/main/java/ch/threema/app/contactdetails/ContactDetailActivity.java
  70. 25 5
      app/src/main/java/ch/threema/app/contactdetails/ContactDetailAdapter.java
  71. 2 0
      app/src/main/java/ch/threema/app/di/DependencyContainer.kt
  72. 25 13
      app/src/main/java/ch/threema/app/di/MasterKeyLockStateChangeHandler.kt
  73. 12 1
      app/src/main/java/ch/threema/app/di/modules/Features.kt
  74. 1 1
      app/src/main/java/ch/threema/app/files/TempFilesCleanupWorker.kt
  75. 70 0
      app/src/main/java/ch/threema/app/fragments/MyIDFragment.kt
  76. 1 1
      app/src/main/java/ch/threema/app/fragments/WorkUserListFragment.kt
  77. 63 0
      app/src/main/java/ch/threema/app/fragments/composemessage/ComposeMessageFragment.java
  78. 23 0
      app/src/main/java/ch/threema/app/fragments/composemessage/ComposeMessageViewModel.kt
  79. 132 47
      app/src/main/java/ch/threema/app/fragments/conversations/ConversationsFragment.kt
  80. 2 0
      app/src/main/java/ch/threema/app/fragments/conversations/ConversationsViewEvent.kt
  81. 30 1
      app/src/main/java/ch/threema/app/fragments/conversations/ConversationsViewModel.kt
  82. 3 0
      app/src/main/java/ch/threema/app/fragments/conversations/ConversationsViewState.kt
  83. 2 0
      app/src/main/java/ch/threema/app/framework/BaseViewModel.kt
  84. 22 18
      app/src/main/java/ch/threema/app/home/HomeActivity.kt
  85. 8 0
      app/src/main/java/ch/threema/app/identitylinks/IdentityLinkFeatureModule.kt
  86. 49 0
      app/src/main/java/ch/threema/app/identitylinks/SMSVerificationLinkActivity.kt
  87. 63 0
      app/src/main/java/ch/threema/app/identitylinks/VerifyMobileNumberUseCase.kt
  88. 1 1
      app/src/main/java/ch/threema/app/logging/ExportDebugLogActivity.kt
  89. 2 2
      app/src/main/java/ch/threema/app/multidevice/MultiDeviceManager.kt
  90. 108 116
      app/src/main/java/ch/threema/app/multidevice/linking/DeviceLinkingDataCollector.kt
  91. 1 1
      app/src/main/java/ch/threema/app/multidevice/unlinking/DropDevicesSteps.kt
  92. 1 1
      app/src/main/java/ch/threema/app/multidevice/wizard/steps/LinkNewDevicePFSInfoFragment.kt
  93. 1 1
      app/src/main/java/ch/threema/app/multidevice/wizard/steps/LinkNewDeviceResultFragment.kt
  94. 5 1
      app/src/main/java/ch/threema/app/onprem/OnPremServerAddressProvider.kt
  95. 1 1
      app/src/main/java/ch/threema/app/pinlock/PinLockActivity.kt
  96. 8 8
      app/src/main/java/ch/threema/app/preference/service/ContactSyncPolicySetting.kt
  97. 8 8
      app/src/main/java/ch/threema/app/preference/service/GroupCallPolicySetting.kt
  98. 8 8
      app/src/main/java/ch/threema/app/preference/service/KeyboardDataCollectionPolicySetting.kt
  99. 8 8
      app/src/main/java/ch/threema/app/preference/service/O2oCallConnectionPolicySetting.kt
  100. 8 8
      app/src/main/java/ch/threema/app/preference/service/O2oCallPolicySetting.kt

+ 18 - 11
app/build.gradle.kts

@@ -32,7 +32,7 @@ if (gradle.startParameter.taskRequests.toString().contains("Hms")) {
 /**
  * Only use the scheme "<major>.<minor>.<patch>" for the appVersion
  */
-val appVersion = "6.4.1"
+val appVersion = "6.4.2"
 
 /**
  * betaSuffix with leading dash (e.g. `-beta1`).
@@ -41,7 +41,7 @@ val appVersion = "6.4.1"
  */
 val betaSuffix = ""
 
-val defaultVersionCode = 1137
+val defaultVersionCode = 1142
 
 /**
  * Map with keystore paths (if found).
@@ -100,8 +100,9 @@ android {
         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_LEGACY", null)
+        stringBuildConfigField("WORK_SERVER_IPV6_URL_LEGACY", null)
         stringBuildConfigField("WORK_SERVER_URL", null)
-        stringBuildConfigField("WORK_SERVER_IPV6_URL", null)
         stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.threema.ch/{deviceGroupIdPrefix8}/")
 
         // Base blob url used for "download" and "done" calls
@@ -232,8 +233,9 @@ android {
             stringBuildConfigField("CHAT_SERVER_PREFIX", "w-")
             stringBuildConfigField("CHAT_SERVER_IPV6_PREFIX", "ds.w-")
             stringBuildConfigField("MEDIA_PATH", "ThreemaWork")
-            stringBuildConfigField("WORK_SERVER_URL", "https://apip-work.threema.ch/")
-            stringBuildConfigField("WORK_SERVER_IPV6_URL", "https://ds-apip-work.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_URL_LEGACY", "https://apip-work.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_IPV6_URL_LEGACY", "https://ds-apip-work.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_URL", "https://work.threema.ch/")
             stringBuildConfigField("APP_RATING_URL", "https://threema.com/app-rating/android-work/{rating}")
             stringBuildConfigField("LOG_TAG", "3mawrk")
             stringBuildConfigField("DEFAULT_APP_THEME", "2")
@@ -306,8 +308,9 @@ android {
             byteArrayBuildConfigField("SERVER_PUBKEY_ALT", PublicKeys.sandboxServer)
             stringBuildConfigField("DIRECTORY_SERVER_URL", "https://apip.test.threema.ch/")
             stringBuildConfigField("DIRECTORY_SERVER_IPV6_URL", "https://ds-apip.test.threema.ch/")
-            stringBuildConfigField("WORK_SERVER_URL", "https://apip-work.test.threema.ch/")
-            stringBuildConfigField("WORK_SERVER_IPV6_URL", "https://ds-apip-work.test.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_URL_LEGACY", "https://apip-work.test.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_IPV6_URL_LEGACY", "https://ds-apip-work.test.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_URL", "https://work.test.threema.ch/")
             stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}/")
             stringBuildConfigField("BLOB_SERVER_URL", "https://blobp-{blobIdPrefix}.test.threema.ch")
             stringBuildConfigField("BLOB_SERVER_IPV6_URL", "https://ds-blobp-{blobIdPrefix}.test.threema.ch")
@@ -326,6 +329,7 @@ android {
             stringBuildConfigField("uriScheme", "threemawork")
             stringBuildConfigField("actionUrl", "work.test.threema.ch")
 
+            booleanBuildConfigField("AVAILABILITY_STATUS_ENABLED", true)
             booleanBuildConfigField("ERROR_REPORTING_SUPPORTED", true)
 
             stringBuildConfigField("MD_CLIENT_DOWNLOAD_URL", "https://three.ma/mdw")
@@ -409,8 +413,9 @@ android {
             byteArrayBuildConfigField("SERVER_PUBKEY_ALT", PublicKeys.sandboxServer)
             stringBuildConfigField("DIRECTORY_SERVER_URL", "https://apip.test.threema.ch/")
             stringBuildConfigField("DIRECTORY_SERVER_IPV6_URL", "https://ds-apip.test.threema.ch/")
-            stringBuildConfigField("WORK_SERVER_URL", "https://apip-work.test.threema.ch/")
-            stringBuildConfigField("WORK_SERVER_IPV6_URL", "https://ds-apip-work.test.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_URL_LEGACY", "https://apip-work.test.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_IPV6_URL_LEGACY", "https://ds-apip-work.test.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_URL", "https://work.test.threema.ch/")
             stringBuildConfigField("MEDIATOR_SERVER_URL", "wss://mediator-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}/")
             stringBuildConfigField("BLOB_SERVER_URL", "https://blobp-{blobIdPrefix}.test.threema.ch")
             stringBuildConfigField("BLOB_SERVER_IPV6_URL", "https://ds-blobp-{blobIdPrefix}.test.threema.ch")
@@ -424,6 +429,7 @@ android {
             stringBuildConfigField("LOG_TAG", "3mablue")
             stringBuildConfigField("BLOB_MIRROR_SERVER_URL", "https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}")
 
+            booleanBuildConfigField("AVAILABILITY_STATUS_ENABLED", true)
             booleanBuildConfigField("ERROR_REPORTING_SUPPORTED", true)
 
             // config fields for action URLs / deep links
@@ -450,8 +456,9 @@ android {
             stringBuildConfigField("CHAT_SERVER_PREFIX", "w-")
             stringBuildConfigField("CHAT_SERVER_IPV6_PREFIX", "ds.w-")
             stringBuildConfigField("MEDIA_PATH", "ThreemaWork")
-            stringBuildConfigField("WORK_SERVER_URL", "https://apip-work.threema.ch/")
-            stringBuildConfigField("WORK_SERVER_IPV6_URL", "https://ds-apip-work.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_URL_LEGACY", "https://apip-work.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_IPV6_URL_LEGACY", "https://ds-apip-work.threema.ch/")
+            stringBuildConfigField("WORK_SERVER_URL", "https://work.threema.ch/")
             stringBuildConfigField("APP_RATING_URL", "https://threema.com/app-rating/android-work/{rating}")
             stringBuildConfigField("LOG_TAG", "3mawrk")
             stringBuildConfigField("DEFAULT_APP_THEME", "2")

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

@@ -74,7 +74,10 @@ class AddOrUpdateContactBackgroundTaskTest : KoinComponent {
             identityProvider = identityProviderMock,
             identityStore = identityStoreMock,
         )
-        contactModelRepository = ModelRepositories(coreServiceManager, identityProviderMock).contacts
+        contactModelRepository = ModelRepositories(
+            coreServiceManager = coreServiceManager,
+            identityProvider = identityProviderMock,
+        ).contacts
     }
 
     @Test

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

@@ -21,6 +21,7 @@ import ch.threema.app.tasks.ReflectContactSyncUpdateTask
 import ch.threema.app.tasks.TaskCreator
 import ch.threema.app.utils.executor.BackgroundExecutor
 import ch.threema.base.crypto.NaCl
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.repositories.ContactModelRepository
 import ch.threema.data.repositories.ModelRepositories
@@ -167,6 +168,8 @@ class MarkContactAsDeletedBackgroundTaskTest {
         jobTitle = null,
         department = null,
         notificationTriggerPolicyOverride = null,
+        availabilityStatus = AvailabilityStatus.None,
+        workLastFullSyncAt = null,
     )
 
     @BeforeTest
@@ -206,7 +209,10 @@ class MarkContactAsDeletedBackgroundTaskTest {
             serviceManager.notificationService,
             ContactModelFactory(databaseProvider, identityProviderMock),
         )
-        contactModelRepository = ModelRepositories(coreServiceManager, identityProviderMock).contacts
+        contactModelRepository = ModelRepositories(
+            coreServiceManager = coreServiceManager,
+            identityProvider = identityProviderMock,
+        ).contacts
 
         // Add a contact "from sync". This has no side effects and does not reflect the contact.
         contactModelRepository.createFromSync(testContactModelData)

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

@@ -10,6 +10,7 @@ import ch.threema.app.multidevice.MultiDeviceManager
 import ch.threema.app.processors.reflectedd2dsync.ReflectedContactSyncTask
 import ch.threema.app.stores.IdentityProvider
 import ch.threema.base.crypto.NaCl
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.datatypes.IdColor
 import ch.threema.data.models.ContactModel
 import ch.threema.data.models.ContactModelData
@@ -25,18 +26,17 @@ import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.stores.IdentityStore
 import ch.threema.domain.types.Identity
+import ch.threema.protobuf.common.unit
 import ch.threema.protobuf.d2d.ContactSyncKt.create
 import ch.threema.protobuf.d2d.ContactSyncKt.update
 import ch.threema.protobuf.d2d.contactSync
-import ch.threema.protobuf.d2d.sync.ContactKt.notificationSoundPolicyOverride
+import ch.threema.protobuf.d2d.sync.Contact
+import ch.threema.protobuf.d2d.sync.ContactKt.deprecatedNotificationSoundPolicyOverride
 import ch.threema.protobuf.d2d.sync.ContactKt.readReceiptPolicyOverride
 import ch.threema.protobuf.d2d.sync.ContactKt.typingIndicatorPolicyOverride
-import ch.threema.protobuf.d2d.sync.MdD2DSync
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.ActivityState
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.ReadReceiptPolicyOverride
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.TypingIndicatorPolicyOverride
+import ch.threema.protobuf.d2d.sync.ConversationCategory
+import ch.threema.protobuf.d2d.sync.ConversationVisibility
 import ch.threema.protobuf.d2d.sync.contact
-import ch.threema.protobuf.unit
 import ch.threema.storage.TestDatabaseProvider
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import com.google.protobuf.kotlin.toByteString
@@ -51,6 +51,7 @@ import kotlin.test.assertNull
 import kotlin.test.assertTrue
 import kotlin.test.fail
 import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
 import org.junit.Rule
 import org.junit.runner.RunWith
 import org.koin.dsl.module
@@ -87,6 +88,8 @@ class ReflectedContactSyncTaskTest {
         jobTitle = null,
         department = null,
         notificationTriggerPolicyOverride = null,
+        availabilityStatus = AvailabilityStatus.None,
+        workLastFullSyncAt = null,
     )
 
     private val instrumentedTestModule = module {
@@ -121,11 +124,14 @@ class ReflectedContactSyncTaskTest {
             taskManager = TestTaskManager(taskCodec),
             identityStore = identityStoreMock,
         )
-        contactModelRepository = ModelRepositories(coreServiceManager, identityProviderMock).contacts
+        contactModelRepository = ModelRepositories(
+            coreServiceManager = coreServiceManager,
+            identityProvider = identityProviderMock,
+        ).contacts
     }
 
     @Test
-    fun testNewReflectedContact() {
+    fun testNewReflectedContact() = runTest {
         val contact = contact {
             identity = "01234567"
             publicKey = ByteArray(NaCl.PUBLIC_KEY_BYTES) { it.toByte() }.toByteString()
@@ -133,26 +139,25 @@ class ReflectedContactSyncTaskTest {
             firstName = "0123"
             // No last name provided
             nickname = "nick"
-            verificationLevel = MdD2DSync.Contact.VerificationLevel.FULLY_VERIFIED
-            workVerificationLevel = MdD2DSync.Contact.WorkVerificationLevel.NONE
-            identityType = MdD2DSync.Contact.IdentityType.WORK
-            acquaintanceLevel = MdD2DSync.Contact.AcquaintanceLevel.DIRECT
-            activityState = ActivityState.INACTIVE
+            verificationLevel = Contact.VerificationLevel.FULLY_VERIFIED
+            workVerificationLevel = Contact.WorkVerificationLevel.NONE
+            identityType = Contact.IdentityType.WORK
+            acquaintanceLevel = Contact.AcquaintanceLevel.DIRECT
+            activityState = Contact.ActivityState.INACTIVE
             featureMask = 123
-            syncState = MdD2DSync.Contact.SyncState.IMPORTED
+            syncState = Contact.SyncState.IMPORTED
             readReceiptPolicyOverride = readReceiptPolicyOverride {
                 default = unit {}
             }
             typingIndicatorPolicyOverride = typingIndicatorPolicyOverride {
-                policy = MdD2DSync.TypingIndicatorPolicy.DONT_SEND_TYPING_INDICATOR
+                policy = ch.threema.protobuf.d2d.sync.TypingIndicatorPolicy.DONT_SEND_TYPING_INDICATOR
             }
-            notificationTriggerPolicyOverride =
-                MdD2DSync.Contact.NotificationTriggerPolicyOverride.getDefaultInstance()
-            notificationSoundPolicyOverride = notificationSoundPolicyOverride {
-                policy = MdD2DSync.NotificationSoundPolicy.MUTED
+            notificationTriggerPolicyOverride = Contact.NotificationTriggerPolicyOverride.getDefaultInstance()
+            deprecatedNotificationSoundPolicyOverride = deprecatedNotificationSoundPolicyOverride {
+                default = unit {}
             }
-            conversationCategory = MdD2DSync.ConversationCategory.DEFAULT
-            conversationVisibility = MdD2DSync.ConversationVisibility.NORMAL
+            conversationCategory = ConversationCategory.DEFAULT
+            conversationVisibility = ConversationVisibility.NORMAL
         }
 
         testReflectedContactCreate(contact) { contactModel ->
@@ -179,7 +184,7 @@ class ReflectedContactSyncTaskTest {
     }
 
     @Test
-    fun testReflectedNicknameChange() {
+    fun testReflectedNicknameChange() = runTest {
         val newNickname = "new nickname"
         testReflectedContactUpdate(
             contact {
@@ -191,16 +196,16 @@ class ReflectedContactSyncTaskTest {
         }
     }
 
-    private fun testReflectedContactCreate(
-        contact: MdD2DSync.Contact,
+    private suspend fun testReflectedContactCreate(
+        contact: Contact,
         assertContactCreated: (contactModel: ContactModel) -> Unit,
     ) {
         assertNull(contactModelRepository.getByIdentity(contact.identity))
 
         ReflectedContactSyncTask(
-            contact.toContactSyncCreate(),
-            contactModelRepository,
-            ThreemaApplication.requireServiceManager(),
+            contactSync = contact.toContactSyncCreate(),
+            contactModelRepository = contactModelRepository,
+            serviceManager = ThreemaApplication.requireServiceManager(),
         ).run()
 
         // Assert that no transaction have been executed
@@ -214,16 +219,16 @@ class ReflectedContactSyncTaskTest {
         assertContactCreated(contactModel)
     }
 
-    private fun testReflectedContactUpdate(
-        contact: MdD2DSync.Contact,
+    private suspend fun testReflectedContactUpdate(
+        contact: Contact,
         assertUpdateApplied: (contactModel: ContactModel) -> Unit,
     ) {
         createContact(initialContactModelData.copy(identity = contact.identity))
 
         ReflectedContactSyncTask(
-            contact.toContactSyncUpdate(),
-            contactModelRepository,
-            ThreemaApplication.requireServiceManager(),
+            contactSync = contact.toContactSyncUpdate(),
+            contactModelRepository = contactModelRepository,
+            serviceManager = ThreemaApplication.requireServiceManager(),
         ).run()
 
         // Assert that no transaction have been executed
@@ -264,83 +269,83 @@ class ReflectedContactSyncTaskTest {
         assertTrue { taskCodec.outboundMessages.isEmpty() }
     }
 
-    private fun MdD2DSync.Contact.toContactSyncCreate() = contactSync {
+    private fun Contact.toContactSyncCreate() = contactSync {
         create = create {
             contact = this@toContactSyncCreate
         }
     }
 
-    private fun MdD2DSync.Contact.toContactSyncUpdate() = contactSync {
+    private fun Contact.toContactSyncUpdate() = contactSync {
         update = update {
             contact = this@toContactSyncUpdate
         }
     }
 
-    private fun MdD2DSync.Contact.VerificationLevel.convert(): VerificationLevel = when (this) {
-        MdD2DSync.Contact.VerificationLevel.FULLY_VERIFIED -> VerificationLevel.FULLY_VERIFIED
-        MdD2DSync.Contact.VerificationLevel.SERVER_VERIFIED -> VerificationLevel.SERVER_VERIFIED
-        MdD2DSync.Contact.VerificationLevel.UNVERIFIED -> VerificationLevel.UNVERIFIED
-        MdD2DSync.Contact.VerificationLevel.UNRECOGNIZED -> fail("Verification level is unrecognized")
+    private fun Contact.VerificationLevel.convert(): VerificationLevel = when (this) {
+        Contact.VerificationLevel.FULLY_VERIFIED -> VerificationLevel.FULLY_VERIFIED
+        Contact.VerificationLevel.SERVER_VERIFIED -> VerificationLevel.SERVER_VERIFIED
+        Contact.VerificationLevel.UNVERIFIED -> VerificationLevel.UNVERIFIED
+        Contact.VerificationLevel.UNRECOGNIZED -> fail("Verification level is unrecognized")
     }
 
-    private fun MdD2DSync.Contact.WorkVerificationLevel.convert(): WorkVerificationLevel =
+    private fun Contact.WorkVerificationLevel.convert(): WorkVerificationLevel =
         when (this) {
-            MdD2DSync.Contact.WorkVerificationLevel.WORK_SUBSCRIPTION_VERIFIED -> WorkVerificationLevel.WORK_SUBSCRIPTION_VERIFIED
-            MdD2DSync.Contact.WorkVerificationLevel.NONE -> WorkVerificationLevel.NONE
-            MdD2DSync.Contact.WorkVerificationLevel.UNRECOGNIZED -> fail("Work verification level is unrecognized")
+            Contact.WorkVerificationLevel.WORK_SUBSCRIPTION_VERIFIED -> WorkVerificationLevel.WORK_SUBSCRIPTION_VERIFIED
+            Contact.WorkVerificationLevel.NONE -> WorkVerificationLevel.NONE
+            Contact.WorkVerificationLevel.UNRECOGNIZED -> fail("Work verification level is unrecognized")
         }
 
-    private fun MdD2DSync.Contact.IdentityType.convert(): IdentityType = when (this) {
-        MdD2DSync.Contact.IdentityType.REGULAR -> IdentityType.NORMAL
-        MdD2DSync.Contact.IdentityType.WORK -> IdentityType.WORK
-        MdD2DSync.Contact.IdentityType.UNRECOGNIZED -> fail("Identity type is unrecognized")
+    private fun Contact.IdentityType.convert(): IdentityType = when (this) {
+        Contact.IdentityType.REGULAR -> IdentityType.NORMAL
+        Contact.IdentityType.WORK -> IdentityType.WORK
+        Contact.IdentityType.UNRECOGNIZED -> fail("Identity type is unrecognized")
     }
 
-    private fun MdD2DSync.Contact.AcquaintanceLevel.convert(): AcquaintanceLevel =
+    private fun Contact.AcquaintanceLevel.convert(): AcquaintanceLevel =
         when (this) {
-            MdD2DSync.Contact.AcquaintanceLevel.DIRECT -> AcquaintanceLevel.DIRECT
-            MdD2DSync.Contact.AcquaintanceLevel.GROUP_OR_DELETED -> AcquaintanceLevel.GROUP
-            MdD2DSync.Contact.AcquaintanceLevel.UNRECOGNIZED -> fail("Acquaintance level is unrecognized")
+            Contact.AcquaintanceLevel.DIRECT -> AcquaintanceLevel.DIRECT
+            Contact.AcquaintanceLevel.GROUP_OR_DELETED -> AcquaintanceLevel.GROUP
+            Contact.AcquaintanceLevel.UNRECOGNIZED -> fail("Acquaintance level is unrecognized")
         }
 
-    private fun ActivityState.convert(): IdentityState = when (this) {
-        ActivityState.ACTIVE -> IdentityState.ACTIVE
-        ActivityState.INACTIVE -> IdentityState.INACTIVE
-        ActivityState.INVALID -> IdentityState.INVALID
-        ActivityState.UNRECOGNIZED -> fail("Activity state is unrecognized")
+    private fun Contact.ActivityState.convert(): IdentityState = when (this) {
+        Contact.ActivityState.ACTIVE -> IdentityState.ACTIVE
+        Contact.ActivityState.INACTIVE -> IdentityState.INACTIVE
+        Contact.ActivityState.INVALID -> IdentityState.INVALID
+        Contact.ActivityState.UNRECOGNIZED -> fail("Activity state is unrecognized")
     }
 
-    private fun MdD2DSync.Contact.SyncState.convert(): ContactSyncState = when (this) {
-        MdD2DSync.Contact.SyncState.INITIAL -> ContactSyncState.INITIAL
-        MdD2DSync.Contact.SyncState.IMPORTED -> ContactSyncState.IMPORTED
-        MdD2DSync.Contact.SyncState.CUSTOM -> ContactSyncState.CUSTOM
-        MdD2DSync.Contact.SyncState.UNRECOGNIZED -> fail("Sync state is unrecognized")
+    private fun Contact.SyncState.convert(): ContactSyncState = when (this) {
+        Contact.SyncState.INITIAL -> ContactSyncState.INITIAL
+        Contact.SyncState.IMPORTED -> ContactSyncState.IMPORTED
+        Contact.SyncState.CUSTOM -> ContactSyncState.CUSTOM
+        Contact.SyncState.UNRECOGNIZED -> fail("Sync state is unrecognized")
     }
 
-    private fun ReadReceiptPolicyOverride.convert(): ReadReceiptPolicy = when (overrideCase) {
-        ReadReceiptPolicyOverride.OverrideCase.DEFAULT -> ReadReceiptPolicy.DEFAULT
-        ReadReceiptPolicyOverride.OverrideCase.POLICY -> when (policy) {
-            MdD2DSync.ReadReceiptPolicy.SEND_READ_RECEIPT -> ReadReceiptPolicy.SEND
-            MdD2DSync.ReadReceiptPolicy.DONT_SEND_READ_RECEIPT -> ReadReceiptPolicy.DONT_SEND
-            MdD2DSync.ReadReceiptPolicy.UNRECOGNIZED -> fail("Read receipt policy is unrecognized")
+    private fun Contact.ReadReceiptPolicyOverride.convert(): ReadReceiptPolicy = when (overrideCase) {
+        Contact.ReadReceiptPolicyOverride.OverrideCase.DEFAULT -> ReadReceiptPolicy.DEFAULT
+        Contact.ReadReceiptPolicyOverride.OverrideCase.POLICY -> when (policy) {
+            ch.threema.protobuf.d2d.sync.ReadReceiptPolicy.SEND_READ_RECEIPT -> ReadReceiptPolicy.SEND
+            ch.threema.protobuf.d2d.sync.ReadReceiptPolicy.DONT_SEND_READ_RECEIPT -> ReadReceiptPolicy.DONT_SEND
+            ch.threema.protobuf.d2d.sync.ReadReceiptPolicy.UNRECOGNIZED -> fail("Read receipt policy is unrecognized")
             null -> fail("Read receipt policy is null")
         }
 
-        ReadReceiptPolicyOverride.OverrideCase.OVERRIDE_NOT_SET -> fail("Read receipt policy override not set")
+        Contact.ReadReceiptPolicyOverride.OverrideCase.OVERRIDE_NOT_SET -> fail("Read receipt policy override not set")
         null -> fail("Read receipt policy override is null")
     }
 
-    private fun TypingIndicatorPolicyOverride.convert(): TypingIndicatorPolicy =
+    private fun Contact.TypingIndicatorPolicyOverride.convert(): TypingIndicatorPolicy =
         when (overrideCase) {
-            TypingIndicatorPolicyOverride.OverrideCase.DEFAULT -> TypingIndicatorPolicy.DEFAULT
-            TypingIndicatorPolicyOverride.OverrideCase.POLICY -> when (policy) {
-                MdD2DSync.TypingIndicatorPolicy.SEND_TYPING_INDICATOR -> TypingIndicatorPolicy.SEND
-                MdD2DSync.TypingIndicatorPolicy.DONT_SEND_TYPING_INDICATOR -> TypingIndicatorPolicy.DONT_SEND
-                MdD2DSync.TypingIndicatorPolicy.UNRECOGNIZED -> fail("Typing indicator policy is unrecognized")
+            Contact.TypingIndicatorPolicyOverride.OverrideCase.DEFAULT -> TypingIndicatorPolicy.DEFAULT
+            Contact.TypingIndicatorPolicyOverride.OverrideCase.POLICY -> when (policy) {
+                ch.threema.protobuf.d2d.sync.TypingIndicatorPolicy.SEND_TYPING_INDICATOR -> TypingIndicatorPolicy.SEND
+                ch.threema.protobuf.d2d.sync.TypingIndicatorPolicy.DONT_SEND_TYPING_INDICATOR -> TypingIndicatorPolicy.DONT_SEND
+                ch.threema.protobuf.d2d.sync.TypingIndicatorPolicy.UNRECOGNIZED -> fail("Typing indicator policy is unrecognized")
                 null -> fail("Typing indicator policy is null")
             }
 
-            TypingIndicatorPolicyOverride.OverrideCase.OVERRIDE_NOT_SET -> fail("Typing indicator policy override not set")
+            Contact.TypingIndicatorPolicyOverride.OverrideCase.OVERRIDE_NOT_SET -> fail("Typing indicator policy override not set")
             null -> fail("Typing indicator policy override is null")
         }
 }

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

@@ -11,6 +11,7 @@ import ch.threema.app.tasks.ReflectGroupSyncCreateTask
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.clearDatabaseAndCaches
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.GroupModel
 import ch.threema.data.models.GroupModelData
@@ -76,6 +77,8 @@ class CreateGroupFlowTest : GroupFlowTest(), KoinComponent {
         jobTitle = null,
         department = null,
         notificationTriggerPolicyOverride = null,
+        availabilityStatus = AvailabilityStatus.None,
+        workLastFullSyncAt = null,
     )
 
     private lateinit var taskManager: ControlledTaskManager

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

@@ -8,6 +8,7 @@ import ch.threema.app.tasks.ReflectGroupSyncDeleteTask
 import ch.threema.app.tasks.ReflectLocalGroupLeaveOrDisband
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.clearDatabaseAndCaches
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModel
@@ -63,6 +64,8 @@ class DisbandGroupFlowTest : GroupFlowTest() {
         jobTitle = null,
         department = null,
         notificationTriggerPolicyOverride = null,
+        availabilityStatus = AvailabilityStatus.None,
+        workLastFullSyncAt = null,
     )
 
     private val myInitialGroupModelData = GroupModelData(

+ 15 - 14
app/src/androidTest/java/ch/threema/app/groupmanagement/GroupConversationListTest.kt

@@ -27,20 +27,21 @@ abstract class GroupConversationListTest<T : AbstractGroupMessage> : GroupContro
 
     private fun startScenario() {
         Intents.init()
-
-        launchActivity<HomeActivity>()
-
-        do {
-            var switchedToMessages = false
-            try {
-                onView(withId(R.id.messages)).perform(click())
-                switchedToMessages = true
-            } catch (_: NoMatchingViewException) {
-                onView(withId(R.id.close_button)).perform(click())
-            }
-        } while (!switchedToMessages)
-
-        Intents.release()
+        try {
+            launchActivity<HomeActivity>()
+
+            do {
+                var switchedToMessages = false
+                try {
+                    onView(withId(R.id.messages)).perform(click())
+                    switchedToMessages = true
+                } catch (_: NoMatchingViewException) {
+                    onView(withId(R.id.close_button)).perform(click())
+                }
+            } while (!switchedToMessages)
+        } finally {
+            Intents.release()
+        }
     }
 
     /**

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

@@ -5,6 +5,7 @@ import ch.threema.app.groupflows.GroupFlowResult
 import ch.threema.app.tasks.ActiveGroupStateResyncTask
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.clearDatabaseAndCaches
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModel
@@ -57,6 +58,8 @@ class GroupResyncFlowTest : GroupFlowTest() {
         jobTitle = null,
         department = null,
         notificationTriggerPolicyOverride = null,
+        availabilityStatus = AvailabilityStatus.None,
+        workLastFullSyncAt = null,
     )
 
     private val myInitialGroupModelData = GroupModelData(

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

@@ -8,6 +8,7 @@ import ch.threema.app.managers.ListenerManager
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
 import ch.threema.base.crypto.NaCl
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.GroupIdentity
 import ch.threema.domain.models.ContactSyncState
@@ -378,7 +379,9 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         )
 
         // Add a revoked contact
-        serviceManager.modelRepositories.contacts.createFromLocal(revokedContactModelData)
+        serviceManager.modelRepositories.contacts.createFromLocal(
+            contactModelData = revokedContactModelData,
+        )
 
         val newGroup = TestGroup(
             newAGroup.apiGroupId,
@@ -655,6 +658,8 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         jobTitle = null,
         department = null,
         notificationTriggerPolicyOverride = null,
+        availabilityStatus = AvailabilityStatus.None,
+        workLastFullSyncAt = null,
     )
 
     override fun testCommonGroupReceiveStepUnknownGroupUserCreator() {

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

@@ -8,6 +8,7 @@ import ch.threema.app.tasks.ReflectGroupSyncDeleteTask
 import ch.threema.app.tasks.ReflectLocalGroupLeaveOrDisband
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.clearDatabaseAndCaches
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModel
@@ -62,6 +63,8 @@ class LeaveGroupFlowTest : GroupFlowTest() {
         jobTitle = null,
         department = null,
         notificationTriggerPolicyOverride = null,
+        availabilityStatus = AvailabilityStatus.None,
+        workLastFullSyncAt = null,
     )
 
     private val myInitialGroupModelData = GroupModelData(

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

@@ -6,6 +6,7 @@ import ch.threema.app.groupflows.GroupFlowResult
 import ch.threema.app.tasks.ReflectGroupSyncDeleteTask
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.clearDatabaseAndCaches
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModel
@@ -59,6 +60,8 @@ class RemoveGroupFlowTest : GroupFlowTest() {
         jobTitle = null,
         department = null,
         notificationTriggerPolicyOverride = null,
+        availabilityStatus = AvailabilityStatus.None,
+        workLastFullSyncAt = null,
     )
 
     private val myInitialGroupModelData = GroupModelData(

+ 5 - 0
app/src/androidTest/java/ch/threema/app/groupmanagement/UpdateGroupFlowTest.kt

@@ -9,6 +9,7 @@ import ch.threema.app.tasks.GroupUpdateTask
 import ch.threema.app.tasks.ReflectLocalGroupUpdate
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.clearDatabaseAndCaches
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModel
@@ -71,6 +72,8 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         jobTitle = null,
         department = null,
         notificationTriggerPolicyOverride = null,
+        availabilityStatus = AvailabilityStatus.None,
+        workLastFullSyncAt = null,
     )
 
     /**
@@ -101,6 +104,8 @@ class UpdateGroupFlowTest : GroupFlowTest() {
         jobTitle = null,
         department = null,
         notificationTriggerPolicyOverride = null,
+        availabilityStatus = AvailabilityStatus.None,
+        workLastFullSyncAt = null,
     )
 
     private val myInitialGroupModelData = GroupModelData(

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

@@ -219,6 +219,8 @@ class IdentityBlockedStepsTest {
             jobTitle = null,
             department = null,
             notificationTriggerPolicyOverride = mockk(),
+            availabilityStatus = mockk(),
+            workLastFullSyncAt = null,
         )
 
         every { data } returns contactModelData

+ 3 - 0
app/src/androidTest/java/ch/threema/app/tasks/GroupCreateTaskTest.kt

@@ -11,6 +11,7 @@ import ch.threema.app.protocolsteps.PredefinedMessageIds
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.clearDatabaseAndCaches
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModelData
@@ -64,6 +65,8 @@ class GroupCreateTaskTest {
         jobTitle = null,
         department = null,
         notificationTriggerPolicyOverride = null,
+        availabilityStatus = AvailabilityStatus.None,
+        workLastFullSyncAt = null,
     )
 
     private val serviceManager by lazy { ThreemaApplication.requireServiceManager() }

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

@@ -13,6 +13,7 @@ import kotlinx.serialization.json.Json
  * representation will be dropped.
  */
 class PersistableTasksTest {
+
     @Test
     fun testContactDeliveryReceiptMessageTask() {
         assertValidEncoding(
@@ -468,6 +469,45 @@ class PersistableTasksTest {
         )
     }
 
+    @Test
+    fun testContactAvailabilityStatusUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate::class,
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate.""" +
+                """ReflectAvailabilityStatusUpdateData","availabilityStatus":{"type":"ch.threema.data.datatypes.AvailabilityStatus.None"},""" +
+                """"identity":"01234567"}""",
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate::class,
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate.""" +
+                """ReflectAvailabilityStatusUpdateData","availabilityStatus":{"type":"ch.threema.data.datatypes.AvailabilityStatus.Unavailable",""" +
+                """"description":"On vacation"},"identity":"01234567"}""",
+        )
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate::class,
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate.""" +
+                """ReflectAvailabilityStatusUpdateData","availabilityStatus":{"type":"ch.threema.data.datatypes.AvailabilityStatus.Busy",""" +
+                """"description":"In a meeting"},"identity":"01234567"}""",
+        )
+    }
+
+    @Test
+    fun testReflectUserAvailabilityStatusTask() {
+        assertValidEncoding(
+            ReflectUserAvailabilityStatusTask::class,
+            """{"type":"ch.threema.app.tasks.ReflectUserAvailabilityStatusTask.ReflectUserAvailabilityStatusTaskData"}""",
+        )
+    }
+
+    @Test
+    fun testWorkLastFullSyncAtUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectWorkLastFullSyncAtUpdate::class,
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectWorkLastFullSyncAtUpdate.""" +
+                """ReflectWorkLastFullSyncAtUpdateData","workLastFullSyncAt":1775144426988, "identity":"01234567"}""",
+        )
+    }
+
     @Test
     fun testUserDefinedProfilePictureUpdate() {
         assertValidEncoding(

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

@@ -12,6 +12,7 @@ import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.mockUser
 import ch.threema.base.crypto.NaCl
 import ch.threema.data.datatypes.AndroidContactLookupInfo
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.datatypes.IdColor
 import ch.threema.data.models.ContactModelData
 import ch.threema.domain.helpers.TransactionAckTaskCodec
@@ -147,6 +148,8 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
             jobTitle = jobTitle,
             department = department,
             notificationTriggerPolicyOverride = null,
+            availabilityStatus = AvailabilityStatus.None,
+            workLastFullSyncAt = null,
         )
     }
 
@@ -178,7 +181,10 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
             identityStore = identityStoreMock,
         )
         this.databaseService = coreServiceManager.databaseService
-        this.contactModelRepository = ModelRepositories(coreServiceManager, identityProviderMock).contacts
+        this.contactModelRepository = ModelRepositories(
+            coreServiceManager = coreServiceManager,
+            identityProvider = identityProviderMock,
+        ).contacts
 
         // Instantiate services where MD is enabled
         this.databaseProviderMd = TestDatabaseProvider()
@@ -196,7 +202,10 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
             identityStore = identityStoreMock,
         )
         this.databaseServiceMd = coreServiceManagerMd.databaseService
-        this.contactModelRepositoryMd = ModelRepositories(coreServiceManagerMd, identityProviderMock).contacts
+        this.contactModelRepositoryMd = ModelRepositories(
+            coreServiceManager = coreServiceManagerMd,
+            identityProvider = identityProviderMock,
+        ).contacts
     }
 
     /**

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

@@ -18,7 +18,6 @@ import kotlin.test.BeforeTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
-import org.koin.core.component.get
 
 class EditHistoryRepositoryTest {
     private lateinit var databaseProvider: TestDatabaseProvider
@@ -39,7 +38,10 @@ class EditHistoryRepositoryTest {
             },
             taskManager = TestTaskManager(UnusedTaskCodec()),
         )
-        editHistoryRepository = ModelRepositories(coreServiceManager, mockk()).editHistory
+        editHistoryRepository = ModelRepositories(
+            coreServiceManager = coreServiceManager,
+            identityProvider = mockk(),
+        ).editHistory
         editHistoryDao = EditHistoryDaoImpl(databaseProvider)
     }
 

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

@@ -63,7 +63,10 @@ class EmojiReactionsRepositoryTest {
             identityStore = identityStoreMock,
         )
 
-        emojiReactionsRepository = ModelRepositories(testCoreServiceManager, mockk()).emojiReaction
+        emojiReactionsRepository = ModelRepositories(
+            coreServiceManager = testCoreServiceManager,
+            identityProvider = mockk(),
+        ).emojiReaction
         emojiReactionDao = EmojiReactionsDaoImpl(databaseProvider)
     }
 

+ 4 - 1
app/src/androidTest/java/ch/threema/data/repositories/GroupModelRepositoryTest.kt

@@ -60,7 +60,10 @@ class GroupModelRepositoryTest {
             },
             taskManager = TestTaskManager(UnusedTaskCodec()),
         )
-        this.groupModelRepository = ModelRepositories(coreServiceManager, mockk()).groups
+        this.groupModelRepository = ModelRepositories(
+            coreServiceManager = coreServiceManager,
+            identityProvider = mockk(),
+        ).groups
     }
 
     @Test

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

@@ -1 +1 @@
-- Verbesserung der Bildvorschau in Benachrichtigungen
+- Verbesserungen und Behebung verschiedener Fehler

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

@@ -1 +1 @@
-- Improved thumbnails in notifications
+- Various under-the-hood improvements and bug fixes

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

@@ -673,7 +673,7 @@
             android:theme="@style/Theme.Threema.Wizard"
             android:windowSoftInputMode="stateAlwaysHidden" />
         <activity
-            android:name=".activities.SMSVerificationLinkActivity"
+            android:name=".identitylinks.SMSVerificationLinkActivity"
             android:exported="true"
             android:theme="@style/Theme.Threema.Translucent">
             <intent-filter>
@@ -748,7 +748,7 @@
             android:theme="@style/Theme.Threema.WithToolbar"
             android:windowSoftInputMode="stateHidden|adjustResize" />
         <activity
-            android:name="ch.threema.app.activities.DirectoryActivity"
+            android:name="ch.threema.app.activities.directory.DirectoryActivity"
             android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
             android:theme="@style/Theme.Threema.WithToolbar"
             android:windowSoftInputMode="adjustResize" />

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

@@ -41,4 +41,5 @@ object AppConstants {
 
     const val THREEMA_SUPPORT_IDENTITY: IdentityString = "*SUPPORT"
     const val THREEMA_CHANNEL_IDENTITY: IdentityString = "*THREEMA"
+    const val THREEMA_WORK_SYNC_IDENTITY: IdentityString = "*3MAW0RK"
 }

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

@@ -3,19 +3,13 @@ package ch.threema.app;
 import android.app.ForegroundServiceStartNotAllowedException;
 import android.content.Context;
 import android.content.Intent;
-import android.content.pm.PackageManager;
-import android.database.ContentObserver;
 import android.os.Handler;
 import android.os.Looper;
-import android.provider.ContactsContract;
 
-import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
 import java.util.Date;
-import java.util.HashSet;
 import java.util.List;
-import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.locks.Lock;
@@ -25,7 +19,7 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.core.content.ContextCompat;
 import ch.threema.android.ToastDuration;
-import ch.threema.app.apptaskexecutor.AppTaskExecutor;
+import ch.threema.app.androidcontactsync.AndroidContactChangeMonitor;
 import ch.threema.app.listeners.BallotVoteListener;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactTypingListener;
@@ -53,7 +47,6 @@ import ch.threema.app.services.MessageService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.services.ballot.BallotService;
 import ch.threema.app.services.notification.NotificationService;
-import ch.threema.app.androidcontactsync.usecases.UpdateContactNameUseCase;
 import ch.threema.app.utils.BallotUtil;
 import ch.threema.app.utils.ConversationNotificationUtil;
 import ch.threema.app.utils.ShortcutUtil;
@@ -72,7 +65,6 @@ import ch.threema.base.ThreemaException;
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
 import ch.threema.data.models.ContactModel;
-import ch.threema.data.models.ContactModelData;
 import ch.threema.data.models.GroupIdentity;
 import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.domain.protocol.csp.messages.file.FileData;
@@ -93,7 +85,6 @@ import ch.threema.storage.models.ballot.IdentityBallotModel;
 import ch.threema.storage.models.ballot.LinkBallotModel;
 import ch.threema.storage.models.data.status.GroupStatusDataModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
-import kotlin.Unit;
 
 import static ch.threema.android.ToastKt.showToast;
 
@@ -102,19 +93,22 @@ public class GlobalListeners {
 
     private static final Logger logger = getThreemaLogger("GlobalListeners");
 
-    private final AppTaskExecutor appTaskExecutor = KoinJavaComponent.get(AppTaskExecutor.class);
-
     public static final Lock onAndroidContactChangeLock = new ReentrantLock();
 
     private final Handler handler = new Handler(Looper.getMainLooper());
 
     private final ExecutorService workerExecutor = Executors.newCachedThreadPool();
 
+    @NonNull
+    private final AndroidContactChangeMonitor androidContactChangeMonitor;
+
     public GlobalListeners(
         @NonNull Context appContext,
+        @NonNull AndroidContactChangeMonitor androidContactChangeMonitor,
         @NonNull ServiceManager serviceManager
     ) {
         this.appContext = appContext;
+        this.androidContactChangeMonitor = androidContactChangeMonitor;
         this.serviceManager = serviceManager;
 
         webClientWakeUpListener = () -> showToast(appContext, R.string.webclient_protocol_version_to_old, ToastDuration.LONG);
@@ -166,21 +160,11 @@ public class GlobalListeners {
     }
 
     private void registerContactNameChangeListener() {
-        if (ContextCompat.checkSelfPermission(appContext,
-            android.Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
-            appContext.getContentResolver()
-                .registerContentObserver(ContactsContract.Contacts.CONTENT_URI,
-                    false,
-                    contentObserverChangeContactNames);
-        }
+        androidContactChangeMonitor.start();
     }
 
     private void unregisterContactNameChangeListener() {
-        if (ContextCompat.checkSelfPermission(appContext,
-            android.Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
-            appContext.getContentResolver()
-                .unregisterContentObserver(contentObserverChangeContactNames);
-        }
+        androidContactChangeMonitor.stop();
     }
 
     private void showNotesGroupNotice(GroupModelOld groupModel, @GroupService.GroupState int oldState, @GroupService.GroupState int newState) {
@@ -786,91 +770,21 @@ public class GlobalListeners {
         }
     };
 
-    @NonNull
-    private final ContentObserver contentObserverChangeContactNames = new ContentObserver(null) {
-        private boolean isRunning = false;
-
-        @Override
-        public boolean deliverSelfNotifications() {
-            return super.deliverSelfNotifications();
-        }
-
-        @Override
-        public void onChange(boolean selfChange) {
-            super.onChange(selfChange);
-
-            logger.info("Contact name change observed");
-
-            if (selfChange || isRunning) {
-                logger.info("Contact name change observer already running");
-                return;
-            }
-
-            try {
-                this.isRunning = true;
-                onAndroidContactChangeLock.lock();
-                logger.info("Starting to update all contact names from android contacts");
-
-                if (serviceManager.getSynchronizeContactsService().isSynchronizationInProgress()) {
-                    logger.warn("Aborting contact name change observer as a contact synchronization is currently in progress");
-                    return;
-                }
-
-                if (!serviceManager.getSynchronizedSettingsService().isSyncContacts()) {
-                    logger.warn("Contact synchronization is not enabled. Aborting.");
-                    return;
-                }
-
-                logger.info("Updating all contact names from android contacts");
-                List<ContactModel> allContactModels = serviceManager.getModelRepositories().getContacts().getAll();
-                Set<ContactModel> linkedContactModels = new HashSet<>();
-                for (ContactModel contactModel : allContactModels) {
-                    ContactModelData contactModelData = contactModel.getData();
-                    if (contactModelData != null && contactModelData.androidContactLookupInfo != null) {
-                        linkedContactModels.add(contactModel);
-                    }
-                }
-                appTaskExecutor.runInAppTask(continuation -> {
-                    // Note that continuation can only be used once to call a suspend function!
-                    // Inject contact name use case here to prevent creating it if contacts never change.
-                    UpdateContactNameUseCase updateContactNameUseCase = KoinJavaComponent.get(UpdateContactNameUseCase.class);
-                    updateContactNameUseCase.call(linkedContactModels, continuation);
-                    logger.info("Finished updating contact names from android contacts");
-                    return Unit.INSTANCE;
-                });
-            } catch (MasterKeyLockedException masterKeyLockedException) {
-                logger.error("Cantact name change observer could not be run successfully", masterKeyLockedException);
-            } finally {
-                this.isRunning = false;
-                onAndroidContactChangeLock.unlock();
-            }
-        }
-    };
-
     @NonNull
     private final SynchronizeContactsListener synchronizeContactsListener = new SynchronizeContactsListener() {
         @Override
         public void onStarted(SynchronizeContactsRoutine startedRoutine) {
-            //disable contact observer
-            appContext.getContentResolver().unregisterContentObserver(contentObserverChangeContactNames);
+            androidContactChangeMonitor.stop();
         }
 
         @Override
         public void onFinished(SynchronizeContactsRoutine finishedRoutine) {
-            //enable contact observer
-            appContext.getContentResolver().registerContentObserver(
-                ContactsContract.Contacts.CONTENT_URI,
-                false,
-                contentObserverChangeContactNames);
+            androidContactChangeMonitor.start();
         }
 
         @Override
         public void onError(SynchronizeContactsRoutine finishedRoutine) {
-            //enable contact observer
-            appContext.getContentResolver().registerContentObserver(
-                ContactsContract.Contacts.CONTENT_URI,
-                false,
-                contentObserverChangeContactNames);
+            androidContactChangeMonitor.start();
         }
     };
 

+ 11 - 6
app/src/main/java/ch/threema/app/ThreemaApplication.kt

@@ -93,11 +93,11 @@ import ch.threema.storage.DatabaseUpdateException
 import ch.threema.storage.SQLDHSessionStore
 import ch.threema.storage.setupDatabaseLogging
 import kotlin.getValue
+import kotlin.system.exitProcess
 import kotlin.time.measureTime
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import org.koin.android.ext.android.inject
@@ -224,12 +224,17 @@ class ThreemaApplication : Application() {
         masterKeyManager: MasterKeyManagerImpl,
     ) = coroutineScope {
         val masterKeyProvider = masterKeyManager.masterKeyProvider
-        while (isActive) {
-            val masterKey = masterKeyProvider.awaitUnlocked()
-            onMasterKeyUnlocked(masterKey)
+        try {
+            while (true) {
+                val masterKey = masterKeyProvider.awaitUnlocked()
+                onMasterKeyUnlocked(masterKey)
 
-            masterKeyProvider.awaitLocked()
-            get<MasterKeyLockStateChangeHandler>().onMasterKeyLocked()
+                masterKeyProvider.awaitLocked()
+                get<MasterKeyLockStateChangeHandler>().onMasterKeyLocked()
+            }
+        } catch (e: Exception) {
+            logger.error("Master key monitoring failed", e)
+            exitProcess(2)
         }
     }
 

+ 4 - 0
app/src/main/java/ch/threema/app/activities/AddContactActivity.java

@@ -82,6 +82,10 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
+        if (dependencies.getIdentityStore().getIdentityString() == null) {
+            finish();
+            return;
+        }
         if (finishAndRestartLaterIfNotReady(this)) {
             return;
         }

+ 4 - 0
app/src/main/java/ch/threema/app/activities/AppLinksActivity.java

@@ -56,6 +56,10 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
+        if (dependencies.getIdentityProvider().getIdentityString() == null) {
+            finish();
+            return;
+        }
         if (finishAndRestartLaterIfNotReady(this)) {
             return;
         }

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

@@ -254,8 +254,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
             return false;
         }
 
-        if (!dependencies.getUserService().hasIdentity() || (ConfigUtils.isSerialLicensed() && !ConfigUtils.isSerialLicenseValid())) {
-            logger.debug("No identity or not licensed");
+        if (ConfigUtils.isSerialLicensed() && !ConfigUtils.isSerialLicenseValid()) {
             finish();
             System.exit(0);
         }
@@ -1373,6 +1372,10 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         logScreenVisibility(this, logger);
+        if (dependencies.getIdentityProvider().getIdentityString() == null) {
+            finish();
+            return;
+        }
 
         // TODO(ANDR-4389): Improve the waiting mechanism
         waitUntilReady(this, () -> {

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

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

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

@@ -44,9 +44,9 @@ import ch.threema.app.R
 import ch.threema.app.compose.common.SpacerVertical
 import ch.threema.app.compose.common.ThemedText
 import ch.threema.app.compose.common.buttons.ButtonIconInfo
-import ch.threema.app.compose.common.buttons.ButtonPrimaryWebsite
 import ch.threema.app.compose.common.buttons.TextButtonNeutral
 import ch.threema.app.compose.common.buttons.TextButtonPrimary
+import ch.threema.app.compose.common.buttons.primary.ButtonPrimaryWebsite
 import ch.threema.app.compose.preview.PreviewThreemaAll
 import ch.threema.app.compose.theme.ThreemaTheme
 import ch.threema.app.compose.theme.ThreemaThemePreview

+ 33 - 26
app/src/main/java/ch/threema/app/activities/DirectoryActivity.java → app/src/main/java/ch/threema/app/activities/directory/DirectoryActivity.java

@@ -1,4 +1,4 @@
-package ch.threema.app.activities;
+package ch.threema.app.activities.directory;
 
 import android.animation.LayoutTransition;
 import android.annotation.SuppressLint;
@@ -23,6 +23,7 @@ import com.google.android.material.search.SearchBar;
 
 import org.json.JSONException;
 import org.json.JSONObject;
+import org.koin.android.compat.ViewModelCompat;
 import org.koin.java.KoinJavaComponent;
 import org.slf4j.Logger;
 
@@ -35,17 +36,21 @@ import androidx.annotation.ColorInt;
 import androidx.annotation.IntDef;
 import androidx.annotation.MainThread;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.appcompat.app.ActionBar;
+import androidx.lifecycle.Lifecycle;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
 import androidx.paging.LivePagedListBuilder;
 import androidx.paging.PagedList;
 import androidx.recyclerview.widget.DefaultItemAnimator;
 import androidx.recyclerview.widget.LinearLayoutManager;
+import ch.threema.android.FlowJavaCompat;
 import ch.threema.app.R;
+import ch.threema.app.activities.ComposeMessageActivity;
+import ch.threema.app.activities.ThreemaToolbarActivity;
 import ch.threema.app.adapters.DirectoryAdapter;
-import ch.threema.app.asynctasks.AddOrUpdateWorkContactBackgroundTask;
 import ch.threema.app.di.DependencyContainer;
 import ch.threema.app.dialogs.MultiChoiceSelectorDialog;
 import ch.threema.app.ui.DirectoryDataSource;
@@ -58,9 +63,9 @@ import ch.threema.app.ui.ViewExtensionsKt;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.executor.BackgroundExecutor;
+
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
-import ch.threema.data.models.ContactModel;
 import ch.threema.domain.protocol.api.work.WorkDirectoryCategory;
 import ch.threema.domain.protocol.api.work.WorkDirectoryContact;
 import ch.threema.domain.protocol.api.work.WorkOrganization;
@@ -91,6 +96,9 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
     private static final int EMPTY_STATE_SEARCHING = 1;
     private static final int EMPTY_STATE_RESULTS = 2;
 
+    @Nullable
+    DirectoryViewModel viewModel;
+
     @NonNull
     private final DependencyContainer dependencies = KoinJavaComponent.get(DependencyContainer.class);
 
@@ -137,6 +145,20 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
         if (!isSessionScopeReady()) {
             finish();
         }
+        viewModel = ViewModelCompat.getViewModel(this, DirectoryViewModel.class);
+        FlowJavaCompat.collect(this, Lifecycle.State.STARTED, viewModel.events, this::onViewModelEvent);
+    }
+
+    private void onViewModelEvent(@NonNull DirectoryScreenEvent directoryScreenEvent) {
+        if (directoryScreenEvent instanceof DirectoryScreenEvent.WorkContactAdded) {
+            final @NonNull DirectoryScreenEvent.WorkContactAdded workContactAddedEvent = (DirectoryScreenEvent.WorkContactAdded) directoryScreenEvent;
+            if (workContactAddedEvent.openOnSuccess) {
+                openContact(workContactAddedEvent.workDirectoryContact.threemaId);
+            }
+            directoryAdapter.notifyItemChanged(workContactAddedEvent.changedAdapterPosition);
+        } else if (directoryScreenEvent instanceof DirectoryScreenEvent.Error) {
+            Toast.makeText(this, R.string.an_error_occurred, Toast.LENGTH_SHORT).show();
+        }
     }
 
     @Override
@@ -254,8 +276,10 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
             }
 
             @Override
-            public void onAdd(WorkDirectoryContact workDirectoryContact, final int position) {
-                addContact(workDirectoryContact, () -> directoryAdapter.notifyItemChanged(position));
+            public void onAdd(@NonNull WorkDirectoryContact workDirectoryContact, final int position) {
+                if (viewModel != null) {
+                    viewModel.addContact(workDirectoryContact, position, false);
+                }
             }
         });
 
@@ -388,7 +412,7 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
         return super.onOptionsItemSelected(item);
     }
 
-    private void openContact(String identity) {
+    private void openContact(@NonNull String identity) {
         Intent intent = new Intent(this, ComposeMessageActivity.class);
         intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
         intent.setData((Uri.parse("foobar://" + SystemClock.elapsedRealtime())));
@@ -398,10 +422,9 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 
     private void launchContact(@NonNull final WorkDirectoryContact workDirectoryContact, final int position) {
         if (dependencies.getContactService().getByIdentity(workDirectoryContact.threemaId) == null) {
-            addContact(workDirectoryContact, () -> {
-                openContact(workDirectoryContact.threemaId);
-                directoryAdapter.notifyItemChanged(position);
-            });
+            if (viewModel != null) {
+                viewModel.addContact(workDirectoryContact, position, true);
+            }
         } else if (workDirectoryContact.threemaId.equalsIgnoreCase(dependencies.getUserService().getIdentity())) {
             Toast.makeText(this, R.string.me_myself_and_i, Toast.LENGTH_LONG).show();
         } else {
@@ -409,22 +432,6 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
         }
     }
 
-    private void addContact(final WorkDirectoryContact workDirectoryContact, Runnable runAfter) {
-        logger.info("Add new work contact");
-        backgroundExecutor.getValue().execute(
-            new AddOrUpdateWorkContactBackgroundTask(
-                workDirectoryContact,
-                dependencies.getUserService().getIdentity(),
-                dependencies.getContactModelRepository()
-            ) {
-                @Override
-                public void runAfter(ContactModel contactModel) {
-                    runAfter.run();
-                }
-            }
-        );
-    }
-
     @NonNull
     private DirectoryHeaderItemDecoration.HeaderCallback getSectionCallback() {
         return new DirectoryHeaderItemDecoration.HeaderCallback() {

+ 8 - 0
app/src/main/java/ch/threema/app/activities/directory/DirectoryFeatureModule.kt

@@ -0,0 +1,8 @@
+package ch.threema.app.activities.directory
+
+import org.koin.core.module.dsl.viewModelOf
+import org.koin.dsl.module
+
+val directoryFeatureModule = module {
+    viewModelOf(::DirectoryViewModel)
+}

+ 17 - 0
app/src/main/java/ch/threema/app/activities/directory/DirectoryScreenEvent.kt

@@ -0,0 +1,17 @@
+package ch.threema.app.activities.directory
+
+import ch.threema.domain.protocol.api.work.WorkDirectoryContact
+
+sealed interface DirectoryScreenEvent {
+
+    data class WorkContactAdded(
+        @JvmField
+        val workDirectoryContact: WorkDirectoryContact,
+        @JvmField
+        val changedAdapterPosition: Int,
+        @JvmField
+        val openOnSuccess: Boolean,
+    ) : DirectoryScreenEvent
+
+    data object Error : DirectoryScreenEvent
+}

+ 59 - 0
app/src/main/java/ch/threema/app/activities/directory/DirectoryViewModel.kt

@@ -0,0 +1,59 @@
+package ch.threema.app.activities.directory
+
+import ch.threema.app.asynctasks.AddOrUpdateWorkContactBackgroundTask
+import ch.threema.app.framework.BaseViewModel
+import ch.threema.app.stores.IdentityProvider
+import ch.threema.app.utils.DispatcherProvider
+import ch.threema.base.utils.getThreemaLogger
+import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.domain.protocol.api.work.WorkDirectoryContact
+import kotlinx.coroutines.withContext
+
+private val logger = getThreemaLogger("DirectoryViewModel")
+
+class DirectoryViewModel(
+    private val dispatcherProvider: DispatcherProvider,
+    private val identityProvider: IdentityProvider,
+    private val contactModelRepository: ContactModelRepository,
+) : BaseViewModel<Unit, DirectoryScreenEvent>() {
+
+    override fun initialize() = runInitialization {}
+
+    fun addContact(
+        workDirectoryContact: WorkDirectoryContact,
+        changedAdapterPosition: Int,
+        openOnSuccess: Boolean,
+    ) = runAction {
+        logger.info("Add new work contact")
+        val contactAdded: Boolean = addContact(workDirectoryContact)
+        emitEvent(
+            if (contactAdded) {
+                DirectoryScreenEvent.WorkContactAdded(
+                    workDirectoryContact = workDirectoryContact,
+                    changedAdapterPosition = changedAdapterPosition,
+                    openOnSuccess = openOnSuccess,
+                )
+            } else {
+                DirectoryScreenEvent.Error
+            },
+        )
+    }
+
+    private suspend fun ViewModelActionScope<Unit, DirectoryScreenEvent>.addContact(workDirectoryContact: WorkDirectoryContact): Boolean {
+        val myIdentity = identityProvider.getIdentity()
+            ?: run {
+                logger.error("Can not add new work contact, as the user's identity is missing")
+                emitEvent(DirectoryScreenEvent.Error)
+                endAction()
+            }
+        val createContactTask = AddOrUpdateWorkContactBackgroundTask(
+            workContact = workDirectoryContact,
+            myIdentity = myIdentity.value,
+            contactModelRepository = contactModelRepository,
+        )
+        val contactModel = withContext(dispatcherProvider.io) {
+            createContactTask.runSynchronously()
+        }
+        return contactModel != null
+    }
+}

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

@@ -53,8 +53,8 @@ import ch.threema.app.compose.common.DynamicSpacerSize3
 import ch.threema.app.compose.common.SpacerHorizontal
 import ch.threema.app.compose.common.SpacerVertical
 import ch.threema.app.compose.common.ThemedText
-import ch.threema.app.compose.common.buttons.ButtonPrimaryOverride
 import ch.threema.app.compose.common.buttons.TextButtonPrimaryOverride
+import ch.threema.app.compose.common.buttons.primary.ButtonPrimaryOverride
 import ch.threema.app.compose.theme.ThreemaTheme
 import ch.threema.app.compose.theme.ThreemaThemePreview
 import ch.threema.app.compose.theme.dimens.GridUnit

+ 4 - 3
app/src/main/java/ch/threema/app/activities/starred/StarredMessageListItem.kt

@@ -55,10 +55,10 @@ import ch.threema.app.compose.common.SpacerHorizontal
 import ch.threema.app.compose.common.ThemedText
 import ch.threema.app.compose.common.avatar.AvatarAsync
 import ch.threema.app.compose.common.colorReferenceResource
+import ch.threema.app.compose.common.extensions.get
 import ch.threema.app.compose.common.immutables.ImmutableBitmap
 import ch.threema.app.compose.common.text.conversation.ConversationText
 import ch.threema.app.compose.common.text.conversation.EmojiSettings
-import ch.threema.app.compose.common.text.conversation.EmojiUpscalingFeature
 import ch.threema.app.compose.common.text.conversation.HighlightFeature
 import ch.threema.app.compose.common.text.conversation.MentionFeature
 import ch.threema.app.compose.preview.PreviewData
@@ -73,6 +73,7 @@ import ch.threema.app.utils.MimeUtil
 import ch.threema.app.utils.NameUtil
 import ch.threema.common.minus
 import ch.threema.common.now
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.datatypes.ContactNameFormat
 import ch.threema.domain.models.ContactReceiverIdentifier
 import ch.threema.domain.models.GroupReceiverIdentifier
@@ -179,6 +180,7 @@ private fun AvatarContent(
                 is StarredMessageUiModel.StarredContactMessage -> starredMessageUiModel.showWorkBadge
                 else -> false
             },
+            availabilityStatus = AvailabilityStatus.None, // TODO(ANDR-4714): Evaluate to set existing value
         )
     } else {
         Box(
@@ -277,7 +279,7 @@ private fun RowScope.MessageBubbleContent(
 
         if (!starredMessageUiModel.messageModel.isDeleted) {
             ConversationText(
-                rawInput = starredMessageUiModel.messageContent?.get(LocalContext.current) ?: "",
+                rawInput = starredMessageUiModel.messageContent?.get() ?: "",
                 textStyle = MaterialTheme.typography.bodyMedium,
                 mentionFeature = MentionFeature.On(
                     ownIdentity = ownIdentity,
@@ -294,7 +296,6 @@ private fun RowScope.MessageBubbleContent(
                 ),
                 emojiSettings = EmojiSettings(
                     style = emojiStyle,
-                    upscalingFeature = EmojiUpscalingFeature.Off,
                 ),
                 markupEnabled = true,
                 maxLines = 4,

+ 5 - 5
app/src/main/java/ch/threema/app/activities/wizard/components/WizardButton.kt

@@ -133,7 +133,7 @@ fun WizardButton(
                 alpha = if (isEnabled) {
                     AlphaValues.FULLY_OPAQUE
                 } else {
-                    AlphaValues.DISABLED_ON_CONTAINER
+                    AlphaValues.DISABLED
                 },
             ),
             maxLines = 1,
@@ -150,7 +150,7 @@ fun WizardButton(
                     alpha = if (isEnabled) {
                         AlphaValues.FULLY_OPAQUE
                     } else {
-                        AlphaValues.DISABLED_ON_CONTAINER
+                        AlphaValues.DISABLED
                     },
                 ),
             )
@@ -172,7 +172,7 @@ private fun buildBorderStroke(
     !isEnabled -> BorderStroke(
         width = BORDER_WIDTH.dp,
         color = colorResource(R.color.md_theme_dark_primary).copy(
-            alpha = AlphaValues.DISABLED_CONTAINER,
+            alpha = AlphaValues.DISABLED,
         ),
     )
 
@@ -191,10 +191,10 @@ private fun buildWizardButtonColors(
         containerColor = containerColor,
         contentColor = contentColor,
         disabledContainerColor = containerColor.copy(
-            alpha = AlphaValues.DISABLED_CONTAINER,
+            alpha = AlphaValues.DISABLED,
         ),
         disabledContentColor = contentColor.copy(
-            alpha = AlphaValues.DISABLED_ON_CONTAINER,
+            alpha = AlphaValues.DISABLED,
         ),
     )
 }

+ 88 - 45
app/src/main/java/ch/threema/app/adapters/DirectoryAdapter.java

@@ -5,6 +5,7 @@ import android.util.TypedValue;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
+import android.widget.ImageView;
 import android.widget.TextView;
 
 import com.google.android.material.chip.Chip;
@@ -14,16 +15,20 @@ import java.util.List;
 
 import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
-import androidx.appcompat.widget.AppCompatImageView;
+import androidx.annotation.Nullable;
 import androidx.paging.PagedListAdapter;
 import androidx.recyclerview.widget.DiffUtil;
 import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
+import ch.threema.app.availabilitystatus.AvailabilityStatusExtensionsKt;
+import ch.threema.app.availabilitystatus.AvailabilityStatusIconElevatedView;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.preference.service.PreferenceService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.ui.InitialAvatarView;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.data.datatypes.AvailabilityStatus;
 import ch.threema.data.datatypes.ContactNameFormat;
 import ch.threema.domain.protocol.api.work.WorkDirectoryCategory;
 import ch.threema.domain.protocol.api.work.WorkDirectoryContact;
@@ -48,8 +53,9 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
     private static class DirectoryHolder extends RecyclerView.ViewHolder {
         private final TextView nameView;
         private final TextView identityView;
-        private final AppCompatImageView statusImageView;
+        private final ImageView statusImageView;
         private final InitialAvatarView avatarView;
+        private final AvailabilityStatusIconElevatedView availabilityStatusIconElevatedView;
         private final TextView categoriesView;
         private final Chip organizationView;
         protected WorkDirectoryContact contact;
@@ -61,6 +67,7 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
             this.identityView = itemView.findViewById(R.id.identity);
             this.statusImageView = itemView.findViewById(R.id.status);
             this.avatarView = itemView.findViewById(R.id.avatar_view);
+            this.availabilityStatusIconElevatedView = itemView.findViewById(R.id.availability_status_avatar_icon);
             this.categoriesView = itemView.findViewById(R.id.categories);
             this.organizationView = itemView.findViewById(R.id.organization);
         }
@@ -98,12 +105,11 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
     public interface OnClickItemListener {
         void onClick(WorkDirectoryContact workDirectoryContact, int position);
 
-        void onAdd(WorkDirectoryContact workDirectoryContact, int position);
+        void onAdd(@NonNull WorkDirectoryContact workDirectoryContact, int position);
     }
 
-    public DirectoryAdapter setOnClickItemListener(DirectoryAdapter.OnClickItemListener onClickItemListener) {
+    public void setOnClickItemListener(DirectoryAdapter.OnClickItemListener onClickItemListener) {
         this.onClickItemListener = onClickItemListener;
-        return this;
     }
 
     @NonNull
@@ -116,31 +122,27 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
 
     @Override
     public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
-        boolean isMe;
-        final DirectoryHolder holder = (DirectoryHolder) viewHolder;
-
-        final WorkDirectoryContact workDirectoryContact = this.getItem(position);
 
+        final @NonNull DirectoryHolder directoryHolder = (DirectoryHolder) viewHolder;
+        final @Nullable WorkDirectoryContact workDirectoryContact = this.getItem(position);
         if (workDirectoryContact == null) {
             return;
         }
 
-        holder.contact = workDirectoryContact;
-        isMe = holder.contact.threemaId.equals(userService.getIdentity());
-
-        if (this.onClickItemListener != null) {
-            if (!isMe) {
-                holder.statusImageView.setOnClickListener(new View.OnClickListener() {
-                    @Override
-                    public void onClick(View v) {
-                        onClickItemListener.onAdd(holder.contact, viewHolder.getAdapterPosition());
-                    }
-                });
-                holder.itemView.setOnClickListener(v -> onClickItemListener.onClick(holder.contact, viewHolder.getAdapterPosition()));
-            }
+        directoryHolder.contact = workDirectoryContact;
+
+        final boolean isMe = directoryHolder.contact.threemaId.equals(userService.getIdentity());
+
+        if (this.onClickItemListener != null && !isMe) {
+            directoryHolder.statusImageView.setOnClickListener(
+                v -> onClickItemListener.onAdd(directoryHolder.contact, viewHolder.getBindingAdapterPosition())
+            );
+            directoryHolder.itemView.setOnClickListener(
+                v -> onClickItemListener.onClick(directoryHolder.contact, viewHolder.getBindingAdapterPosition())
+            );
         }
 
-        String name;
+        @NonNull String name;
         if (preferenceService.getContactNameFormat() == ContactNameFormat.FIRSTNAME_LASTNAME) {
             name = (workDirectoryContact.firstName != null ? workDirectoryContact.firstName + " " : "") +
                 (workDirectoryContact.lastName != null ? workDirectoryContact.lastName : "");
@@ -148,14 +150,13 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
             name = (workDirectoryContact.lastName != null ? workDirectoryContact.lastName + " " : "") +
                 (workDirectoryContact.firstName != null ? workDirectoryContact.firstName : "");
         }
-
         if (!TestUtil.isEmptyOrNull(workDirectoryContact.csi)) {
             name += " " + workDirectoryContact.csi;
         }
 
-        holder.nameView.setText(name);
+        directoryHolder.nameView.setText(name);
 
-        StringBuilder categoriesBuilder = new StringBuilder("");
+        StringBuilder categoriesBuilder = new StringBuilder();
         if (!workDirectoryContact.categoryIds.isEmpty()) {
             int count = 0;
             for (String categoryId : workDirectoryContact.categoryIds) {
@@ -166,38 +167,80 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
                 count++;
             }
         }
-        holder.categoriesView.setText(categoriesBuilder.toString());
-        holder.avatarView.setInitials(workDirectoryContact.firstName, workDirectoryContact.lastName);
-        holder.identityView.setText(workDirectoryContact.threemaId);
-
-        if (workDirectoryContact.organization != null &&
-            workDirectoryContact.organization.getName() != null &&
-            !workDirectoryContact.organization.getName().equals(workOrganization.getName())) {
-            holder.organizationView.setText(workDirectoryContact.organization.getName());
-            holder.organizationView.setVisibility(View.VISIBLE);
+        directoryHolder.categoriesView.setText(categoriesBuilder.toString());
+
+        directoryHolder.avatarView.setInitials(workDirectoryContact.firstName, workDirectoryContact.lastName);
+
+        bindAvailabilityStatusIcon(directoryHolder, workDirectoryContact);
+
+        directoryHolder.identityView.setText(workDirectoryContact.threemaId);
+
+        if (workDirectoryContact.organization.getName() != null && !workDirectoryContact.organization.getName().equals(workOrganization.getName())) {
+            directoryHolder.organizationView.setText(workDirectoryContact.organization.getName());
+            directoryHolder.organizationView.setVisibility(View.VISIBLE);
         } else {
-            holder.organizationView.setVisibility(View.GONE);
+            directoryHolder.organizationView.setVisibility(View.GONE);
         }
 
-        boolean isAddedContact = contactService.getByIdentity(workDirectoryContact.threemaId) != null;
+        final boolean isAddedContact = contactService.getByIdentity(workDirectoryContact.threemaId) != null;
+
+        directoryHolder.statusImageView.setBackgroundResource(isAddedContact ? 0 : this.backgroundRes);
+        directoryHolder.statusImageView.setImageResource(
+            isMe
+                ? R.drawable.ic_person_outline
+                : (isAddedContact ? R.drawable.ic_keyboard_arrow_right_black_24dp : R.drawable.ic_add_circle_outline_black_24dp)
+        );
+        directoryHolder.statusImageView.setContentDescription(
+            context.getString(
+                isMe
+                    ? R.string.me_myself_and_i
+                    : (isAddedContact ? R.string.title_compose_message : R.string.menu_add_contact)
+            )
+        );
+        directoryHolder.statusImageView.setClickable(!isAddedContact && !isMe);
+        directoryHolder.statusImageView.setFocusable(!isAddedContact && !isMe);
+    }
+
+    private void bindAvailabilityStatusIcon(@NonNull DirectoryHolder holder, @NonNull WorkDirectoryContact workDirectoryContact) {
+        if (!ConfigUtils.supportsAvailabilityStatus()) {
+            return;
+        }
 
-        holder.statusImageView.setBackgroundResource(isAddedContact ? 0 : this.backgroundRes);
-        holder.statusImageView.setImageResource(isMe ? R.drawable.ic_person_outline : (isAddedContact ? R.drawable.ic_keyboard_arrow_right_black_24dp : R.drawable.ic_add_circle_outline_black_24dp));
-        holder.statusImageView.setContentDescription(context.getString(isMe ? R.string.me_myself_and_i : (isAddedContact ? R.string.title_compose_message : R.string.menu_add_contact)));
-        holder.statusImageView.setClickable(!isAddedContact && !isMe);
-        holder.statusImageView.setFocusable(!isAddedContact && !isMe);
+        /*
+         * The work directory will miss the "availability" JSON response key entirely, if the status was set to "None" by the work user. This is why
+         * in this case we need to map "null" to "None".
+         */
+        final @Nullable AvailabilityStatus availabilityStatus = (workDirectoryContact.availability != null)
+            ? AvailabilityStatus.fromProtocolBase64(workDirectoryContact.availability)
+            : AvailabilityStatus.None.INSTANCE;
+
+        holder.availabilityStatusIconElevatedView.setVisibility(
+            availabilityStatus instanceof AvailabilityStatus.Set
+                ? View.VISIBLE
+                : View.GONE
+        );
+        holder.availabilityStatusIconElevatedView.setStatus(
+            availabilityStatus instanceof AvailabilityStatus.Set
+                ? (AvailabilityStatus.Set) availabilityStatus
+                : null
+        );
+        holder.availabilityStatusIconElevatedView.setContentDescription(
+            availabilityStatus instanceof AvailabilityStatus.Set
+                ? context.getString(AvailabilityStatusExtensionsKt.displayNameRes(availabilityStatus))
+                : null
+        );
     }
 
     private static final DiffUtil.ItemCallback<WorkDirectoryContact> DIFF_CALLBACK =
-        new DiffUtil.ItemCallback<WorkDirectoryContact>() {
+        new DiffUtil.ItemCallback<>() {
             @Override
             public boolean areItemsTheSame(@NonNull WorkDirectoryContact oldItem, @NonNull WorkDirectoryContact newItem) {
-                return oldItem.threemaId != null && oldItem.threemaId.equals(newItem.threemaId);
+                return oldItem.threemaId.equals(newItem.threemaId);
             }
 
             @Override
             public boolean areContentsTheSame(@NonNull WorkDirectoryContact oldItem, @NonNull WorkDirectoryContact newItem) {
-                return oldItem.threemaId != null && oldItem.threemaId.equals(newItem.threemaId);
+                return oldItem.threemaId.equals(newItem.threemaId);
             }
         };
 }

+ 133 - 0
app/src/main/java/ch/threema/app/androidcontactsync/AndroidContactChangeMonitor.kt

@@ -0,0 +1,133 @@
+package ch.threema.app.androidcontactsync
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.database.ContentObserver
+import android.provider.ContactsContract
+import androidx.core.content.ContextCompat
+import ch.threema.app.GlobalListeners
+import ch.threema.app.androidcontactsync.usecases.UpdateContactNameUseCase
+import ch.threema.app.di.injectNonBinding
+import ch.threema.app.preference.service.SynchronizedSettingsService
+import ch.threema.app.services.SynchronizeContactsService
+import ch.threema.base.utils.getThreemaLogger
+import ch.threema.common.DispatcherProvider
+import ch.threema.data.repositories.ContactModelRepository
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import org.koin.core.component.KoinComponent
+
+private val logger = getThreemaLogger("AndroidContactChangeMonitor")
+
+// TODO(ANDR-4752): Convert this to a proper monitor.
+class AndroidContactChangeMonitor(
+    private val appContext: Context,
+    private val updateContactNameUseCase: UpdateContactNameUseCase,
+    dispatcherProvider: DispatcherProvider,
+) : KoinComponent {
+    private val synchronizeContactsService: SynchronizeContactsService by injectNonBinding()
+    private val synchronizedSettingsService: SynchronizedSettingsService by injectNonBinding()
+    private val contactModelRepository: ContactModelRepository by injectNonBinding()
+
+    private val coroutineScope = CoroutineScope(dispatcherProvider.io)
+    private val mutex = Mutex()
+
+    private val contentObserverChangeContactNames: ContentObserver = object : ContentObserver(null) {
+        override fun onChange(selfChange: Boolean) {
+            if (selfChange) {
+                return
+            }
+
+            coroutineScope.launch {
+                if (!mutex.tryLock()) {
+                    return@launch
+                }
+                try {
+                    delay(initialDelayDuration)
+                    onContactChange()
+                    delay(cooldownDuration)
+                } finally {
+                    mutex.unlock()
+                }
+            }
+        }
+
+        private suspend fun onContactChange() {
+            logger.info("Contact name change observed")
+
+            try {
+                GlobalListeners.onAndroidContactChangeLock.lock()
+                logger.info("Starting to update all contact names from android contacts")
+
+                if (synchronizeContactsService.isSynchronizationInProgress()) {
+                    logger.warn("Aborting contact name change observer as a contact synchronization is currently in progress")
+                    return
+                }
+
+                if (!synchronizedSettingsService.isSyncContacts()) {
+                    logger.warn("Contact synchronization is not enabled. Aborting.")
+                    return
+                }
+
+                logger.info("Updating all contact names from android contacts")
+                val contactModels = contactModelRepository
+                    .getAll()
+                    .filter { contactModel -> contactModel.data?.androidContactLookupInfo != null }
+                    .toSet()
+                updateContactNameUseCase.call(contactModels)
+                logger.info("Finished updating contact names from android contacts")
+            } catch (e: Exception) {
+                logger.error("Contact name change observer could not be run successfully", e)
+            } finally {
+                GlobalListeners.onAndroidContactChangeLock.unlock()
+            }
+        }
+    }
+
+    fun start() {
+        if (ContextCompat.checkSelfPermission(
+                appContext,
+                Manifest.permission.READ_CONTACTS,
+            ) != PackageManager.PERMISSION_GRANTED
+        ) {
+            return
+        }
+
+        try {
+            appContext.contentResolver
+                .registerContentObserver(
+                    ContactsContract.Contacts.CONTENT_URI,
+                    false,
+                    contentObserverChangeContactNames,
+                )
+        } catch (e: Exception) {
+            logger.error("Could not register content observer", e)
+        }
+    }
+
+    fun stop() {
+        try {
+            appContext.contentResolver
+                .unregisterContentObserver(contentObserverChangeContactNames)
+        } catch (e: Exception) {
+            logger.error("Could not unregister content observer", e)
+        }
+    }
+
+    companion object {
+        /**
+         * Before starting to update the contact names after a contact change has been observed, we wait for the [initialDelayDuration]. On some
+         * devices the content observer is triggered before the actual change is readable.
+         */
+        private val initialDelayDuration = 5.seconds
+
+        /**
+         * The cooldown duration is used to prevent querying contact name changes too often in case the contacts are modified frequently.
+         */
+        private val cooldownDuration = 10.seconds
+    }
+}

+ 1 - 0
app/src/main/java/ch/threema/app/androidcontactsync/AndroidContactFeatureModule.kt

@@ -11,6 +11,7 @@ import org.koin.core.module.dsl.singleOf
 import org.koin.dsl.module
 
 val androidContactFeatureModule = module {
+    singleOf(::AndroidContactChangeMonitor)
     singleOf(::AndroidContactReader)
     singleOf(::GetAndroidContactNameUseCase)
     singleOf(::GetRawContactNameUseCase)

+ 1 - 1
app/src/main/java/ch/threema/app/apptaskexecutor/tasks/RemoteSecretDeleteStepsTask.kt

@@ -69,7 +69,7 @@ class RemoteSecretDeleteStepsTask(
     private fun getRemoteSecretClientParameters(): RemoteSecretClientParameters? {
         return RemoteSecretClientParameters(
             workServerBaseUrl = serverAddressProvider
-                .getWorkServerUrl(preferenceService.isIpv6Preferred())
+                .getWorkServerUrlLegacy(preferenceService.isIpv6Preferred())
                 ?: return null,
             userIdentity = identityProvider.getIdentity()
                 ?: return null,

+ 2 - 0
app/src/main/java/ch/threema/app/archive/ArchiveActivity.kt

@@ -76,6 +76,7 @@ import ch.threema.app.usecases.conversations.AvatarIteration
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.voip.activities.GroupCallActivity
 import ch.threema.common.consume
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.datatypes.ContactNameFormat
 import ch.threema.domain.models.ContactReceiverIdentifier
 import ch.threema.domain.models.DistributionListReceiverIdentifier
@@ -474,6 +475,7 @@ private fun ArchiveActivityContent_Preview() {
                             showWorkBadge = true,
                             isTyping = false,
                             avatarIteration = AvatarIteration.initial,
+                            availabilityStatus = AvailabilityStatus.Unavailable(),
                         ),
                         isChecked = false,
                         isHighlighted = false,

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

@@ -23,6 +23,7 @@ import ch.threema.app.services.GroupService
 import ch.threema.app.services.RingtoneService
 import ch.threema.app.services.UserService
 import ch.threema.app.usecases.WatchTypingIdentitiesUseCase
+import ch.threema.app.usecases.availabilitystatus.WatchAllContactAvailabilityStatusesUseCase
 import ch.threema.app.usecases.avatar.GetAndPrepareAvatarUseCase
 import ch.threema.app.usecases.contacts.WatchAllMentionNamesUseCase
 import ch.threema.app.usecases.contacts.WatchContactNameFormatSettingUseCase
@@ -78,6 +79,7 @@ class ArchiveViewModel(
     private val watchAvatarIterationsUseCase: WatchAvatarIterationsUseCase,
     private val watchContactNameFormatSettingUseCase: WatchContactNameFormatSettingUseCase,
     private val watchAllMentionNamesUseCase: WatchAllMentionNamesUseCase,
+    private val watchAllContactAvailabilityStatusesUseCase: WatchAllContactAvailabilityStatusesUseCase,
 ) : ViewModel() {
 
     private val watchArchivedConversationListItemsUseCase = WatchArchivedConversationListItemsUseCase(
@@ -92,6 +94,7 @@ class ArchiveViewModel(
         watchAvatarIterationsUseCase = watchAvatarIterationsUseCase,
         watchContactNameFormatSettingUseCase = watchContactNameFormatSettingUseCase,
         watchAllMentionNamesUseCase = watchAllMentionNamesUseCase,
+        watchAllContactAvailabilityStatusesUseCase = watchAllContactAvailabilityStatusesUseCase,
         draftManager = draftManager,
     )
 

+ 4 - 1
app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateContactBackgroundTask.kt

@@ -9,6 +9,7 @@ import ch.threema.base.ThreemaException
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.common.Http
 import ch.threema.common.now
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.datatypes.IdColor
 import ch.threema.data.models.ContactModel
 import ch.threema.data.models.ContactModelData
@@ -174,7 +175,7 @@ abstract class AddOrUpdateContactBackgroundTask<T>(
         return runBlocking {
             try {
                 val contactModel = contactModelRepository.createFromLocal(
-                    ContactModelData(
+                    contactModelData = ContactModelData(
                         identity = result.identity,
                         publicKey = result.publicKey,
                         createdAt = now(),
@@ -199,6 +200,8 @@ abstract class AddOrUpdateContactBackgroundTask<T>(
                         jobTitle = null,
                         department = null,
                         notificationTriggerPolicyOverride = null,
+                        availabilityStatus = AvailabilityStatus.None,
+                        workLastFullSyncAt = null,
                     ),
                 )
                 ContactCreated(contactModel)

+ 29 - 75
app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateWorkContactBackgroundTask.kt

@@ -1,12 +1,12 @@
 package ch.threema.app.asynctasks
 
 import androidx.annotation.WorkerThread
-import ch.threema.app.services.license.LicenseService
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.executor.BackgroundTask
 import ch.threema.base.crypto.NaCl
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.common.now
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.datatypes.IdColor
 import ch.threema.data.models.ContactModel
 import ch.threema.data.models.ContactModelData
@@ -17,10 +17,8 @@ import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
 import ch.threema.domain.models.ReadReceiptPolicy
 import ch.threema.domain.models.TypingIndicatorPolicy
-import ch.threema.domain.models.UserCredentials
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
-import ch.threema.domain.protocol.api.APIConnector
 import ch.threema.domain.protocol.api.work.WorkContact
 import ch.threema.domain.types.IdentityString
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
@@ -34,7 +32,7 @@ private val logger = getThreemaLogger("AddOrUpdateWorkContactBackgroundTask")
  *
  * This task does not do anything if it isn't a work build.
  */
-open class AddOrUpdateWorkContactBackgroundTask(
+class AddOrUpdateWorkContactBackgroundTask(
     /**
      * The work contact information of the contact that will be added or updated.
      */
@@ -57,10 +55,13 @@ open class AddOrUpdateWorkContactBackgroundTask(
     fun runSynchronously(): ContactModel? {
         runBefore()
 
-        runInBackground().let {
-            runAfter(it)
-            return it
-        }
+        runInBackground()
+            .let { contactModel: ContactModel? ->
+                runAfter(
+                    result = contactModel,
+                )
+                return contactModel
+            }
     }
 
     @WorkerThread
@@ -104,7 +105,7 @@ open class AddOrUpdateWorkContactBackgroundTask(
         return runBlocking {
             try {
                 contactModelRepository.createFromLocal(
-                    ContactModelData(
+                    contactModelData = ContactModelData(
                         identity = workContact.threemaId,
                         publicKey = workContact.publicKey,
                         createdAt = now(),
@@ -132,6 +133,8 @@ open class AddOrUpdateWorkContactBackgroundTask(
                         jobTitle = workContact.jobTitle,
                         department = workContact.department,
                         notificationTriggerPolicyOverride = null,
+                        availabilityStatus = workContact.getAvailabilityStatusOrNone(),
+                        workLastFullSyncAt = workContact.workLastFullSyncAt,
                     ),
                 )
             } catch (e: ContactCreateException) {
@@ -141,6 +144,7 @@ open class AddOrUpdateWorkContactBackgroundTask(
         }
     }
 
+    @WorkerThread
     private fun updateContact(contactModel: ContactModel) {
         logger.info("Updating work contact {}", contactModel.identity)
 
@@ -177,75 +181,25 @@ open class AddOrUpdateWorkContactBackgroundTask(
         if (currentContactModelData.verificationLevel == VerificationLevel.UNVERIFIED) {
             contactModel.setVerificationLevelFromLocal(VerificationLevel.SERVER_VERIFIED)
         }
-    }
-}
-
-/**
- * This task fetches the information whether the given identity is a work identity. If it is, then
- * a new work contact is created or the existing contact is updated.
- *
- * This task does not do anything if it is not a work build.
- */
-class AddOrUpdateWorkIdentityBackgroundTask(
-    private val identity: IdentityString,
-    private val myIdentity: IdentityString,
-    private val licenseService: LicenseService<*>,
-    private val apiConnector: APIConnector,
-    private val contactModelRepository: ContactModelRepository,
-) : BackgroundTask<ContactModel?> {
-    /**
-     * Add the work contact if the identity belongs to a work contact.
-     *
-     * @return the newly inserted contact model or null if it could not be inserted
-     */
-    @WorkerThread
-    fun runSynchronously(): ContactModel? {
-        runBefore()
-
-        runInBackground().let {
-            runAfter(it)
-            return it
-        }
-    }
 
-    @WorkerThread
-    override fun runInBackground(): ContactModel? {
-        if (!ConfigUtils.isWorkBuild()) {
-            logger.error("Cannot fetch work contact in non-work builds")
-            return null
-        }
-
-        val credentials = licenseService.loadCredentials()
-
-        if (credentials !is UserCredentials) {
-            logger.error("No user credentials available")
-            return null
-        }
-
-        logger.info("Fetching contact with identity {} from work server", identity)
-
-        val workContact = apiConnector.fetchWorkContacts(
-            credentials.username,
-            credentials.password,
-            arrayOf(identity),
-        ).firstOrNull() ?: run {
-            logger.info("Identity {} is not a work contact", identity)
-            return null
+        // Update the workLastFullSyncAt timestamp
+        workContact.workLastFullSyncAt?.let { workLastFullSyncAt ->
+            if (currentContactModelData.workLastFullSyncAt != workContact.workLastFullSyncAt) {
+                contactModel.setWorkLastFullSyncFromLocal(workLastFullSyncAt)
+            }
         }
 
-        if (workContact.threemaId != identity) {
-            logger.error(
-                "Received different identity from server: {} instead of {}",
-                workContact.threemaId,
-                identity,
-            )
-            return null
+        // Update availability status
+        if (ConfigUtils.supportsAvailabilityStatus()) {
+            val workContactAvailabilityStatus = workContact.getAvailabilityStatusOrNone()
+            if (currentContactModelData.availabilityStatus != workContactAvailabilityStatus) {
+                contactModel.setAvailabilityStatusFromLocal(workContactAvailabilityStatus)
+            }
         }
-
-        return AddOrUpdateWorkContactBackgroundTask(
-            workContact,
-            myIdentity,
-            contactModelRepository,
-        ).runSynchronously()
     }
+
+    private fun WorkContact.getAvailabilityStatusOrNone(): AvailabilityStatus =
+        availability
+            ?.let(AvailabilityStatus::fromProtocolBase64)
+            ?: AvailabilityStatus.None
 }

+ 109 - 0
app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusContactBannerView.kt

@@ -0,0 +1,109 @@
+package ch.threema.app.availabilitystatus
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.AbstractComposeView
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import ch.threema.app.R
+import ch.threema.app.compose.common.extensions.get
+import ch.threema.app.compose.common.text.conversation.ConversationText
+import ch.threema.app.compose.common.text.conversation.EmojiSettings
+import ch.threema.app.compose.common.text.conversation.MentionFeature
+import ch.threema.app.compose.theme.ThreemaTheme
+import ch.threema.app.compose.theme.dimens.GridUnit
+import ch.threema.app.preference.service.PreferenceService
+import ch.threema.app.preference.service.PreferenceService.EmojiStyle
+import ch.threema.data.datatypes.AvailabilityStatus
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class AvailabilityStatusContactBannerView
+@JvmOverloads
+constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0,
+) : AbstractComposeView(context, attrs, defStyleAttr) {
+
+    private val state = MutableStateFlow(
+        AvailabilityStatusContactBannerState(
+            availabilityStatusSet = null,
+            emojiStyle = PreferenceService.EMOJI_STYLE_ANDROID,
+        ),
+    )
+
+    fun setState(availabilityStatusSet: AvailabilityStatus.Set?, @EmojiStyle emojiStyle: Int) {
+        state.value = AvailabilityStatusContactBannerState(
+            availabilityStatusSet = availabilityStatusSet,
+            emojiStyle = emojiStyle,
+        )
+    }
+
+    @Composable
+    override fun Content() {
+        val state: AvailabilityStatusContactBannerState by state.collectAsStateWithLifecycle()
+        state.availabilityStatusSet?.let { availabilityStatusSet ->
+            ThreemaTheme {
+                AvailabilityStatusContactBanner(
+                    status = availabilityStatusSet,
+                    emojiStyle = state.emojiStyle,
+                )
+            }
+        }
+    }
+}
+
+private data class AvailabilityStatusContactBannerState(
+    val availabilityStatusSet: AvailabilityStatus.Set?,
+    @EmojiStyle val emojiStyle: Int,
+)
+
+@Composable
+private fun AvailabilityStatusContactBanner(
+    modifier: Modifier = Modifier,
+    status: AvailabilityStatus.Set,
+    @EmojiStyle emojiStyle: Int,
+) {
+    ConversationText(
+        modifier = modifier
+            .padding(
+                top = GridUnit.x1,
+                bottom = dimensionResource(R.dimen.notice_views_vertical_margin),
+                start = GridUnit.x1,
+                end = GridUnit.x1,
+            )
+            .fillMaxWidth()
+            .clip(
+                shape = RoundedCornerShape(
+                    size = dimensionResource(R.dimen.cardview_border_radius),
+                ),
+            )
+            .background(
+                color = status.containerColor(),
+            )
+            .padding(
+                vertical = GridUnit.x1,
+                horizontal = GridUnit.x2,
+            ),
+        rawInput = status.displayText().get(),
+        textStyle = MaterialTheme.typography.bodyMedium,
+        color = status.onContainerColor(),
+        maxLines = 2,
+        textAlign = TextAlign.Center,
+        emojiSettings = EmojiSettings(
+            style = emojiStyle,
+        ),
+        mentionFeature = MentionFeature.Off,
+        markupEnabled = false,
+    )
+}

+ 65 - 0
app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusExtensions.kt

@@ -0,0 +1,65 @@
+package ch.threema.app.availabilitystatus
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.graphics.Color
+import ch.threema.android.ResolvableString
+import ch.threema.android.ResourceIdString
+import ch.threema.android.toResolvedString
+import ch.threema.app.R
+import ch.threema.app.compose.theme.color.CustomColors
+import ch.threema.common.takeUnlessBlank
+import ch.threema.data.datatypes.AvailabilityStatus
+
+@StringRes
+fun AvailabilityStatus.displayNameRes(): Int =
+    when (this) {
+        AvailabilityStatus.None -> R.string.availability_status_none
+        is AvailabilityStatus.Busy -> R.string.availability_status_busy
+        is AvailabilityStatus.Unavailable -> R.string.availability_status_unavailable
+    }
+
+fun AvailabilityStatus.displayText(): ResolvableString =
+    when (this) {
+        AvailabilityStatus.None -> ResourceIdString(R.string.availability_status_none)
+        is AvailabilityStatus.Busy -> description.takeUnlessBlank()?.toResolvedString()
+            ?: ResourceIdString(R.string.availability_status_busy)
+        is AvailabilityStatus.Unavailable -> description.takeUnlessBlank()?.toResolvedString()
+            ?: ResourceIdString(R.string.availability_status_unavailable)
+    }
+
+@DrawableRes
+fun AvailabilityStatus.iconRes(): Int =
+    when (this) {
+        AvailabilityStatus.None -> R.drawable.ic_availability_status_none
+        is AvailabilityStatus.Busy -> R.drawable.ic_availability_status_busy
+        is AvailabilityStatus.Unavailable -> R.drawable.ic_availability_status_unavailable
+    }
+
+@Composable
+@ReadOnlyComposable
+fun AvailabilityStatus.iconColor(): Color =
+    when (this) {
+        AvailabilityStatus.None -> CustomColors.availabilityStatusIconNone
+        is AvailabilityStatus.Busy -> CustomColors.availabilityStatusIconBusy
+        is AvailabilityStatus.Unavailable -> CustomColors.availabilityStatusIconUnavailable
+    }
+
+@Composable
+@ReadOnlyComposable
+fun AvailabilityStatus.containerColor(): Color =
+    when (this) {
+        AvailabilityStatus.None -> CustomColors.availabilityStatusContainerNone
+        is AvailabilityStatus.Busy -> CustomColors.availabilityStatusContainerBusy
+        is AvailabilityStatus.Unavailable -> CustomColors.availabilityStatusContainerUnavailable
+    }
+
+@Composable
+@ReadOnlyComposable
+fun AvailabilityStatus.Set.onContainerColor(): Color =
+    when (this) {
+        is AvailabilityStatus.Busy -> CustomColors.availabilityStatusOnContainerBusy
+        is AvailabilityStatus.Unavailable -> CustomColors.availabilityStatusOnContainerUnavailable
+    }

+ 17 - 0
app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusFeatureModule.kt

@@ -0,0 +1,17 @@
+package ch.threema.app.availabilitystatus
+
+import ch.threema.app.availabilitystatus.edit.EditAvailabilityStatusViewModel
+import ch.threema.app.usecases.availabilitystatus.UpdateUserAvailabilityStatusUseCase
+import ch.threema.app.usecases.availabilitystatus.WatchAllContactAvailabilityStatusesUseCase
+import ch.threema.storage.factories.ContactAvailabilityStatusModelFactory
+import org.koin.core.module.dsl.factoryOf
+import org.koin.core.module.dsl.singleOf
+import org.koin.core.module.dsl.viewModelOf
+import org.koin.dsl.module
+
+val availabilityStatusFeatureModule = module {
+    viewModelOf(::EditAvailabilityStatusViewModel)
+    factoryOf(::UpdateUserAvailabilityStatusUseCase)
+    factoryOf(::WatchAllContactAvailabilityStatusesUseCase)
+    singleOf(::ContactAvailabilityStatusModelFactory)
+}

+ 76 - 0
app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusIconElevatedView.kt

@@ -0,0 +1,76 @@
+package ch.threema.app.availabilitystatus
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.AbstractComposeView
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import ch.threema.app.compose.theme.ThreemaTheme
+import ch.threema.data.datatypes.AvailabilityStatus
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class AvailabilityStatusIconElevatedView
+@JvmOverloads
+constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0,
+) : AbstractComposeView(context, attrs, defStyleAttr) {
+
+    private val statusFlow = MutableStateFlow<AvailabilityStatus.Set?>(null)
+
+    fun setStatus(status: AvailabilityStatus.Set?) {
+        statusFlow.value = status
+    }
+
+    @Composable
+    override fun Content() {
+        ThreemaTheme {
+            val status by statusFlow.collectAsStateWithLifecycle()
+            status?.let { availabilityStatusSet ->
+                AvailabilityStatusIconElevated(
+                    status = availabilityStatusSet,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+fun AvailabilityStatusIconElevated(
+    modifier: Modifier = Modifier,
+    status: AvailabilityStatus.Set,
+) {
+    Box(
+        modifier = modifier
+            .size(16.dp)
+            .clip(
+                shape = CircleShape,
+            )
+            .background(
+                color = Color.White,
+            ),
+        contentAlignment = Alignment.Center,
+    ) {
+        Icon(
+            modifier = Modifier.fillMaxSize(),
+            painter = painterResource(status.iconRes()),
+            tint = status.iconColor(),
+            contentDescription = stringResource(status.displayNameRes()),
+        )
+    }
+}

+ 100 - 0
app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusLabelView.kt

@@ -0,0 +1,100 @@
+package ch.threema.app.availabilitystatus
+
+import android.content.Context
+import android.util.AttributeSet
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.AbstractComposeView
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import ch.threema.app.compose.common.extensions.get
+import ch.threema.app.compose.common.text.conversation.ConversationText
+import ch.threema.app.compose.common.text.conversation.EmojiSettings
+import ch.threema.app.compose.common.text.conversation.MentionFeature
+import ch.threema.app.compose.theme.ThreemaTheme
+import ch.threema.app.compose.theme.dimens.GridUnit
+import ch.threema.app.preference.service.PreferenceService
+import ch.threema.app.preference.service.PreferenceService.EmojiStyle
+import ch.threema.data.datatypes.AvailabilityStatus
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class AvailabilityStatusLabelView
+@JvmOverloads
+constructor(
+    context: Context,
+    attrs: AttributeSet? = null,
+    defStyleAttr: Int = 0,
+) : AbstractComposeView(context, attrs, defStyleAttr) {
+
+    private val state = MutableStateFlow(
+        AvailabilityStatusLabelState(
+            availabilityStatusSet = null,
+            emojiStyle = PreferenceService.EMOJI_STYLE_ANDROID,
+        ),
+    )
+
+    fun setState(availabilityStatusSet: AvailabilityStatus.Set?, @EmojiStyle emojiStyle: Int) {
+        state.value = AvailabilityStatusLabelState(
+            availabilityStatusSet = availabilityStatusSet,
+            emojiStyle = emojiStyle,
+        )
+    }
+
+    @Composable
+    override fun Content() {
+        val state by state.collectAsStateWithLifecycle()
+        state.availabilityStatusSet?.let { availabilityStatusSet ->
+            ThreemaTheme {
+                AvailabilityStatusLabel(
+                    availabilityStatusSet = availabilityStatusSet,
+                    emojiStyle = state.emojiStyle,
+                )
+            }
+        }
+    }
+}
+
+private data class AvailabilityStatusLabelState(
+    val availabilityStatusSet: AvailabilityStatus.Set?,
+    @EmojiStyle val emojiStyle: Int,
+)
+
+@Composable
+private fun AvailabilityStatusLabel(
+    availabilityStatusSet: AvailabilityStatus.Set,
+    @EmojiStyle emojiStyle: Int,
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(vertical = GridUnit.x1),
+        horizontalArrangement = Arrangement.spacedBy(GridUnit.x1),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        Icon(
+            modifier = Modifier.size(20.dp),
+            painter = painterResource(availabilityStatusSet.iconRes()),
+            tint = availabilityStatusSet.iconColor(),
+            contentDescription = null,
+        )
+        ConversationText(
+            rawInput = availabilityStatusSet.displayText().get(),
+            textStyle = MaterialTheme.typography.bodyLarge,
+            emojiSettings = EmojiSettings(
+                style = emojiStyle,
+            ),
+            mentionFeature = MentionFeature.Off,
+            markupEnabled = false,
+        )
+    }
+}

+ 155 - 0
app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusOwnBanner.kt

@@ -0,0 +1,155 @@
+package ch.threema.app.availabilitystatus
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.dp
+import ch.threema.app.R
+import ch.threema.app.compose.common.SpacerHorizontal
+import ch.threema.app.compose.common.ThemedText
+import ch.threema.app.compose.common.extensions.get
+import ch.threema.app.compose.common.text.conversation.ConversationText
+import ch.threema.app.compose.common.text.conversation.EmojiSettings
+import ch.threema.app.compose.common.text.conversation.MentionFeature
+import ch.threema.app.compose.theme.ThreemaThemePreview
+import ch.threema.app.compose.theme.dimens.GridUnit
+import ch.threema.app.preference.service.PreferenceService
+import ch.threema.app.preference.service.PreferenceService.EmojiStyle
+import ch.threema.data.datatypes.AvailabilityStatus
+
+@Composable
+fun AvailabilityStatusOwnBanner(
+    modifier: Modifier = Modifier,
+    status: AvailabilityStatus.Set,
+    onClickEdit: () -> Unit,
+    @EmojiStyle emojiStyle: Int,
+) {
+    Row(
+        modifier = modifier
+            .clip(
+                shape = RoundedCornerShape(
+                    size = dimensionResource(R.dimen.cardview_border_radius),
+                ),
+            )
+            .background(
+                color = status.containerColor(),
+            )
+            .padding(
+                horizontal = GridUnit.x2,
+                vertical = GridUnit.x0_5,
+            ),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        CompositionLocalProvider(LocalContentColor provides status.onContainerColor()) {
+            Icon(
+                painter = painterResource(status.iconRes()),
+                contentDescription = null,
+                tint = status.iconColor(),
+            )
+            SpacerHorizontal(GridUnit.x1)
+            ConversationText(
+                modifier = Modifier.weight(1f),
+                rawInput = status.displayText().get(),
+                textStyle = MaterialTheme.typography.bodyLarge,
+                color = LocalContentColor.current,
+                maxLines = 1,
+                emojiSettings = EmojiSettings(
+                    style = emojiStyle,
+                ),
+                mentionFeature = MentionFeature.Off,
+                markupEnabled = false,
+            )
+            SpacerHorizontal(GridUnit.x2)
+            Button(
+                colors = ButtonDefaults.outlinedButtonColors()
+                    .copy(
+                        containerColor = MaterialTheme.colorScheme.background,
+                        contentColor = MaterialTheme.colorScheme.onBackground,
+                    ),
+                contentPadding = PaddingValues(
+                    start = GridUnit.x2,
+                    top = ButtonDefaults.ContentPadding.calculateTopPadding(),
+                    end = GridUnit.x2,
+                    bottom = ButtonDefaults.ContentPadding.calculateBottomPadding(),
+                ),
+                onClick = onClickEdit,
+                shape = RoundedCornerShape(12.dp),
+            ) {
+                ThemedText(
+                    text = stringResource(R.string.edit),
+                    style = MaterialTheme.typography.labelMedium,
+                    color = LocalContentColor.current,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                    textAlign = TextAlign.Center,
+                )
+            }
+        }
+    }
+}
+
+@PreviewLightDark
+@Composable
+private fun Preview_AvailabilityStatusOwnBanner(
+    @PreviewParameter(PreviewProviderAvailabilityStatusOwnBanner::class)
+    availabilityStatus: AvailabilityStatus.Set,
+) {
+    ThreemaThemePreview {
+        Surface {
+            Box(
+                modifier = Modifier
+                    .padding(all = GridUnit.x2),
+            ) {
+                AvailabilityStatusOwnBanner(
+                    modifier = Modifier.fillMaxWidth(),
+                    status = availabilityStatus,
+                    onClickEdit = {},
+                    emojiStyle = PreferenceService.EMOJI_STYLE_ANDROID,
+                )
+            }
+        }
+    }
+}
+
+private class PreviewProviderAvailabilityStatusOwnBanner : PreviewParameterProvider<AvailabilityStatus.Set> {
+
+    override val values: Sequence<AvailabilityStatus.Set> = sequenceOf(
+        AvailabilityStatus.Busy(),
+        AvailabilityStatus.Busy(
+            description = "In a short coffee break \u2615\uD83D\uDE42\u200D\u2195\uFE0F",
+        ),
+        AvailabilityStatus.Busy(
+            description = "I cant keep my status description short because I am a person that likes to talk a lot.",
+        ),
+        AvailabilityStatus.Unavailable(),
+        AvailabilityStatus.Unavailable(
+            description = "Free day today",
+        ),
+        AvailabilityStatus.Unavailable(
+            description = "I am on vacation and want to base jump mount everest. Hope to see you all when I make it back.",
+        ),
+    )
+}

+ 506 - 0
app/src/main/java/ch/threema/app/availabilitystatus/edit/EditAvailabilityStatusBottomSheetDialog.kt

@@ -0,0 +1,506 @@
+package ch.threema.app.availabilitystatus.edit
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.core.os.bundleOf
+import androidx.fragment.app.setFragmentResult
+import ch.threema.app.R
+import ch.threema.app.availabilitystatus.containerColor
+import ch.threema.app.availabilitystatus.displayNameRes
+import ch.threema.app.availabilitystatus.iconColor
+import ch.threema.app.availabilitystatus.iconRes
+import ch.threema.app.compose.common.SpacerHorizontal
+import ch.threema.app.compose.common.SpacerVertical
+import ch.threema.app.compose.common.ThemedText
+import ch.threema.app.compose.common.buttons.ButtonOutlined
+import ch.threema.app.compose.common.buttons.primary.ButtonPrimaryRounded
+import ch.threema.app.compose.theme.ThreemaTheme
+import ch.threema.app.compose.theme.ThreemaThemePreview
+import ch.threema.app.compose.theme.color.AlphaValues
+import ch.threema.app.compose.theme.color.BrandGreyColors
+import ch.threema.app.compose.theme.dimens.GridUnit
+import ch.threema.app.framework.EventHandler
+import ch.threema.app.framework.WithViewState
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.data.datatypes.AvailabilityStatus
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class EditAvailabilityStatusBottomSheetDialog : BottomSheetDialogFragment() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        if (!ConfigUtils.supportsAvailabilityStatus()) {
+            throw IllegalStateException("Can not show this bottom sheet as the current build does not support this feature in general.")
+        }
+    }
+
+    @OptIn(ExperimentalMaterial3Api::class)
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?,
+    ): View {
+        return ComposeView(requireContext()).apply {
+            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+            setContent {
+                ThreemaTheme {
+                    val viewModel by viewModel<EditAvailabilityStatusViewModel>()
+
+                    EventHandler(viewModel, ::onEvent)
+
+                    WithViewState(viewModel) { state ->
+                        if (state != null) {
+                            ModalBottomSheet(
+                                sheetState = rememberModalBottomSheetState(
+                                    skipPartiallyExpanded = true,
+                                ),
+                                onDismissRequest = {
+                                    dismiss()
+                                },
+                                sheetGesturesEnabled = false,
+                                dragHandle = null,
+                                contentWindowInsets = {
+                                    BottomSheetDefaults.windowInsets
+                                        .only(
+                                            sides = WindowInsetsSides.Top,
+                                        )
+                                },
+                            ) {
+                                SetStatusBottomSheetContent(
+                                    windowInsetsBottom = BottomSheetDefaults.windowInsets
+                                        .only(
+                                            sides = WindowInsetsSides.Bottom,
+                                        ),
+                                    status = state.status,
+                                    descriptionState = state.descriptionState,
+                                    isLoading = state.isLoading,
+                                    hasError = state.hasError,
+                                    onClickStatus = viewModel::onClickStatus,
+                                    onChangeDescription = viewModel::onChangeDescription,
+                                    onClickCancel = viewModel::onClickCancel,
+                                    onClickSave = viewModel::onClickSave,
+                                )
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private fun onEvent(event: EditAvailabilityStatusEvent) {
+        when (event) {
+            EditAvailabilityStatusEvent.Cancel, EditAvailabilityStatusEvent.Saved -> {
+                setFragmentResultIfRequested(
+                    didChangeStatus = event == EditAvailabilityStatusEvent.Saved,
+                )
+                dismiss()
+            }
+        }
+    }
+
+    private fun setFragmentResultIfRequested(didChangeStatus: Boolean) {
+        val requestKey = arguments?.getString(REQUEST_KEY)
+        if (requestKey != null) {
+            setFragmentResult(
+                requestKey = requestKey,
+                result = bundleOf(
+                    RESULT_KEY_DID_CHANGE_STATUS to didChangeStatus,
+                ),
+            )
+        }
+    }
+
+    companion object {
+
+        const val RESULT_KEY_DID_CHANGE_STATUS = "did-change-status"
+        private const val REQUEST_KEY = "request-key"
+
+        fun newInstance(requestKey: String? = null): EditAvailabilityStatusBottomSheetDialog =
+            EditAvailabilityStatusBottomSheetDialog().apply {
+                arguments = bundleOf(
+                    REQUEST_KEY to requestKey,
+                )
+            }
+    }
+}
+
+@Composable
+fun SetStatusBottomSheetContent(
+    windowInsetsBottom: WindowInsets,
+    status: AvailabilityStatus,
+    descriptionState: AvailabilityStatusDescriptionState,
+    isLoading: Boolean,
+    hasError: Boolean,
+    onClickStatus: (AvailabilityStatus) -> Unit,
+    onChangeDescription: (String) -> Unit,
+    onClickCancel: () -> Unit,
+    onClickSave: () -> Unit,
+) {
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(
+                horizontal = GridUnit.x2,
+            )
+            .verticalScroll(
+                state = rememberScrollState(),
+            ),
+    ) {
+        SpacerVertical(GridUnit.x4)
+
+        ThemedText(
+            text = stringResource(R.string.edit_availability_status_modal_title),
+            style = MaterialTheme.typography.titleLarge,
+        )
+
+        SpacerVertical(GridUnit.x1)
+
+        ThemedText(
+            text = stringResource(R.string.edit_availability_status_modal_subtitle),
+            style = MaterialTheme.typography.bodyMedium,
+        )
+
+        SpacerVertical(GridUnit.x2)
+
+        StatusOptionItem(
+            status = AvailabilityStatus.None,
+            isSelected = status is AvailabilityStatus.None,
+            enabled = !isLoading,
+            onClick = onClickStatus,
+        )
+
+        StatusOptionItem(
+            status = AvailabilityStatus.Busy(),
+            isSelected = status is AvailabilityStatus.Busy,
+            enabled = !isLoading,
+            onClick = onClickStatus,
+        )
+
+        StatusOptionItem(
+            status = AvailabilityStatus.Unavailable(),
+            isSelected = status is AvailabilityStatus.Unavailable,
+            enabled = !isLoading,
+            onClick = onClickStatus,
+        )
+
+        AnimatedVisibility(
+            visible = status != AvailabilityStatus.None,
+            enter = fadeIn() + expandVertically(
+                animationSpec = spring(
+                    dampingRatio = Spring.DampingRatioMediumBouncy,
+                    stiffness = Spring.StiffnessLow,
+                    visibilityThreshold = IntSize.VisibilityThreshold,
+                ),
+            ),
+        ) {
+            Column {
+                SpacerVertical(GridUnit.x2)
+
+                val defaultTextFieldColors = OutlinedTextFieldDefaults.colors()
+
+                OutlinedTextField(
+                    modifier = Modifier.fillMaxWidth(),
+                    value = descriptionState.description,
+                    onValueChange = onChangeDescription,
+                    label = {
+                        ThemedText(
+                            text = stringResource(R.string.edit_availability_status_modal_optional_message_label),
+                            style = MaterialTheme.typography.bodyMedium,
+                            color = LocalContentColor.current,
+                            maxLines = 1,
+                        )
+                    },
+                    placeholder = {
+                        ThemedText(
+                            text = stringResource(R.string.edit_availability_status_modal_optional_message_placeholder),
+                            style = MaterialTheme.typography.bodyMedium,
+                            color = LocalContentColor.current,
+                        )
+                    },
+                    keyboardOptions = KeyboardOptions(
+                        imeAction = ImeAction.Done,
+                    ),
+                    enabled = !isLoading && status is AvailabilityStatus.Set,
+                    isError = descriptionState.exceedsLimit,
+                    colors = defaultTextFieldColors.copy(
+                        unfocusedLabelColor = defaultTextFieldColors.unfocusedLabelColor.copy(
+                            alpha = 0.8f,
+                        ),
+                        unfocusedPlaceholderColor = defaultTextFieldColors.unfocusedPlaceholderColor.copy(
+                            alpha = 0.5f,
+                        ),
+                        focusedPlaceholderColor = defaultTextFieldColors.focusedPlaceholderColor.copy(
+                            alpha = 0.5f,
+                        ),
+                    ),
+                    maxLines = 2,
+                )
+
+                AnimatedVisibility(
+                    visible = descriptionState.exceedsLimit,
+                    enter = fadeIn() + expandVertically(
+                        animationSpec = spring(
+                            dampingRatio = Spring.DampingRatioMediumBouncy,
+                            stiffness = Spring.StiffnessLow,
+                            visibilityThreshold = IntSize.VisibilityThreshold,
+                        ),
+                    ),
+                ) {
+                    ThemedText(
+                        modifier = Modifier.padding(
+                            top = GridUnit.x0_25,
+                            start = GridUnit.x0_25,
+                            end = GridUnit.x0_25,
+                        ),
+                        text = stringResource(R.string.edit_availability_status_modal_optional_message_too_long),
+                        style = MaterialTheme.typography.bodyMedium,
+                        color = MaterialTheme.colorScheme.error,
+                    )
+                }
+
+                SpacerVertical(GridUnit.x1)
+
+                ThemedText(
+                    text = stringResource(R.string.edit_availability_status_modal_optional_message_description),
+                    style = MaterialTheme.typography.bodySmall,
+                )
+            }
+        }
+
+        AnimatedVisibility(
+            visible = hasError,
+            enter = fadeIn() + expandVertically(
+                animationSpec = spring(
+                    dampingRatio = Spring.DampingRatioMediumBouncy,
+                    stiffness = Spring.StiffnessLow,
+                    visibilityThreshold = IntSize.VisibilityThreshold,
+                ),
+            ),
+        ) {
+            ThemedText(
+                modifier = Modifier.padding(
+                    top = GridUnit.x3,
+                ),
+                text = stringResource(R.string.an_error_occurred),
+                style = MaterialTheme.typography.bodyMedium,
+                color = MaterialTheme.colorScheme.error,
+            )
+        }
+
+        SpacerVertical(GridUnit.x3)
+
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.End,
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            ButtonOutlined(
+                text = stringResource(R.string.cancel),
+                onClick = onClickCancel,
+                maxLines = 1,
+            )
+
+            SpacerHorizontal(GridUnit.x1_5)
+
+            ButtonPrimaryRounded(
+                text = stringResource(R.string.save),
+                onClick = onClickSave,
+                enabled = !isLoading &&
+                    (!descriptionState.exceedsLimit || status !is AvailabilityStatus.Set),
+                isLoading = isLoading,
+            )
+        }
+
+        SpacerVertical(GridUnit.x2)
+
+        SpacerVertical(windowInsetsBottom.asPaddingValues().calculateBottomPadding())
+    }
+}
+
+@Composable
+fun StatusOptionItem(
+    status: AvailabilityStatus,
+    isSelected: Boolean,
+    enabled: Boolean,
+    onClick: (clickedStatus: AvailabilityStatus) -> Unit,
+) {
+    val isDarkTheme: Boolean = isSystemInDarkTheme()
+    val backgroundColor = remember(isSelected, isDarkTheme) {
+        if (isSelected) {
+            Color(if (isDarkTheme) BrandGreyColors.SHADE_700 else BrandGreyColors.SHADE_300)
+        } else {
+            Color.Transparent
+        }
+    }
+
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .clip(
+                shape = RoundedCornerShape(GridUnit.x1),
+            )
+            .background(backgroundColor)
+            .clickable(
+                enabled = enabled,
+                onClick = {
+                    onClick(status)
+                },
+            )
+            .padding(
+                vertical = GridUnit.x1_5,
+            )
+            .alpha(
+                if (enabled) AlphaValues.FULLY_OPAQUE else AlphaValues.DISABLED,
+            ),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        SpacerHorizontal(GridUnit.x1_5)
+
+        Box(
+            modifier = Modifier
+                .size(28.dp)
+                .clip(
+                    shape = RoundedCornerShape(
+                        size = GridUnit.x1,
+                    ),
+                )
+                .background(
+                    color = status.containerColor(),
+                ),
+            contentAlignment = Alignment.Center,
+        ) {
+            Icon(
+                modifier = Modifier.size(20.dp),
+                painter = painterResource(status.iconRes()),
+                tint = status.iconColor(),
+                contentDescription = null,
+            )
+        }
+
+        SpacerHorizontal(GridUnit.x1_5)
+
+        ThemedText(
+            modifier = Modifier.weight(1f),
+            text = stringResource(status.displayNameRes()),
+            style = MaterialTheme.typography.bodyMedium,
+            maxLines = 1,
+        )
+
+        SpacerHorizontal(GridUnit.x2)
+    }
+}
+
+private class PreviewProviderSetStatusBottomSheetContent : PreviewParameterProvider<AvailabilityStatus> {
+
+    override val values: Sequence<AvailabilityStatus> = sequenceOf(
+        AvailabilityStatus.None,
+        AvailabilityStatus.Busy(),
+        AvailabilityStatus.Busy(
+            description = "In a short coffee break",
+        ),
+        AvailabilityStatus.Busy(
+            description = "I cant keep my status description short because I am a person that likes to talk a lot.",
+        ),
+        AvailabilityStatus.Unavailable(),
+        AvailabilityStatus.Unavailable(
+            description = "Free day today",
+        ),
+        AvailabilityStatus.Unavailable(
+            description = "I am on vacation and want to base jump mount everest. Hope to see you all when I make it back.",
+        ),
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@PreviewLightDark
+@Composable
+private fun Preview_SetStatusBottomSheetContent(
+    @PreviewParameter(PreviewProviderSetStatusBottomSheetContent::class)
+    availabilityStatus: AvailabilityStatus,
+) {
+    ThreemaThemePreview {
+        ModalBottomSheet(
+            onDismissRequest = {},
+            sheetState = rememberModalBottomSheetState(
+                skipPartiallyExpanded = true,
+            ),
+        ) {
+            SetStatusBottomSheetContent(
+                windowInsetsBottom = BottomSheetDefaults.windowInsets
+                    .only(
+                        sides = WindowInsetsSides.Bottom,
+                    ),
+                status = availabilityStatus,
+                descriptionState = AvailabilityStatusDescriptionState(
+                    description = when (availabilityStatus) {
+                        AvailabilityStatus.None -> ""
+                        is AvailabilityStatus.Busy -> availabilityStatus.description
+                        is AvailabilityStatus.Unavailable -> availabilityStatus.description
+                    },
+                    exceedsLimit = true,
+                ),
+                isLoading = false,
+                hasError = true,
+                onClickStatus = {},
+                onChangeDescription = {},
+                onClickCancel = {},
+                onClickSave = {},
+            )
+        }
+    }
+}

+ 126 - 0
app/src/main/java/ch/threema/app/availabilitystatus/edit/EditAvailabilityStatusViewModel.kt

@@ -0,0 +1,126 @@
+package ch.threema.app.availabilitystatus.edit
+
+import ch.threema.app.framework.BaseViewModel
+import ch.threema.app.preference.service.PreferenceService
+import ch.threema.app.usecases.availabilitystatus.UpdateUserAvailabilityStatusUseCase
+import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.domain.protocol.csp.ProtocolDefines
+
+class EditAvailabilityStatusViewModel(
+    private val preferenceService: PreferenceService,
+    private val updateUserAvailabilityStatusUseCase: UpdateUserAvailabilityStatusUseCase,
+) : BaseViewModel<EditAvailabilityStatusState, EditAvailabilityStatusEvent>() {
+
+    override fun initialize() = runInitialization {
+        val currentAvailabilityStatus = getCurrentAvailabilityStatusOrNone()
+        EditAvailabilityStatusState(
+            status = currentAvailabilityStatus,
+            descriptionState = AvailabilityStatusDescriptionState.create(
+                description = when (currentAvailabilityStatus) {
+                    AvailabilityStatus.None -> ""
+                    is AvailabilityStatus.Set -> currentAvailabilityStatus.description
+                },
+            ),
+            isLoading = false,
+            hasError = false,
+        )
+    }
+
+    fun onClickStatus(availabilityStatus: AvailabilityStatus) = runAction {
+        updateViewState {
+            copy(
+                status = when (availabilityStatus) {
+                    AvailabilityStatus.None -> availabilityStatus
+                    is AvailabilityStatus.Busy -> AvailabilityStatus.Busy(
+                        description = currentViewState.descriptionState.description,
+                    )
+                    is AvailabilityStatus.Unavailable -> AvailabilityStatus.Unavailable(
+                        description = currentViewState.descriptionState.description,
+                    )
+                },
+                hasError = false,
+            )
+        }
+    }
+
+    fun onChangeDescription(description: String) = runAction {
+        val updatedStatus =
+            when (val selectedStatus = currentViewState.status) {
+                AvailabilityStatus.None -> selectedStatus
+                is AvailabilityStatus.Busy -> selectedStatus.copy(
+                    description = description,
+                )
+                is AvailabilityStatus.Unavailable -> selectedStatus.copy(
+                    description = description,
+                )
+            }
+        updateViewState {
+            copy(
+                status = updatedStatus,
+                descriptionState = AvailabilityStatusDescriptionState.create(
+                    description = description,
+                ),
+                hasError = false,
+            )
+        }
+    }
+
+    fun onClickCancel() = runAction {
+        emitEvent(EditAvailabilityStatusEvent.Cancel)
+    }
+
+    fun onClickSave() = runAction {
+        val currentAvailabilityStatus = getCurrentAvailabilityStatusOrNone()
+        if (currentAvailabilityStatus == currentViewState.status) {
+            emitEvent(EditAvailabilityStatusEvent.Cancel)
+            endAction()
+        }
+
+        updateViewState {
+            copy(
+                isLoading = true,
+                hasError = false,
+            )
+        }
+        val updateResult = updateUserAvailabilityStatusUseCase.call(
+            availabilityStatus = currentViewState.status,
+        )
+        updateViewState {
+            copy(
+                isLoading = false,
+                hasError = updateResult.isFailure,
+            )
+        }
+        if (updateResult.isSuccess) {
+            emitEvent(EditAvailabilityStatusEvent.Saved)
+        }
+    }
+
+    private fun getCurrentAvailabilityStatusOrNone() =
+        preferenceService.getAvailabilityStatus()
+            ?: AvailabilityStatus.None
+}
+
+data class EditAvailabilityStatusState(
+    val status: AvailabilityStatus,
+    val descriptionState: AvailabilityStatusDescriptionState,
+    val isLoading: Boolean = false,
+    val hasError: Boolean = false,
+)
+
+data class AvailabilityStatusDescriptionState(
+    val description: String,
+    val exceedsLimit: Boolean,
+) {
+    companion object {
+        fun create(description: String) = AvailabilityStatusDescriptionState(
+            description = description,
+            exceedsLimit = description.toByteArray().size > ProtocolDefines.MAX_AVAILABILITY_STATUS_DESCRIPTION_BYTES,
+        )
+    }
+}
+
+sealed interface EditAvailabilityStatusEvent {
+    data object Cancel : EditAvailabilityStatusEvent
+    data object Saved : EditAvailabilityStatusEvent
+}

+ 39 - 0
app/src/main/java/ch/threema/app/compose/common/avatar/Avatar.kt

@@ -16,10 +16,12 @@ import androidx.compose.ui.draw.clip
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.tooling.preview.Preview
 import ch.threema.app.R
+import ch.threema.app.availabilitystatus.AvailabilityStatusIconElevated
 import ch.threema.app.compose.common.extensions.thenIf
 import ch.threema.app.compose.common.immutables.ImmutableBitmap
 import ch.threema.app.compose.theme.ThreemaThemePreview
 import ch.threema.app.compose.theme.dimens.GridUnit
+import ch.threema.data.datatypes.AvailabilityStatus
 
 @Composable
 fun Avatar(
@@ -28,6 +30,7 @@ fun Avatar(
     contentDescription: String?,
     @DrawableRes fallbackIcon: Int = R.drawable.ic_contact,
     showWorkBadge: Boolean,
+    availabilityStatus: AvailabilityStatus? = null,
     onClick: (() -> Unit)? = null,
 ) {
     Box(
@@ -66,6 +69,14 @@ fun Avatar(
                 contentDescription = null,
             )
         }
+
+        if (availabilityStatus is AvailabilityStatus.Set) {
+            AvailabilityStatusIconElevated(
+                modifier = Modifier
+                    .align(Alignment.BottomEnd),
+                status = availabilityStatus,
+            )
+        }
     }
 }
 
@@ -81,3 +92,31 @@ private fun Avatar_Preview() {
         )
     }
 }
+
+@Preview
+@Composable
+private fun Avatar_Preview_Unavailable() {
+    ThreemaThemePreview {
+        Avatar(
+            bitmap = null,
+            contentDescription = null,
+            showWorkBadge = true,
+            availabilityStatus = AvailabilityStatus.Unavailable(),
+            onClick = {},
+        )
+    }
+}
+
+@Preview
+@Composable
+private fun Avatar_Preview_Busy() {
+    ThreemaThemePreview {
+        Avatar(
+            bitmap = null,
+            contentDescription = null,
+            showWorkBadge = true,
+            availabilityStatus = AvailabilityStatus.Busy(),
+            onClick = {},
+        )
+    }
+}

+ 6 - 0
app/src/main/java/ch/threema/app/compose/common/avatar/AvatarAsync.kt

@@ -20,6 +20,7 @@ import ch.threema.app.compose.common.immutables.toImmutableBitmap
 import ch.threema.app.compose.preview.PreviewData
 import ch.threema.app.compose.theme.ThreemaThemePreview
 import ch.threema.app.usecases.conversations.AvatarIteration
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.domain.models.ContactReceiverIdentifier
 import ch.threema.domain.models.ReceiverIdentifier
 
@@ -38,6 +39,7 @@ fun AvatarAsync(
     contentDescription: String?,
     @DrawableRes fallbackIcon: Int,
     showWorkBadge: Boolean,
+    availabilityStatus: AvailabilityStatus?,
     onClick: (() -> Unit)? = null,
 ) {
     // A new bitmap asset will be requested from bitmapProvider if one of these values change
@@ -55,6 +57,7 @@ fun AvatarAsync(
         contentDescription = contentDescription,
         fallbackIcon = fallbackIcon,
         showWorkBadge = showWorkBadge,
+        availabilityStatus = availabilityStatus,
         onClick = onClick,
     )
 }
@@ -77,6 +80,7 @@ private fun AvatarAsync_Preview() {
                 contentDescription = null,
                 fallbackIcon = R.drawable.ic_contact,
                 showWorkBadge = true,
+                availabilityStatus = AvailabilityStatus.None,
                 onClick = {},
             )
         }
@@ -96,6 +100,7 @@ private fun AvatarAsync_Preview_NoAvatar() {
                 contentDescription = null,
                 fallbackIcon = R.drawable.ic_contact,
                 showWorkBadge = true,
+                availabilityStatus = AvailabilityStatus.Unavailable(),
                 onClick = {},
             )
         }
@@ -120,6 +125,7 @@ private fun AvatarAsync_Preview_Dark() {
                 contentDescription = null,
                 fallbackIcon = R.drawable.ic_contact,
                 showWorkBadge = true,
+                availabilityStatus = AvailabilityStatus.Busy(),
                 onClick = {},
             )
         }

+ 1 - 1
app/src/main/java/ch/threema/app/compose/common/buttons/ButtonOutlined.kt

@@ -41,7 +41,7 @@ fun ButtonOutlined(
         colors = ButtonDefaults.outlinedButtonColors().copy(
             contentColor = MaterialTheme.colorScheme.onSurface,
             disabledContentColor = MaterialTheme.colorScheme.onSurface
-                .copy(alpha = AlphaValues.DISABLED_ON_CONTAINER),
+                .copy(alpha = AlphaValues.DISABLED),
         ),
     ) {
         if (leadingIcon != null) {

+ 1 - 1
app/src/main/java/ch/threema/app/compose/common/buttons/TextButton.kt

@@ -134,7 +134,7 @@ private fun TextButtonBase(
             contentColor = contentColor,
             disabledContainerColor = Color.Transparent,
             disabledContentColor = contentColor.copy(
-                alpha = AlphaValues.DISABLED_ON_CONTAINER,
+                alpha = AlphaValues.DISABLED,
             ),
         ),
         shape = ShapeDefaults.Medium,

+ 7 - 6
app/src/main/java/ch/threema/app/compose/common/buttons/ButtonPrimary.kt → app/src/main/java/ch/threema/app/compose/common/buttons/primary/ButtonPrimary.kt

@@ -1,4 +1,4 @@
-package ch.threema.app.compose.common.buttons
+package ch.threema.app.compose.common.buttons.primary
 
 import android.content.res.Configuration
 import androidx.compose.foundation.layout.PaddingValues
@@ -32,21 +32,22 @@ import androidx.compose.ui.unit.TextUnit
 import androidx.compose.ui.unit.sp
 import ch.threema.app.R
 import ch.threema.app.compose.common.ThemedText
+import ch.threema.app.compose.common.buttons.ButtonIconInfo
 import ch.threema.app.compose.theme.ThreemaThemePreview
 import ch.threema.app.compose.theme.color.AlphaValues
 import ch.threema.app.compose.theme.dimens.GridUnit
 import ch.threema.app.utils.compose.stringResourceOrNull
 
-private val primaryButtonColors: ButtonColors
+val primaryButtonColors: ButtonColors
     @Composable
     get() = ButtonColors(
         containerColor = MaterialTheme.colorScheme.primary,
         contentColor = MaterialTheme.colorScheme.onPrimary,
         disabledContainerColor = MaterialTheme.colorScheme.primary.copy(
-            alpha = AlphaValues.DISABLED_CONTAINER,
+            alpha = AlphaValues.DISABLED,
         ),
         disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(
-            alpha = AlphaValues.DISABLED_ON_CONTAINER,
+            alpha = AlphaValues.DISABLED,
         ),
     )
 
@@ -105,10 +106,10 @@ fun ButtonPrimaryOverride(
             containerColor = colorPrimaryOverride,
             contentColor = colorOnPrimaryOverride,
             disabledContainerColor = colorPrimaryOverride.copy(
-                alpha = AlphaValues.DISABLED_CONTAINER,
+                alpha = AlphaValues.DISABLED,
             ),
             disabledContentColor = colorOnPrimaryOverride.copy(
-                alpha = AlphaValues.DISABLED_ON_CONTAINER,
+                alpha = AlphaValues.DISABLED,
             ),
         ),
         contentPadding = PaddingValues(

+ 424 - 0
app/src/main/java/ch/threema/app/compose/common/buttons/primary/ButtonPrimaryRounded.kt

@@ -0,0 +1,424 @@
+package ch.threema.app.compose.common.buttons.primary
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.PreviewDynamicColors
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import ch.threema.app.R
+import ch.threema.app.compose.common.SpacerHorizontal
+import ch.threema.app.compose.common.ThemedText
+import ch.threema.app.compose.common.buttons.ButtonIconInfo
+import ch.threema.app.compose.theme.ThreemaThemePreview
+import ch.threema.app.compose.theme.dimens.GridUnit
+import ch.threema.app.utils.compose.stringResourceOrNull
+
+private val minSize = GridUnit.x5
+
+@Composable
+fun ButtonPrimaryRounded(
+    modifier: Modifier = Modifier,
+    onClick: () -> Unit,
+    text: String,
+    enabled: Boolean = true,
+    isLoading: Boolean = false,
+    icon: ButtonIconInfo? = null,
+) {
+    var buttonHeight: Dp by remember { mutableStateOf(minSize) }
+    val colorContainer = if (enabled || isLoading) primaryButtonColors.containerColor else primaryButtonColors.disabledContainerColor
+    val colorContent = if (enabled || isLoading) primaryButtonColors.contentColor else primaryButtonColors.disabledContentColor
+
+    Box(
+        modifier = modifier
+            .sizeIn(
+                minHeight = minSize,
+                minWidth = minSize,
+            )
+            .clip(
+                shape = CircleShape,
+            )
+            .background(
+                color = colorContainer,
+            )
+            .clickable(
+                enabled = enabled,
+                onClick = onClick,
+                role = Role.Button,
+            )
+            .animateContentSize(
+                animationSpec = spring(
+                    dampingRatio = Spring.DampingRatioMediumBouncy,
+                    stiffness = Spring.StiffnessLow,
+                    visibilityThreshold = IntSize.VisibilityThreshold,
+                ),
+            ),
+        contentAlignment = Alignment.Center,
+    ) {
+        CompositionLocalProvider(LocalContentColor provides colorContent) {
+            AnimatedContent(
+                targetState = isLoading,
+                contentAlignment = Alignment.Center,
+            ) { isLoading ->
+                if (isLoading) {
+                    LoadingIndicator(
+                        modifier = Modifier.height(buttonHeight),
+                    )
+                } else {
+                    val density = LocalDensity.current
+                    ButtonPrimaryRoundedBase(
+                        modifier = Modifier.onSizeChanged { size ->
+                            buttonHeight = with(density) { size.height.toDp() }
+                        },
+                        text = text,
+                        icon = icon,
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun ButtonPrimaryRoundedBase(
+    modifier: Modifier,
+    text: String,
+    icon: ButtonIconInfo?,
+) {
+    Row(
+        modifier = modifier
+            .padding(
+                paddingValues = ButtonDefaults.ContentPadding,
+            ),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        if (icon != null) {
+            Icon(
+                modifier = Modifier.size(20.dp),
+                painter = painterResource(icon.icon),
+                contentDescription = stringResourceOrNull(icon.contentDescription),
+                tint = LocalContentColor.current,
+            )
+            SpacerHorizontal(GridUnit.x1)
+        }
+
+        ThemedText(
+            text = text,
+            style = MaterialTheme.typography.labelMedium,
+            color = LocalContentColor.current,
+            maxLines = 1,
+            overflow = TextOverflow.Ellipsis,
+            textAlign = TextAlign.Center,
+        )
+    }
+}
+
+@Composable
+private fun LoadingIndicator(
+    modifier: Modifier,
+) {
+    Box(
+        modifier = modifier
+            .aspectRatio(1f),
+        contentAlignment = Alignment.Center,
+    ) {
+        CircularProgressIndicator(
+            modifier = Modifier.size(GridUnit.x3),
+            strokeWidth = 3.dp,
+            color = LocalContentColor.current,
+        )
+    }
+}
+
+@PreviewLightDark
+@Composable
+fun ButtonPrimaryRounded_Preview() {
+    ThreemaThemePreview {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier.padding(GridUnit.x1),
+                onClick = {},
+                text = "Sign In",
+            )
+        }
+    }
+}
+
+@PreviewLightDark
+@Composable
+fun ButtonPrimaryRounded_Preview_Loading() {
+    ThreemaThemePreview {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier.padding(GridUnit.x1),
+                onClick = {},
+                isLoading = true,
+                text = "Sign In",
+            )
+        }
+    }
+}
+
+@PreviewLightDark
+@Composable
+fun ButtonPrimaryRounded_Preview_Leading_Icon() {
+    ThreemaThemePreview {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier.padding(GridUnit.x1),
+                onClick = {},
+                text = "Sign In",
+                icon = ButtonIconInfo(
+                    icon = R.drawable.ic_language_outline,
+                    contentDescription = null,
+                ),
+            )
+        }
+    }
+}
+
+@PreviewLightDark
+@Composable
+fun ButtonPrimaryRounded_Preview_Leading_Icon_Loading() {
+    ThreemaThemePreview {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier.padding(GridUnit.x1),
+                onClick = {},
+                text = "Sign In",
+                isLoading = true,
+                icon = ButtonIconInfo(
+                    icon = R.drawable.ic_language_outline,
+                    contentDescription = null,
+                ),
+            )
+        }
+    }
+}
+
+@PreviewLightDark
+@Composable
+fun ButtonPrimaryRounded_Preview_Disabled() {
+    ThreemaThemePreview {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier.padding(GridUnit.x1),
+                onClick = {},
+                text = "Sign In",
+                icon = ButtonIconInfo(
+                    icon = R.drawable.ic_language_outline,
+                    contentDescription = null,
+                ),
+                enabled = false,
+            )
+        }
+    }
+}
+
+@PreviewLightDark
+@Composable
+fun ButtonPrimaryRounded_Preview_Disabled_Loading() {
+    ThreemaThemePreview {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier.padding(GridUnit.x1),
+                onClick = {},
+                text = "Sign In",
+                icon = ButtonIconInfo(
+                    icon = R.drawable.ic_language_outline,
+                    contentDescription = null,
+                ),
+                enabled = false,
+                isLoading = true,
+            )
+        }
+    }
+}
+
+@PreviewLightDark
+@Composable
+fun ButtonPrimaryRounded_Preview_FullWidth() {
+    ThreemaThemePreview {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier
+                    .padding(GridUnit.x1)
+                    .fillMaxWidth(),
+                onClick = {},
+                text = "Sign In",
+            )
+        }
+    }
+}
+
+@PreviewLightDark
+@Composable
+fun ButtonPrimaryRounded_Preview_FullWidth_Loading() {
+    ThreemaThemePreview {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier
+                    .padding(GridUnit.x1)
+                    .fillMaxWidth(),
+                onClick = {},
+                text = "Sign In",
+                isLoading = true,
+            )
+        }
+    }
+}
+
+@PreviewDynamicColors
+@Composable
+fun ButtonPrimaryRounded_Preview_DynamicColors() {
+    ThreemaThemePreview(shouldUseDynamicColors = true) {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier.padding(GridUnit.x1),
+                onClick = {},
+                text = "Sign In",
+                icon = ButtonIconInfo(
+                    icon = R.drawable.ic_language_outline,
+                    contentDescription = null,
+                ),
+            )
+        }
+    }
+}
+
+@PreviewDynamicColors
+@Composable
+fun ButtonPrimaryRounded_Preview_DynamicColors_Loading() {
+    ThreemaThemePreview(shouldUseDynamicColors = true) {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier.padding(GridUnit.x1),
+                onClick = {},
+                text = "Sign In",
+                icon = ButtonIconInfo(
+                    icon = R.drawable.ic_language_outline,
+                    contentDescription = null,
+                ),
+                isLoading = true,
+            )
+        }
+    }
+}
+
+@PreviewDynamicColors
+@Composable
+fun ButtonPrimaryRounded_Preview_DynamicColors_Disabled() {
+    ThreemaThemePreview(shouldUseDynamicColors = true) {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier.padding(GridUnit.x1),
+                onClick = {},
+                text = "Sign In",
+                icon = ButtonIconInfo(
+                    icon = R.drawable.ic_language_outline,
+                    contentDescription = null,
+                ),
+                enabled = false,
+            )
+        }
+    }
+}
+
+@PreviewDynamicColors
+@Composable
+fun ButtonPrimaryRounded_Preview_DynamicColors_Disabled_Loading() {
+    ThreemaThemePreview(shouldUseDynamicColors = true) {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier.padding(GridUnit.x1),
+                onClick = {},
+                text = "Sign In",
+                icon = ButtonIconInfo(
+                    icon = R.drawable.ic_language_outline,
+                    contentDescription = null,
+                ),
+                enabled = false,
+                isLoading = true,
+            )
+        }
+    }
+}
+
+@PreviewDynamicColors
+@Composable
+fun ButtonPrimaryRounded_Preview_DynamicColors_Dark() {
+    ThreemaThemePreview(
+        isDarkTheme = true,
+        shouldUseDynamicColors = true,
+    ) {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier.padding(GridUnit.x1),
+                onClick = {},
+                text = "Sign In",
+                icon = ButtonIconInfo(
+                    icon = R.drawable.ic_language_outline,
+                    contentDescription = null,
+                ),
+            )
+        }
+    }
+}
+
+@PreviewDynamicColors
+@Composable
+fun ButtonPrimaryRounded_Preview_DynamicColors_Dark_Loading() {
+    ThreemaThemePreview(
+        isDarkTheme = true,
+        shouldUseDynamicColors = true,
+    ) {
+        Surface {
+            ButtonPrimaryRounded(
+                modifier = Modifier.padding(GridUnit.x1),
+                onClick = {},
+                text = "Sign In",
+                icon = ButtonIconInfo(
+                    icon = R.drawable.ic_language_outline,
+                    contentDescription = null,
+                ),
+                isLoading = true,
+            )
+        }
+    }
+}

+ 2 - 1
app/src/main/java/ch/threema/app/compose/common/buttons/FloatingActionButtonPrimary.kt → app/src/main/java/ch/threema/app/compose/common/buttons/primary/FloatingActionButtonPrimary.kt

@@ -1,4 +1,4 @@
-package ch.threema.app.compose.common.buttons
+package ch.threema.app.compose.common.buttons.primary
 
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.padding
@@ -18,6 +18,7 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.tooling.preview.Preview
 import ch.threema.app.R
 import ch.threema.app.compose.common.ThemedText
+import ch.threema.app.compose.common.buttons.ButtonIconInfo
 import ch.threema.app.compose.theme.ThreemaThemePreview
 import ch.threema.app.compose.theme.dimens.GridUnit
 

+ 4 - 2
app/src/main/java/ch/threema/app/compose/common/text/conversation/ConversationText.kt

@@ -40,6 +40,7 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign
 import androidx.compose.ui.text.PlatformTextStyle
 import androidx.compose.ui.text.TextLayoutResult
 import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -88,6 +89,7 @@ fun ConversationText(
     textStyle: TextStyle = MaterialTheme.typography.bodyMedium,
     color: Color = LocalContentColor.current,
     maxLines: Int = Int.MAX_VALUE,
+    textAlign: TextAlign? = null,
     overflow: TextOverflow = TextOverflow.Ellipsis,
     emojiSettings: EmojiSettings = ConversationTextDefaults.EmojiSettings,
     mentionFeature: MentionFeature = MentionFeature.Off,
@@ -172,6 +174,7 @@ fun ConversationText(
                 ),
             ),
             maxLines = maxLines,
+            textAlign = textAlign,
             overflow = overflow,
             color = color,
             onTextLayout = { layoutResult ->
@@ -354,7 +357,7 @@ private fun TextLayoutResult.getBoundingBoxes(
 @Immutable
 data class EmojiSettings(
     @EmojiStyle val style: Int,
-    val upscalingFeature: EmojiUpscalingFeature,
+    val upscalingFeature: EmojiUpscalingFeature = EmojiUpscalingFeature.Off,
 ) {
     internal val useSystemEmojis: Boolean = style == EMOJI_STYLE_ANDROID
 }
@@ -363,7 +366,6 @@ object ConversationTextDefaults {
 
     val EmojiSettings = EmojiSettings(
         style = ConfigUtils.emojiStyle,
-        upscalingFeature = EmojiUpscalingFeature.Off,
     )
 }
 

+ 5 - 0
app/src/main/java/ch/threema/app/compose/conversation/AvatarAsyncCheckable.kt

@@ -32,6 +32,7 @@ import ch.threema.app.compose.preview.PreviewData
 import ch.threema.app.compose.theme.ThreemaThemePreview
 import ch.threema.app.compose.theme.dimens.GridUnit
 import ch.threema.app.usecases.conversations.AvatarIteration
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.domain.models.ContactReceiverIdentifier
 import ch.threema.domain.models.ReceiverIdentifier
 
@@ -49,6 +50,7 @@ fun AvatarAsyncCheckable(
     contentDescription: String?,
     @DrawableRes fallbackIcon: Int,
     showWorkBadge: Boolean,
+    availabilityStatus: AvailabilityStatus?,
     isChecked: Boolean,
     onClick: () -> Unit,
 ) {
@@ -79,6 +81,7 @@ fun AvatarAsyncCheckable(
                 contentDescription = contentDescription,
                 fallbackIcon = fallbackIcon,
                 showWorkBadge = showWorkBadge,
+                availabilityStatus = availabilityStatus,
                 onClick = onClick,
             )
         } else {
@@ -133,6 +136,7 @@ private fun AvatarAsyncCheckable_Preview_Unchecked() {
                 contentDescription = null,
                 fallbackIcon = R.drawable.ic_contact,
                 showWorkBadge = true,
+                availabilityStatus = AvailabilityStatus.Busy(),
                 isChecked = false,
                 onClick = {},
             )
@@ -154,6 +158,7 @@ private fun AvatarAsyncCheckable_Preview_Checked() {
                 contentDescription = null,
                 fallbackIcon = R.drawable.ic_contact,
                 showWorkBadge = true,
+                availabilityStatus = AvailabilityStatus.Busy(),
                 isChecked = true,
                 onClick = {},
             )

+ 7 - 1
app/src/main/java/ch/threema/app/compose/conversation/ConversationListItem.kt

@@ -73,7 +73,7 @@ import ch.threema.app.R
 import ch.threema.app.compose.common.LocalDayOfYear
 import ch.threema.app.compose.common.SpacerHorizontal
 import ch.threema.app.compose.common.ThemedText
-import ch.threema.app.compose.common.buttons.ButtonPrimaryDense
+import ch.threema.app.compose.common.buttons.primary.ButtonPrimaryDense
 import ch.threema.app.compose.common.immutables.ImmutableBitmap
 import ch.threema.app.compose.common.list.swipe.ListItemSwipeContainer
 import ch.threema.app.compose.common.list.swipe.ListItemSwipeFeature
@@ -104,6 +104,7 @@ import ch.threema.app.voip.groupcall.sfu.CallId
 import ch.threema.common.emptyByteArray
 import ch.threema.common.now
 import ch.threema.common.toHMMSS
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.datatypes.ContactNameFormat
 import ch.threema.data.datatypes.LocalGroupId
 import ch.threema.domain.models.ContactReceiverIdentifier
@@ -376,6 +377,10 @@ private fun AvatarContent(
             is ConversationUiModel.ContactConversation -> conversationUiModel.showWorkBadge
             else -> false
         },
+        availabilityStatus = when (conversationUiModel) {
+            is ConversationUiModel.ContactConversation -> conversationUiModel.availabilityStatus
+            else -> null
+        },
         isChecked = conversationListItemUiModel.isChecked,
         onClick = onClick,
     )
@@ -1061,6 +1066,7 @@ private class PreviewProviderContactConversationListItemItemUiModel : PreviewPar
                 showWorkBadge = showWorkBadge,
                 isTyping = isTyping,
                 avatarIteration = AvatarIteration.initial,
+                availabilityStatus = AvailabilityStatus.Unavailable(),
             ),
             isChecked = isChecked,
             isHighlighted = isHighlighted,

+ 2 - 0
app/src/main/java/ch/threema/app/compose/conversation/models/ConversationUiModel.kt

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
 import ch.threema.android.ResolvableString
 import ch.threema.app.usecases.conversations.AvatarIteration
 import ch.threema.app.utils.TextUtil
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.domain.models.ContactReceiverIdentifier
 import ch.threema.domain.models.DistributionListReceiverIdentifier
 import ch.threema.domain.models.GroupReceiverIdentifier
@@ -57,6 +58,7 @@ sealed interface ConversationUiModel {
         override val avatarIteration: AvatarIteration,
         val showWorkBadge: Boolean,
         val isTyping: Boolean,
+        val availabilityStatus: AvailabilityStatus?,
     ) : ConversationUiModel
 
     @Immutable

+ 1 - 2
app/src/main/java/ch/threema/app/compose/theme/color/AlphaValues.kt

@@ -2,6 +2,5 @@ package ch.threema.app.compose.theme.color
 
 object AlphaValues {
     const val FULLY_OPAQUE: Float = 1f
-    const val DISABLED_CONTAINER: Float = 0.5f
-    const val DISABLED_ON_CONTAINER: Float = 0.5f
+    const val DISABLED: Float = 0.5f
 }

+ 40 - 0
app/src/main/java/ch/threema/app/compose/theme/color/CustomColors.kt

@@ -12,4 +12,44 @@ object CustomColors {
         @Composable
         @ReadOnlyComposable
         get() = colorResource(R.color.settings_multipane_selection_bg)
+
+    val availabilityStatusIconNone: Color
+        @Composable
+        @ReadOnlyComposable
+        get() = colorResource(R.color.availability_status_icon_none)
+
+    val availabilityStatusContainerNone: Color
+        @Composable
+        @ReadOnlyComposable
+        get() = colorResource(R.color.availability_status_container_none)
+
+    val availabilityStatusIconUnavailable: Color
+        @Composable
+        @ReadOnlyComposable
+        get() = colorResource(R.color.availability_status_icon_unavailable)
+
+    val availabilityStatusContainerUnavailable: Color
+        @Composable
+        @ReadOnlyComposable
+        get() = colorResource(R.color.availability_status_container_unavailable)
+
+    val availabilityStatusOnContainerUnavailable: Color
+        @Composable
+        @ReadOnlyComposable
+        get() = colorResource(R.color.availability_status_on_container_unavailable)
+
+    val availabilityStatusIconBusy: Color
+        @Composable
+        @ReadOnlyComposable
+        get() = colorResource(R.color.availability_status_icon_busy)
+
+    val availabilityStatusContainerBusy: Color
+        @Composable
+        @ReadOnlyComposable
+        get() = colorResource(R.color.availability_status_container_busy)
+
+    val availabilityStatusOnContainerBusy: Color
+        @Composable
+        @ReadOnlyComposable
+        get() = colorResource(R.color.availability_status_on_container_busy)
 }

+ 0 - 6
app/src/main/java/ch/threema/app/contactdetails/AvailabilityStatus.kt

@@ -1,6 +0,0 @@
-package ch.threema.app.contactdetails
-
-enum class AvailabilityStatus {
-    AVAILABLE,
-    UNAVAILABLE,
-}

+ 0 - 101
app/src/main/java/ch/threema/app/contactdetails/ContactAvailabilityView.kt

@@ -1,101 +0,0 @@
-package ch.threema.app.contactdetails
-
-import android.content.Context
-import android.util.AttributeSet
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.Icon
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.AbstractComposeView
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import ch.threema.app.R
-import ch.threema.app.compose.common.ThemedText
-import ch.threema.app.compose.theme.AppTypography
-import ch.threema.app.compose.theme.ThreemaTheme
-import ch.threema.app.compose.theme.ThreemaThemePreview
-import ch.threema.app.compose.theme.dimens.GridUnit
-import kotlinx.coroutines.flow.MutableStateFlow
-
-class ContactAvailabilityView
-@JvmOverloads
-constructor(
-    context: Context,
-    attrs: AttributeSet? = null,
-    defStyleAttr: Int = 0,
-) : AbstractComposeView(context, attrs, defStyleAttr) {
-
-    private val statusFlow = MutableStateFlow(AvailabilityStatus.AVAILABLE)
-
-    fun setStatus(status: AvailabilityStatus) {
-        statusFlow.value = status
-    }
-
-    @Composable
-    override fun Content() {
-        val status by statusFlow.collectAsStateWithLifecycle()
-
-        ThreemaTheme {
-            ContactAvailability(status)
-        }
-    }
-}
-
-@Composable
-private fun ContactAvailability(status: AvailabilityStatus) {
-    Row(
-        modifier = Modifier.fillMaxWidth()
-            .padding(vertical = GridUnit.x1),
-        horizontalArrangement = Arrangement.spacedBy(GridUnit.x0_5),
-        verticalAlignment = Alignment.CenterVertically,
-    ) {
-        Icon(
-            painter = when (status) {
-                AvailabilityStatus.AVAILABLE -> painterResource(R.drawable.ic_check)
-                AvailabilityStatus.UNAVAILABLE -> painterResource(R.drawable.ic_block)
-            },
-            tint = when (status) {
-                AvailabilityStatus.AVAILABLE -> colorResource(R.color.availability_status_available)
-                AvailabilityStatus.UNAVAILABLE -> colorResource(R.color.availability_status_unavailable)
-            },
-            contentDescription = null,
-            modifier = Modifier.size(16.dp),
-        )
-
-        ThemedText(
-            text = when (status) {
-                AvailabilityStatus.AVAILABLE -> stringResource(R.string.contact_availability_status_available)
-                AvailabilityStatus.UNAVAILABLE -> stringResource(R.string.contact_availability_status_unavailable)
-            },
-            style = AppTypography.bodyMedium,
-            fontWeight = FontWeight.Bold,
-        )
-    }
-}
-
-@Preview
-@Composable
-private fun ContactAvailability_Preview_Available() {
-    ThreemaThemePreview {
-        ContactAvailability(status = AvailabilityStatus.AVAILABLE)
-    }
-}
-
-@Preview
-@Composable
-private fun ContactAvailability_Preview_UnAvailable() {
-    ThreemaThemePreview {
-        ContactAvailability(status = AvailabilityStatus.UNAVAILABLE)
-    }
-}

+ 6 - 4
app/src/main/java/ch/threema/app/contactdetails/ContactDetailActivity.java

@@ -180,10 +180,11 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
             logger.error("View model is null. Cannot refresh adapter.");
             return;
         }
-
-        ContactModelData fetchedData = viewModel.contactModelData.getValue();
-        if (fetchedData != null) {
-            contactDetailRecyclerView.setAdapter(setupAdapter(fetchedData));
+        final @Nullable ContactModelData contactModelData = viewModel.contactModelData.getValue();
+        if (contactModelData != null) {
+            contactDetailRecyclerView.setAdapter(
+                setupAdapter(contactModelData)
+            );
         }
     }
 
@@ -583,6 +584,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
             this.groupList,
             viewModel.getContactModel(),
             contactModelData,
+            dependencies.getPreferenceService().getEmojiStyle(),
             Glide.with(this)
         );
 

+ 25 - 5
app/src/main/java/ch/threema/app/contactdetails/ContactDetailAdapter.java

@@ -29,12 +29,14 @@ import androidx.annotation.StringRes;
 import androidx.annotation.UiThread;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.recyclerview.widget.RecyclerView;
-import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.availabilitystatus.AvailabilityStatusLabelView;
 import ch.threema.app.dialogs.PublicKeyDialog;
 import ch.threema.app.glide.AvatarOptions;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.preference.service.PreferenceService;
+import ch.threema.app.preference.service.PreferenceService.EmojiStyle;
 import ch.threema.app.preference.service.SynchronizedSettingsService;
 import ch.threema.app.services.BlockedIdentitiesService;
 import ch.threema.app.services.ContactService;
@@ -45,7 +47,10 @@ import ch.threema.app.ui.VerificationLevelImageView;
 import ch.threema.app.utils.AndroidContactUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ViewUtil;
+
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+
+import ch.threema.data.datatypes.AvailabilityStatus;
 import ch.threema.data.models.ContactModelData;
 import ch.threema.domain.models.ReadReceiptPolicy;
 import ch.threema.domain.models.TypingIndicatorPolicy;
@@ -83,6 +88,7 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
     private final BlockedIdentitiesService blockedIdentitiesService;
     private final @NonNull ch.threema.data.models.ContactModel contactModel;
     private final @NonNull ContactModelData contactModelData;
+    private final @EmojiStyle int emojiStyle;
     private final List<GroupModelOld> values;
     private OnClickListener onClickListener;
     private final @NonNull RequestManager requestManager;
@@ -108,7 +114,8 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
         private final @NonNull SectionHeaderView departmentHeaderView;
         private final @NonNull TextView departmentTextView;
         private final @NonNull TextView threemaIdView;
-        private final @NonNull ContactAvailabilityView availabilityView;
+        private final @NonNull LinearLayout availabilityStatusContainer;
+        private final @NonNull AvailabilityStatusLabelView availabilityStatusLabelView;
         private final @NonNull MaterialSwitch synchronize;
         private final @NonNull View nicknameContainer, synchronizeContainer;
         private final @NonNull ImageView syncSourceIcon;
@@ -127,7 +134,8 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
             this.departmentHeaderView = Objects.requireNonNull(itemView.findViewById(R.id.header_department));
             this.departmentTextView = Objects.requireNonNull(itemView.findViewById(R.id.value_department));
             this.threemaIdView = Objects.requireNonNull(itemView.findViewById(R.id.threema_id));
-            this.availabilityView = Objects.requireNonNull(itemView.findViewById(R.id.availability_status));
+            this.availabilityStatusContainer = Objects.requireNonNull(itemView.findViewById(R.id.availability_status_container));
+            this.availabilityStatusLabelView = Objects.requireNonNull(itemView.findViewById(R.id.availability_status_view));
             this.verificationLevelImageView = Objects.requireNonNull(itemView.findViewById(R.id.verification_level_image));
             ImageView verificationLevelIconView = Objects.requireNonNull(itemView.findViewById(R.id.verification_information_icon));
             this.synchronize = Objects.requireNonNull(itemView.findViewById(R.id.synchronize_contact));
@@ -152,8 +160,18 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
                 return true;
             });
 
-            if (!BuildConfig.AVAILABILITY_STATUS_ENABLED) {
-                availabilityView.setVisibility(View.GONE);
+            if (ConfigUtils.supportsAvailabilityStatus()) {
+                availabilityStatusContainer.setVisibility(
+                    contactModelData.availabilityStatus instanceof AvailabilityStatus.Set
+                        ? View.VISIBLE
+                        : View.GONE
+                );
+                availabilityStatusLabelView.setState(
+                    contactModelData.availabilityStatus instanceof AvailabilityStatus.Set
+                        ? (AvailabilityStatus.Set) contactModelData.availabilityStatus
+                        : null,
+                    emojiStyle
+                );
             }
 
             // When clicking ten times on the Threema ID, the clear forward security session button
@@ -214,12 +232,14 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
         List<GroupModelOld> values,
         @NonNull ch.threema.data.models.ContactModel contactModel,
         @NonNull ContactModelData contactModelData,
+        @EmojiStyle int emojiStyle,
         @NonNull RequestManager requestManager
     ) {
         this.context = context;
         this.values = values;
         this.contactModel = contactModel;
         this.contactModelData = contactModelData;
+        this.emojiStyle = emojiStyle;
         this.requestManager = requestManager;
 
         ServiceManager serviceManager = ThreemaApplication.requireServiceManager();

+ 2 - 0
app/src/main/java/ch/threema/app/di/DependencyContainer.kt

@@ -33,6 +33,7 @@ import ch.threema.app.services.WallpaperService
 import ch.threema.app.services.ballot.BallotService
 import ch.threema.app.services.license.LicenseService
 import ch.threema.app.services.notification.NotificationService
+import ch.threema.app.stores.IdentityProvider
 import ch.threema.app.tasks.TaskCreator
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig
 import ch.threema.app.threemasafe.ThreemaSafeService
@@ -93,6 +94,7 @@ class DependencyContainer : KoinComponent {
     val groupFlowDispatcher: GroupFlowDispatcher by inject()
     val groupModelRepository: GroupModelRepository by inject()
     val groupService: GroupService by inject()
+    val identityProvider: IdentityProvider by inject()
     val identityStore: IdentityStore by inject()
     val licenseService: LicenseService<*> by inject()
     val lifetimeService: LifetimeService by inject()

+ 25 - 13
app/src/main/java/ch/threema/app/di/MasterKeyLockStateChangeHandler.kt

@@ -2,6 +2,7 @@ package ch.threema.app.di
 
 import android.content.Context
 import ch.threema.app.GlobalListeners
+import ch.threema.app.androidcontactsync.AndroidContactChangeMonitor
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.preference.service.PreferenceService
 import ch.threema.app.services.ServiceManagerProviderImpl
@@ -17,15 +18,17 @@ import ch.threema.app.workers.ShareTargetUpdateWorker
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.storage.DatabaseProviderImpl
 import ch.threema.storage.DatabaseState
+import kotlin.system.exitProcess
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.TimeoutCancellationException
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withTimeout
 import org.koin.core.component.KoinComponent
-import org.koin.core.component.get
+import org.koin.core.component.inject
 
 private val logger = getThreemaLogger("MasterKeyLockStateChangeHandler")
 
@@ -43,6 +46,8 @@ class MasterKeyLockStateChangeHandler(
     @Volatile
     private var globalListeners: GlobalListeners? = null
 
+    private val androidContactChangeMonitor: AndroidContactChangeMonitor by inject()
+
     fun onMasterKeyUnlocked(
         serviceManager: ServiceManager,
         databaseState: StateFlow<DatabaseState>,
@@ -51,7 +56,7 @@ class MasterKeyLockStateChangeHandler(
         serviceManagerProvider.setServiceManager(serviceManager)
 
         appStartupMonitor.onMasterKeyUnlocked(databaseState, systemUpdateState)
-        globalListeners = GlobalListeners(appContext, serviceManager).apply {
+        globalListeners = GlobalListeners(appContext, androidContactChangeMonitor, serviceManager).apply {
             setUp()
         }
 
@@ -59,6 +64,7 @@ class MasterKeyLockStateChangeHandler(
     }
 
     suspend fun onMasterKeyLocked() = coroutineScope {
+        logger.info("Master key locked")
         appStartupMonitor.onMasterKeyLocked()
 
         serviceManagerProvider.getServiceManagerOrNull()
@@ -76,14 +82,19 @@ class MasterKeyLockStateChangeHandler(
     }
 
     private suspend fun cleanUpSession(serviceManager: ServiceManager) {
-        withTimeout(SERVICE_MANAGER_CLEANUP_TIMEOUT) {
-            stopOngoingCalls(serviceManager)
-            dismissNotifications(serviceManager)
-            stopConnection(serviceManager)
-            deleteShareTargetShortcuts()
-            stopWebClientSession(serviceManager)
-            clearAvatarCache(serviceManager)
-            closeDatabases(serviceManager)
+        try {
+            withTimeout(SERVICE_MANAGER_CLEANUP_TIMEOUT) {
+                stopOngoingCalls(serviceManager)
+                dismissNotifications(serviceManager)
+                stopConnection(serviceManager)
+                deleteShareTargetShortcuts()
+                stopWebClientSession(serviceManager)
+                clearAvatarCache(serviceManager)
+                closeDatabases(serviceManager)
+            }
+        } catch (e: TimeoutCancellationException) {
+            logger.error("Failed to clean up the session within {}", SERVICE_MANAGER_CLEANUP_TIMEOUT, e)
+            exitProcess(1)
         }
 
         delay(SERVICE_MANAGER_CLEANUP_GRACE_PERIOD)
@@ -135,10 +146,11 @@ class MasterKeyLockStateChangeHandler(
 
     companion object {
         /**
-         * Locking the master key must complete quickly. If it doesn't, we'd rather crash the app to ensure
-         * that the master key is cleared from memory than to linger here and wait for the full cleanup.
+         * When the master key is locked, we need to clean up various services, caches, etc. This must complete quickly. If it doesn't,
+         * we'd rather stop the app entirely than to linger here and wait for the full cleanup, to ensure that potentially sensitive user data
+         * is cleared from memory.
          */
-        private val SERVICE_MANAGER_CLEANUP_TIMEOUT = 3.seconds
+        private val SERVICE_MANAGER_CLEANUP_TIMEOUT = 10.seconds
 
         /**
          * we grant a short grace period for services to shutdown properly before the service manager is closed,

+ 12 - 1
app/src/main/java/ch/threema/app/di/modules/Features.kt

@@ -1,11 +1,13 @@
 package ch.threema.app.di.modules
 
+import ch.threema.app.activities.directory.directoryFeatureModule
 import ch.threema.app.activities.referral.referralFeatureModule
 import ch.threema.app.activities.starred.starredFeatureModule
 import ch.threema.app.androidcontactsync.androidContactFeatureModule
 import ch.threema.app.applock.appLockFeatureModule
 import ch.threema.app.apptaskexecutor.appTaskExecutorFeatureModule
 import ch.threema.app.archive.archiveFeatureModule
+import ch.threema.app.availabilitystatus.availabilityStatusFeatureModule
 import ch.threema.app.camera.cameraFeatureModule
 import ch.threema.app.compose.edithistory.editHistoryFeatureModule
 import ch.threema.app.contactdetails.contactDetailsFeatureModule
@@ -19,6 +21,7 @@ import ch.threema.app.fragments.composemessage.composeMessageFeatureModule
 import ch.threema.app.fragments.conversations.conversationsFeatureModule
 import ch.threema.app.globalsearch.globalSearchFeatureModule
 import ch.threema.app.home.homeFeatureModule
+import ch.threema.app.identitylinks.identityLinkFeatureModule
 import ch.threema.app.location.locationFeatureModule
 import ch.threema.app.logging.loggingFeatureModule
 import ch.threema.app.mediaattacher.mediaAttacherFeatureModule
@@ -45,6 +48,7 @@ import ch.threema.app.voicemessage.voiceMessageFeatureModule
 import ch.threema.app.voip.voipFeatureModule
 import ch.threema.app.webclient.webclientFeatureModule
 import ch.threema.app.widget.widgetFeatureModule
+import ch.threema.app.work.workFeatureModule
 import ch.threema.app.workers.workersFeatureModule
 import ch.threema.localcrypto.localCryptoFeatureModule
 import org.koin.core.module.dsl.viewModelOf
@@ -59,12 +63,14 @@ val featuresModule = module {
         appLockFeatureModule,
         appRestrictionsFeatureModule,
         appTaskExecutorFeatureModule,
-        conversationsFeatureModule,
+        availabilityStatusFeatureModule,
         archiveFeatureModule,
+        conversationsFeatureModule,
         cameraFeatureModule,
         composeMessageFeatureModule,
         contactDetailsFeatureModule,
         devFeatureModule,
+        directoryFeatureModule,
         draftsFeatureModule,
         editHistoryFeatureModule,
         emojiReactionsFeatureModule,
@@ -72,6 +78,7 @@ val featuresModule = module {
         filesFeatureModule,
         globalSearchFeatureModule,
         homeFeatureModule,
+        identityLinkFeatureModule,
         loggingFeatureModule,
         localCryptoFeatureModule,
         locationFeatureModule,
@@ -98,6 +105,10 @@ val featuresModule = module {
         workersFeatureModule,
     )
 
+    if (ConfigUtils.isWorkBuild()) {
+        includes(workFeatureModule)
+    }
+
     if (ConfigUtils.isOnPremBuild()) {
         includes(onPremFeatureModule)
     }

+ 1 - 1
app/src/main/java/ch/threema/app/files/TempFilesCleanupWorker.kt

@@ -55,7 +55,7 @@ class TempFilesCleanupWorker(
                 } else if (file.isFile) {
                     try {
                         logger.info("Deleting temp file {}", file.path)
-                        file.deleteSecurely()
+                        file.deleteSecurely(applicationContext.filesDir)
                     } catch (e: IOException) {
                         logger.error("Failed to delete temp file", e)
                     }

+ 70 - 0
app/src/main/java/ch/threema/app/fragments/MyIDFragment.kt

@@ -8,13 +8,18 @@ import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
 import android.widget.ArrayAdapter
+import android.widget.ImageView
+import android.widget.LinearLayout
 import android.widget.TextView
+import android.widget.Toast.LENGTH_SHORT
 import androidx.annotation.StringRes
 import androidx.annotation.UiThread
 import androidx.core.view.isVisible
 import androidx.core.widget.NestedScrollView
 import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.setFragmentResultListener
 import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
 import ch.threema.android.ToastDuration
 import ch.threema.android.showToast
 import ch.threema.app.AppConstants
@@ -22,6 +27,9 @@ import ch.threema.app.R
 import ch.threema.app.activities.ExportIDActivity
 import ch.threema.app.activities.ProfilePicRecipientsActivity
 import ch.threema.app.applock.CheckAppLockContract
+import ch.threema.app.availabilitystatus.displayText
+import ch.threema.app.availabilitystatus.edit.EditAvailabilityStatusBottomSheetDialog
+import ch.threema.app.availabilitystatus.iconRes
 import ch.threema.app.dialogs.GenericAlertDialog
 import ch.threema.app.dialogs.GenericAlertDialog.DialogClickListener
 import ch.threema.app.dialogs.GenericProgressDialog
@@ -55,10 +63,12 @@ import ch.threema.app.utils.LocaleUtil
 import ch.threema.app.utils.ShareUtil
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.common.takeUnlessEmpty
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.domain.protocol.api.LinkMobileNoException
 import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.domain.taskmanager.TriggerSource
 import com.google.android.material.button.MaterialButton
+import com.google.android.material.snackbar.Snackbar
 import com.google.android.material.textfield.MaterialAutoCompleteTextView
 import kotlin.system.exitProcess
 import kotlinx.coroutines.launch
@@ -87,6 +97,9 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
     private var picReleaseConfigView: View? = null
     private var picReleaseSpinner: MaterialAutoCompleteTextView? = null
     private var nicknameTextView: EmojiTextView? = null
+    private var currentAvailabilityStatusContainer: LinearLayout? = null
+    private var currentAvailabilityStatusIcon: ImageView? = null
+    private var currentAvailabilityStatusName: EmojiTextView? = null
 
     private var hidden = false
     private var isDisabledProfilePicReleaseSettings = false
@@ -152,6 +165,17 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         retainInstance = true
+
+        if (ConfigUtils.supportsAvailabilityStatus()) {
+            setFragmentResultListener(
+                requestKey = REQUEST_KEY_EDIT_AVAILABILITY_STATUS,
+            ) { _, bundle ->
+                val didChangeStatus = bundle.getBoolean(EditAvailabilityStatusBottomSheetDialog.RESULT_KEY_DID_CHANGE_STATUS)
+                if (didChangeStatus && this.view != null && isAdded) {
+                    Snackbar.make(requireView(), R.string.edit_availability_status_did_change, LENGTH_SHORT).show()
+                }
+            }
+        }
     }
 
     override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@@ -195,10 +219,17 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
         val picReleaseSpinner = fragmentView.findViewById<MaterialAutoCompleteTextView>(R.id.picrelease_spinner)
         val picReleaseConfigView = fragmentView.findViewById<View>(R.id.picrelease_config)
         val picReleaseTextView = fragmentView.findViewById<View>(R.id.picrelease_text)
+        val currentAvailabilityStatusContainer = fragmentView.findViewById<LinearLayout>(R.id.current_availability_status_container)
+        val currentAvailabilityStatusIcon = fragmentView.findViewById<ImageView>(R.id.current_availability_status_icon)
+        val currentAvailabilityStatusName = fragmentView.findViewById<EmojiTextView>(R.id.current_availability_status_name)
+        val changeAvailabilityStatusButton = fragmentView.findViewById<MaterialButton>(R.id.change_availability_status)
         this.avatarView = avatarView
         this.nicknameTextView = nicknameTextView
         this.picReleaseConfigView = picReleaseConfigView
         this.picReleaseSpinner = picReleaseSpinner
+        this.currentAvailabilityStatusContainer = currentAvailabilityStatusContainer
+        this.currentAvailabilityStatusIcon = currentAvailabilityStatusIcon
+        this.currentAvailabilityStatusName = currentAvailabilityStatusName
 
         picReleaseConfigView.setOnClickListener {
             launchProfilePictureRecipientsSelector()
@@ -207,6 +238,25 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
 
         policyExplainView.isVisible = isReadonlyProfile || isBackupsDisabled
 
+        currentAvailabilityStatusContainer.isVisible = ConfigUtils.supportsAvailabilityStatus()
+        if (ConfigUtils.supportsAvailabilityStatus()) {
+            setUpAvailabilityStatusDisplay(
+                availabilityStatus = preferenceService.getAvailabilityStatus(),
+            )
+            changeAvailabilityStatusButton.setOnClickListener {
+                EditAvailabilityStatusBottomSheetDialog
+                    .newInstance(
+                        requestKey = REQUEST_KEY_EDIT_AVAILABILITY_STATUS,
+                    )
+                    .show(
+                        /* manager = */
+                        parentFragmentManager,
+                        /* tag = */
+                        "edit-availability-status-from-user-profile",
+                    )
+            }
+        }
+
         if (isDisabledProfilePicReleaseSettings) {
             picReleaseSpinner.isVisible = false
             picReleaseConfigView.isVisible = false
@@ -296,6 +346,24 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
         return fragmentView
     }
 
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        if (ConfigUtils.supportsAvailabilityStatus()) {
+            viewLifecycleOwner.lifecycleScope.launch {
+                viewLifecycleOwner.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
+                    preferenceService.watchAvailabilityStatus().collect(::setUpAvailabilityStatusDisplay)
+                }
+            }
+        }
+    }
+
+    private fun setUpAvailabilityStatusDisplay(availabilityStatus: AvailabilityStatus?) {
+        val availabilityStatusEffective = availabilityStatus ?: AvailabilityStatus.None
+        currentAvailabilityStatusIcon?.setImageResource(availabilityStatusEffective.iconRes())
+        currentAvailabilityStatusName?.text = availabilityStatusEffective.displayText().get(requireContext())
+    }
+
     private fun setupPicReleaseSpinner() {
         val spinner = picReleaseSpinner ?: return
 
@@ -878,5 +946,7 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
         private const val DIALOG_TAG_DELETE_ID = "deleteId"
         private const val DIALOG_TAG_LINKED_MOBILE_CONFIRM = "cfm"
         private const val DIALOG_TAG_VERIFY_MOBILE_ERROR = "ve"
+
+        private const val REQUEST_KEY_EDIT_AVAILABILITY_STATUS = "edit-availability-status-from-user-profile"
     }
 }

+ 1 - 1
app/src/main/java/ch/threema/app/fragments/WorkUserListFragment.kt

@@ -13,7 +13,7 @@ import android.widget.TextView
 import androidx.lifecycle.lifecycleScope
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
-import ch.threema.app.activities.DirectoryActivity
+import ch.threema.app.activities.directory.DirectoryActivity
 import ch.threema.app.adapters.UserListAdapter
 import ch.threema.app.preference.service.PreferenceService
 import ch.threema.app.services.BlockedIdentitiesService

+ 63 - 0
app/src/main/java/ch/threema/app/fragments/composemessage/ComposeMessageFragment.java

@@ -149,6 +149,8 @@ import ch.threema.app.adapters.decorators.ImageChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.MessagePlayerFactory;
 import ch.threema.app.adapters.decorators.VoipStatusDataChatAdapterDecorator;
 import ch.threema.app.asynctasks.EmptyOrDeleteConversationsAsyncTask;
+import ch.threema.app.availabilitystatus.AvailabilityStatusContactBannerView;
+import ch.threema.app.availabilitystatus.AvailabilityStatusIconElevatedView;
 import ch.threema.app.cache.ThumbnailCache;
 import ch.threema.app.compose.common.interop.ComposeJavaBridge;
 import ch.threema.app.contactdetails.ContactDetailActivity;
@@ -274,8 +276,10 @@ import ch.threema.app.voip.services.VoipCallService;
 import ch.threema.app.voip.services.VoipStateService;
 import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.app.webviews.WorkExplainActivity;
+import ch.threema.data.datatypes.AvailabilityStatus;
 import ch.threema.data.datatypes.IdColor;
 import ch.threema.data.datatypes.NotificationTriggerPolicyOverride;
+import ch.threema.data.models.ContactModelData;
 import ch.threema.data.models.GroupIdentity;
 import ch.threema.data.models.GroupModel;
 import ch.threema.data.models.GroupModelData;
@@ -509,6 +513,7 @@ public class ComposeMessageFragment extends Fragment implements
     private TextView actionBarSubtitleTextView;
     private VerificationLevelImageView actionBarSubtitleImageView;
     private AvatarView actionBarAvatarView;
+    private AvailabilityStatusIconElevatedView availabilityStatusAvatarIconView;
     private ImageView wallpaperView;
     private ActionBar actionBar;
     private TooltipPopup workTooltipPopup;
@@ -516,6 +521,7 @@ public class ComposeMessageFragment extends Fragment implements
     private QuotePopup quotePopup;
     private OpenBallotNoticeView openBallotNoticeView;
     private ReportSpamView reportSpamView;
+    private AvailabilityStatusContactBannerView availabilityStatusBannerView;
     private ComposeMessageActivity activity;
     private View fragmentView;
     private FrameLayout coordinatorLayout;
@@ -1340,6 +1346,8 @@ public class ComposeMessageFragment extends Fragment implements
             this.reportSpamView = this.fragmentView.findViewById(R.id.report_spam_layout);
             this.reportSpamView.setListener(this);
 
+            this.availabilityStatusBannerView = this.fragmentView.findViewById(R.id.availability_status_banner_view);
+
             quickscrollDownContainer.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
                 @Override
                 public void onGlobalLayout() {
@@ -1429,6 +1437,7 @@ public class ComposeMessageFragment extends Fragment implements
 
     private void setObservers() {
         viewModel.getEvents().observe(getViewLifecycleOwner(), this::handleViewModelEvent);
+        viewModel.getContactAvailabilityStatus().observe(getViewLifecycleOwner(), this::onContactAvailabilityStatusChanged);
     }
 
     private void handleViewModelEvent(@NonNull ComposeMessageEvent event) {
@@ -1437,6 +1446,39 @@ public class ComposeMessageFragment extends Fragment implements
         }
     }
 
+    private void onContactAvailabilityStatusChanged(@NonNull AvailabilityStatus availabilityStatus) {
+        if (!ConfigUtils.supportsAvailabilityStatus()) {
+            return;
+        }
+        // Toolbar avatar icon
+        if (availabilityStatusAvatarIconView != null) {
+            availabilityStatusAvatarIconView.setVisibility(
+                availabilityStatus instanceof AvailabilityStatus.Set
+                    ? View.VISIBLE
+                    : View.GONE
+            );
+            availabilityStatusAvatarIconView.setStatus(
+                availabilityStatus instanceof AvailabilityStatus.Set
+                    ? (AvailabilityStatus.Set) availabilityStatus
+                    : null
+            );
+        }
+        // Banner
+        if (availabilityStatusBannerView != null) {
+            availabilityStatusBannerView.setVisibility(
+                availabilityStatus instanceof AvailabilityStatus.Set
+                    ? View.VISIBLE
+                    : View.GONE
+            );
+            availabilityStatusBannerView.setState(
+                availabilityStatus instanceof AvailabilityStatus.Set
+                    ? (AvailabilityStatus.Set) availabilityStatus
+                    : null,
+                preferenceService.getEmojiStyle()
+            );
+        }
+    }
+
     private void onNextRecordsLoadedEvent(@NonNull ComposeMessageEvent.NextRecordsLoaded nextRecodsLoadedEvent) {
         valuesLoaded(nextRecodsLoadedEvent.messageModels);
         if (composeMessageAdapter != null) {
@@ -1738,6 +1780,8 @@ public class ComposeMessageFragment extends Fragment implements
         }
 
         updateOngoingCallNotice();
+
+        viewModel.onResume(messageReceiver);
     }
 
     @Override
@@ -2414,6 +2458,7 @@ public class ComposeMessageFragment extends Fragment implements
             this.actionBarSubtitleImageView = actionBarTitleView.findViewById(R.id.subtitle_image);
             this.actionBarSubtitleTextView = actionBarTitleView.findViewById(R.id.subtitle_text);
             this.actionBarAvatarView = actionBarTitleView.findViewById(R.id.avatar_view);
+            this.availabilityStatusAvatarIconView = actionBarTitleView.findViewById(R.id.availability_status_avatar_icon);
             final RelativeLayout actionBarTitleContainer = actionBarTitleView.findViewById(R.id.title_container);
             actionBarTitleContainer.setOnClickListener(v -> {
                 Intent intent;
@@ -2434,6 +2479,24 @@ public class ComposeMessageFragment extends Fragment implements
                 }
             });
 
+            if (ConfigUtils.supportsAvailabilityStatus() && messageReceiver != null && messageReceiver instanceof ContactMessageReceiver) {
+                final @Nullable ch.threema.data.models.ContactModel contactModel = ((ContactMessageReceiver) messageReceiver).getContactModel();
+                final @Nullable ContactModelData contactModelData = contactModel != null ? contactModel.getData() : null;
+                final @Nullable AvailabilityStatus availabilityStatus = contactModelData != null ? contactModelData.availabilityStatus : null;
+                if (availabilityStatus != null) {
+                    availabilityStatusAvatarIconView.setVisibility(
+                        availabilityStatus instanceof AvailabilityStatus.Set
+                            ? View.VISIBLE
+                            : View.GONE
+                    );
+                    availabilityStatusAvatarIconView.setStatus(
+                        availabilityStatus instanceof AvailabilityStatus.Set
+                            ? (AvailabilityStatus.Set) availabilityStatus
+                            : null
+                    );
+                }
+            }
+
             if (contactModel != null) {
                 if (contactModel.getIdentityType() == IdentityType.WORK) {
                     if (!ConfigUtils.isWorkBuild()) {

+ 23 - 0
app/src/main/java/ch/threema/app/fragments/composemessage/ComposeMessageViewModel.kt

@@ -1,23 +1,46 @@
 package ch.threema.app.fragments.composemessage
 
 import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
+import ch.threema.app.messagereceiver.ContactMessageReceiver
 import ch.threema.app.messagereceiver.MessageReceiver
 import ch.threema.app.services.MessageService
+import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.DispatcherProvider
 import ch.threema.app.utils.SingleLiveEvent
+import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.repositories.ContactModelRepository
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 
 class ComposeMessageViewModel(
     private val messageService: MessageService,
     private val dispatcherProvider: DispatcherProvider,
+    private val contactModelRepository: ContactModelRepository,
 ) : ViewModel() {
 
     private val _events = SingleLiveEvent<ComposeMessageEvent>()
     val events: LiveData<ComposeMessageEvent> = _events
 
+    private val _contactAvailabilityStatus = MutableLiveData<AvailabilityStatus>(AvailabilityStatus.None)
+    val contactAvailabilityStatus: LiveData<AvailabilityStatus> = _contactAvailabilityStatus
+
+    fun onResume(messageReceiver: MessageReceiver<*>) {
+        if (ConfigUtils.supportsAvailabilityStatus() && messageReceiver is ContactMessageReceiver) {
+            viewModelScope.launch {
+                contactModelRepository
+                    .getByIdentity(messageReceiver.contact.identity)
+                    ?.data
+                    ?.availabilityStatus
+                    ?.let { availabilityStatus ->
+                        _contactAvailabilityStatus.postValue(availabilityStatus)
+                    }
+            }
+        }
+    }
+
     fun loadNextRecords(
         messageReceiver: MessageReceiver<*>,
         filter: MessageService.MessageFilter,

+ 132 - 47
app/src/main/java/ch/threema/app/fragments/conversations/ConversationsFragment.kt

@@ -10,7 +10,6 @@ import android.view.MenuInflater
 import android.view.MenuItem
 import android.view.View
 import android.view.ViewGroup
-import android.widget.Toast.LENGTH_SHORT
 import androidx.activity.result.launch
 import androidx.annotation.AnyThread
 import androidx.annotation.StringRes
@@ -26,6 +25,8 @@ import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
 import androidx.compose.foundation.layout.displayCutout
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
@@ -34,6 +35,7 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.systemBars
 import androidx.compose.foundation.layout.union
+import androidx.compose.foundation.layout.widthIn
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyListState
 import androidx.compose.foundation.lazy.items
@@ -44,6 +46,8 @@ import androidx.compose.material3.Icon
 import androidx.compose.material3.LocalContentColor
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.derivedStateOf
@@ -51,19 +55,23 @@ import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.res.colorResource
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.pluralStringResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
 import androidx.core.view.MenuItemCompat
+import androidx.fragment.app.setFragmentResultListener
 import ch.threema.app.AppConstants
 import ch.threema.app.AppConstants.MAX_PW_LENGTH_BACKUP
 import ch.threema.app.AppConstants.MIN_PW_LENGTH_BACKUP
@@ -76,13 +84,15 @@ import ch.threema.app.activities.ThreemaActivity
 import ch.threema.app.applock.AppLockUtil
 import ch.threema.app.applock.CheckAppLockContract
 import ch.threema.app.archive.ArchiveActivity
+import ch.threema.app.availabilitystatus.AvailabilityStatusOwnBanner
+import ch.threema.app.availabilitystatus.edit.EditAvailabilityStatusBottomSheetDialog
 import ch.threema.app.backuprestore.BackupChatService
 import ch.threema.app.compose.common.LocalDayOfYear
 import ch.threema.app.compose.common.SpacerVertical
 import ch.threema.app.compose.common.ThemedText
 import ch.threema.app.compose.common.buttons.ButtonIconInfo
 import ch.threema.app.compose.common.buttons.ButtonOutlined
-import ch.threema.app.compose.common.buttons.ExtendedFloatingActionButtonPrimary
+import ch.threema.app.compose.common.buttons.primary.ExtendedFloatingActionButtonPrimary
 import ch.threema.app.compose.common.list.swipe.ListItemSwipeFeature
 import ch.threema.app.compose.common.list.swipe.ListItemSwipeFeatureState
 import ch.threema.app.compose.common.rememberRefreshingLocalDayOfYear
@@ -140,6 +150,7 @@ import ch.threema.base.utils.getThreemaLogger
 import ch.threema.base.utils.onCompleted
 import ch.threema.common.consume
 import ch.threema.common.toIntCapped
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.datatypes.ContactNameFormat
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModel
@@ -164,6 +175,7 @@ import kotlin.time.Duration
 import kotlin.time.Duration.Companion.seconds
 import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
 import org.koin.android.ext.android.inject
 import org.koin.androidx.viewmodel.ext.android.viewModel
 import org.koin.core.parameter.parametersOf
@@ -258,6 +270,17 @@ class ConversationsFragment :
         ListenerManager.groupListeners.add(groupListener)
 
         this.resumePauseHandler = ResumePauseHandler.getByActivity(this, requireActivity())
+
+        if (ConfigUtils.supportsAvailabilityStatus()) {
+            setFragmentResultListener(
+                requestKey = REQUEST_KEY_EDIT_AVAILABILITY_STATUS,
+            ) { _, bundle ->
+                val didChangeStatus = bundle.getBoolean(EditAvailabilityStatusBottomSheetDialog.RESULT_KEY_DID_CHANGE_STATUS)
+                if (didChangeStatus) {
+                    viewModel.onAvailabilityStatusWasChanged()
+                }
+            }
+        }
     }
 
     override fun onDestroy() {
@@ -738,7 +761,21 @@ class ConversationsFragment :
         myIdentity = userService.getIdentity()?.toIdentityOrNull()
         return ComposeView(requireContext()).apply {
             setContent {
-                EventHandler(viewModel, ::handleEvent)
+                val snackbarHostState = remember { SnackbarHostState() }
+                val scope = rememberCoroutineScope()
+
+                EventHandler(viewModel) { event ->
+                    handleEvent(
+                        event = event,
+                        requestShowSnackbar = { messageRes ->
+                            scope.launch {
+                                snackbarHostState.showSnackbar(
+                                    message = getString(messageRes),
+                                )
+                            }
+                        },
+                    )
+                }
 
                 val contentWindowInsets: WindowInsets =
                     WindowInsets.systemBars
@@ -755,6 +792,9 @@ class ConversationsFragment :
                     val lazyListState = rememberLazyListState()
 
                     Scaffold(
+                        snackbarHost = {
+                            SnackbarHost(snackbarHostState)
+                        },
                         contentWindowInsets = contentWindowInsets,
                         floatingActionButton = {
                             ExtendedFloatingActionButtonPrimary(
@@ -776,46 +816,87 @@ class ConversationsFragment :
                                     )
                                 }
 
-                                when (val itemsState = conversationsViewState.itemsState) {
-                                    is ItemsState.Loaded -> {
-                                        if (itemsState.items.isNotEmpty()) {
-                                            ConversationList(
-                                                insetsPadding = insetsPadding,
-                                                myIdentity = myIdentity!!,
-                                                lazyListState = lazyListState,
-                                                emojiStyle = preferenceService.getEmojiStyle(),
-                                                items = itemsState.items,
-                                                contactNameFormat = conversationsViewState.contactNameFormat,
-                                                archivedCount = conversationsViewState.archivedConversationsCount,
-                                                filterQuery = conversationsViewState.filterQuery,
-                                            )
-                                        }
+                                val emojiStyle: Int = remember {
+                                    preferenceService.getEmojiStyle()
+                                }
 
-                                        AnimatedVisibility(
-                                            visible = itemsState.items.isEmpty(),
-                                            enter = fadeIn(
-                                                animationSpec = spring(
-                                                    stiffness = Spring.StiffnessVeryLow,
+                                Column(
+                                    modifier = Modifier.fillMaxSize(),
+                                    horizontalAlignment = Alignment.CenterHorizontally,
+                                ) {
+                                    if (conversationsViewState.availabilityStatus is AvailabilityStatus.Set) {
+                                        val layoutDirection = LocalLayoutDirection.current
+                                        AvailabilityStatusOwnBanner(
+                                            modifier = Modifier
+                                                .padding(
+                                                    start = insetsPadding.calculateStartPadding(layoutDirection),
+                                                    end = insetsPadding.calculateEndPadding(layoutDirection),
+                                                )
+                                                .padding(
+                                                    all = GridUnit.x1,
+                                                )
+                                                .widthIn(
+                                                    max = 550.dp,
+                                                )
+                                                .fillMaxWidth(),
+                                            status = conversationsViewState.availabilityStatus,
+                                            onClickEdit = {
+                                                EditAvailabilityStatusBottomSheetDialog
+                                                    .newInstance(
+                                                        requestKey = REQUEST_KEY_EDIT_AVAILABILITY_STATUS,
+                                                    )
+                                                    .show(
+                                                        /* manager = */
+                                                        parentFragmentManager,
+                                                        /* tag = */
+                                                        "edit-availability-status-from-conversations-list",
+                                                    )
+                                            },
+                                            emojiStyle = emojiStyle,
+                                        )
+                                    }
+
+                                    when (val itemsState = conversationsViewState.itemsState) {
+                                        is ItemsState.Loaded -> {
+                                            if (itemsState.items.isNotEmpty()) {
+                                                ConversationList(
+                                                    insetsPadding = insetsPadding,
+                                                    myIdentity = myIdentity!!,
+                                                    lazyListState = lazyListState,
+                                                    emojiStyle = emojiStyle,
+                                                    items = itemsState.items,
+                                                    contactNameFormat = conversationsViewState.contactNameFormat,
+                                                    archivedCount = conversationsViewState.archivedConversationsCount,
+                                                    filterQuery = conversationsViewState.filterQuery,
+                                                )
+                                            }
+
+                                            AnimatedVisibility(
+                                                visible = itemsState.items.isEmpty(),
+                                                enter = fadeIn(
+                                                    animationSpec = spring(
+                                                        stiffness = Spring.StiffnessVeryLow,
+                                                    ),
                                                 ),
-                                            ),
-                                            exit = fadeOut(
-                                                animationSpec = spring(
-                                                    stiffness = Spring.StiffnessMedium,
+                                                exit = fadeOut(
+                                                    animationSpec = spring(
+                                                        stiffness = Spring.StiffnessMedium,
+                                                    ),
                                                 ),
-                                            ),
-                                        ) {
-                                            EmptyContent(
+                                            ) {
+                                                EmptyContent(
+                                                    insetsPadding = insetsPadding,
+                                                    conversationsViewState = conversationsViewState,
+                                                )
+                                            }
+                                        }
+                                        ItemsState.Failed -> {
+                                            FailureContent(
                                                 insetsPadding = insetsPadding,
-                                                conversationsViewState = conversationsViewState,
+                                                onClickContactSupport = viewModel::onClickContactSupport,
                                             )
                                         }
                                     }
-                                    ItemsState.Failed -> {
-                                        FailureContent(
-                                            insetsPadding = insetsPadding,
-                                            onClickContactSupport = viewModel::onClickContactSupport,
-                                        )
-                                    }
                                 }
                             }
                         }
@@ -825,7 +906,13 @@ class ConversationsFragment :
         }
     }
 
-    private fun handleEvent(event: ConversationsViewEvent) {
+    /**
+     *  @param requestShowSnackbar Block receiving [StringRes]
+     */
+    private fun handleEvent(
+        event: ConversationsViewEvent,
+        requestShowSnackbar: (Int) -> Unit,
+    ) {
         when (event) {
             is ConversationsViewEvent.OpenConversationActionDialog ->
                 onOpenConversationActionDialog(
@@ -848,14 +935,14 @@ class ConversationsFragment :
                     conversationModel = event.conversationModel,
                 )
 
-            ConversationsViewEvent.ConversationMarkAsPrivateSuccess -> showSnackbar(R.string.chat_hidden)
+            ConversationsViewEvent.ConversationMarkAsPrivateSuccess -> requestShowSnackbar(R.string.chat_hidden)
 
             is ConversationsViewEvent.UnlockRequiredToUnmarkConversationAsPrivate ->
                 onUnlockRequiredToUnmarkConversationAsPrivate(
                     conversationModel = event.conversationModel,
                 )
 
-            ConversationsViewEvent.ConversationUnmarkAsPrivateSuccess -> showSnackbar(R.string.chat_visible)
+            ConversationsViewEvent.ConversationUnmarkAsPrivateSuccess -> requestShowSnackbar(R.string.chat_visible)
 
             ConversationsViewEvent.UnlockRequiredToShowPrivateConversations ->
                 checkLockToShowPrivateConversationLauncher.launch()
@@ -878,21 +965,17 @@ class ConversationsFragment :
                     conversationModel = event.conversationModel,
                 )
 
-            ConversationsViewEvent.OnSystemLockWasRemoved -> showSnackbar(R.string.no_lockscreen_set)
+            ConversationsViewEvent.OnSystemLockWasRemoved -> requestShowSnackbar(R.string.no_lockscreen_set)
 
             is ConversationsViewEvent.OnSupportContactAvailable -> openConversation(event.receiverIdentifier)
 
-            is ConversationsViewEvent.OnSupportContactUnavailable -> showSnackbar(event.message)
+            is ConversationsViewEvent.OnSupportContactUnavailable -> requestShowSnackbar(event.message)
 
             ConversationsViewEvent.UpdateWidgets -> widgetUpdater.updateWidgets()
 
-            ConversationsViewEvent.InternalError -> showSnackbar(R.string.an_error_occurred)
-        }
-    }
+            ConversationsViewEvent.OnAvailabilityStatusChanged -> requestShowSnackbar(R.string.edit_availability_status_did_change)
 
-    private fun showSnackbar(@StringRes messageRes: Int) {
-        view?.let { fragmentView ->
-            Snackbar.make(fragmentView, messageRes, LENGTH_SHORT).show()
+            ConversationsViewEvent.InternalError -> requestShowSnackbar(R.string.an_error_occurred)
         }
     }
 
@@ -1691,6 +1774,8 @@ class ConversationsFragment :
         private const val DIALOG_TAG_REALLY_DELETE_MY_GROUP = "rdmg"
         private const val DIALOG_TAG_REALLY_DELETE_GROUP = "rdgcc"
 
+        private const val REQUEST_KEY_EDIT_AVAILABILITY_STATUS = "edit-availability-status-from-conversation-list"
+
         private const val TAG_EMPTY_CONVERSATION = 1
         private const val TAG_DELETE_DISTRIBUTION_LIST = 2
         private const val TAG_LEAVE_GROUP = 3

+ 2 - 0
app/src/main/java/ch/threema/app/fragments/conversations/ConversationsViewEvent.kt

@@ -37,5 +37,7 @@ sealed interface ConversationsViewEvent {
 
     data object UpdateWidgets : ConversationsViewEvent
 
+    data object OnAvailabilityStatusChanged : ConversationsViewEvent
+
     data object InternalError : ConversationsViewEvent
 }

+ 30 - 1
app/src/main/java/ch/threema/app/fragments/conversations/ConversationsViewModel.kt

@@ -37,6 +37,7 @@ import ch.threema.app.utils.DispatcherProvider
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.common.stateFlowOf
 import ch.threema.common.takeUnlessEmpty
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.datatypes.ContactNameFormat
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.repositories.ContactModelRepository
@@ -141,6 +142,7 @@ class ConversationsViewModel(
                 logger.error("Failed to read contact name format setting from preferences", throwable)
                 ContactNameFormat.DEFAULT
             }
+
         return ConversationsViewState(
             itemsState = initialItemsState,
             filterQuery = null,
@@ -148,6 +150,7 @@ class ConversationsViewModel(
             hasPrivateConversations = conversationCategoryService.hasPrivateChats(),
             archivedConversationsCount = archivedConversationsCount,
             contactNameFormat = contactNameFormatOrDefault,
+            availabilityStatus = getAvailabilityStatusOrNone(),
         )
     }
 
@@ -177,7 +180,8 @@ class ConversationsViewModel(
             flow2 = preferenceService.watchArePrivateChatsHidden(),
             flow3 = highlightedConversationUidFlow,
             flow4 = watchContactNameFormatSettingUseCase.call(),
-        ) { conversationUiModelsResult, hidePrivateConversations, openedConversationUID, contactNameFormat ->
+            flow5 = watchAvailabilityStatusOrNone(),
+        ) { conversationUiModelsResult, hidePrivateConversations, openedConversationUID, contactNameFormat, availabilityStatus ->
             latestConversationUiModelsResult = conversationUiModelsResult
             val updatedItemsState = conversationUiModelsResult
                 .fold(
@@ -212,11 +216,32 @@ class ConversationsViewModel(
                     hasPrivateConversations = conversationCategoryService.hasPrivateChats(),
                     archivedConversationsCount = archivedConversationsCount,
                     contactNameFormat = contactNameFormat,
+                    availabilityStatus = availabilityStatus ?: AvailabilityStatus.None,
                 )
             }
         }.launchIn(viewModelScope)
     }
 
+    private fun getAvailabilityStatusOrNone(): AvailabilityStatus =
+        runCatching {
+            preferenceService.getAvailabilityStatus()
+                ?: AvailabilityStatus.None
+        }.getOrElse { throwable ->
+            logger.error("Failed to users availability status from preferences", throwable)
+            AvailabilityStatus.None
+        }
+
+    private fun watchAvailabilityStatusOrNone(): StateFlow<AvailabilityStatus> =
+        preferenceService.watchAvailabilityStatus()
+            .map { availabilityStatus ->
+                availabilityStatus ?: AvailabilityStatus.None
+            }
+            .stateIn(
+                scope = viewModelScope,
+                started = SharingStarted.Lazily,
+                initialValue = getAvailabilityStatusOrNone(),
+            )
+
     fun onLongClickConversationItem(conversationUiModel: ConversationUiModel) = runAction {
         val conversationModel: ConversationModel? = conversationService.get(conversationUiModel.receiverIdentifier)
         if (conversationModel != null) {
@@ -634,4 +659,8 @@ class ConversationsViewModel(
             ).runSynchronously()
         }
     }
+
+    fun onAvailabilityStatusWasChanged() = runAction {
+        emitEvent(ConversationsViewEvent.OnAvailabilityStatusChanged)
+    }
 }

+ 3 - 0
app/src/main/java/ch/threema/app/fragments/conversations/ConversationsViewState.kt

@@ -2,6 +2,7 @@ package ch.threema.app.fragments.conversations
 
 import androidx.compose.runtime.Immutable
 import ch.threema.app.compose.conversation.models.ConversationListItemUiModel
+import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.datatypes.ContactNameFormat
 
 /**
@@ -10,6 +11,7 @@ import ch.threema.data.datatypes.ContactNameFormat
  *  excluded from visible items.
  *  @param hasPrivateConversations    Is `true` if at least one private-marked conversation exist that is not archived.
  *  @param archivedConversationsCount The count of all archived conversations that match an optional [filterQuery]. The count respects the users
+ *  @param availabilityStatus         The [AvailabilityStatus] of the user
  *  setting to hide private conversations. If private conversations should be hidden, these will never be counted.
  */
 @Immutable
@@ -20,6 +22,7 @@ data class ConversationsViewState(
     val hasPrivateConversations: Boolean,
     val archivedConversationsCount: Long,
     val contactNameFormat: ContactNameFormat,
+    val availabilityStatus: AvailabilityStatus,
 )
 
 @Immutable

+ 2 - 0
app/src/main/java/ch/threema/app/framework/BaseViewModel.kt

@@ -40,6 +40,8 @@ abstract class BaseViewModel<ViewState : Any, ViewEvent : Any> : ViewModel() {
     val viewState = _viewState.asStateFlow()
 
     private val _events = MutableSharedFlow<ViewEvent>()
+
+    @JvmField
     val events = _events.asSharedFlow()
 
     init {

+ 22 - 18
app/src/main/java/ch/threema/app/home/HomeActivity.kt

@@ -45,7 +45,6 @@ import ch.threema.app.R
 import ch.threema.app.activities.BackupAdminActivity
 import ch.threema.app.activities.BackupRestoreProgressActivity
 import ch.threema.app.activities.ComposeMessageActivity
-import ch.threema.app.activities.DirectoryActivity
 import ch.threema.app.activities.DistributionListAddActivity
 import ch.threema.app.activities.DownloadApkActivity
 import ch.threema.app.activities.EnterSerialActivity
@@ -54,6 +53,7 @@ import ch.threema.app.activities.ServerMessageActivity
 import ch.threema.app.activities.ThreemaAppCompatActivity
 import ch.threema.app.activities.WhatsNewActivity
 import ch.threema.app.activities.WorkIntroActivity
+import ch.threema.app.activities.directory.DirectoryActivity
 import ch.threema.app.activities.starred.StarredMessagesActivity
 import ch.threema.app.activities.wizard.WizardBaseActivity
 import ch.threema.app.activities.wizard.WizardStartActivity
@@ -86,6 +86,7 @@ import ch.threema.app.home.usecases.GetStarredMessagesCountUseCase
 import ch.threema.app.home.usecases.GetUnreadConversationCountUseCase
 import ch.threema.app.home.usecases.SetUpThreemaChannelUseCase
 import ch.threema.app.home.usecases.ShouldShowWorkIntroScreenUseCase
+import ch.threema.app.identitylinks.VerifyMobileNumberUseCase
 import ch.threema.app.listeners.AppIconListener
 import ch.threema.app.listeners.ContactCountListener
 import ch.threema.app.listeners.ConversationListener
@@ -220,6 +221,7 @@ class HomeActivity : ThreemaAppCompatActivity(), SMSVerificationDialogCallback,
     private val getUnreadConversationCountUseCase: GetUnreadConversationCountUseCase by inject()
     private val getStarredMessagesCountUseCase: GetStarredMessagesCountUseCase by inject()
     private val checkServerMessagesUseCase: CheckServerMessagesUseCase by inject()
+    private val verifyMobileNumberUseCase: VerifyMobileNumberUseCase by inject()
     private val debugLogHelper: DebugLogHelper by inject()
     private val viewModel: HomeViewModel by viewModel()
     private val destroyer = createDestroyer()
@@ -1271,25 +1273,27 @@ class HomeActivity : ThreemaAppCompatActivity(), SMSVerificationDialogCallback,
     private fun verifyPhoneCode(code: String?) {
         logger.info("Verifying phone code")
         lifecycleScope.launch {
-            val errorMessage = withContext(dispatcherProvider.worker) {
-                try {
-                    userService.verifyMobileNumber(code, TriggerSource.LOCAL)
-                    null
-                } catch (e: LinkMobileNoException) {
-                    logger.warn("Invalid phone code used", e)
-                    getString(R.string.code_invalid)
-                } catch (e: Exception) {
-                    logger.error("Failed to verify phone code", e)
-                    getString(R.string.verify_failed_summary)
+            val verificationResult = verifyMobileNumberUseCase.call(
+                verificationCode = code,
+            )
+
+            when (verificationResult) {
+                is VerifyMobileNumberUseCase.VerificationResult.Success -> {
+                    showToast(R.string.verify_success_text, ToastDuration.LONG)
+                    DialogUtil.dismissDialog(supportFragmentManager, DIALOG_TAG_VERIFY_CODE, true)
                 }
-            }
-            if (errorMessage != null) {
-                supportFragmentManager.runTransaction(allowStateLoss = true) {
-                    add(SimpleStringAlertDialog.newInstance(R.string.error, errorMessage), null)
+
+                is VerifyMobileNumberUseCase.VerificationResult.Failure -> {
+                    supportFragmentManager.runTransaction(allowStateLoss = true) {
+                        add(
+                            SimpleStringAlertDialog.newInstance(
+                                R.string.error,
+                                verificationResult.errorMessage.get(this@HomeActivity),
+                            ),
+                            null,
+                        )
+                    }
                 }
-            } else {
-                showToast(R.string.verify_success_text, ToastDuration.LONG)
-                DialogUtil.dismissDialog(supportFragmentManager, DIALOG_TAG_VERIFY_CODE, true)
             }
         }
     }

+ 8 - 0
app/src/main/java/ch/threema/app/identitylinks/IdentityLinkFeatureModule.kt

@@ -0,0 +1,8 @@
+package ch.threema.app.identitylinks
+
+import org.koin.core.module.dsl.factoryOf
+import org.koin.dsl.module
+
+val identityLinkFeatureModule = module {
+    factoryOf(::VerifyMobileNumberUseCase)
+}

+ 49 - 0
app/src/main/java/ch/threema/app/identitylinks/SMSVerificationLinkActivity.kt

@@ -0,0 +1,49 @@
+package ch.threema.app.identitylinks
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import ch.threema.android.ResourceIdString
+import ch.threema.android.ToastDuration
+import ch.threema.android.showToast
+import ch.threema.app.R
+import ch.threema.app.startup.finishAndRestartLaterIfNotReady
+import ch.threema.app.utils.logScreenVisibility
+import ch.threema.base.utils.getThreemaLogger
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
+
+private val logger = getThreemaLogger("SMSVerificationLinkActivity")
+
+class SMSVerificationLinkActivity : AppCompatActivity() {
+    init {
+        logScreenVisibility(logger)
+    }
+
+    private val verifyMobileNumberUseCase: VerifyMobileNumberUseCase by inject()
+
+    public override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        if (finishAndRestartLaterIfNotReady()) {
+            return
+        }
+
+        lifecycleScope.launch {
+            verifyMobileNumber()
+            finish()
+        }
+    }
+
+    private suspend fun verifyMobileNumber() {
+        val verificationResult = verifyMobileNumberUseCase.call(
+            verificationCode = intent.data?.getQueryParameter("code"),
+        )
+
+        val message = when (verificationResult) {
+            is VerifyMobileNumberUseCase.VerificationResult.Success -> ResourceIdString(resId = R.string.verify_success_text)
+            is VerifyMobileNumberUseCase.VerificationResult.Failure -> verificationResult.errorMessage
+        }
+
+        showToast(message = message.get(this), duration = ToastDuration.LONG)
+    }
+}

+ 63 - 0
app/src/main/java/ch/threema/app/identitylinks/VerifyMobileNumberUseCase.kt

@@ -0,0 +1,63 @@
+package ch.threema.app.identitylinks
+
+import ch.threema.android.ResolvableString
+import ch.threema.android.ResourceIdString
+import ch.threema.app.R
+import ch.threema.app.services.UserService
+import ch.threema.base.utils.getThreemaLogger
+import ch.threema.common.DispatcherProvider
+import ch.threema.domain.taskmanager.TriggerSource
+import kotlinx.coroutines.withContext
+
+private val logger = getThreemaLogger("VerifyMobileNumberUseCase")
+
+class VerifyMobileNumberUseCase(
+    private val userService: UserService,
+    private val dispatcherProvider: DispatcherProvider,
+) {
+    suspend fun call(verificationCode: String?): VerificationResult {
+        when (userService.getMobileLinkingState()) {
+            UserService.LinkingState_PENDING -> {
+                if (!isValidCodeFormat(verificationCode)) {
+                    return VerificationResult.Failure(errorMessage = ResourceIdString(R.string.verify_failed_summary))
+                }
+                try {
+                    withContext(dispatcherProvider.worker) {
+                        userService.verifyMobileNumber(verificationCode, TriggerSource.LOCAL)
+                    }
+                    return VerificationResult.Success
+                } catch (e: Exception) {
+                    logger.error("Failed to verify mobile number", e)
+                    return VerificationResult.Failure(errorMessage = ResourceIdString(R.string.verify_failed_summary))
+                }
+            }
+
+            UserService.LinkingState_LINKED -> {
+                return VerificationResult.Failure(errorMessage = ResourceIdString(resId = R.string.verify_success_text))
+            }
+
+            UserService.LinkingState_NONE -> {
+                return VerificationResult.Failure(errorMessage = ResourceIdString(resId = R.string.verify_failed_not_linked))
+            }
+        }
+
+        return VerificationResult.Success
+    }
+
+    private fun isValidCodeFormat(verificationCode: String?): Boolean {
+        if (verificationCode.isNullOrBlank()) {
+            return false
+        }
+
+        return verificationCode.length <= MAX_DIGITS && verificationCode.all(Char::isDigit)
+    }
+
+    sealed interface VerificationResult {
+        object Success : VerificationResult
+        data class Failure(val errorMessage: ResolvableString) : VerificationResult
+    }
+
+    companion object {
+        private const val MAX_DIGITS = 8
+    }
+}

+ 1 - 1
app/src/main/java/ch/threema/app/logging/ExportDebugLogActivity.kt

@@ -28,7 +28,7 @@ import ch.threema.android.disableEnterTransition
 import ch.threema.android.showToast
 import ch.threema.app.R
 import ch.threema.app.compose.common.ThemedText
-import ch.threema.app.compose.common.buttons.ButtonPrimary
+import ch.threema.app.compose.common.buttons.primary.ButtonPrimary
 import ch.threema.app.compose.preview.PreviewThreemaAll
 import ch.threema.app.compose.theme.ThreemaTheme
 import ch.threema.app.compose.theme.ThreemaThemePreview

+ 2 - 2
app/src/main/java/ch/threema/app/multidevice/MultiDeviceManager.kt

@@ -14,7 +14,7 @@ import ch.threema.domain.protocol.connection.data.DeviceId
 import ch.threema.domain.protocol.connection.data.InboundD2mMessage.DevicesInfo
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor
 import ch.threema.domain.taskmanager.ActiveTaskCodec
-import ch.threema.protobuf.d2d.MdD2D
+import ch.threema.protobuf.d2d.ProtocolVersion
 import kotlinx.coroutines.flow.Flow
 
 @SessionScoped
@@ -82,7 +82,7 @@ interface MultiDeviceManager {
         /**
          * This defines the oldest version of the d2d protocol the android client would link to.
          */
-        val minimumSupportedD2dProtocolVersion = MdD2D.ProtocolVersion.V0_2
+        val minimumSupportedD2dProtocolVersion = ProtocolVersion.V0_2
 
         const val DEVICE_JOIN_OFFER_URI_PREFIX: String = "threema://device-group/join"
     }

+ 108 - 116
app/src/main/java/ch/threema/app/multidevice/linking/DeviceLinkingDataCollector.kt

@@ -15,53 +15,56 @@ import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.ContactUtil
 import ch.threema.app.utils.ConversationUtil
 import ch.threema.app.utils.ConversationUtil.getConversationUid
-import ch.threema.app.utils.GroupUtil
 import ch.threema.base.crypto.NaCl
 import ch.threema.base.crypto.NonceScope
 import ch.threema.base.utils.getThreemaLogger
-import ch.threema.data.datatypes.NotificationTriggerPolicyOverride.*
+import ch.threema.data.datatypes.AvailabilityStatus
+import ch.threema.data.datatypes.NotificationTriggerPolicyOverride.MutedIndefinite
+import ch.threema.data.datatypes.NotificationTriggerPolicyOverride.MutedIndefiniteExceptMentions
+import ch.threema.data.datatypes.NotificationTriggerPolicyOverride.MutedUntil
+import ch.threema.data.datatypes.NotificationTriggerPolicyOverride.NotMuted
 import ch.threema.data.models.ContactModel
 import ch.threema.data.models.ContactModelData
 import ch.threema.data.models.GroupModel
 import ch.threema.data.models.GroupModelData
 import ch.threema.domain.models.IdentityState
 import ch.threema.domain.models.IdentityType
-import ch.threema.domain.models.ReadReceiptPolicy
-import ch.threema.domain.models.TypingIndicatorPolicy
 import ch.threema.domain.models.UserState
+import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.csp.ProtocolDefines
-import ch.threema.protobuf.Common.BlobData
-import ch.threema.protobuf.Common.DeltaImage
-import ch.threema.protobuf.Common.Identities
-import ch.threema.protobuf.Common.Image
-import ch.threema.protobuf.blob
-import ch.threema.protobuf.blobData
+import ch.threema.protobuf.common.BlobData
+import ch.threema.protobuf.common.DeltaImage
+import ch.threema.protobuf.common.Identities
+import ch.threema.protobuf.common.Image
+import ch.threema.protobuf.common.blob
+import ch.threema.protobuf.common.blobData
+import ch.threema.protobuf.common.deltaImage
+import ch.threema.protobuf.common.groupIdentity
+import ch.threema.protobuf.common.identities
+import ch.threema.protobuf.common.image
+import ch.threema.protobuf.common.unit
+import ch.threema.protobuf.d2d.join.EssentialData
 import ch.threema.protobuf.d2d.join.EssentialDataKt.augmentedContact
 import ch.threema.protobuf.d2d.join.EssentialDataKt.augmentedDistributionList
 import ch.threema.protobuf.d2d.join.EssentialDataKt.augmentedGroup
 import ch.threema.protobuf.d2d.join.EssentialDataKt.deviceGroupData
 import ch.threema.protobuf.d2d.join.EssentialDataKt.identityData
-import ch.threema.protobuf.d2d.join.MdD2DJoin.EssentialData
-import ch.threema.protobuf.d2d.join.MdD2DJoin.EssentialData.AugmentedContact
-import ch.threema.protobuf.d2d.join.MdD2DJoin.EssentialData.AugmentedDistributionList
-import ch.threema.protobuf.d2d.join.MdD2DJoin.EssentialData.AugmentedGroup
-import ch.threema.protobuf.d2d.join.MdD2DJoin.EssentialData.IdentityData
+import ch.threema.protobuf.d2d.sync.Contact
 import ch.threema.protobuf.d2d.sync.ContactKt
 import ch.threema.protobuf.d2d.sync.ContactKt.readReceiptPolicyOverride
 import ch.threema.protobuf.d2d.sync.ContactKt.typingIndicatorPolicyOverride
+import ch.threema.protobuf.d2d.sync.ConversationCategory
+import ch.threema.protobuf.d2d.sync.ConversationVisibility
+import ch.threema.protobuf.d2d.sync.Group
 import ch.threema.protobuf.d2d.sync.GroupKt
-import ch.threema.protobuf.d2d.sync.MdD2DSync
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.NotificationSoundPolicyOverride
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.NotificationTriggerPolicyOverride
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.NotificationTriggerPolicyOverride.Policy.NotificationTriggerPolicy
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.SyncState
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Contact.VerificationLevel
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Settings
-import ch.threema.protobuf.d2d.sync.MdD2DSync.UserProfile.IdentityLinks
-import ch.threema.protobuf.d2d.sync.MdD2DSync.UserProfile.ProfilePictureShareWith
+import ch.threema.protobuf.d2d.sync.MdmParameters
 import ch.threema.protobuf.d2d.sync.MdmParametersKt.parameter
+import ch.threema.protobuf.d2d.sync.ReadReceiptPolicy
+import ch.threema.protobuf.d2d.sync.Settings
+import ch.threema.protobuf.d2d.sync.ThreemaWorkCredentials
+import ch.threema.protobuf.d2d.sync.TypingIndicatorPolicy
+import ch.threema.protobuf.d2d.sync.UserProfile
 import ch.threema.protobuf.d2d.sync.UserProfileKt.profilePictureShareWith
 import ch.threema.protobuf.d2d.sync.contact
 import ch.threema.protobuf.d2d.sync.distributionList
@@ -70,11 +73,6 @@ import ch.threema.protobuf.d2d.sync.mdmParameters
 import ch.threema.protobuf.d2d.sync.settings
 import ch.threema.protobuf.d2d.sync.threemaWorkCredentials
 import ch.threema.protobuf.d2d.sync.userProfile
-import ch.threema.protobuf.deltaImage
-import ch.threema.protobuf.groupIdentity
-import ch.threema.protobuf.identities
-import ch.threema.protobuf.image
-import ch.threema.protobuf.unit
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.storage.models.DistributionListModel
 import com.google.protobuf.ByteString
@@ -93,6 +91,7 @@ class BlobDataProvider(private val blobId: ByteArray, private val dataProvider:
         FAIL,
         NOT_USED,
     }
+
     private var state = BlobDataProviderState.NOT_USED
 
     /**
@@ -141,7 +140,7 @@ class BlobDataProvider(private val blobId: ByteArray, private val dataProvider:
 }
 
 class AugmentedContactProvider(
-    private val augmentedContact: AugmentedContact,
+    private val augmentedContact: EssentialData.AugmentedContact,
     private val contactDefinedProfilePictureProvider: BlobDataProvider?,
     private val userDefinedProfilePictureProvider: BlobDataProvider?,
 ) {
@@ -150,7 +149,7 @@ class AugmentedContactProvider(
      *
      * @throws IllegalStateException if the profile picture providers have not been used at all
      */
-    fun get(): AugmentedContact {
+    fun get(): EssentialData.AugmentedContact {
         val invalidContactDefinedProfilePicture = contactDefinedProfilePictureProvider?.hasBeenSuccessfullyUsed() == false
         val invalidUserDefinedProfilePicture = userDefinedProfilePictureProvider?.hasBeenSuccessfullyUsed() == false
         if (invalidContactDefinedProfilePicture || invalidUserDefinedProfilePicture) {
@@ -174,7 +173,7 @@ class AugmentedContactProvider(
 }
 
 class AugmentedGroupProvider(
-    private val augmentedGroup: AugmentedGroup,
+    private val augmentedGroup: EssentialData.AugmentedGroup,
     private val groupProfilePictureProvider: BlobDataProvider?,
 ) {
     /**
@@ -182,7 +181,7 @@ class AugmentedGroupProvider(
      *
      * @throws IllegalStateException if the profile picture provider has not been used at all
      */
-    fun get(): AugmentedGroup {
+    fun get(): EssentialData.AugmentedGroup {
         if (groupProfilePictureProvider?.hasBeenSuccessfullyUsed() == false) {
             // In case the group profile picture could be used successfully, we remove it from the group. Otherwise, the group would contain an
             // invalid blob id.
@@ -221,6 +220,7 @@ class DeviceLinkingDataCollector(
     serviceManager: ServiceManager,
 ) : KoinComponent {
     private val identityStore by lazy { serviceManager.identityStore }
+    private val preferenceService by lazy { serviceManager.preferenceService }
     private val userService by lazy { serviceManager.userService }
     private val contactService by lazy { serviceManager.contactService }
     private val contactModelRepository by lazy { serviceManager.modelRepositories.contacts }
@@ -233,7 +233,6 @@ class DeviceLinkingDataCollector(
     private val fileService by lazy { serviceManager.fileService }
     private val conversationCategoryService by lazy { serviceManager.conversationCategoryService }
     private val conversationService by lazy { serviceManager.conversationService }
-    private val ringtoneService by lazy { serviceManager.ringtoneService }
     private val nonceFactory by lazy { serviceManager.nonceFactory }
     private val licenseService by lazy { serviceManager.licenseService }
     private val appRestrictions: AppRestrictions by inject()
@@ -322,7 +321,7 @@ class DeviceLinkingDataCollector(
         return DeviceLinkingData(blobsSequence, essentialDataProvider)
     }
 
-    private fun collectIdentityData(): IdentityData {
+    private fun collectIdentityData(): EssentialData.IdentityData {
         return identityData {
             identity = identityStore.getIdentityString()!!
             ck = identityStore.getPrivateKey()!!.toByteString()
@@ -331,7 +330,7 @@ class DeviceLinkingDataCollector(
         }
     }
 
-    private fun collectUserProfile(): Pair<BlobDataProvider?, MdD2DSync.UserProfile> {
+    private fun collectUserProfile(): Pair<BlobDataProvider?, UserProfile> {
         return collectUserProfilePicture().let { profilePictureData ->
             profilePictureData?.first to userProfile {
                 nickname = identityStore.getPublicNickname()
@@ -340,6 +339,12 @@ class DeviceLinkingDataCollector(
                 }
                 profilePictureShareWith = collectProfilePictureShareWith()
                 identityLinks = collectIdentityLinks()
+                if (ConfigUtils.supportsAvailabilityStatus()) {
+                    val availabilityStatus = preferenceService.getAvailabilityStatus()
+                    if (availabilityStatus != null) {
+                        workAvailabilityStatus = availabilityStatus.toProtocolModel()
+                    }
+                }
             }
         }
     }
@@ -376,7 +381,7 @@ class DeviceLinkingDataCollector(
         }
     }
 
-    private fun collectProfilePictureShareWith(): ProfilePictureShareWith {
+    private fun collectProfilePictureShareWith(): UserProfile.ProfilePictureShareWith {
         val policy = contactService.profilePictureSharePolicy
 
         return profilePictureShareWith {
@@ -390,7 +395,7 @@ class DeviceLinkingDataCollector(
         }
     }
 
-    private fun collectIdentityLinks(): IdentityLinks {
+    private fun collectIdentityLinks(): UserProfile.IdentityLinks {
         return ReflectUserProfileIdentityLinksTask.getUserProfileSyncIdentityLinks(userService)
     }
 
@@ -407,14 +412,14 @@ class DeviceLinkingDataCollector(
                 Settings.UnknownContactPolicy.ALLOW_UNKNOWN
             }
             readReceiptPolicy = if (synchronizedSettingsService.areReadReceiptsEnabled()) {
-                MdD2DSync.ReadReceiptPolicy.SEND_READ_RECEIPT
+                ReadReceiptPolicy.SEND_READ_RECEIPT
             } else {
-                MdD2DSync.ReadReceiptPolicy.DONT_SEND_READ_RECEIPT
+                ReadReceiptPolicy.DONT_SEND_READ_RECEIPT
             }
             typingIndicatorPolicy = if (synchronizedSettingsService.isTypingIndicatorEnabled()) {
-                MdD2DSync.TypingIndicatorPolicy.SEND_TYPING_INDICATOR
+                TypingIndicatorPolicy.SEND_TYPING_INDICATOR
             } else {
-                MdD2DSync.TypingIndicatorPolicy.DONT_SEND_TYPING_INDICATOR
+                TypingIndicatorPolicy.DONT_SEND_TYPING_INDICATOR
             }
             o2OCallPolicy = if (synchronizedSettingsService.isVoipEnabled()) {
                 Settings.O2oCallPolicy.ALLOW_O2O_CALL
@@ -517,7 +522,9 @@ class DeviceLinkingDataCollector(
             readReceiptPolicyOverride = mapReadReceiptPolicyOverride(contactModelData)
             typingIndicatorPolicyOverride = mapTypingIndicatorPolicyOverride(contactModelData)
             notificationTriggerPolicyOverride = collectContactNotificationTriggerPolicyOverride(contactModelData)
-            notificationSoundPolicyOverride = collectNotificationSoundPolicyOverride(contactModelData)
+            deprecatedNotificationSoundPolicyOverride = ContactKt.deprecatedNotificationSoundPolicyOverride {
+                default = unit {}
+            }
 
             if (contactDefinedProfilePictureInfo != null) {
                 blobDataProviders.add(contactDefinedProfilePictureInfo.first)
@@ -530,17 +537,25 @@ class DeviceLinkingDataCollector(
             }
 
             conversationCategory = if (conversationCategoryService.isPrivateChat(ContactUtil.getUniqueIdString(contactModelData.identity))) {
-                MdD2DSync.ConversationCategory.PROTECTED
+                ConversationCategory.PROTECTED
             } else {
-                MdD2DSync.ConversationCategory.DEFAULT
+                ConversationCategory.DEFAULT
             }
 
             conversationVisibility = if (conversationStats?.isPinned == true) {
-                MdD2DSync.ConversationVisibility.PINNED
+                ConversationVisibility.PINNED
             } else if (conversationStats?.isArchived == true) {
-                MdD2DSync.ConversationVisibility.ARCHIVED
+                ConversationVisibility.ARCHIVED
             } else {
-                MdD2DSync.ConversationVisibility.NORMAL
+                ConversationVisibility.NORMAL
+            }
+
+            if (ConfigUtils.isWorkBuild() && contactModelData.workLastFullSyncAt != null) {
+                workLastFullSyncAt = contactModelData.workLastFullSyncAt.toEpochMilli()
+            }
+
+            if (ConfigUtils.supportsAvailabilityStatus() && contactModelData.availabilityStatus != AvailabilityStatus.None) {
+                workAvailabilityStatus = contactModelData.availabilityStatus.toProtocolModel()
             }
         }
 
@@ -561,23 +576,23 @@ class DeviceLinkingDataCollector(
         return blobDataProviders to augmentedContactProvider
     }
 
-    private fun collectSyncState(contactModelData: ContactModelData): SyncState {
+    private fun collectSyncState(contactModelData: ContactModelData): Contact.SyncState {
         // TODO(ANDR-2327): Consolidate this mechanism
         return if (contactModelData.isLinkedToAndroidContact()) {
-            SyncState.IMPORTED
+            Contact.SyncState.IMPORTED
         } else if (contactModelData.lastName.isBlank() && contactModelData.firstName.isBlank()) {
-            SyncState.INITIAL
+            Contact.SyncState.INITIAL
         } else {
-            SyncState.CUSTOM
+            Contact.SyncState.CUSTOM
         }
     }
 
     private fun mapReadReceiptPolicyOverride(contactModelData: ContactModelData): Contact.ReadReceiptPolicyOverride {
         return readReceiptPolicyOverride {
             when (contactModelData.readReceiptPolicy) {
-                ReadReceiptPolicy.DEFAULT -> default = unit {}
-                ReadReceiptPolicy.SEND -> policy = MdD2DSync.ReadReceiptPolicy.SEND_READ_RECEIPT
-                ReadReceiptPolicy.DONT_SEND -> policy = MdD2DSync.ReadReceiptPolicy.DONT_SEND_READ_RECEIPT
+                ch.threema.domain.models.ReadReceiptPolicy.DEFAULT -> default = unit {}
+                ch.threema.domain.models.ReadReceiptPolicy.SEND -> policy = ReadReceiptPolicy.SEND_READ_RECEIPT
+                ch.threema.domain.models.ReadReceiptPolicy.DONT_SEND -> policy = ReadReceiptPolicy.DONT_SEND_READ_RECEIPT
             }
         }
     }
@@ -585,20 +600,20 @@ class DeviceLinkingDataCollector(
     private fun mapTypingIndicatorPolicyOverride(contactModelData: ContactModelData): Contact.TypingIndicatorPolicyOverride {
         return typingIndicatorPolicyOverride {
             when (contactModelData.typingIndicatorPolicy) {
-                TypingIndicatorPolicy.DEFAULT -> default = unit {}
-                TypingIndicatorPolicy.SEND -> policy = MdD2DSync.TypingIndicatorPolicy.SEND_TYPING_INDICATOR
-                TypingIndicatorPolicy.DONT_SEND -> policy = MdD2DSync.TypingIndicatorPolicy.DONT_SEND_TYPING_INDICATOR
+                ch.threema.domain.models.TypingIndicatorPolicy.DEFAULT -> default = unit {}
+                ch.threema.domain.models.TypingIndicatorPolicy.SEND -> policy = TypingIndicatorPolicy.SEND_TYPING_INDICATOR
+                ch.threema.domain.models.TypingIndicatorPolicy.DONT_SEND -> policy = TypingIndicatorPolicy.DONT_SEND_TYPING_INDICATOR
             }
         }
     }
 
-    private fun collectContactNotificationTriggerPolicyOverride(contactModelData: ContactModelData): NotificationTriggerPolicyOverride {
+    private fun collectContactNotificationTriggerPolicyOverride(contactModelData: ContactModelData): Contact.NotificationTriggerPolicyOverride {
         return ContactKt.notificationTriggerPolicyOverride {
             when (val modelPolicy = contactModelData.currentNotificationTriggerPolicyOverride) {
                 NotMuted -> default = unit {}
 
                 MutedIndefinite -> policy = ContactKt.NotificationTriggerPolicyOverrideKt.policy {
-                    policy = NotificationTriggerPolicy.NEVER
+                    policy = Contact.NotificationTriggerPolicyOverride.Policy.NotificationTriggerPolicy.NEVER
                 }
 
                 MutedIndefiniteExceptMentions -> throw IllegalStateException(
@@ -606,7 +621,7 @@ class DeviceLinkingDataCollector(
                 )
 
                 is MutedUntil -> policy = ContactKt.NotificationTriggerPolicyOverrideKt.policy {
-                    policy = NotificationTriggerPolicy.NEVER
+                    policy = Contact.NotificationTriggerPolicyOverride.Policy.NotificationTriggerPolicy.NEVER
                     assertValidTimestamp(modelPolicy.utcMillis, "Contact notificationTriggerPolicyOverride (${contactModelData.identity})")
                     expiresAt = modelPolicy.utcMillis
                 }
@@ -614,16 +629,6 @@ class DeviceLinkingDataCollector(
         }
     }
 
-    private fun collectNotificationSoundPolicyOverride(contactModelData: ContactModelData): NotificationSoundPolicyOverride {
-        return ContactKt.notificationSoundPolicyOverride {
-            if (ringtoneService.isSilent(ContactUtil.getUniqueIdString(contactModelData.identity), false)) {
-                policy = MdD2DSync.NotificationSoundPolicy.MUTED
-            } else {
-                default = unit {}
-            }
-        }
-    }
-
     private fun collectContactDefinedProfilePicture(contactModelData: ContactModelData): Pair<BlobDataProvider, DeltaImage>? {
         return if (fileService.hasContactDefinedProfilePicture(contactModelData.identity)) {
             createJpegBlobAssets { fileService.getContactDefinedProfilePicture(contactModelData.identity) }
@@ -658,11 +663,11 @@ class DeviceLinkingDataCollector(
         return blobDataProvider to picture
     }
 
-    private fun mapVerificationLevel(contactModelData: ContactModelData): VerificationLevel {
+    private fun mapVerificationLevel(contactModelData: ContactModelData): Contact.VerificationLevel {
         return when (contactModelData.verificationLevel) {
-            ch.threema.domain.models.VerificationLevel.UNVERIFIED -> VerificationLevel.UNVERIFIED
-            ch.threema.domain.models.VerificationLevel.SERVER_VERIFIED -> VerificationLevel.SERVER_VERIFIED
-            ch.threema.domain.models.VerificationLevel.FULLY_VERIFIED -> VerificationLevel.FULLY_VERIFIED
+            VerificationLevel.UNVERIFIED -> Contact.VerificationLevel.UNVERIFIED
+            VerificationLevel.SERVER_VERIFIED -> Contact.VerificationLevel.SERVER_VERIFIED
+            VerificationLevel.FULLY_VERIFIED -> Contact.VerificationLevel.FULLY_VERIFIED
         }
     }
 
@@ -733,26 +738,27 @@ class DeviceLinkingDataCollector(
             assertValidTimestamp(data.createdAt.time, "Group createdAt (${groupModel.groupIdentity})")
             createdAt = data.createdAt.time
             userState = collectUserState(data)
-            notificationTriggerPolicyOverride =
-                collectGroupNotificationTriggerPolicyOverride(groupModel)
-            notificationSoundPolicyOverride =
-                collectGroupNotificationSoundPolicyOverride(groupModel)
+            notificationTriggerPolicyOverride = collectGroupNotificationTriggerPolicyOverride(groupModel)
+            deprecatedNotificationSoundPolicyOverride = GroupKt.deprecatedNotificationSoundPolicyOverride {
+                default = unit {}
+            }
+
             if (groupAvatarInfo != null) {
                 blobDataProviders.add(groupAvatarInfo.first)
                 profilePicture = groupAvatarInfo.second
             }
             memberIdentities = collectGroupIdentities(data)
             conversationCategory = if (conversationCategoryService.isPrivateGroupChat(groupModel.getDatabaseId())) {
-                MdD2DSync.ConversationCategory.PROTECTED
+                ConversationCategory.PROTECTED
             } else {
-                MdD2DSync.ConversationCategory.DEFAULT
+                ConversationCategory.DEFAULT
             }
             conversationVisibility = if (conversationStats?.isPinned == true) {
-                MdD2DSync.ConversationVisibility.PINNED
+                ConversationVisibility.PINNED
             } else if (conversationStats?.isArchived == true) {
-                MdD2DSync.ConversationVisibility.ARCHIVED
+                ConversationVisibility.ARCHIVED
             } else {
-                MdD2DSync.ConversationVisibility.NORMAL
+                ConversationVisibility.NORMAL
             }
         }
 
@@ -780,11 +786,11 @@ class DeviceLinkingDataCollector(
         }
     }
 
-    private fun collectUserState(groupModelData: GroupModelData): MdD2DSync.Group.UserState {
+    private fun collectUserState(groupModelData: GroupModelData): Group.UserState {
         return when (groupModelData.userState) {
-            UserState.MEMBER -> MdD2DSync.Group.UserState.MEMBER
-            UserState.LEFT -> MdD2DSync.Group.UserState.LEFT
-            UserState.KICKED -> MdD2DSync.Group.UserState.KICKED
+            UserState.MEMBER -> Group.UserState.MEMBER
+            UserState.LEFT -> Group.UserState.LEFT
+            UserState.KICKED -> Group.UserState.KICKED
         }
     }
 
@@ -797,21 +803,21 @@ class DeviceLinkingDataCollector(
         }
     }
 
-    private fun collectGroupNotificationTriggerPolicyOverride(groupModel: GroupModel): MdD2DSync.Group.NotificationTriggerPolicyOverride {
+    private fun collectGroupNotificationTriggerPolicyOverride(groupModel: GroupModel): Group.NotificationTriggerPolicyOverride {
         return GroupKt.notificationTriggerPolicyOverride {
             when (val modelPolicy = groupModel.data?.currentNotificationTriggerPolicyOverride) {
                 NotMuted -> default = unit {}
 
                 MutedIndefinite -> policy = GroupKt.NotificationTriggerPolicyOverrideKt.policy {
-                    policy = MdD2DSync.Group.NotificationTriggerPolicyOverride.Policy.NotificationTriggerPolicy.NEVER
+                    policy = Group.NotificationTriggerPolicyOverride.Policy.NotificationTriggerPolicy.NEVER
                 }
 
                 MutedIndefiniteExceptMentions -> policy = GroupKt.NotificationTriggerPolicyOverrideKt.policy {
-                    policy = MdD2DSync.Group.NotificationTriggerPolicyOverride.Policy.NotificationTriggerPolicy.MENTIONED
+                    policy = Group.NotificationTriggerPolicyOverride.Policy.NotificationTriggerPolicy.MENTIONED
                 }
 
                 is MutedUntil -> policy = GroupKt.NotificationTriggerPolicyOverrideKt.policy {
-                    policy = MdD2DSync.Group.NotificationTriggerPolicyOverride.Policy.NotificationTriggerPolicy.NEVER
+                    policy = Group.NotificationTriggerPolicyOverride.Policy.NotificationTriggerPolicy.NEVER
                     assertValidTimestamp(modelPolicy.utcMillis, "Group notificationTriggerPolicyOverride (${groupModel.groupIdentity})")
                     expiresAt = modelPolicy.utcMillis
                 }
@@ -821,24 +827,10 @@ class DeviceLinkingDataCollector(
         }
     }
 
-    private fun collectGroupNotificationSoundPolicyOverride(groupModel: GroupModel): MdD2DSync.Group.NotificationSoundPolicyOverride {
-        return GroupKt.notificationSoundPolicyOverride {
-            if (ringtoneService.isSilent(groupModel.getUniqueId(), true)) {
-                policy = MdD2DSync.NotificationSoundPolicy.MUTED
-            } else {
-                default = unit {}
-            }
-        }
-    }
-
-    private fun GroupModel.getUniqueId(): String {
-        return GroupUtil.getUniqueIdString(this)
-    }
-
     /**
      * Collect the distribution lists and ignore lists without members.
      */
-    private fun collectDistributionLists(conversationsStats: Map<String, ConversationStats>): List<AugmentedDistributionList> {
+    private fun collectDistributionLists(conversationsStats: Map<String, ConversationStats>): List<EssentialData.AugmentedDistributionList> {
         return distributionListService.all.mapNotNull {
             mapToAugmentedDistributionList(it, conversationsStats)
         }.also { logger.trace("{} distribution lists", it.size) }
@@ -850,7 +842,7 @@ class DeviceLinkingDataCollector(
     private fun mapToAugmentedDistributionList(
         distributionListModel: DistributionListModel,
         conversationsStats: Map<String, ConversationStats>,
-    ): AugmentedDistributionList? {
+    ): EssentialData.AugmentedDistributionList? {
         val conversationStats = conversationsStats[distributionListModel.getConversationUid()]
 
         return collectDistributionListIdentities(distributionListModel)?.let { identities ->
@@ -862,16 +854,16 @@ class DeviceLinkingDataCollector(
                 memberIdentities = identities
                 conversationCategory =
                     if (conversationCategoryService.isPrivateChat(distributionListModel.getUniqueId())) {
-                        MdD2DSync.ConversationCategory.PROTECTED
+                        ConversationCategory.PROTECTED
                     } else {
-                        MdD2DSync.ConversationCategory.DEFAULT
+                        ConversationCategory.DEFAULT
                     }
                 conversationVisibility = if (conversationStats?.isPinned == true) {
-                    MdD2DSync.ConversationVisibility.PINNED
+                    ConversationVisibility.PINNED
                 } else if (conversationStats?.isArchived == true) {
-                    MdD2DSync.ConversationVisibility.ARCHIVED
+                    ConversationVisibility.ARCHIVED
                 } else {
-                    MdD2DSync.ConversationVisibility.NORMAL
+                    ConversationVisibility.NORMAL
                 }
             }
         }?.let {
@@ -913,7 +905,7 @@ class DeviceLinkingDataCollector(
             .also { logger.trace("{} d2d nonce hashes", it.size) }
     }
 
-    private fun collectWorkCredentials(): MdD2DSync.ThreemaWorkCredentials? {
+    private fun collectWorkCredentials(): ThreemaWorkCredentials? {
         val credentials = licenseService.let {
             if (it is LicenseServiceUser) {
                 it.loadCredentials()
@@ -930,7 +922,7 @@ class DeviceLinkingDataCollector(
     }
 
     // TODO(ANDR-2670): Collect all mdm parameters
-    private fun collectMdmParameters(): MdD2DSync.MdmParameters? {
+    private fun collectMdmParameters(): MdmParameters? {
         // Currently we only send the remote secret mdm parameter
         val remoteSecretMdmParamValue = appRestrictions.isRemoteSecretEnabledOrNull()
         if (remoteSecretMdmParamValue != null) {

+ 1 - 1
app/src/main/java/ch/threema/app/multidevice/unlinking/DropDevicesSteps.kt

@@ -15,7 +15,7 @@ import ch.threema.domain.taskmanager.MessageFilterInstruction
 import ch.threema.domain.taskmanager.NetworkException
 import ch.threema.domain.taskmanager.TRANSACTION_TTL_MAX
 import ch.threema.domain.taskmanager.createTransaction
-import ch.threema.protobuf.d2d.MdD2D.TransactionScope
+import ch.threema.protobuf.d2d.TransactionScope
 
 private val logger = getThreemaLogger("DropDevicesSteps")
 

+ 1 - 1
app/src/main/java/ch/threema/app/multidevice/wizard/steps/LinkNewDevicePFSInfoFragment.kt

@@ -39,7 +39,7 @@ import ch.threema.app.R
 import ch.threema.app.compose.common.DynamicSpacerSize1
 import ch.threema.app.compose.common.DynamicSpacerSize4
 import ch.threema.app.compose.common.ThemedText
-import ch.threema.app.compose.common.buttons.ButtonPrimary
+import ch.threema.app.compose.common.buttons.primary.ButtonPrimary
 import ch.threema.app.compose.common.rememberLinkifyWeb
 import ch.threema.app.compose.preview.PreviewThreemaAll
 import ch.threema.app.compose.theme.ThreemaTheme

+ 1 - 1
app/src/main/java/ch/threema/app/multidevice/wizard/steps/LinkNewDeviceResultFragment.kt

@@ -42,7 +42,7 @@ import ch.threema.app.R
 import ch.threema.app.compose.common.DynamicSpacerSize1
 import ch.threema.app.compose.common.DynamicSpacerSize4
 import ch.threema.app.compose.common.ThemedText
-import ch.threema.app.compose.common.buttons.ButtonPrimary
+import ch.threema.app.compose.common.buttons.primary.ButtonPrimary
 import ch.threema.app.compose.preview.PreviewThreemaPhone
 import ch.threema.app.compose.theme.ThreemaTheme
 import ch.threema.app.compose.theme.ThreemaThemePreview

+ 5 - 1
app/src/main/java/ch/threema/app/onprem/OnPremServerAddressProvider.kt

@@ -47,7 +47,11 @@ class OnPremServerAddressProvider(
         fetch().directory.url
 
     @Throws(ThreemaException::class)
-    override fun getWorkServerUrl(ipv6: Boolean): String =
+    override fun getWorkServerUrlLegacy(ipv6: Boolean): String =
+        fetch().work.url
+
+    @Throws(ThreemaException::class)
+    override fun getWorkServerUrl(): String =
         fetch().work.url
 
     @Throws(ThreemaException::class)

+ 1 - 1
app/src/main/java/ch/threema/app/pinlock/PinLockActivity.kt

@@ -52,7 +52,7 @@ import ch.threema.app.activities.ThreemaAppCompatActivity
 import ch.threema.app.compose.common.SpacerHorizontal
 import ch.threema.app.compose.common.SpacerVertical
 import ch.threema.app.compose.common.ThemedText
-import ch.threema.app.compose.common.buttons.ButtonPrimary
+import ch.threema.app.compose.common.buttons.primary.ButtonPrimary
 import ch.threema.app.compose.common.extensions.get
 import ch.threema.app.compose.preview.PreviewThreemaAll
 import ch.threema.app.compose.theme.ThreemaTheme

+ 8 - 8
app/src/main/java/ch/threema/app/preference/service/ContactSyncPolicySetting.kt

@@ -9,7 +9,7 @@ import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Settings.ContactSyncPolicy
+import ch.threema.protobuf.d2d.sync.Settings
 
 private val logger = getThreemaLogger("ContactSyncPolicySetting")
 
@@ -30,17 +30,17 @@ class ContactSyncPolicySetting internal constructor(
     override fun instantiateReflectionTask(): Task<*, TaskCodec> =
         ReflectSettingsSyncTask.ReflectContactSyncPolicySyncUpdate()
 
-    fun getContactSyncPolicy(): ContactSyncPolicy =
+    fun getContactSyncPolicy(): Settings.ContactSyncPolicy =
         when (get()) {
-            true -> ContactSyncPolicy.SYNC
-            false -> ContactSyncPolicy.NOT_SYNCED
+            true -> Settings.ContactSyncPolicy.SYNC
+            false -> Settings.ContactSyncPolicy.NOT_SYNCED
         }
 
-    fun setFromSync(contactSyncPolicy: ContactSyncPolicy) {
+    fun setFromSync(contactSyncPolicy: Settings.ContactSyncPolicy) {
         val value = when (contactSyncPolicy) {
-            ContactSyncPolicy.SYNC -> true
-            ContactSyncPolicy.NOT_SYNCED -> false
-            ContactSyncPolicy.UNRECOGNIZED -> {
+            Settings.ContactSyncPolicy.SYNC -> true
+            Settings.ContactSyncPolicy.NOT_SYNCED -> false
+            Settings.ContactSyncPolicy.UNRECOGNIZED -> {
                 logger.warn("Cannot set unrecognized contact sync policy")
                 return
             }

+ 8 - 8
app/src/main/java/ch/threema/app/preference/service/GroupCallPolicySetting.kt

@@ -9,7 +9,7 @@ import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Settings.GroupCallPolicy
+import ch.threema.protobuf.d2d.sync.Settings
 
 private val logger = getThreemaLogger("GroupCallPolicySetting")
 
@@ -30,17 +30,17 @@ class GroupCallPolicySetting internal constructor(
     override fun instantiateReflectionTask(): Task<*, TaskCodec> =
         ReflectSettingsSyncTask.ReflectGroupCallPolicySyncUpdate()
 
-    fun getGroupCallPolicy(): GroupCallPolicy =
+    fun getGroupCallPolicy(): Settings.GroupCallPolicy =
         when (get()) {
-            true -> GroupCallPolicy.ALLOW_GROUP_CALL
-            false -> GroupCallPolicy.DENY_GROUP_CALL
+            true -> Settings.GroupCallPolicy.ALLOW_GROUP_CALL
+            false -> Settings.GroupCallPolicy.DENY_GROUP_CALL
         }
 
-    fun setFromSync(groupCallPolicy: GroupCallPolicy) {
+    fun setFromSync(groupCallPolicy: Settings.GroupCallPolicy) {
         val value = when (groupCallPolicy) {
-            GroupCallPolicy.ALLOW_GROUP_CALL -> true
-            GroupCallPolicy.DENY_GROUP_CALL -> false
-            GroupCallPolicy.UNRECOGNIZED -> {
+            Settings.GroupCallPolicy.ALLOW_GROUP_CALL -> true
+            Settings.GroupCallPolicy.DENY_GROUP_CALL -> false
+            Settings.GroupCallPolicy.UNRECOGNIZED -> {
                 logger.warn("Cannot set unrecognized group call policy")
                 return
             }

+ 8 - 8
app/src/main/java/ch/threema/app/preference/service/KeyboardDataCollectionPolicySetting.kt

@@ -9,7 +9,7 @@ import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Settings.KeyboardDataCollectionPolicy
+import ch.threema.protobuf.d2d.sync.Settings
 
 private val logger = getThreemaLogger("KeyboardDataCollectionPolicySetting")
 
@@ -30,17 +30,17 @@ class KeyboardDataCollectionPolicySetting internal constructor(
     override fun instantiateReflectionTask(): Task<*, TaskCodec> =
         ReflectSettingsSyncTask.ReflectKeyboardDataCollectionPolicySyncUpdate()
 
-    fun getKeyboardDataCollectionPolicy(): KeyboardDataCollectionPolicy =
+    fun getKeyboardDataCollectionPolicy(): Settings.KeyboardDataCollectionPolicy =
         when (get()) {
-            true -> KeyboardDataCollectionPolicy.DENY_DATA_COLLECTION
-            false -> KeyboardDataCollectionPolicy.ALLOW_DATA_COLLECTION
+            true -> Settings.KeyboardDataCollectionPolicy.DENY_DATA_COLLECTION
+            false -> Settings.KeyboardDataCollectionPolicy.ALLOW_DATA_COLLECTION
         }
 
-    fun setFromSync(keyboardDataCollectionPolicy: KeyboardDataCollectionPolicy) {
+    fun setFromSync(keyboardDataCollectionPolicy: Settings.KeyboardDataCollectionPolicy) {
         val value = when (keyboardDataCollectionPolicy) {
-            KeyboardDataCollectionPolicy.DENY_DATA_COLLECTION -> true
-            KeyboardDataCollectionPolicy.ALLOW_DATA_COLLECTION -> false
-            KeyboardDataCollectionPolicy.UNRECOGNIZED -> {
+            Settings.KeyboardDataCollectionPolicy.DENY_DATA_COLLECTION -> true
+            Settings.KeyboardDataCollectionPolicy.ALLOW_DATA_COLLECTION -> false
+            Settings.KeyboardDataCollectionPolicy.UNRECOGNIZED -> {
                 logger.warn("Cannot set unrecognized keyboard data collection policy")
                 return
             }

+ 8 - 8
app/src/main/java/ch/threema/app/preference/service/O2oCallConnectionPolicySetting.kt

@@ -9,7 +9,7 @@ import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Settings.O2oCallConnectionPolicy
+import ch.threema.protobuf.d2d.sync.Settings
 
 private val logger = getThreemaLogger("O2oCallConnectionPolicySetting")
 
@@ -30,17 +30,17 @@ class O2oCallConnectionPolicySetting internal constructor(
     override fun instantiateReflectionTask(): Task<*, TaskCodec> =
         ReflectSettingsSyncTask.ReflectO2oCallConnectionPolicySyncUpdate()
 
-    fun getO2oCallConnectionPolicy(): O2oCallConnectionPolicy =
+    fun getO2oCallConnectionPolicy(): Settings.O2oCallConnectionPolicy =
         when (get()) {
-            true -> O2oCallConnectionPolicy.REQUIRE_RELAYED_CONNECTION
-            false -> O2oCallConnectionPolicy.ALLOW_DIRECT_CONNECTION
+            true -> Settings.O2oCallConnectionPolicy.REQUIRE_RELAYED_CONNECTION
+            false -> Settings.O2oCallConnectionPolicy.ALLOW_DIRECT_CONNECTION
         }
 
-    fun setFromSync(o2oCallConnectionPolicy: O2oCallConnectionPolicy) {
+    fun setFromSync(o2oCallConnectionPolicy: Settings.O2oCallConnectionPolicy) {
         val value = when (o2oCallConnectionPolicy) {
-            O2oCallConnectionPolicy.REQUIRE_RELAYED_CONNECTION -> true
-            O2oCallConnectionPolicy.ALLOW_DIRECT_CONNECTION -> false
-            O2oCallConnectionPolicy.UNRECOGNIZED -> {
+            Settings.O2oCallConnectionPolicy.REQUIRE_RELAYED_CONNECTION -> true
+            Settings.O2oCallConnectionPolicy.ALLOW_DIRECT_CONNECTION -> false
+            Settings.O2oCallConnectionPolicy.UNRECOGNIZED -> {
                 logger.warn("Cannot set unrecognized 1:1 call connection policy")
                 return
             }

+ 8 - 8
app/src/main/java/ch/threema/app/preference/service/O2oCallPolicySetting.kt

@@ -9,7 +9,7 @@ import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.taskmanager.Task
 import ch.threema.domain.taskmanager.TaskCodec
 import ch.threema.domain.taskmanager.TaskManager
-import ch.threema.protobuf.d2d.sync.MdD2DSync.Settings.O2oCallPolicy
+import ch.threema.protobuf.d2d.sync.Settings
 
 private val logger = getThreemaLogger("O2oCallPolicySetting")
 
@@ -31,17 +31,17 @@ class O2oCallPolicySetting internal constructor(
         return ReflectSettingsSyncTask.ReflectO2oCallPolicySyncUpdate()
     }
 
-    fun getO2oCallPolicy(): O2oCallPolicy =
+    fun getO2oCallPolicy(): Settings.O2oCallPolicy =
         when (get()) {
-            true -> O2oCallPolicy.ALLOW_O2O_CALL
-            false -> O2oCallPolicy.DENY_O2O_CALL
+            true -> Settings.O2oCallPolicy.ALLOW_O2O_CALL
+            false -> Settings.O2oCallPolicy.DENY_O2O_CALL
         }
 
-    fun setFromSync(o2oCallPolicy: O2oCallPolicy) {
+    fun setFromSync(o2oCallPolicy: Settings.O2oCallPolicy) {
         val value = when (o2oCallPolicy) {
-            O2oCallPolicy.ALLOW_O2O_CALL -> true
-            O2oCallPolicy.DENY_O2O_CALL -> false
-            O2oCallPolicy.UNRECOGNIZED -> {
+            Settings.O2oCallPolicy.ALLOW_O2O_CALL -> true
+            Settings.O2oCallPolicy.DENY_O2O_CALL -> false
+            Settings.O2oCallPolicy.UNRECOGNIZED -> {
                 logger.warn("Cannot set unrecognized 1:1 call policy")
                 return
             }

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio