Sfoglia il codice sorgente

Version 6.4.3-1148

Threema 6 giorni fa
parent
commit
ef63ad83ff
100 ha cambiato i file con 1737 aggiunte e 867 eliminazioni
  1. 5 4
      app/build.gradle.kts
  2. 12 1
      app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt
  3. 5 4
      app/src/androidTest/java/ch/threema/app/testutils/AndroidTestUtils.kt
  4. 6 0
      app/src/main/java/ch/threema/app/activities/directory/DirectoryViewModel.kt
  5. 12 7
      app/src/main/java/ch/threema/app/adapters/ContactListAdapter.java
  6. 4 18
      app/src/main/java/ch/threema/app/adapters/DirectoryAdapter.java
  7. 1 1
      app/src/main/java/ch/threema/app/adapters/GroupDetailAdapter.java
  8. 13 2
      app/src/main/java/ch/threema/app/adapters/MentionSelectorAdapter.java
  9. 1 1
      app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java
  10. 65 9
      app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateWorkContactBackgroundTask.kt
  11. 70 19
      app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusContactBannerView.kt
  12. 7 0
      app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusExtensions.kt
  13. 1 0
      app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusLabelView.kt
  14. 1 1
      app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusOwnBanner.kt
  15. 73 0
      app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusTooltipPopupManager.kt
  16. 163 0
      app/src/main/java/ch/threema/app/availabilitystatus/ViewFullAvailabilityStatusBottomSheetDialog.kt
  17. 29 55
      app/src/main/java/ch/threema/app/availabilitystatus/edit/EditAvailabilityStatusBottomSheetDialog.kt
  18. 2 1
      app/src/main/java/ch/threema/app/availabilitystatus/edit/EditAvailabilityStatusViewModel.kt
  19. 2 0
      app/src/main/java/ch/threema/app/compose/common/text/conversation/ConversationText.kt
  20. 3 2
      app/src/main/java/ch/threema/app/contactdetails/ContactDetailAdapter.java
  21. 9 1
      app/src/main/java/ch/threema/app/emojireactions/EmojiHintPopupManager.kt
  22. 1 1
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewListAdapter.kt
  23. 26 4
      app/src/main/java/ch/threema/app/fragments/MyIDFragment.kt
  24. 55 31
      app/src/main/java/ch/threema/app/fragments/composemessage/ComposeMessageFragment.java
  25. 23 8
      app/src/main/java/ch/threema/app/fragments/composemessage/ComposeMessageViewModel.kt
  26. 2 1
      app/src/main/java/ch/threema/app/fragments/conversations/ConversationsFragment.kt
  27. 1 1
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchAdapter.java
  28. 2 2
      app/src/main/java/ch/threema/app/multidevice/linking/DeviceLinkingDataCollector.kt
  29. 7 4
      app/src/main/java/ch/threema/app/preference/service/PreferenceServiceImpl.kt
  30. 11 1
      app/src/main/java/ch/threema/app/processors/incomingcspmessage/conversation/IncomingContactDeleteMessageTask.kt
  31. 12 2
      app/src/main/java/ch/threema/app/processors/incomingcspmessage/conversation/IncomingContactEditMessageTask.kt
  32. 12 1
      app/src/main/java/ch/threema/app/processors/incomingcspmessage/conversation/IncomingGroupDeleteMessageTask.kt
  33. 12 1
      app/src/main/java/ch/threema/app/processors/incomingcspmessage/conversation/IncomingGroupEditMessageTask.kt
  34. 3 2
      app/src/main/java/ch/threema/app/processors/incomingcspmessage/workdelta/IncomingWorkSyncDeltaMessageTask.kt
  35. 2 2
      app/src/main/java/ch/threema/app/processors/reflectedd2dsync/ReflectedContactSyncTask.kt
  36. 2 3
      app/src/main/java/ch/threema/app/processors/reflectedd2dsync/ReflectedUserProfileSyncTask.kt
  37. 9 1
      app/src/main/java/ch/threema/app/processors/reflectedoutgoingmessage/ReflectedOutgoingDeleteMessageTask.kt
  38. 9 1
      app/src/main/java/ch/threema/app/processors/reflectedoutgoingmessage/ReflectedOutgoingEditMessageTask.kt
  39. 9 1
      app/src/main/java/ch/threema/app/processors/reflectedoutgoingmessage/ReflectedOutgoingGroupDeleteMessageTask.kt
  40. 10 1
      app/src/main/java/ch/threema/app/processors/reflectedoutgoingmessage/ReflectedOutgoingGroupEditMessageTask.kt
  41. 20 15
      app/src/main/java/ch/threema/app/services/ContactServiceImpl.java
  42. 4 3
      app/src/main/java/ch/threema/app/services/GroupService.java
  43. 22 10
      app/src/main/java/ch/threema/app/services/GroupServiceImpl.java
  44. 15 36
      app/src/main/java/ch/threema/app/tasks/DeleteMessageUtils.kt
  45. 16 35
      app/src/main/java/ch/threema/app/tasks/EditMessageUtils.kt
  46. 2 2
      app/src/main/java/ch/threema/app/tasks/OutgoingGroupDeleteMessageTask.kt
  47. 2 2
      app/src/main/java/ch/threema/app/tasks/ReflectContactSyncTask.kt
  48. 48 2
      app/src/main/java/ch/threema/app/tasks/ReflectContactSyncUpdateTask.kt
  49. 5 9
      app/src/main/java/ch/threema/app/tasks/ReflectUserAvailabilityStatusTask.kt
  50. 3 3
      app/src/main/java/ch/threema/app/ui/AvatarListItemUtil.java
  51. 0 75
      app/src/main/java/ch/threema/app/ui/AvatarView.java
  52. 94 0
      app/src/main/java/ch/threema/app/ui/AvatarView.kt
  53. 20 1
      app/src/main/java/ch/threema/app/ui/InitialAvatarView.java
  54. 45 23
      app/src/main/java/ch/threema/app/ui/TooltipPopup.kt
  55. 1 0
      app/src/main/java/ch/threema/app/usecases/OverrideOneTimeHintsUseCase.kt
  56. 8 5
      app/src/main/java/ch/threema/app/usecases/availabilitystatus/UpdateUserAvailabilityStatusUseCase.kt
  57. 2 2
      app/src/main/java/ch/threema/app/usecases/availabilitystatus/WatchAllContactAvailabilityStatusesUseCase.kt
  58. 0 4
      app/src/main/java/ch/threema/app/utils/ConfigUtils.java
  59. 1 1
      app/src/main/java/ch/threema/app/voip/groupcall/GroupCallManagerImpl.kt
  60. 6 0
      app/src/main/java/ch/threema/app/workers/WorkSyncWorker.kt
  61. 1 0
      app/src/main/java/ch/threema/data/models/BaseModel.kt
  62. 24 9
      app/src/main/java/ch/threema/data/models/ContactModel.kt
  63. 34 41
      app/src/main/java/ch/threema/data/storage/SqliteDatabaseBackend.kt
  64. 6 0
      app/src/main/java/ch/threema/storage/ColumnIndexCache.kt
  65. 164 74
      app/src/main/java/ch/threema/storage/factories/ContactModelFactory.java
  66. 14 0
      app/src/main/java/ch/threema/storage/models/ContactModel.java
  67. 2 9
      app/src/main/res/layout/actionbar_compose_title.xml
  68. 14 2
      app/src/main/res/layout/avatar_view.xml
  69. 15 5
      app/src/main/res/layout/initial_avatar_view.xml
  70. 2 1
      app/src/main/res/layout/item_contact_list.xml
  71. 0 8
      app/src/main/res/layout/item_directory.xml
  72. 9 8
      app/src/main/res/layout/popup_tooltip.xml
  73. 221 209
      app/src/main/res/values-be-rBY/strings.xml
  74. 45 37
      app/src/main/res/values-be-rBY/voip_strings.xml
  75. 12 12
      app/src/main/res/values-be-rBY/webclient_strings.xml
  76. 3 0
      app/src/main/res/values-bg/strings.xml
  77. 3 0
      app/src/main/res/values-ca/strings.xml
  78. 9 0
      app/src/main/res/values-cs/strings.xml
  79. 7 0
      app/src/main/res/values-de/strings.xml
  80. 15 6
      app/src/main/res/values-es/strings.xml
  81. 5 5
      app/src/main/res/values-es/voip_strings.xml
  82. 15 6
      app/src/main/res/values-fr/strings.xml
  83. 1 1
      app/src/main/res/values-fr/voip_strings.xml
  84. 9 0
      app/src/main/res/values-gsw/strings.xml
  85. 3 0
      app/src/main/res/values-hu/strings.xml
  86. 16 7
      app/src/main/res/values-it/strings.xml
  87. 1 1
      app/src/main/res/values-it/voip_strings.xml
  88. 5 0
      app/src/main/res/values-ja/strings.xml
  89. 3 0
      app/src/main/res/values-nl-rNL/strings.xml
  90. 3 0
      app/src/main/res/values-no/strings.xml
  91. 3 0
      app/src/main/res/values-pl/strings.xml
  92. 3 0
      app/src/main/res/values-pt-rBR/strings.xml
  93. 9 0
      app/src/main/res/values-ru/strings.xml
  94. 3 0
      app/src/main/res/values-sk/strings.xml
  95. 21 0
      app/src/main/res/values-tr/strings.xml
  96. 9 0
      app/src/main/res/values-uk/strings.xml
  97. 9 0
      app/src/main/res/values-zh-rCN/strings.xml
  98. 10 1
      app/src/main/res/values-zh-rTW/strings.xml
  99. 2 2
      app/src/main/res/values/colors.xml
  100. 1 1
      app/src/main/res/values/dimens.xml

+ 5 - 4
app/build.gradle.kts

@@ -1,6 +1,5 @@
 import com.android.build.gradle.internal.api.ApkVariantOutputImpl
 import com.android.build.gradle.internal.tasks.factory.dependsOn
-import config.BuildFeatureFlags
 import config.PublicKeys
 import config.SentryConfig
 import config.setProductNames
@@ -32,7 +31,7 @@ if (gradle.startParameter.taskRequests.toString().contains("Hms")) {
 /**
  * Only use the scheme "<major>.<minor>.<patch>" for the appVersion
  */
-val appVersion = "6.4.2"
+val appVersion = "6.4.3"
 
 /**
  * betaSuffix with leading dash (e.g. `-beta1`).
@@ -41,7 +40,7 @@ val appVersion = "6.4.2"
  */
 val betaSuffix = ""
 
-val defaultVersionCode = 1142
+val defaultVersionCode = 1148
 
 /**
  * Map with keystore paths (if found).
@@ -130,7 +129,7 @@ android {
 
         stringArrayBuildConfigField("ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", emptyArray())
         booleanBuildConfigField("MD_SYNC_DISTRIBUTION_LISTS", false)
-        booleanBuildConfigField("AVAILABILITY_STATUS_ENABLED", BuildFeatureFlags["availability_status"] ?: false)
+        booleanBuildConfigField("AVAILABILITY_STATUS_ENABLED", false)
         booleanBuildConfigField("ERROR_REPORTING_SUPPORTED", false)
 
         // config fields for action URLs / deep links
@@ -239,6 +238,7 @@ android {
             stringBuildConfigField("APP_RATING_URL", "https://threema.com/app-rating/android-work/{rating}")
             stringBuildConfigField("LOG_TAG", "3mawrk")
             stringBuildConfigField("DEFAULT_APP_THEME", "2")
+            booleanBuildConfigField("AVAILABILITY_STATUS_ENABLED", true)
 
             // config fields for action URLs / deep links
             stringBuildConfigField("uriScheme", "threemawork")
@@ -462,6 +462,7 @@ android {
             stringBuildConfigField("APP_RATING_URL", "https://threema.com/app-rating/android-work/{rating}")
             stringBuildConfigField("LOG_TAG", "3mawrk")
             stringBuildConfigField("DEFAULT_APP_THEME", "2")
+            booleanBuildConfigField("AVAILABILITY_STATUS_ENABLED", true)
 
             // config fields for action URLs / deep links
             stringBuildConfigField("uriScheme", "threemawork")

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

@@ -500,7 +500,7 @@ class PersistableTasksTest {
     }
 
     @Test
-    fun testWorkLastFullSyncAtUpdate() {
+    fun testReflectWorkLastFullSyncAtUpdate() {
         assertValidEncoding(
             ReflectContactSyncUpdateTask.ReflectWorkLastFullSyncAtUpdate::class,
             """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectWorkLastFullSyncAtUpdate.""" +
@@ -508,6 +508,17 @@ class PersistableTasksTest {
         )
     }
 
+    @Test
+    fun testReflectWorkLastFullSyncAtWithAvailabilityStatusUpdate() {
+        assertValidEncoding(
+            ReflectContactSyncUpdateTask.ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdate::class,
+            """{"type":"ch.threema.app.tasks.ReflectContactSyncUpdateTask.ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdate.""" +
+                """ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdateData","workLastFullSyncAt":1775144426988, """ +
+                """"availabilityStatus":{"type":"ch.threema.data.datatypes.AvailabilityStatus.Busy", "description":"In a meeting"}, """ +
+                """"identity":"01234567"}""",
+        )
+    }
+
     @Test
     fun testUserDefinedProfilePictureUpdate() {
         assertValidEncoding(

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

@@ -2,6 +2,7 @@ package ch.threema.app.testutils
 
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ServiceManager
+import ch.threema.data.models.ContactModel
 import ch.threema.data.models.GroupIdentity
 import ch.threema.storage.DatabaseProvider
 import ch.threema.storage.runTransaction
@@ -9,10 +10,10 @@ import org.koin.mp.KoinPlatform
 
 fun clearDatabaseAndCaches(serviceManager: ServiceManager) {
     // First get all available contacts and groups
-    val contactIdentities =
-        serviceManager.databaseService.contactModelFactory.all.map { contact ->
-            contact.identity
-        }
+    val contactIdentities = serviceManager.modelRepositories
+        .contacts
+        .getAll()
+        .map(ContactModel::identity)
     val groupModelRepository = serviceManager.modelRepositories.groups
     val groups = serviceManager.databaseService.groupModelFactory.all
         .map { group ->

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

@@ -2,11 +2,13 @@ package ch.threema.app.activities.directory
 
 import ch.threema.app.asynctasks.AddOrUpdateWorkContactBackgroundTask
 import ch.threema.app.framework.BaseViewModel
+import ch.threema.app.multidevice.MultiDeviceManager
 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 ch.threema.domain.taskmanager.TaskManager
 import kotlinx.coroutines.withContext
 
 private val logger = getThreemaLogger("DirectoryViewModel")
@@ -15,6 +17,8 @@ class DirectoryViewModel(
     private val dispatcherProvider: DispatcherProvider,
     private val identityProvider: IdentityProvider,
     private val contactModelRepository: ContactModelRepository,
+    private val taskManager: TaskManager,
+    private val multiDeviceManager: MultiDeviceManager,
 ) : BaseViewModel<Unit, DirectoryScreenEvent>() {
 
     override fun initialize() = runInitialization {}
@@ -50,6 +54,8 @@ class DirectoryViewModel(
             workContact = workDirectoryContact,
             myIdentity = myIdentity.value,
             contactModelRepository = contactModelRepository,
+            taskManager = taskManager,
+            multiDeviceManager = multiDeviceManager,
         )
         val contactModel = withContext(dispatcherProvider.io) {
             createContactTask.runSynchronously()

+ 12 - 7
app/src/main/java/ch/threema/app/adapters/ContactListAdapter.java

@@ -13,6 +13,7 @@ import android.widget.SectionIndexer;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.constraintlayout.widget.ConstraintLayout;
 
 import com.bumptech.glide.RequestManager;
@@ -34,6 +35,7 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.stream.Collectors;
 
+import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.emojis.EmojiTextView;
@@ -48,7 +50,10 @@ import ch.threema.app.ui.listitemholder.AvatarListItemHolder;
 import ch.threema.app.utils.AdapterUtil;
 import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.ViewUtil;
+
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+
+import ch.threema.data.datatypes.AvailabilityStatus;
 import ch.threema.storage.models.ContactModel;
 
 public class ContactListAdapter extends FilterableListAdapter implements SectionIndexer {
@@ -438,6 +443,13 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
             }
         }
 
+        if (BuildConfig.AVAILABILITY_STATUS_ENABLED && holder.avatarView != null) {
+            final @Nullable AvailabilityStatus.Set availabilityStatusSet = (contactModel.getAvailabilityStatus() instanceof AvailabilityStatus.Set)
+                ? (AvailabilityStatus.Set) contactModel.getAvailabilityStatus()
+                : null;
+            holder.avatarView.setAvailabilityStatusBadgeState(availabilityStatusSet);
+        }
+
         return itemView;
     }
 
@@ -583,13 +595,6 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
         return 0;
     }
 
-    public int getViewTypeFromView(View v) {
-        if (v != null && v.getTag() != null) {
-            return ((ContactListHolder) v.getTag()).viewType;
-        }
-        return VIEW_TYPE_NORMAL;
-    }
-
     public String getInitial(int position) {
         if (position < values.size() && position > 0) {
             return getInitial(values.get(position), true, position);

+ 4 - 18
app/src/main/java/ch/threema/app/adapters/DirectoryAdapter.java

@@ -19,14 +19,13 @@ import androidx.annotation.Nullable;
 import androidx.paging.PagedListAdapter;
 import androidx.recyclerview.widget.DiffUtil;
 import androidx.recyclerview.widget.RecyclerView;
+
+import ch.threema.app.BuildConfig;
 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;
@@ -55,7 +54,6 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
         private final TextView identityView;
         private final ImageView statusImageView;
         private final InitialAvatarView avatarView;
-        private final AvailabilityStatusIconElevatedView availabilityStatusIconElevatedView;
         private final TextView categoriesView;
         private final Chip organizationView;
         protected WorkDirectoryContact contact;
@@ -67,7 +65,6 @@ 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);
         }
@@ -202,7 +199,7 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
     }
 
     private void bindAvailabilityStatusIcon(@NonNull DirectoryHolder holder, @NonNull WorkDirectoryContact workDirectoryContact) {
-        if (!ConfigUtils.supportsAvailabilityStatus()) {
+        if (!BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             return;
         }
 
@@ -213,22 +210,11 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
         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(
+        holder.avatarView.setAvailabilityStatusBadgeState(
             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 =

+ 1 - 1
app/src/main/java/ch/threema/app/adapters/GroupDetailAdapter.java

@@ -189,7 +189,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
             itemHolder.idView.setText(displayableContactOrUser.getIdentity());
             AdapterUtil.styleContact(itemHolder.nameView, displayableContactOrUser.getIdentityState());
             itemHolder.avatarView.setImageBitmap(avatar);
-            itemHolder.avatarView.setBadgeVisible(displayableGroupParticipant.getDisplayableContactOrUser().getShowBadge());
+            itemHolder.avatarView.setWorkBadgeVisible(displayableGroupParticipant.getDisplayableContactOrUser().getShowBadge());
             itemHolder.view.setOnClickListener(v -> onClickListener.onGroupMemberClick(v, displayableContactOrUser.getIdentity()));
 
             boolean isCreator = displayableGroupParticipant instanceof DisplayableGroupParticipant.Creator;

+ 13 - 2
app/src/main/java/ch/threema/app/adapters/MentionSelectorAdapter.java

@@ -8,7 +8,10 @@ import android.view.ViewGroup;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.recyclerview.widget.RecyclerView;
+
+import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.glide.AvatarOptions;
 import ch.threema.app.preference.service.PreferenceService;
@@ -18,6 +21,7 @@ import ch.threema.app.services.UserService;
 import ch.threema.app.ui.AvatarView;
 import ch.threema.app.utils.AdapterUtil;
 import ch.threema.app.utils.NameUtil;
+import ch.threema.data.datatypes.AvailabilityStatus;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.data.models.GroupModel;
 
@@ -94,13 +98,20 @@ public class MentionSelectorAdapter extends AbstractRecyclerAdapter<ContactModel
             } else {
                 itemHolder.avatarView.setImageResource(R.drawable.ic_group);
             }
-            itemHolder.avatarView.setBadgeVisible(false);
+            itemHolder.avatarView.setWorkBadgeVisible(false);
         } else {
             avatar = this.contactService.getAvatar(contactModel.getIdentity(), false);
 
             itemHolder.idView.setText(contactModel.getIdentity());
             itemHolder.avatarView.setImageBitmap(avatar);
-            itemHolder.avatarView.setBadgeVisible(contactService.showBadge(contactModel));
+            itemHolder.avatarView.setWorkBadgeVisible(contactService.showBadge(contactModel));
+
+            if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
+                final @Nullable AvailabilityStatus.Set availabilityStatusSet = (contactModel.getAvailabilityStatus() instanceof AvailabilityStatus.Set)
+                    ? (AvailabilityStatus.Set) contactModel.getAvailabilityStatus()
+                    : null;
+                itemHolder.avatarView.setAvailabilityStatusBadgeState(availabilityStatusSet);
+            }
         }
         AdapterUtil.styleContact(itemHolder.nameView, contactModel);
         itemHolder.view.setOnClickListener(v -> onClickListener.onItemClick(v, contactModel));

+ 1 - 1
app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java

@@ -359,7 +359,7 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator implements L
                         viewHolder.avatarView.setImageBitmap(contactCache.avatar);
                         viewHolder.avatarView.setVisibility(View.VISIBLE);
                         if (contactCache.contactModel != null) {
-                            viewHolder.avatarView.setBadgeVisible(helper.getContactService().showBadge(contactCache.contactModel));
+                            viewHolder.avatarView.setWorkBadgeVisible(helper.getContactService().showBadge(contactCache.contactModel));
                         }
                     } else {
                         // hide avatar in grouped messages

+ 65 - 9
app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateWorkContactBackgroundTask.kt

@@ -1,6 +1,9 @@
 package ch.threema.app.asynctasks
 
 import androidx.annotation.WorkerThread
+import ch.threema.app.BuildConfig
+import ch.threema.app.multidevice.MultiDeviceManager
+import ch.threema.app.tasks.ReflectContactSyncUpdateTask
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.executor.BackgroundTask
 import ch.threema.base.crypto.NaCl
@@ -20,8 +23,10 @@ import ch.threema.domain.models.TypingIndicatorPolicy
 import ch.threema.domain.models.VerificationLevel
 import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.api.work.WorkContact
+import ch.threema.domain.taskmanager.TaskManager
 import ch.threema.domain.types.IdentityString
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import java.time.Instant
 import kotlinx.coroutines.runBlocking
 
 private val logger = getThreemaLogger("AddOrUpdateWorkContactBackgroundTask")
@@ -45,6 +50,8 @@ class AddOrUpdateWorkContactBackgroundTask(
      * The contact model repository.
      */
     private val contactModelRepository: ContactModelRepository,
+    private val taskManager: TaskManager,
+    private val multiDeviceManager: MultiDeviceManager,
 ) : BackgroundTask<ContactModel?> {
     /**
      * Add the work contact if the identity belongs to a work contact.
@@ -182,20 +189,69 @@ class AddOrUpdateWorkContactBackgroundTask(
             contactModel.setVerificationLevelFromLocal(VerificationLevel.SERVER_VERIFIED)
         }
 
-        // Update the workLastFullSyncAt timestamp
-        workContact.workLastFullSyncAt?.let { workLastFullSyncAt ->
-            if (currentContactModelData.workLastFullSyncAt != workContact.workLastFullSyncAt) {
-                contactModel.setWorkLastFullSyncFromLocal(workLastFullSyncAt)
+        updateWorkLastFullSyncAtAndAvailabilityStatus(
+            contactModel = contactModel,
+            currentContactModelData = currentContactModelData,
+        )
+    }
+
+    // TODO(ANDR-4946): Remove this workaround and reflect updates to workLastFullSyncAt and availabilityStatus as usual
+    /**
+     *  Handling the update `contact.workLastFullSyncAt` and `contact.availabilityStatus`.
+     *
+     *  In case both values are available from the given `workContact` and multi device is active, we reflect the `contact.availabilityStatus`
+     *  even if there was no effective change. This is required because linked clients may not have had support for availability statuses and
+     *  therefore may have dropped the previously synced availability statuses.
+     *
+     *  To save on sync messages, the task
+     *  [ReflectContactSyncUpdateTask.ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdate] combines both values in one sync message.
+     */
+    private fun updateWorkLastFullSyncAtAndAvailabilityStatus(
+        contactModel: ContactModel,
+        currentContactModelData: ContactModelData,
+    ) {
+        val workLastFullSyncAt: Instant? = workContact.workLastFullSyncAt
+
+        @Suppress("KotlinConstantConditions")
+        val workContactAvailabilityStatus: AvailabilityStatus? =
+            if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
+                workContact.getAvailabilityStatusOrNone()
+            } else {
+                null
             }
+
+        // Persist availability-status locally if present
+        if (workContactAvailabilityStatus != null) {
+            contactModel.persistAvailabilityStatus(workContactAvailabilityStatus)
         }
 
-        // Update availability status
-        if (ConfigUtils.supportsAvailabilityStatus()) {
-            val workContactAvailabilityStatus = workContact.getAvailabilityStatusOrNone()
-            if (currentContactModelData.availabilityStatus != workContactAvailabilityStatus) {
-                contactModel.setAvailabilityStatusFromLocal(workContactAvailabilityStatus)
+        // Update work-last-full-sync-at if present
+        if (workLastFullSyncAt != null) {
+            if (multiDeviceManager.isMultiDeviceActive && workContactAvailabilityStatus != null) {
+                contactModel.persistWorkLastFullSyncAt(workLastFullSyncAt)
+            } else {
+                contactModel.setWorkLastFullSyncFromLocal(workLastFullSyncAt)
             }
         }
+
+        // Reflect availability status (combined with workLastFullSyncAt timestamp if both present)
+        if (multiDeviceManager.isMultiDeviceActive && workContactAvailabilityStatus != null) {
+            val contactSyncUpdateTask =
+                if (workLastFullSyncAt != null) {
+                    ReflectContactSyncUpdateTask.ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdate(
+                        workLastFullSyncAt = workLastFullSyncAt,
+                        availabilityStatus = workContactAvailabilityStatus,
+                        contactIdentity = currentContactModelData.identity,
+                    )
+                } else {
+                    ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate(
+                        newAvailabilityStatus = workContactAvailabilityStatus,
+                        contactIdentity = currentContactModelData.identity,
+                    )
+                }
+            @Suppress("DeferredResultUnused")
+            taskManager.schedule(contactSyncUpdateTask)
+        }
     }
 
     private fun WorkContact.getAvailabilityStatusOrNone(): AvailabilityStatus =

+ 70 - 19
app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusContactBannerView.kt

@@ -3,19 +3,33 @@ package ch.threema.app.availabilitystatus
 import android.content.Context
 import android.util.AttributeSet
 import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 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.platform.AbstractComposeView
 import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
 import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import ch.threema.app.R
+import ch.threema.app.compose.common.SpacerHorizontal
 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
@@ -39,24 +53,29 @@ constructor(
         AvailabilityStatusContactBannerState(
             availabilityStatusSet = null,
             emojiStyle = PreferenceService.EMOJI_STYLE_ANDROID,
+            onClickOnOverflowListener = null,
         ),
     )
 
-    fun setState(availabilityStatusSet: AvailabilityStatus.Set?, @EmojiStyle emojiStyle: Int) {
+    fun setState(
+        availabilityStatusSet: AvailabilityStatus.Set?,
+        @EmojiStyle emojiStyle: Int,
+        onClickOnOverflowListener: (() -> Unit)?,
+    ) {
         state.value = AvailabilityStatusContactBannerState(
             availabilityStatusSet = availabilityStatusSet,
             emojiStyle = emojiStyle,
+            onClickOnOverflowListener = onClickOnOverflowListener,
         )
     }
 
     @Composable
     override fun Content() {
         val state: AvailabilityStatusContactBannerState by state.collectAsStateWithLifecycle()
-        state.availabilityStatusSet?.let { availabilityStatusSet ->
+        state.availabilityStatusSet?.let {
             ThreemaTheme {
                 AvailabilityStatusContactBanner(
-                    status = availabilityStatusSet,
-                    emojiStyle = state.emojiStyle,
+                    state = state,
                 )
             }
         }
@@ -66,15 +85,19 @@ constructor(
 private data class AvailabilityStatusContactBannerState(
     val availabilityStatusSet: AvailabilityStatus.Set?,
     @EmojiStyle val emojiStyle: Int,
+    val onClickOnOverflowListener: (() -> Unit)?,
 )
 
 @Composable
 private fun AvailabilityStatusContactBanner(
     modifier: Modifier = Modifier,
-    status: AvailabilityStatus.Set,
-    @EmojiStyle emojiStyle: Int,
+    state: AvailabilityStatusContactBannerState,
 ) {
-    ConversationText(
+    state.availabilityStatusSet ?: return
+    var hasVisualOverflow: Boolean by remember(state.availabilityStatusSet, state.emojiStyle) {
+        mutableStateOf(false)
+    }
+    Row(
         modifier = modifier
             .padding(
                 top = GridUnit.x1,
@@ -89,21 +112,49 @@ private fun AvailabilityStatusContactBanner(
                 ),
             )
             .background(
-                color = status.containerColor(),
+                color = state.availabilityStatusSet.containerColor(),
+            )
+            .clickable(
+                enabled = hasVisualOverflow && state.onClickOnOverflowListener != null,
+                onClick = {
+                    state.onClickOnOverflowListener?.invoke()
+                },
+                onClickLabel = stringResource(R.string.accessibility_view_full_availability_status),
+                role = Role.Button,
             )
             .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,
-    )
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        ConversationText(
+            modifier = Modifier.weight(1f),
+            rawInput = state.availabilityStatusSet.displayText().get(),
+            textStyle = MaterialTheme.typography.bodyMedium,
+            color = state.availabilityStatusSet.onContainerColor(),
+            maxLines = 2,
+            textAlign = TextAlign.Center,
+            onTextLaidOut = { updatedHasVisualOverflow ->
+                hasVisualOverflow = updatedHasVisualOverflow
+            },
+            emojiSettings = EmojiSettings(
+                style = state.emojiStyle,
+            ),
+            mentionFeature = MentionFeature.Off,
+            markupEnabled = false,
+        )
+
+        if (hasVisualOverflow) {
+            SpacerHorizontal(GridUnit.x2)
+            Icon(
+                modifier = Modifier
+                    .size(24.dp)
+                    .clearAndSetSemantics { },
+                painter = painterResource(R.drawable.ic_info_rounded),
+                contentDescription = null,
+                tint = state.availabilityStatusSet.onContainerColor(),
+            )
+        }
+    }
 }

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

@@ -63,3 +63,10 @@ fun AvailabilityStatus.Set.onContainerColor(): Color =
         is AvailabilityStatus.Busy -> CustomColors.availabilityStatusOnContainerBusy
         is AvailabilityStatus.Unavailable -> CustomColors.availabilityStatusOnContainerUnavailable
     }
+
+fun AvailabilityStatus.withTrimmedDescription(): AvailabilityStatus =
+    when (this) {
+        is AvailabilityStatus.None -> this
+        is AvailabilityStatus.Busy -> copy(description = description.trim())
+        is AvailabilityStatus.Unavailable -> copy(description = description.trim())
+    }

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

@@ -90,6 +90,7 @@ private fun AvailabilityStatusLabel(
         ConversationText(
             rawInput = availabilityStatusSet.displayText().get(),
             textStyle = MaterialTheme.typography.bodyLarge,
+            color = MaterialTheme.colorScheme.onSurface,
             emojiSettings = EmojiSettings(
                 style = emojiStyle,
             ),

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

@@ -142,7 +142,7 @@ private class PreviewProviderAvailabilityStatusOwnBanner : PreviewParameterProvi
             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.",
+            description = "I can't keep my status description short because I am a person that likes to talk a lot.",
         ),
         AvailabilityStatus.Unavailable(),
         AvailabilityStatus.Unavailable(

+ 73 - 0
app/src/main/java/ch/threema/app/availabilitystatus/AvailabilityStatusTooltipPopupManager.kt

@@ -0,0 +1,73 @@
+package ch.threema.app.availabilitystatus
+
+import android.view.View
+import androidx.core.view.isVisible
+import androidx.fragment.app.FragmentActivity
+import ch.threema.android.getLocation
+import ch.threema.android.postDelayed
+import ch.threema.app.R
+import ch.threema.app.ui.TooltipPopup
+import kotlin.time.Duration.Companion.seconds
+
+object AvailabilityStatusTooltipPopupManager {
+    @JvmStatic
+    fun showInConversation(activity: FragmentActivity, anchor: View): TooltipPopup? {
+        val tooltipPopup = createTooltipPopup(activity)
+            ?: return null
+        anchor.postDelayed(1.seconds) {
+            if (!anchor.isVisible || !anchor.isAttachedToWindow) {
+                return@postDelayed
+            }
+            val tooltipWidth = activity.resources.getDimensionPixelSize(R.dimen.tooltip_max_width)
+            tooltipPopup.show(
+                activity = activity,
+                anchor = anchor,
+                title = activity.getString(R.string.tooltip_title_availability_status),
+                text = activity.getString(R.string.tooltip_text_availability_status_in_chat),
+                alignment = TooltipPopup.Alignment.BELOW_ANCHOR_ARROW_LEFT,
+                originLocation = anchor.getLocation(
+                    xOffset = (anchor.width - tooltipWidth) / 2,
+                    yOffset = anchor.height,
+                ),
+            )
+        }
+        return tooltipPopup
+    }
+
+    @JvmStatic
+    fun showInProfile(activity: FragmentActivity, anchor: View): TooltipPopup? {
+        val tooltipPopup = createTooltipPopup(activity)
+            ?: return null
+        anchor.postDelayed(1.seconds) {
+            if (!anchor.isVisible || !anchor.isAttachedToWindow) {
+                return@postDelayed
+            }
+            tooltipPopup.show(
+                activity = activity,
+                anchor = anchor,
+                title = activity.getString(R.string.tooltip_title_availability_status),
+                text = activity.getString(R.string.tooltip_text_availability_status_in_profile),
+                alignment = TooltipPopup.Alignment.BELOW_ANCHOR_ARROW_LEFT,
+            )
+        }
+        return tooltipPopup
+    }
+
+    private fun createTooltipPopup(activity: FragmentActivity): TooltipPopup? {
+        val tooltipPopup = TooltipPopup(
+            context = activity,
+            preferenceKey = R.string.preferences__tooltip_availability_status_shown,
+            lifecycleOwner = activity,
+            showArrow = false,
+        )
+        if (tooltipPopup.isForeverDismissed()) {
+            return null
+        }
+        tooltipPopup.listener = object : TooltipPopup.TooltipPopupListener() {
+            override fun onShown(tooltipPopup: TooltipPopup) {
+                tooltipPopup.markAsShown()
+            }
+        }
+        return tooltipPopup
+    }
+}

+ 163 - 0
app/src/main/java/ch/threema/app/availabilitystatus/ViewFullAvailabilityStatusBottomSheetDialog.kt

@@ -0,0 +1,163 @@
+package ch.threema.app.availabilitystatus
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.BottomSheetDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.core.os.bundleOf
+import ch.threema.app.BuildConfig
+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.extensions.get
+import ch.threema.app.compose.theme.ThreemaTheme
+import ch.threema.app.compose.theme.ThreemaThemePreview
+import ch.threema.app.compose.theme.dimens.GridUnit
+import ch.threema.data.datatypes.AvailabilityStatus
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+
+class ViewFullAvailabilityStatusBottomSheetDialog : BottomSheetDialogFragment() {
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        check(BuildConfig.AVAILABILITY_STATUS_ENABLED) {
+            "Can not show this bottom sheet as the current build does not support this feature in general."
+        }
+    }
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
+        dialog.behavior.apply {
+            skipCollapsed = true
+            state = BottomSheetBehavior.STATE_EXPANDED
+            isDraggable = true
+        }
+        return dialog
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?,
+    ): View {
+        val availabilityStatus: AvailabilityStatus? = arguments
+            ?.getString(ARGUMENT_AVAILABILITY_STATUS)
+            ?.let(AvailabilityStatus::fromJson)
+
+        return ComposeView(requireContext()).apply {
+            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+            setContent {
+                ThreemaTheme {
+                    ViewFullStatusBottomSheetContent(availabilityStatus)
+                }
+            }
+        }
+    }
+
+    companion object {
+
+        private const val ARGUMENT_AVAILABILITY_STATUS = "availability-status"
+
+        @JvmStatic
+        fun newInstance(availabilityStatus: AvailabilityStatus): ViewFullAvailabilityStatusBottomSheetDialog =
+            ViewFullAvailabilityStatusBottomSheetDialog().apply {
+                arguments = bundleOf(
+                    ARGUMENT_AVAILABILITY_STATUS to availabilityStatus.toJson(),
+                )
+            }
+    }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ViewFullStatusBottomSheetContent(
+    availabilityStatus: AvailabilityStatus?,
+) {
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(
+                horizontal = GridUnit.x2,
+            )
+            .verticalScroll(
+                state = rememberScrollState(),
+            ),
+    ) {
+        BottomSheetDefaults.DragHandle(
+            modifier = Modifier.align(Alignment.CenterHorizontally),
+        )
+
+        SpacerVertical(GridUnit.x1)
+
+        ThemedText(
+            text = stringResource(R.string.view_full_availability_status_modal_title),
+            style = MaterialTheme.typography.titleLarge,
+        )
+
+        SpacerVertical(GridUnit.x2)
+
+        ThemedText(
+            text = availabilityStatus?.displayText()?.get() ?: "-",
+            style = MaterialTheme.typography.bodyMedium,
+        )
+
+        SpacerVertical(GridUnit.x2)
+    }
+}
+
+private class PreviewProviderViewFullStatusBottomSheetContent : PreviewParameterProvider<AvailabilityStatus?> {
+
+    override val values: Sequence<AvailabilityStatus?> = sequenceOf(
+        AvailabilityStatus.None,
+        AvailabilityStatus.Busy(),
+        AvailabilityStatus.Busy(
+            description = "In a short coffee break",
+        ),
+        AvailabilityStatus.Busy(
+            description = "I can't 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.",
+        ),
+        null,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@PreviewLightDark
+@Composable
+private fun Preview_ViewFullStatusBottomSheetContent(
+    @PreviewParameter(PreviewProviderViewFullStatusBottomSheetContent::class)
+    availabilityStatus: AvailabilityStatus?,
+) {
+    ThreemaThemePreview {
+        Surface {
+            ViewFullStatusBottomSheetContent(availabilityStatus)
+        }
+    }
+}

+ 29 - 55
app/src/main/java/ch/threema/app/availabilitystatus/edit/EditAvailabilityStatusBottomSheetDialog.kt

@@ -1,5 +1,6 @@
 package ch.threema.app.availabilitystatus.edit
 
+import android.app.Dialog
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.View
@@ -17,26 +18,20 @@ 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.material3.Surface
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
@@ -56,6 +51,7 @@ 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.BuildConfig
 import ch.threema.app.R
 import ch.threema.app.availabilitystatus.containerColor
 import ch.threema.app.availabilitystatus.displayNameRes
@@ -73,8 +69,9 @@ 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.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
 import com.google.android.material.bottomsheet.BottomSheetDialogFragment
 import org.koin.androidx.viewmodel.ext.android.viewModel
 
@@ -82,10 +79,20 @@ class EditAvailabilityStatusBottomSheetDialog : BottomSheetDialogFragment() {
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
+        check(BuildConfig.AVAILABILITY_STATUS_ENABLED) {
+            "Can not show this bottom sheet as the current build does not support this feature in general."
+        }
+    }
 
-        if (!ConfigUtils.supportsAvailabilityStatus()) {
-            throw IllegalStateException("Can not show this bottom sheet as the current build does not support this feature in general.")
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
+        dialog.behavior.apply {
+            skipCollapsed = true
+            state = BottomSheetBehavior.STATE_EXPANDED
+            isDraggable = false
         }
+        dialog.setCanceledOnTouchOutside(false)
+        return dialog
     }
 
     @OptIn(ExperimentalMaterial3Api::class)
@@ -104,37 +111,16 @@ class EditAvailabilityStatusBottomSheetDialog : BottomSheetDialogFragment() {
 
                     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,
-                                )
-                            }
+                            SetStatusBottomSheetContent(
+                                status = state.status,
+                                descriptionState = state.descriptionState,
+                                isLoading = state.isLoading,
+                                hasError = state.hasError,
+                                onClickStatus = viewModel::onClickStatus,
+                                onChangeDescription = viewModel::onChangeDescription,
+                                onClickCancel = viewModel::onClickCancel,
+                                onClickSave = viewModel::onClickSave,
+                            )
                         }
                     }
                 }
@@ -181,7 +167,6 @@ class EditAvailabilityStatusBottomSheetDialog : BottomSheetDialogFragment() {
 
 @Composable
 fun SetStatusBottomSheetContent(
-    windowInsetsBottom: WindowInsets,
     status: AvailabilityStatus,
     descriptionState: AvailabilityStatusDescriptionState,
     isLoading: Boolean,
@@ -367,8 +352,6 @@ fun SetStatusBottomSheetContent(
         }
 
         SpacerVertical(GridUnit.x2)
-
-        SpacerVertical(windowInsetsBottom.asPaddingValues().calculateBottomPadding())
     }
 }
 
@@ -454,7 +437,7 @@ private class PreviewProviderSetStatusBottomSheetContent : PreviewParameterProvi
             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.",
+            description = "I can't keep my status description short because I am a person that likes to talk a lot.",
         ),
         AvailabilityStatus.Unavailable(),
         AvailabilityStatus.Unavailable(
@@ -474,17 +457,8 @@ private fun Preview_SetStatusBottomSheetContent(
     availabilityStatus: AvailabilityStatus,
 ) {
     ThreemaThemePreview {
-        ModalBottomSheet(
-            onDismissRequest = {},
-            sheetState = rememberModalBottomSheetState(
-                skipPartiallyExpanded = true,
-            ),
-        ) {
+        Surface {
             SetStatusBottomSheetContent(
-                windowInsetsBottom = BottomSheetDefaults.windowInsets
-                    .only(
-                        sides = WindowInsetsSides.Bottom,
-                    ),
                 status = availabilityStatus,
                 descriptionState = AvailabilityStatusDescriptionState(
                     description = when (availabilityStatus) {

+ 2 - 1
app/src/main/java/ch/threema/app/availabilitystatus/edit/EditAvailabilityStatusViewModel.kt

@@ -1,5 +1,6 @@
 package ch.threema.app.availabilitystatus.edit
 
+import ch.threema.app.availabilitystatus.withTrimmedDescription
 import ch.threema.app.framework.BaseViewModel
 import ch.threema.app.preference.service.PreferenceService
 import ch.threema.app.usecases.availabilitystatus.UpdateUserAvailabilityStatusUseCase
@@ -71,7 +72,7 @@ class EditAvailabilityStatusViewModel(
 
     fun onClickSave() = runAction {
         val currentAvailabilityStatus = getCurrentAvailabilityStatusOrNone()
-        if (currentAvailabilityStatus == currentViewState.status) {
+        if (currentAvailabilityStatus == currentViewState.status.withTrimmedDescription()) {
             emitEvent(EditAvailabilityStatusEvent.Cancel)
             endAction()
         }

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

@@ -91,6 +91,7 @@ fun ConversationText(
     maxLines: Int = Int.MAX_VALUE,
     textAlign: TextAlign? = null,
     overflow: TextOverflow = TextOverflow.Ellipsis,
+    onTextLaidOut: ((hasVisualOverflow: Boolean) -> Unit)? = null,
     emojiSettings: EmojiSettings = ConversationTextDefaults.EmojiSettings,
     mentionFeature: MentionFeature = MentionFeature.Off,
     markupEnabled: Boolean = true,
@@ -178,6 +179,7 @@ fun ConversationText(
             overflow = overflow,
             color = color,
             onTextLayout = { layoutResult ->
+                onTextLaidOut?.invoke(layoutResult.hasVisualOverflow)
                 if (mentionFeature is MentionFeature.On) {
                     mentionBackgroundSpans = annotatedString.getStringAnnotations(
                         tag = SPAN_TAG_BACKGROUND_MENTION,

+ 3 - 2
app/src/main/java/ch/threema/app/contactdetails/ContactDetailAdapter.java

@@ -29,13 +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;
@@ -160,7 +161,7 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
                 return true;
             });
 
-            if (ConfigUtils.supportsAvailabilityStatus()) {
+            if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
                 availabilityStatusContainer.setVisibility(
                     contactModelData.availabilityStatus instanceof AvailabilityStatus.Set
                         ? View.VISIBLE

+ 9 - 1
app/src/main/java/ch/threema/app/emojireactions/EmojiHintPopupManager.kt

@@ -49,8 +49,16 @@ class EmojiHintPopupManager(
         showOrDismissIfNecessary()
     }
 
+    var isSuppressed: Boolean = false
+        set(value) {
+            field = value
+            if (value) {
+                dismiss()
+            }
+        }
+
     fun showOrDismissIfNecessary() {
-        if (isScrolling || !shouldShowPopup) {
+        if (isScrolling || !shouldShowPopup || isSuppressed) {
             dismiss()
         } else {
             handler.removeCallbacks(showPopupRunnable)

+ 1 - 1
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewListAdapter.kt

@@ -86,7 +86,7 @@ class EmojiReactionsOverviewListAdapter(
             AdapterUtil.styleContact(contactNameTextView, contactModel)
 
             contactAvatarView.setImageBitmap(avatar)
-            contactAvatarView.setBadgeVisible(contactService.showBadge(contactModel))
+            contactAvatarView.setWorkBadgeVisible(contactService.showBadge(contactModel))
 
             removeIconView.setOnClickListener {
                 val position = absoluteAdapterPosition

+ 26 - 4
app/src/main/java/ch/threema/app/fragments/MyIDFragment.kt

@@ -2,6 +2,7 @@ package ch.threema.app.fragments
 
 import android.animation.LayoutTransition
 import android.content.Intent
+import android.content.res.Configuration
 import android.os.Bundle
 import android.text.InputType
 import android.view.LayoutInflater
@@ -23,10 +24,12 @@ import androidx.lifecycle.repeatOnLifecycle
 import ch.threema.android.ToastDuration
 import ch.threema.android.showToast
 import ch.threema.app.AppConstants
+import ch.threema.app.BuildConfig
 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.AvailabilityStatusTooltipPopupManager
 import ch.threema.app.availabilitystatus.displayText
 import ch.threema.app.availabilitystatus.edit.EditAvailabilityStatusBottomSheetDialog
 import ch.threema.app.availabilitystatus.iconRes
@@ -55,6 +58,7 @@ import ch.threema.app.tasks.TaskCreator
 import ch.threema.app.ui.AvatarEditView
 import ch.threema.app.ui.InsetSides.Companion.horizontal
 import ch.threema.app.ui.QRCodePopup
+import ch.threema.app.ui.TooltipPopup
 import ch.threema.app.ui.applyDeviceInsetsAsPadding
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.DialogUtil
@@ -100,6 +104,7 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
     private var currentAvailabilityStatusContainer: LinearLayout? = null
     private var currentAvailabilityStatusIcon: ImageView? = null
     private var currentAvailabilityStatusName: EmojiTextView? = null
+    private var availabilityStatusTooltipPopup: TooltipPopup? = null
 
     private var hidden = false
     private var isDisabledProfilePicReleaseSettings = false
@@ -166,7 +171,7 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
         super.onCreate(savedInstanceState)
         retainInstance = true
 
-        if (ConfigUtils.supportsAvailabilityStatus()) {
+        if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             setFragmentResultListener(
                 requestKey = REQUEST_KEY_EDIT_AVAILABILITY_STATUS,
             ) { _, bundle ->
@@ -238,8 +243,8 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
 
         policyExplainView.isVisible = isReadonlyProfile || isBackupsDisabled
 
-        currentAvailabilityStatusContainer.isVisible = ConfigUtils.supportsAvailabilityStatus()
-        if (ConfigUtils.supportsAvailabilityStatus()) {
+        currentAvailabilityStatusContainer.isVisible = BuildConfig.AVAILABILITY_STATUS_ENABLED
+        if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             setUpAvailabilityStatusDisplay(
                 availabilityStatus = preferenceService.getAvailabilityStatus(),
             )
@@ -349,7 +354,7 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
 
-        if (ConfigUtils.supportsAvailabilityStatus()) {
+        if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             viewLifecycleOwner.lifecycleScope.launch {
                 viewLifecycleOwner.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
                     preferenceService.watchAvailabilityStatus().collect(::setUpAvailabilityStatusDisplay)
@@ -898,10 +903,27 @@ class MyIDFragment : MainFragment(), DialogClickListener, TextEntryDialogClickLi
         super.onHiddenChanged(hidden)
         if (!hidden && this.hidden) {
             updatePendingState(requireView(), false)
+            if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
+                currentAvailabilityStatusIcon?.let { currentAvailabilityStatusIcon ->
+                    availabilityStatusTooltipPopup = AvailabilityStatusTooltipPopupManager.showInProfile(
+                        activity = requireActivity(),
+                        anchor = currentAvailabilityStatusIcon,
+                    )
+                }
+            }
+        } else if (hidden && !this.hidden) {
+            availabilityStatusTooltipPopup?.dismissForever(immediate = true)
+            availabilityStatusTooltipPopup = null
         }
         this.hidden = hidden
     }
 
+    override fun onConfigurationChanged(newConfig: Configuration) {
+        super.onConfigurationChanged(newConfig)
+        availabilityStatusTooltipPopup?.dismissForever(immediate = true)
+        availabilityStatusTooltipPopup = null
+    }
+
     override fun onStart() {
         super.onStart()
         ListenerManager.smsVerificationListeners.add(smsVerificationListener)

+ 55 - 31
app/src/main/java/ch/threema/app/fragments/composemessage/ComposeMessageFragment.java

@@ -124,6 +124,7 @@ import androidx.transition.Transition;
 import androidx.transition.TransitionManager;
 import ch.threema.android.ActivityExtensionsKt;
 import ch.threema.app.AppConstants;
+import ch.threema.app.BuildConfig;
 import ch.threema.app.ExecutorServices;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -151,6 +152,8 @@ 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.availabilitystatus.AvailabilityStatusTooltipPopupManager;
+import ch.threema.app.availabilitystatus.ViewFullAvailabilityStatusBottomSheetDialog;
 import ch.threema.app.cache.ThumbnailCache;
 import ch.threema.app.compose.common.interop.ComposeJavaBridge;
 import ch.threema.app.contactdetails.ContactDetailActivity;
@@ -309,6 +312,7 @@ import ch.threema.storage.models.data.MessageContentsType;
 import ch.threema.storage.models.group.GroupMessageModel;
 import ch.threema.storage.models.group.GroupModelOld;
 import kotlin.Unit;
+import kotlin.jvm.functions.Function0;
 
 import static android.view.WindowManager.LayoutParams.FLAG_SECURE;
 import static android.widget.AbsListView.CHOICE_MODE_MULTIPLE;
@@ -513,10 +517,10 @@ 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;
+    private TooltipPopup availabilityStatusTooltipPopup;
     private EmojiReactionsPopup emojiReactionsPopup;
     private QuotePopup quotePopup;
     private OpenBallotNoticeView openBallotNoticeView;
@@ -1447,17 +1451,12 @@ public class ComposeMessageFragment extends Fragment implements
     }
 
     private void onContactAvailabilityStatusChanged(@NonNull AvailabilityStatus availabilityStatus) {
-        if (!ConfigUtils.supportsAvailabilityStatus()) {
+        if (!BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             return;
         }
         // Toolbar avatar icon
-        if (availabilityStatusAvatarIconView != null) {
-            availabilityStatusAvatarIconView.setVisibility(
-                availabilityStatus instanceof AvailabilityStatus.Set
-                    ? View.VISIBLE
-                    : View.GONE
-            );
-            availabilityStatusAvatarIconView.setStatus(
+        if (actionBarAvatarView != null) {
+            actionBarAvatarView.setAvailabilityStatusBadgeState(
                 availabilityStatus instanceof AvailabilityStatus.Set
                     ? (AvailabilityStatus.Set) availabilityStatus
                     : null
@@ -1465,20 +1464,43 @@ public class ComposeMessageFragment extends Fragment implements
         }
         // Banner
         if (availabilityStatusBannerView != null) {
-            availabilityStatusBannerView.setVisibility(
-                availabilityStatus instanceof AvailabilityStatus.Set
-                    ? View.VISIBLE
-                    : View.GONE
-            );
+            final @Nullable AvailabilityStatus.Set availabilityStatusSet = availabilityStatus instanceof AvailabilityStatus.Set
+                ? (AvailabilityStatus.Set) availabilityStatus
+                : null;
+            final @Nullable Function0<Unit> onClickOnOverflowListener = availabilityStatusSet != null
+                ? () -> onClickViewFullAvailabilityStatus(availabilityStatusSet)
+                : null;
+            final int bannerVisibility = availabilityStatusSet != null
+                ? View.VISIBLE
+                : View.GONE;
+            availabilityStatusBannerView.setVisibility(bannerVisibility);
             availabilityStatusBannerView.setState(
-                availabilityStatus instanceof AvailabilityStatus.Set
-                    ? (AvailabilityStatus.Set) availabilityStatus
-                    : null,
-                preferenceService.getEmojiStyle()
+                availabilityStatusSet,
+                preferenceService.getEmojiStyle(),
+                onClickOnOverflowListener
             );
+
+            if (availabilityStatusSet != null) {
+                availabilityStatusTooltipPopup = AvailabilityStatusTooltipPopupManager.showInConversation(requireActivity(), availabilityStatusBannerView);
+                if (availabilityStatusTooltipPopup != null) {
+                    emojiHintPopupManager.setSuppressed(true);
+                }
+            }
         }
     }
 
+    private Unit onClickViewFullAvailabilityStatus(@NonNull AvailabilityStatus.Set availabilityStatusSet) {
+        ViewFullAvailabilityStatusBottomSheetDialog
+            .newInstance(
+                availabilityStatusSet
+            )
+            .show(
+                getParentFragmentManager(),
+                "view-full-availability-status-bottom-sheet"
+            );
+        return Unit.INSTANCE;
+    }
+
     private void onNextRecordsLoadedEvent(@NonNull ComposeMessageEvent.NextRecordsLoaded nextRecodsLoadedEvent) {
         valuesLoaded(nextRecodsLoadedEvent.messageModels);
         if (composeMessageAdapter != null) {
@@ -1866,6 +1888,10 @@ public class ComposeMessageFragment extends Fragment implements
 
             dismissTooltipPopup(workTooltipPopup, true);
             workTooltipPopup = null;
+            if (availabilityStatusTooltipPopup != null) {
+                availabilityStatusTooltipPopup.dismiss(true);
+                availabilityStatusTooltipPopup = null;
+            }
 
             dismissMentionPopup();
             dismissQuotePopup();
@@ -2458,7 +2484,6 @@ 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;
@@ -2479,17 +2504,12 @@ public class ComposeMessageFragment extends Fragment implements
                 }
             });
 
-            if (ConfigUtils.supportsAvailabilityStatus() && messageReceiver != null && messageReceiver instanceof ContactMessageReceiver) {
+            if (BuildConfig.AVAILABILITY_STATUS_ENABLED && 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(
+                if (availabilityStatus != null && actionBarAvatarView != null) {
+                    actionBarAvatarView.setAvailabilityStatusBadgeState(
                         availabilityStatus instanceof AvailabilityStatus.Set
                             ? (AvailabilityStatus.Set) availabilityStatus
                             : null
@@ -3745,7 +3765,7 @@ public class ComposeMessageFragment extends Fragment implements
         logger.debug("setIdentityColors");
 
         if (this.isGroupChat) {
-            Map<String, IdColor> colorIndices = this.groupService.getGroupMemberIDColors(groupModel);
+            Map<String, IdColor> colorIndices = groupService.getGroupParticipantIDColors(groupModel);
             Map<String, Integer> colors = new HashMap<>();
             boolean darkTheme = ConfigUtils.isTheDarkSide(getContext());
             for (Map.Entry<String, IdColor> entry : colorIndices.entrySet()) {
@@ -4623,7 +4643,7 @@ public class ComposeMessageFragment extends Fragment implements
                     Glide.with(requireActivity())
                 );
             }
-            actionBarAvatarView.setBadgeVisible(false);
+            actionBarAvatarView.setWorkBadgeVisible(false);
             setAvatarContentDescription(R.string.prefs_group_notifications);
         } else if (this.isDistributionListChat) {
             actionBarSubtitleTextView.setText(this.distributionListService.getMembersString(this.distributionListModel));
@@ -4641,7 +4661,7 @@ public class ComposeMessageFragment extends Fragment implements
                     );
                 }
             }
-            actionBarAvatarView.setBadgeVisible(false);
+            actionBarAvatarView.setWorkBadgeVisible(false);
             setAvatarContentDescription(R.string.distribution_list);
         } else {
             if (contactModel != null) {
@@ -4658,7 +4678,7 @@ public class ComposeMessageFragment extends Fragment implements
                         Glide.with(requireActivity())
                     );
                 }
-                this.actionBarAvatarView.setBadgeVisible(contactService.showBadge(contactModel));
+                this.actionBarAvatarView.setWorkBadgeVisible(contactService.showBadge(contactModel));
             }
             setAvatarContentDescription(R.string.prefs_header_chat);
         }
@@ -6248,6 +6268,10 @@ public class ComposeMessageFragment extends Fragment implements
         dismissMentionPopup();
         dismissTooltipPopup(workTooltipPopup, true);
         workTooltipPopup = null;
+        if (availabilityStatusTooltipPopup != null) {
+            availabilityStatusTooltipPopup.dismissForever(true);
+            availabilityStatusTooltipPopup = null;
+        }
 
         if (ConfigUtils.isTabletLayout()) {
             // make sure layout changes after rotate are reflected in thumbnail size etc.

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

@@ -4,14 +4,16 @@ import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
+import ch.threema.app.BuildConfig
 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.Job
+import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 
@@ -27,17 +29,30 @@ class ComposeMessageViewModel(
     private val _contactAvailabilityStatus = MutableLiveData<AvailabilityStatus>(AvailabilityStatus.None)
     val contactAvailabilityStatus: LiveData<AvailabilityStatus> = _contactAvailabilityStatus
 
+    private var getContactAvailabilityStatusJob: Job? = null
+
     fun onResume(messageReceiver: MessageReceiver<*>) {
-        if (ConfigUtils.supportsAvailabilityStatus() && messageReceiver is ContactMessageReceiver) {
-            viewModelScope.launch {
-                contactModelRepository
-                    .getByIdentity(messageReceiver.contact.identity)
+        @Suppress("KotlinConstantConditions")
+        if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
+            refreshAvailabilityStatus(messageReceiver)
+        }
+    }
+
+    private fun refreshAvailabilityStatus(messageReceiver: MessageReceiver<*>) {
+        getContactAvailabilityStatusJob?.cancel()
+        if (messageReceiver is ContactMessageReceiver) {
+            getContactAvailabilityStatusJob = viewModelScope.launch(dispatcherProvider.io) {
+                val availabilityStatus = contactModelRepository.getByIdentity(messageReceiver.contact.identity)
                     ?.data
                     ?.availabilityStatus
-                    ?.let { availabilityStatus ->
-                        _contactAvailabilityStatus.postValue(availabilityStatus)
-                    }
+                if (isActive) {
+                    _contactAvailabilityStatus.postValue(
+                        availabilityStatus ?: AvailabilityStatus.None,
+                    )
+                }
             }
+        } else {
+            _contactAvailabilityStatus.value = AvailabilityStatus.None
         }
     }
 

+ 2 - 1
app/src/main/java/ch/threema/app/fragments/conversations/ConversationsFragment.kt

@@ -75,6 +75,7 @@ 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
+import ch.threema.app.BuildConfig
 import ch.threema.app.BuildFlavor
 import ch.threema.app.R
 import ch.threema.app.activities.ComposeMessageActivity
@@ -271,7 +272,7 @@ class ConversationsFragment :
 
         this.resumePauseHandler = ResumePauseHandler.getByActivity(this, requireActivity())
 
-        if (ConfigUtils.supportsAvailabilityStatus()) {
+        if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             setFragmentResultListener(
                 requestKey = REQUEST_KEY_EDIT_AVAILABILITY_STATUS,
             ) { _, bundle ->

+ 1 - 1
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchAdapter.java

@@ -155,7 +155,7 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
                 viewHolder.titleView.setText("");
                 viewHolder.snippetView.setText(R.string.private_chat_subject);
                 viewHolder.avatarListItemHolder.avatarView.setVisibility(View.INVISIBLE);
-                viewHolder.avatarListItemHolder.avatarView.setBadgeVisible(false);
+                viewHolder.avatarListItemHolder.avatarView.setWorkBadgeVisible(false);
             } else if (messageModel.isDeleted()) {
                 initDeletedViewHolder(viewHolder, messageModel);
             } else {

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

@@ -339,7 +339,7 @@ class DeviceLinkingDataCollector(
                 }
                 profilePictureShareWith = collectProfilePictureShareWith()
                 identityLinks = collectIdentityLinks()
-                if (ConfigUtils.supportsAvailabilityStatus()) {
+                if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
                     val availabilityStatus = preferenceService.getAvailabilityStatus()
                     if (availabilityStatus != null) {
                         workAvailabilityStatus = availabilityStatus.toProtocolModel()
@@ -554,7 +554,7 @@ class DeviceLinkingDataCollector(
                 workLastFullSyncAt = contactModelData.workLastFullSyncAt.toEpochMilli()
             }
 
-            if (ConfigUtils.supportsAvailabilityStatus() && contactModelData.availabilityStatus != AvailabilityStatus.None) {
+            if (BuildConfig.AVAILABILITY_STATUS_ENABLED && contactModelData.availabilityStatus != AvailabilityStatus.None) {
                 workAvailabilityStatus = contactModelData.availabilityStatus.toProtocolModel()
             }
         }

+ 7 - 4
app/src/main/java/ch/threema/app/preference/service/PreferenceServiceImpl.kt

@@ -662,6 +662,9 @@ class PreferenceServiceImpl(
 
     override fun setTooltipPopupDismissed(key: Int, dismissed: Boolean) {
         preferenceStore.save(getKeyName(key), dismissed)
+        if (key == R.string.preferences__tooltip_emoji_reactions_shown && !dismissed) {
+            preferenceStore.save(getKeyName(R.string.preferences__tooltip_emoji_reactions_shown_counter), 0)
+        }
     }
 
     override fun setThreemaSafeEnabled(value: Boolean) {
@@ -827,7 +830,7 @@ class PreferenceServiceImpl(
     }
 
     override fun getLicensedStatus(): Boolean =
-        preferenceStore.getBoolean(getKeyName(R.string.preferences__license_status), true)
+        preferenceStore.getBoolean(getKeyName(R.string.preferences__license_status), false)
 
     override fun setShowDeveloperMenu(show: Boolean) {
         preferenceStore.save(getKeyName(R.string.preferences__developer_menu), show)
@@ -1075,7 +1078,7 @@ class PreferenceServiceImpl(
         preferenceStore.getInstant(getKeyName(R.string.preferences__debug_log_enable_time))
 
     override fun setAvailabilityStatus(availabilityStatus: AvailabilityStatus) {
-        if (!ConfigUtils.supportsAvailabilityStatus()) {
+        if (!BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             logger.error("The current build does not support this feature")
             return
         }
@@ -1086,7 +1089,7 @@ class PreferenceServiceImpl(
     }
 
     override fun getAvailabilityStatus(): AvailabilityStatus? {
-        if (!ConfigUtils.supportsAvailabilityStatus()) {
+        if (!BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             return null
         }
         return preferenceStore
@@ -1095,7 +1098,7 @@ class PreferenceServiceImpl(
     }
 
     override fun watchAvailabilityStatus(): Flow<AvailabilityStatus?> {
-        if (!ConfigUtils.supportsAvailabilityStatus()) {
+        if (!BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             return flowOf(null)
         }
         return preferenceStore

+ 11 - 1
app/src/main/java/ch/threema/app/processors/incomingcspmessage/conversation/IncomingContactDeleteMessageTask.kt

@@ -18,6 +18,7 @@ class IncomingContactDeleteMessageTask(
 ) : IncomingCspMessageSubTask<DeleteMessage>(message, triggerSource, serviceManager) {
     private val messageService by lazy { serviceManager.messageService }
     private val contactService by lazy { serviceManager.contactService }
+    private val identityStore by lazy { serviceManager.identityStore }
 
     override suspend fun executeMessageStepsFromRemote(handle: ActiveTaskCodec) =
         processContactDeleteMessage()
@@ -26,6 +27,8 @@ class IncomingContactDeleteMessageTask(
 
     private fun processContactDeleteMessage(): ReceiveStepsResult {
         logger.debug("IncomingContactDeleteMessageTask id: {}", message.data.messageId)
+        val myIdentity = identityStore.getIdentityString()
+            ?: throw IllegalStateException("Cannot apply edit when no identity is available")
 
         val contactModel = contactService.getByIdentity(message.fromIdentity)
         if (contactModel == null) {
@@ -34,7 +37,14 @@ class IncomingContactDeleteMessageTask(
         }
 
         val receiver = contactService.createReceiver(contactModel)
-        val messageModel = runCommonDeleteMessageReceiveSteps(message, receiver, messageService)
+        val messageModel = runCommonDeleteMessageReceiveSteps(
+            myIdentity = myIdentity,
+            deleteMessageSenderIdentity = message.fromIdentity,
+            deleteMessageCreatedAt = message.date,
+            messageId = message.data.messageId,
+            receiver = receiver,
+            messageService = messageService,
+        )
             ?: return ReceiveStepsResult.DISCARD
 
         messageService.deleteMessageContentsAndRelatedData(messageModel, message.date)

+ 12 - 2
app/src/main/java/ch/threema/app/processors/incomingcspmessage/conversation/IncomingContactEditMessageTask.kt

@@ -20,6 +20,7 @@ class IncomingContactEditMessageTask(
 ) : IncomingCspMessageSubTask<EditMessage>(editMessage, triggerSource, serviceManager) {
     private val messageService by lazy { serviceManager.messageService }
     private val contactService by lazy { serviceManager.contactService }
+    private val identityStore by lazy { serviceManager.identityStore }
 
     override suspend fun executeMessageStepsFromRemote(handle: ActiveTaskCodec): ReceiveStepsResult {
         return applyEdit()
@@ -31,16 +32,25 @@ class IncomingContactEditMessageTask(
 
     private fun applyEdit(): ReceiveStepsResult {
         logger.debug("IncomingContactEditMessageTask id: {}", message.data.messageId)
+        val myIdentity = identityStore.getIdentityString()
+            ?: throw IllegalStateException("Cannot apply edit when no identity is available")
 
         val contactModel = contactService.getByIdentity(message.fromIdentity)
         if (contactModel == null) {
-            logger.warn("Incoming Edit Message: No contact found for ${message.fromIdentity}")
+            logger.warn("Incoming Edit Message: No contact found for {}", message.fromIdentity)
             return ReceiveStepsResult.DISCARD
         }
 
         val fromReceiver: ContactMessageReceiver = contactService.createReceiver(contactModel)
         val editedMessage: AbstractMessageModel =
-            runCommonEditMessageReceiveSteps(message, fromReceiver, messageService)
+            runCommonEditMessageReceiveSteps(
+                myIdentity = myIdentity,
+                editMessageSenderIdentity = message.fromIdentity,
+                editMessageCreatedAt = message.date,
+                messageId = message.data.messageId,
+                receiver = fromReceiver,
+                messageService = messageService,
+            )
                 ?: return ReceiveStepsResult.DISCARD
 
         messageService.saveEditedMessageText(editedMessage, message.data.text, message.date)

+ 12 - 1
app/src/main/java/ch/threema/app/processors/incomingcspmessage/conversation/IncomingGroupDeleteMessageTask.kt

@@ -20,6 +20,8 @@ class IncomingGroupDeleteMessageTask(
     private val messageService = serviceManager.messageService
     private val groupService = serviceManager.groupService
 
+    private val identityStore by lazy { serviceManager.identityStore }
+
     override suspend fun executeMessageStepsFromRemote(handle: ActiveTaskCodec): ReceiveStepsResult {
         if (runCommonGroupReceiveSteps(message, handle, serviceManager) == null) {
             logger.info("Could not find group for delete message")
@@ -33,12 +35,21 @@ class IncomingGroupDeleteMessageTask(
 
     private fun processGroupDeleteMessage(): ReceiveStepsResult {
         logger.debug("IncomingGroupDeleteMessageTask id: {}", message.data.messageId)
+        val myIdentity = identityStore.getIdentityString()
+            ?: throw IllegalStateException("Cannot apply edit when no identity is available")
 
         val groupModel =
             groupService.getByGroupMessage(message) ?: return ReceiveStepsResult.DISCARD
 
         val receiver = groupService.createReceiver(groupModel)
-        val messageModel = runCommonDeleteMessageReceiveSteps(message, receiver, messageService)
+        val messageModel = runCommonDeleteMessageReceiveSteps(
+            myIdentity = myIdentity,
+            deleteMessageSenderIdentity = message.fromIdentity,
+            deleteMessageCreatedAt = message.date,
+            messageId = message.data.messageId,
+            receiver = receiver,
+            messageService = messageService,
+        )
             ?: return ReceiveStepsResult.DISCARD
 
         messageService.deleteMessageContentsAndRelatedData(messageModel, message.date)

+ 12 - 1
app/src/main/java/ch/threema/app/processors/incomingcspmessage/conversation/IncomingGroupEditMessageTask.kt

@@ -21,6 +21,7 @@ class IncomingGroupEditMessageTask(
     private val messageService by lazy { serviceManager.messageService }
     private val groupService by lazy { serviceManager.groupService }
     private val groupModelRepository by lazy { serviceManager.modelRepositories.groups }
+    private val identityStore by lazy { serviceManager.identityStore }
 
     override suspend fun executeMessageStepsFromRemote(handle: ActiveTaskCodec): ReceiveStepsResult {
         logger.debug("IncomingGroupEditMessageTask id: {}", message.data.messageId)
@@ -48,11 +49,21 @@ class IncomingGroupEditMessageTask(
     }
 
     private fun applyEdit(groupModel: GroupModel): ReceiveStepsResult {
+        val myIdentity = identityStore.getIdentityString()
+            ?: throw IllegalStateException("Cannot apply edit when no identity is available")
+
         val receiver = groupService.createReceiver(groupModel) ?: run {
             logger.error("Could not get message receiver")
             return ReceiveStepsResult.DISCARD
         }
-        val editedMessage = runCommonEditMessageReceiveSteps(message, receiver, messageService)
+        val editedMessage = runCommonEditMessageReceiveSteps(
+            myIdentity = myIdentity,
+            editMessageSenderIdentity = message.fromIdentity,
+            editMessageCreatedAt = message.date,
+            messageId = message.data.messageId,
+            receiver = receiver,
+            messageService = messageService,
+        )
             ?: return ReceiveStepsResult.DISCARD
 
         messageService.saveEditedMessageText(editedMessage, message.data.text, message.date)

+ 3 - 2
app/src/main/java/ch/threema/app/processors/incomingcspmessage/workdelta/IncomingWorkSyncDeltaMessageTask.kt

@@ -1,6 +1,7 @@
 package ch.threema.app.processors.incomingcspmessage.workdelta
 
 import ch.threema.app.AppConstants
+import ch.threema.app.BuildConfig
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.processors.incomingcspmessage.IncomingCspMessageSubTask
@@ -48,7 +49,7 @@ class IncomingWorkSyncDeltaMessageTask(
     private suspend fun processWorkSyncDeltaMessage(handle: ActiveTaskCodec): ReceiveStepsResult {
         logger.debug("IncomingWorkSyncDeltaMessageTask id: {}", message.messageId)
 
-        if (!ConfigUtils.supportsAvailabilityStatus()) {
+        if (!BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             logger.info("Discarding work sync delta message because it is not supported by current build")
             return ReceiveStepsResult.DISCARD
         }
@@ -124,7 +125,7 @@ class IncomingWorkSyncDeltaMessageTask(
 
                 // 4.5 Apply all changes persistently.
                 changesToApplyLocally.forEach { (model, status) ->
-                    model.setAvailabilityStatusFromSync(status)
+                    model.persistAvailabilityStatus(status)
                 }
 
                 return ReceiveStepsResult.SUCCESS

+ 2 - 2
app/src/main/java/ch/threema/app/processors/reflectedd2dsync/ReflectedContactSyncTask.kt

@@ -1,10 +1,10 @@
 package ch.threema.app.processors.reflectedd2dsync
 
+import ch.threema.app.BuildConfig
 import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.services.DeadlineListService.DEADLINE_INDEFINITE
 import ch.threema.app.utils.AppVersionProvider
-import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.ContactUtil
 import ch.threema.app.utils.ExifInterface
 import ch.threema.app.utils.ShortcutUtil
@@ -147,7 +147,7 @@ class ReflectedContactSyncTask(
 
         applyConversationVisibility(contact)
 
-        if (ConfigUtils.supportsAvailabilityStatus()) {
+        if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             applyAvailabilityStatus(contactModel, contact)
         }
     }

+ 2 - 3
app/src/main/java/ch/threema/app/processors/reflectedd2dsync/ReflectedUserProfileSyncTask.kt

@@ -1,11 +1,11 @@
 package ch.threema.app.processors.reflectedd2dsync
 
+import ch.threema.app.BuildConfig
 import ch.threema.app.preference.service.PreferenceService
 import ch.threema.app.profilepicture.RawProfilePicture
 import ch.threema.app.services.ContactService.ProfilePictureUploadData
 import ch.threema.app.services.ProfilePictureRecipientsService
 import ch.threema.app.services.UserService
-import ch.threema.app.utils.ConfigUtils
 import ch.threema.base.crypto.SymmetricEncryptionService
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.data.datatypes.AvailabilityStatus
@@ -160,9 +160,8 @@ class ReflectedUserProfileSyncTask(
         }
     }
 
-    // TODO(ANDR-4751): Test when desktop is ready
     private fun applyReflectedAvailabilityStatus(userProfile: UserProfile) {
-        if (ConfigUtils.supportsAvailabilityStatus() && userProfile.hasWorkAvailabilityStatus()) {
+        if (BuildConfig.AVAILABILITY_STATUS_ENABLED && userProfile.hasWorkAvailabilityStatus()) {
             val availabilityStatus = AvailabilityStatus.fromProtocolModel(userProfile.workAvailabilityStatus)
             if (availabilityStatus == null) {
                 logger.error("Failed to apply user availability status")

+ 9 - 1
app/src/main/java/ch/threema/app/processors/reflectedoutgoingmessage/ReflectedOutgoingDeleteMessageTask.kt

@@ -16,10 +16,18 @@ internal class ReflectedOutgoingDeleteMessageTask(
     serviceManager = serviceManager,
 ) {
     private val messageService by lazy { serviceManager.messageService }
+    private val identityStore by lazy { serviceManager.identityStore }
 
     override fun processOutgoingMessage() {
+        val myIdentity = identityStore.getIdentityString()
+            ?: throw IllegalStateException("Cannot process reflected outgoing edit message when no identity exists")
+
         runCommonDeleteMessageReceiveSteps(
-            deleteMessage = message,
+            myIdentity = myIdentity,
+            // Note that we are processing only outgoing messages here
+            deleteMessageSenderIdentity = myIdentity,
+            deleteMessageCreatedAt = message.date,
+            messageId = message.data.messageId,
             receiver = messageReceiver,
             messageService = messageService,
         )?.let { validatedMessageModelToDelete ->

+ 9 - 1
app/src/main/java/ch/threema/app/processors/reflectedoutgoingmessage/ReflectedOutgoingEditMessageTask.kt

@@ -17,10 +17,18 @@ internal class ReflectedOutgoingEditMessageTask(
     serviceManager = serviceManager,
 ) {
     private val messageService by lazy { serviceManager.messageService }
+    private val identityStore by lazy { serviceManager.identityStore }
 
     override fun processOutgoingMessage() {
+        val myIdentity = identityStore.getIdentityString()
+            ?: throw IllegalStateException("Cannot process reflected outgoing edit message when no identity exists")
+
         runCommonEditMessageReceiveSteps(
-            editMessage = message,
+            myIdentity = myIdentity,
+            // Note that we are processing only outgoing messages here
+            editMessageSenderIdentity = myIdentity,
+            editMessageCreatedAt = message.date,
+            messageId = message.data.messageId,
             receiver = messageReceiver,
             messageService = messageService,
         )?.let { validEditMessageModel: AbstractMessageModel ->

+ 9 - 1
app/src/main/java/ch/threema/app/processors/reflectedoutgoingmessage/ReflectedOutgoingGroupDeleteMessageTask.kt

@@ -16,10 +16,18 @@ internal class ReflectedOutgoingGroupDeleteMessageTask(
     serviceManager = serviceManager,
 ) {
     private val messageService by lazy { serviceManager.messageService }
+    private val identityStore by lazy { serviceManager.identityStore }
 
     override fun processOutgoingMessage() {
+        val myIdentity = identityStore.getIdentityString()
+            ?: throw IllegalStateException("Cannot process reflected outgoing edit message when no identity exists")
+
         runCommonDeleteMessageReceiveSteps(
-            deleteMessage = message,
+            myIdentity = myIdentity,
+            // Note that we are processing only outgoing messages here
+            deleteMessageSenderIdentity = myIdentity,
+            deleteMessageCreatedAt = message.date,
+            messageId = message.data.messageId,
             receiver = messageReceiver,
             messageService = messageService,
         )?.let { validatedMessageModelToDelete ->

+ 10 - 1
app/src/main/java/ch/threema/app/processors/reflectedoutgoingmessage/ReflectedOutgoingGroupEditMessageTask.kt

@@ -17,11 +17,20 @@ internal class ReflectedOutgoingGroupEditMessageTask(
     serviceManager = serviceManager,
 ) {
     private val messageService by lazy { serviceManager.messageService }
+    private val identityStore by lazy { serviceManager.identityStore }
 
     override fun processOutgoingMessage() {
         check(outgoingMessage.conversation.hasGroup()) { "The message does not have a group identity set" }
+
+        val myIdentity = identityStore.getIdentityString()
+            ?: throw IllegalStateException("Cannot process reflected outgoing edit message when no identity exists")
+
         runCommonEditMessageReceiveSteps(
-            editMessage = message,
+            myIdentity = myIdentity,
+            // Note that we are processing only outgoing messages here
+            editMessageSenderIdentity = myIdentity,
+            editMessageCreatedAt = message.date,
+            messageId = message.data.messageId,
             receiver = messageReceiver,
             messageService = messageService,
         )?.let { validEditMessageModel: AbstractMessageModel ->

+ 20 - 15
app/src/main/java/ch/threema/app/services/ContactServiceImpl.java

@@ -264,41 +264,46 @@ public class ContactServiceImpl implements ContactService {
     @NonNull
     public List<ContactModel> find(@Nullable Filter filter) {
         final @NonNull ContactModelFactory contactModelFactory = this.databaseService.getContactModelFactory();
-        // TODO(ANDR-XXXX): move this to database factory!
         final @NonNull QueryBuilder queryBuilder = new QueryBuilder();
-        final @NonNull List<String> placeholders = new ArrayList<>();
+        final @NonNull List<String> selectionArgs = new ArrayList<>();
 
         if (filter != null) {
-            IdentityState[] filterStates = filter.states();
+            final IdentityState[] filterStates = filter.states();
             if (filterStates != null && filterStates.length > 0) {
-                //dirty, add placeholder should be added to makePlaceholders
-                queryBuilder.appendWhere(ContactModel.COLUMN_STATE + " IN (" + DatabaseUtil.makePlaceholders(filterStates.length) + ")");
-                for (IdentityState s : filterStates) {
-                    placeholders.add(s.toString());
+                // dirty, add placeholder should be added to makePlaceholders
+                final @NonNull String placeholders = DatabaseUtil.makePlaceholders(filterStates.length);
+                queryBuilder.appendWhere(
+                    ContactModelFactory.COLUMN_CONTACTS_STATE + " IN (" + placeholders + ")"
+                );
+                for (IdentityState identityState : filterStates) {
+                    selectionArgs.add(identityState.toString());
                 }
             }
 
             if (!filter.includeHidden()) {
-                queryBuilder.appendWhere(ContactModel.COLUMN_ACQUAINTANCE_LEVEL + "=0");
+                queryBuilder.appendWhere(ContactModelFactory.COLUMN_CONTACTS_ACQUAINTANCE_LEVEL + "=0");
             }
 
             if (!filter.includeMyself()) {
-                String myIdentity = userService.getIdentity();
+                final @Nullable String myIdentity = userService.getIdentity();
                 if (myIdentity != null) {
-                    queryBuilder.appendWhere(ContactModel.COLUMN_IDENTITY + "!=?");
-                    placeholders.add(myIdentity);
+                    queryBuilder.appendWhere(
+                        ContactModelFactory.COLUMN_CONTACTS_IDENTITY + "!=?"
+                    );
+                    selectionArgs.add(myIdentity);
                 }
             }
 
             if (filter.onlyWithReceiptSettings()) {
-                queryBuilder.appendWhere(ContactModel.COLUMN_TYPING_INDICATORS + " !=0 OR " + ContactModel.COLUMN_READ_RECEIPTS + " !=0");
+                queryBuilder.appendWhere(
+                    ContactModelFactory.COLUMN_CONTACTS_TYPING_INDICATORS + " !=0 OR " + ContactModelFactory.COLUMN_CONTACTS_READ_RECEIPTS + " !=0"
+                );
             }
-
         }
 
-        @NonNull List<ContactModel> result = contactModelFactory.convert(
+        @NonNull List<ContactModel> result = contactModelFactory.select(
             queryBuilder,
-            placeholders.toArray(new String[0]),
+            selectionArgs.toArray(new String[0]),
             null
         );
 

+ 4 - 3
app/src/main/java/ch/threema/app/services/GroupService.java

@@ -350,13 +350,14 @@ public interface GroupService extends AvatarService<GroupModelOld> {
     int countMembersWithoutUser(@NonNull GroupModelOld model);
 
     /**
-     * Get a map from the group member identity to its id color.
+     * Get a map from the group participant identity to their id color.
+     * This includes the creator of the group.
      *
      * @param model the group model
-     * @return a map with the ID colors of the members
+     * @return a map with the ID colors of the participants
      */
     @NonNull
-    Map<String, IdColor> getGroupMemberIDColors(@NonNull GroupModel model);
+    Map<String, IdColor> getGroupParticipantIDColors(@NonNull GroupModel model);
 
     /**
      * Send a group sync request for the group id to the creator.

+ 22 - 10
app/src/main/java/ch/threema/app/services/GroupServiceImpl.java

@@ -15,6 +15,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedList;
@@ -105,7 +106,7 @@ public class GroupServiceImpl implements GroupService {
     private final GroupModelRepository groupModelRepository;
 
     // TODO(ANDR-3755): Consolidate this cache
-    private final SparseArrayCompat<Map<String, IdColor>> groupMemberColorCache;
+    private final SparseArrayCompat<Map<String, IdColor>> groupParticipantColorCache;
     private final SparseArrayCompat<GroupModelOld> groupModelCache;
     private final SparseArrayCompat<String[]> groupIdentityCache;
     private @Nullable TaskManager taskManager = null;
@@ -146,7 +147,7 @@ public class GroupServiceImpl implements GroupService {
 
         this.groupModelCache = cacheService.getGroupModelCache();
         this.groupIdentityCache = cacheService.getGroupIdentityCache();
-        this.groupMemberColorCache = cacheService.getGroupMemberColorCache();
+        this.groupParticipantColorCache = cacheService.getGroupMemberColorCache();
     }
 
     @Override
@@ -533,8 +534,8 @@ public class GroupServiceImpl implements GroupService {
         synchronized (groupIdentityCache) {
             groupIdentityCache.remove(groupModelId);
         }
-        synchronized (groupMemberColorCache) {
-            groupMemberColorCache.remove(groupModelId);
+        synchronized (groupParticipantColorCache) {
+            groupParticipantColorCache.remove(groupModelId);
         }
     }
 
@@ -554,8 +555,8 @@ public class GroupServiceImpl implements GroupService {
             this.groupIdentityCache.remove(groupModelId);
         }
 
-        synchronized (this.groupMemberColorCache) {
-            this.groupMemberColorCache.remove(groupModelId);
+        synchronized (this.groupParticipantColorCache) {
+            this.groupParticipantColorCache.remove(groupModelId);
         }
     }
 
@@ -858,12 +859,23 @@ public class GroupServiceImpl implements GroupService {
 
     @Override
     @NonNull
-    public Map<String, IdColor> getGroupMemberIDColors(@NonNull GroupModel model) {
+    public Map<String, IdColor> getGroupParticipantIDColors(@NonNull GroupModel model) {
         long groupDatabaseId = model.getDatabaseId();
-        Map<String, IdColor> colors = this.groupMemberColorCache.get((int) groupDatabaseId);
+        Map<String, IdColor> colors = groupParticipantColorCache.get((int) groupDatabaseId);
         if (colors == null || colors.isEmpty()) {
-            colors = this.databaseService.getGroupMemberModelFactory().getIDColors(groupDatabaseId);
-            this.groupMemberColorCache.put((int) groupDatabaseId, colors);
+            colors = databaseService.getGroupMemberModelFactory().getIDColors(groupDatabaseId);
+            var groupData = model.getData();
+            if (groupData != null) {
+                var creatorIdentity = groupData.groupIdentity.getCreatorIdentity();
+                var creator = contactModelRepository.getByIdentity(creatorIdentity);
+                var creatorData = creator != null ? creator.getData() : null;
+                var creatorIdColor = creatorData != null ? creatorData.getIdColor() : null;
+                if (creatorIdColor != null) {
+                    colors = new HashMap<>(colors);
+                    colors.put(creatorIdentity, creatorIdColor);
+                }
+            }
+            groupParticipantColorCache.put((int) groupDatabaseId, colors);
         }
 
         return colors;

+ 15 - 36
app/src/main/java/ch/threema/app/tasks/DeleteMessageUtils.kt

@@ -5,41 +5,16 @@ import ch.threema.app.services.MessageService
 import ch.threema.app.utils.MessageUtil
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.models.MessageId
-import ch.threema.domain.protocol.csp.messages.AbstractMessage
-import ch.threema.domain.protocol.csp.messages.DeleteMessage
-import ch.threema.domain.protocol.csp.messages.GroupDeleteMessage
+import ch.threema.domain.types.IdentityString
 import ch.threema.storage.models.AbstractMessageModel
+import java.util.Date
 
 private val logger = getThreemaLogger("DeleteMessageUtils")
 
 fun runCommonDeleteMessageReceiveSteps(
-    deleteMessage: DeleteMessage,
-    receiver: MessageReceiver<*>,
-    messageService: MessageService,
-): AbstractMessageModel? {
-    return runCommonDeleteMessageReceiveSteps(
-        deleteMessage,
-        deleteMessage.data.messageId,
-        receiver,
-        messageService,
-    )
-}
-
-fun runCommonDeleteMessageReceiveSteps(
-    deleteMessage: GroupDeleteMessage,
-    receiver: MessageReceiver<*>,
-    messageService: MessageService,
-): AbstractMessageModel? {
-    return runCommonDeleteMessageReceiveSteps(
-        deleteMessage,
-        deleteMessage.data.messageId,
-        receiver,
-        messageService,
-    )
-}
-
-private fun runCommonDeleteMessageReceiveSteps(
-    deleteMessage: AbstractMessage,
+    myIdentity: IdentityString,
+    deleteMessageSenderIdentity: IdentityString,
+    deleteMessageCreatedAt: Date,
     messageId: Long,
     receiver: MessageReceiver<*>,
     messageService: MessageService,
@@ -55,12 +30,16 @@ private fun runCommonDeleteMessageReceiveSteps(
         return null
     }
     // 2. If `message` is not ... or the sender is not the original sender of `message`, discard the message and abort these steps.
-    // Note: We only perform this check if the message is inbox
-    if (!message.isOutbox && deleteMessage.fromIdentity != message.identity) {
+    val originalMessageSender = if (message.isOutbox) {
+        myIdentity
+    } else {
+        message.identity
+    }
+    if (deleteMessageSenderIdentity != originalMessageSender) {
         logger.warn(
-            "Delete Message: original message's sender {} does not equal deleted message's sender {}",
-            message.identity,
-            deleteMessage.fromIdentity,
+            "Delete Message: original message's sender {} does not equal delete-message's sender {}",
+            originalMessageSender,
+            deleteMessageSenderIdentity,
         )
         return null
     }
@@ -74,7 +53,7 @@ private fun runCommonDeleteMessageReceiveSteps(
 
     // Replace `message` with a message informing the user that the message of
     //  the sender has been removed at `created-at`.
-    message.deletedAt = deleteMessage.date
+    message.deletedAt = deleteMessageCreatedAt
 
     return message
 }

+ 16 - 35
app/src/main/java/ch/threema/app/tasks/EditMessageUtils.kt

@@ -4,41 +4,16 @@ import ch.threema.app.messagereceiver.MessageReceiver
 import ch.threema.app.services.MessageService
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.domain.models.MessageId
-import ch.threema.domain.protocol.csp.messages.AbstractMessage
-import ch.threema.domain.protocol.csp.messages.EditMessage
-import ch.threema.domain.protocol.csp.messages.GroupEditMessage
+import ch.threema.domain.types.IdentityString
 import ch.threema.storage.models.AbstractMessageModel
+import java.util.Date
 
 private val logger = getThreemaLogger("EditMessageUtils")
 
 fun runCommonEditMessageReceiveSteps(
-    editMessage: EditMessage,
-    receiver: MessageReceiver<*>,
-    messageService: MessageService,
-): AbstractMessageModel? {
-    return runCommonEditMessageReceiveSteps(
-        editMessage,
-        editMessage.data.messageId,
-        receiver,
-        messageService,
-    )
-}
-
-fun runCommonEditMessageReceiveSteps(
-    editMessage: GroupEditMessage,
-    receiver: MessageReceiver<*>,
-    messageService: MessageService,
-): AbstractMessageModel? {
-    return runCommonEditMessageReceiveSteps(
-        editMessage,
-        editMessage.data.messageId,
-        receiver,
-        messageService,
-    )
-}
-
-private fun runCommonEditMessageReceiveSteps(
-    editMessage: AbstractMessage,
+    myIdentity: IdentityString,
+    editMessageSenderIdentity: IdentityString,
+    editMessageCreatedAt: Date,
     messageId: Long,
     receiver: MessageReceiver<*>,
     messageService: MessageService,
@@ -48,15 +23,21 @@ private fun runCommonEditMessageReceiveSteps(
 
     // 2. "If referred-message is not defined or ..., discard"
     if (message == null) {
-        logger.warn("Incoming Edit Message: No message found for id: $apiMessageId")
+        logger.warn("Incoming Edit Message: No message found for id: {}", apiMessageId)
         return null
     }
 
     // 2.1 "If referred-message is ... or the sender is not the original sender of referred-message, discard"
-    // Note: We only perform this check if the message is inbox
-    if (!message.isOutbox && editMessage.fromIdentity != message.identity) {
+    val originalMessageSender = if (message.isOutbox) {
+        myIdentity
+    } else {
+        message.identity
+    }
+    if (editMessageSenderIdentity != originalMessageSender) {
         logger.warn(
-            "Incoming Edit Message: original message's sender ${message.identity} does not equal edited message's sender ${editMessage.fromIdentity}",
+            "Incoming Edit Message: original message's sender {} does not equal edit-message's sender {}",
+            originalMessageSender,
+            editMessageSenderIdentity,
         )
         return null
     }
@@ -69,7 +50,7 @@ private fun runCommonEditMessageReceiveSteps(
 
     // 4. "Edit referred-message ... and add an indicator to referred-message, informing the user that
     // the message has been edited by the sender at the message's (the EditMessage's) created-at."
-    message.editedAt = editMessage.date
+    message.editedAt = editMessageCreatedAt
 
     return message
 }

+ 2 - 2
app/src/main/java/ch/threema/app/tasks/OutgoingGroupDeleteMessageTask.kt

@@ -23,7 +23,7 @@ class OutgoingGroupDeleteMessageTask(
         val message = getGroupMessageModel(messageModelId)
             ?: throw ThreemaException("No group message model found for messageModelId=$messageModelId")
 
-        val editedMessageIdLong = message.messageId!!.messageIdLong
+        val deletedMessageIdLong = message.messageId!!.messageIdLong
 
         val group = groupService.getById(message.groupId)
             ?: throw ThreemaException("No group model found for groupId=${message.groupId}")
@@ -34,7 +34,7 @@ class OutgoingGroupDeleteMessageTask(
             null,
             deletedAt,
             messageId,
-            createAbstractMessage = { createDeleteMessage(editedMessageIdLong) },
+            createAbstractMessage = { createDeleteMessage(deletedMessageIdLong) },
             handle,
         )
     }

+ 2 - 2
app/src/main/java/ch/threema/app/tasks/ReflectContactSyncTask.kt

@@ -1,9 +1,9 @@
 package ch.threema.app.tasks
 
+import ch.threema.app.BuildConfig
 import ch.threema.app.services.ContactService.ProfilePictureUploadData
 import ch.threema.app.services.ConversationCategoryService
 import ch.threema.app.services.ConversationService
-import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.ContactUtil
 import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.data.datatypes.NotificationTriggerPolicyOverride
@@ -76,7 +76,7 @@ fun ContactModelData.toFullSyncContact(
         conversationCategory = data.getSyncConversationCategory(conversationCategoryService)
         conversationVisibility = data.getSyncConversationVisibility(conversationService)
 
-        if (ConfigUtils.supportsAvailabilityStatus() && data.availabilityStatus != AvailabilityStatus.None) {
+        if (BuildConfig.AVAILABILITY_STATUS_ENABLED && data.availabilityStatus != AvailabilityStatus.None) {
             workAvailabilityStatus = data.availabilityStatus.toProtocolModel()
         }
     }

+ 48 - 2
app/src/main/java/ch/threema/app/tasks/ReflectContactSyncUpdateTask.kt

@@ -624,7 +624,6 @@ abstract class ReflectContactSyncUpdateTask(
         }
     }
 
-    // TODO(ANDR-4751): Test when desktop is ready
     class ReflectAvailabilityStatusUpdate(
         private val newAvailabilityStatus: AvailabilityStatus,
         contactIdentity: IdentityString,
@@ -885,7 +884,7 @@ abstract class ReflectContactSyncUpdateTask(
     }
 
     /**
-     *  Reflect a new work-last-full-sync-at timestamp
+     *  Reflect a new `workLastFullSyncAt` timestamp
      */
     class ReflectWorkLastFullSyncAtUpdate(
         private val workLastFullSyncAt: Instant,
@@ -923,6 +922,53 @@ abstract class ReflectContactSyncUpdateTask(
         }
     }
 
+    /**
+     *  Reflect a new `workLastFullSyncAt` timestamp together with a `workAvailabilityStatus`.
+     */
+    class ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdate(
+        private val workLastFullSyncAt: Instant,
+        private val availabilityStatus: AvailabilityStatus,
+        contactIdentity: IdentityString,
+    ) : ReflectContactSyncUpdateTask(
+        contactIdentity,
+    ) {
+        override val type = "ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdate"
+
+        override fun isChangeValid(currentData: ContactModelData) =
+            currentData.workLastFullSyncAt == workLastFullSyncAt &&
+                currentData.availabilityStatus == availabilityStatus
+
+        override fun getContactSync(): Contact {
+            val updatedWorkLastFullSyncAt = workLastFullSyncAt.toEpochMilli()
+            val updatedAvailabilityStatus = availabilityStatus.toProtocolModel()
+            return contact {
+                this.identity = contactIdentity
+                this.workLastFullSyncAt = updatedWorkLastFullSyncAt
+                this.workAvailabilityStatus = updatedAvailabilityStatus
+            }
+        }
+
+        override fun serialize(): SerializableTaskData = ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdateData(
+            workLastFullSyncAt = workLastFullSyncAt.toEpochMilli(),
+            availabilityStatus = availabilityStatus,
+            identity = contactIdentity,
+        )
+
+        @Serializable
+        data class ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdateData(
+            private val workLastFullSyncAt: Long,
+            private val availabilityStatus: AvailabilityStatus,
+            private val identity: IdentityString,
+        ) : SerializableTaskData {
+            override fun createTask(): Task<*, TaskCodec> =
+                ReflectWorkLastFullSyncAtWithAvailabilityStatusUpdate(
+                    workLastFullSyncAt = Instant.ofEpochMilli(workLastFullSyncAt),
+                    availabilityStatus = availabilityStatus,
+                    contactIdentity = identity,
+                )
+        }
+    }
+
     /**
      * Reflect a new conversation visibility regarding the pin option.
      *

+ 5 - 9
app/src/main/java/ch/threema/app/tasks/ReflectUserAvailabilityStatusTask.kt

@@ -1,10 +1,9 @@
 package ch.threema.app.tasks
 
+import ch.threema.app.BuildConfig
 import ch.threema.app.preference.service.PreferenceService
-import ch.threema.app.utils.ConfigUtils
 import ch.threema.base.crypto.NonceFactory
 import ch.threema.base.utils.getThreemaLogger
-import ch.threema.data.datatypes.AvailabilityStatus
 import ch.threema.domain.taskmanager.ActiveTask
 import ch.threema.domain.taskmanager.ActiveTaskCodec
 import ch.threema.domain.taskmanager.Task
@@ -25,18 +24,15 @@ class ReflectUserAvailabilityStatusTask :
     private val preferenceService: PreferenceService by inject()
     private val nonceFactory: NonceFactory by inject()
 
-    private val availabilityStatus: AvailabilityStatus? by lazy {
-        preferenceService.getAvailabilityStatus()
-    }
-
     override val type: String = "ReflectUserAvailabilityStatusTask"
 
     override val runPrecondition: () -> Boolean = precondition@{
-        if (!ConfigUtils.supportsAvailabilityStatus()) {
+        @Suppress("KotlinConstantConditions")
+        if (!BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             logger.error("Cannot reflect availability status because this feature is not supported by this build")
             return@precondition false
         }
-        if (availabilityStatus == null) {
+        if (preferenceService.getAvailabilityStatus() == null) {
             logger.error("Cannot reflect availability status because it is null")
             return@precondition false
         }
@@ -45,7 +41,7 @@ class ReflectUserAvailabilityStatusTask :
 
     override val runInsideTransaction: suspend (handle: ActiveTaskCodec) -> Result<Unit> = transaction@{ handle ->
 
-        val currentAvailabilityStatus = availabilityStatus
+        val currentAvailabilityStatus = preferenceService.getAvailabilityStatus()
             ?: return@transaction Result.failure(IllegalStateException("Missing current availability status"))
 
         handle.reflectAndAwaitAck(

+ 3 - 3
app/src/main/java/ch/threema/app/ui/AvatarListItemUtil.java

@@ -86,7 +86,7 @@ public class AvatarListItemUtil {
 
         // Set work badge
         boolean isWork = contactService.showBadge(conversationModel.getContact());
-        holder.avatarView.setBadgeVisible(isWork);
+        holder.avatarView.setWorkBadgeVisible(isWork);
     }
 
     public static <S> void loadAvatar(
@@ -102,9 +102,9 @@ public class AvatarListItemUtil {
         }
 
         if (subject instanceof String) {
-            holder.avatarView.setBadgeVisible(((ContactService) avatarService).showBadge((String) subject));
+            holder.avatarView.setWorkBadgeVisible(((ContactService) avatarService).showBadge((String) subject));
         } else {
-            holder.avatarView.setBadgeVisible(false);
+            holder.avatarView.setWorkBadgeVisible(false);
         }
 
         AvatarOptions options;

+ 0 - 75
app/src/main/java/ch/threema/app/ui/AvatarView.java

@@ -1,75 +0,0 @@
-package ch.threema.app.ui;
-
-import android.content.Context;
-import android.graphics.Bitmap;
-import android.graphics.drawable.Drawable;
-import android.util.AttributeSet;
-import android.view.LayoutInflater;
-import android.widget.FrameLayout;
-import android.widget.ImageView;
-
-import androidx.annotation.DrawableRes;
-import ch.threema.app.R;
-
-public class AvatarView extends FrameLayout {
-    private ImageView avatar, badge;
-
-    public AvatarView(Context context) {
-        super(context);
-        init(context);
-    }
-
-    public AvatarView(Context context, AttributeSet attrs) {
-        super(context, attrs);
-        init(context);
-    }
-
-    public AvatarView(Context context, AttributeSet attrs, int defStyleAttr) {
-        super(context, attrs, defStyleAttr);
-        init(context);
-    }
-
-    private void init(Context context) {
-        LayoutInflater inflater = (LayoutInflater) context
-            .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
-        inflater.inflate(R.layout.avatar_view, this);
-    }
-
-    @Override
-    protected void onFinishInflate() {
-        super.onFinishInflate();
-
-        avatar = this.findViewById(R.id.avatar);
-        badge = this.findViewById(R.id.avatar_badge);
-
-        badge.setVisibility(GONE);
-    }
-
-    public void setImageResource(@DrawableRes int resource) {
-        avatar.setImageResource(resource);
-        avatar.requestLayout();
-    }
-
-    public void setImageBitmap(Bitmap bitmap) {
-        avatar.setImageBitmap(bitmap);
-        avatar.requestLayout();
-    }
-
-    public void setImageDrawable(Drawable drawable) {
-        avatar.setImageDrawable(drawable);
-        avatar.requestLayout();
-    }
-
-    /**
-     * This returns the avatar image view. This is mainly needed for glide to directly set the avatars.
-     *
-     * @return the image view of the avatar drawable
-     */
-    public ImageView getAvatarView() {
-        return avatar;
-    }
-
-    public void setBadgeVisible(boolean visibile) {
-        badge.setVisibility(visibile ? VISIBLE : GONE);
-    }
-}

+ 94 - 0
app/src/main/java/ch/threema/app/ui/AvatarView.kt

@@ -0,0 +1,94 @@
+package ch.threema.app.ui
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.FrameLayout
+import android.widget.ImageView
+import androidx.annotation.DrawableRes
+import androidx.core.content.getSystemService
+import androidx.core.view.isVisible
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import ch.threema.app.BuildConfig
+import ch.threema.app.R
+import ch.threema.app.availabilitystatus.AvailabilityStatusIconElevatedView
+import ch.threema.data.datatypes.AvailabilityStatus
+
+class AvatarView : FrameLayout {
+    private lateinit var avatar: ImageView
+    private lateinit var badgeWork: ImageView
+    private var badgeAvailabilityStatusContainer: FrameLayout? = null
+    private var badgeAvailabilityStatus: AvailabilityStatusIconElevatedView? = null
+
+    constructor(context: Context) : super(context)
+
+    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
+
+    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
+
+    init {
+        context.getSystemService<LayoutInflater>()!!.inflate(R.layout.avatar_view, this)
+    }
+
+    override fun onFinishInflate() {
+        super.onFinishInflate()
+
+        avatar = findViewById(R.id.avatar)
+        badgeWork = findViewById(R.id.avatar_badge_work)
+        badgeWork.isVisible = false
+
+        @Suppress("KotlinConstantConditions")
+        if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
+            // AvailabilityStatusIconElevatedView is a ComposeView. For it to not cause a crash when used inside a popup window such as the
+            // MentionSelectorPopup, we first need to set the view tree lifecycle owner before we can add the view itself.
+            badgeAvailabilityStatus = AvailabilityStatusIconElevatedView(context)
+            badgeAvailabilityStatusContainer = findViewById<FrameLayout>(R.id.avatar_badge_availability_status_container)?.apply {
+                id = android.R.id.content
+                setViewTreeLifecycleOwner(context as LifecycleOwner)
+                setViewTreeSavedStateRegistryOwner(context as SavedStateRegistryOwner)
+                addView(badgeAvailabilityStatus)
+            }
+        }
+    }
+
+    fun setImageResource(@DrawableRes resource: Int) {
+        avatar.setImageResource(resource)
+        avatar.requestLayout()
+    }
+
+    fun setImageBitmap(bitmap: Bitmap?) {
+        avatar.setImageBitmap(bitmap)
+        avatar.requestLayout()
+    }
+
+    fun setImageDrawable(drawable: Drawable?) {
+        avatar.setImageDrawable(drawable)
+        avatar.requestLayout()
+    }
+
+    /**
+     * This returns the avatar image view. This is mainly needed for glide to directly set the avatars.
+     *
+     * @return the image view of the avatar drawable
+     */
+    val avatarView: ImageView
+        get() = avatar
+
+    fun setWorkBadgeVisible(visible: Boolean) {
+        badgeWork.setVisibility(if (visible) VISIBLE else GONE)
+    }
+
+    fun setAvailabilityStatusBadgeState(availabilityStatusSet: AvailabilityStatus.Set?) {
+        @Suppress("KotlinConstantConditions")
+        if (!BuildConfig.AVAILABILITY_STATUS_ENABLED) {
+            return
+        }
+        badgeAvailabilityStatus?.setStatus(availabilityStatusSet)
+        badgeAvailabilityStatusContainer?.isVisible = availabilityStatusSet != null
+    }
+}

+ 20 - 1
app/src/main/java/ch/threema/app/ui/InitialAvatarView.java

@@ -6,10 +6,16 @@ import android.view.LayoutInflater;
 import android.widget.FrameLayout;
 import android.widget.TextView;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
+import ch.threema.app.availabilitystatus.AvailabilityStatusIconElevatedView;
+import ch.threema.data.datatypes.AvailabilityStatus;
 
 public class InitialAvatarView extends FrameLayout {
     private TextView avatarInitials;
+    private @Nullable AvailabilityStatusIconElevatedView badgeAvailabilityStatus;
 
     public InitialAvatarView(Context context) {
         super(context);
@@ -26,7 +32,7 @@ public class InitialAvatarView extends FrameLayout {
         init(context);
     }
 
-    private void init(Context context) {
+    private void init(@NonNull Context context) {
         LayoutInflater inflater = (LayoutInflater) context
             .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
         inflater.inflate(R.layout.initial_avatar_view, this);
@@ -37,6 +43,11 @@ public class InitialAvatarView extends FrameLayout {
         super.onFinishInflate();
 
         avatarInitials = this.findViewById(R.id.avatar_initials);
+
+        if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
+            badgeAvailabilityStatus = this.findViewById(R.id.avatar_badge_availability_status);
+            badgeAvailabilityStatus.setVisibility(GONE);
+        }
     }
 
     public void setInitials(String firstName, String lastName) {
@@ -51,4 +62,12 @@ public class InitialAvatarView extends FrameLayout {
         avatarInitials.setText(initialsBuilder.length() > 0 ? initialsBuilder.toString() : "");
         requestLayout();
     }
+
+    public void setAvailabilityStatusBadgeState(@Nullable AvailabilityStatus.Set availabilityStatusSet) {
+        if (!BuildConfig.AVAILABILITY_STATUS_ENABLED || badgeAvailabilityStatus == null) {
+            return;
+        }
+        badgeAvailabilityStatus.setVisibility(availabilityStatusSet != null ? VISIBLE : GONE);
+        badgeAvailabilityStatus.setStatus(availabilityStatusSet);
+    }
 }

+ 45 - 23
app/src/main/java/ch/threema/app/ui/TooltipPopup.kt

@@ -39,6 +39,7 @@ constructor(
     @DrawableRes
     private val icon: Int = 0,
     private val showCloseButton: Boolean = true,
+    private val showArrow: Boolean = true,
 ) : PopupWindow(context), DefaultLifecycleObserver, KoinComponent {
 
     private val preferenceService: PreferenceService by inject()
@@ -82,12 +83,16 @@ constructor(
             }
     }
 
-    fun dismissForever() {
+    fun markAsShown() {
         if (preferenceKey != 0) {
             preferenceService.setTooltipPopupDismissed(preferenceKey, true)
         }
+    }
 
-        dismiss(false)
+    @JvmOverloads
+    fun dismissForever(immediate: Boolean = false) {
+        markAsShown()
+        dismiss(immediate)
     }
 
     fun dismiss(immediate: Boolean) {
@@ -110,7 +115,7 @@ constructor(
      * @param title          Optional title text to show in tooltip
      * @param text           Text to show in tooltip
      * @param alignment         Where to align the tooltip and where the arrow should be shown
-     * @param originLocation The location on screen where the tip of the arrow should point to
+     * @param originLocation The location on screen where the tip of the arrow should point to, or null to latch directly onto the anchor view
      * @param timeoutMs      How long the tooltip should be shown until it fades out
      */
     fun show(
@@ -119,7 +124,7 @@ constructor(
         title: String? = null,
         text: String?,
         alignment: Alignment,
-        originLocation: IntArray,
+        originLocation: IntArray? = null,
         timeoutMs: Int = 0,
     ) {
         if (isForeverDismissed() || TestUtil.isInDeviceTest()) {
@@ -148,29 +153,38 @@ constructor(
             screenHeight = resources.displayMetrics.heightPixels + ConfigUtils.getNavigationBarHeight(activity)
         }
         val maxWidth = resources.getDimensionPixelSize(R.dimen.tooltip_max_width)
-        val arrowInset = resources.getDimensionPixelSize(R.dimen.tooltip_popup_arrow_inset)
         val marginOnOtherEdge = resources.getDimensionPixelSize(R.dimen.tooltip_margin_on_other_edge)
-        val arrowOffset = (resources.getDimensionPixelSize(R.dimen.identity_popup_arrow_width) / 2) + arrowInset
-        var popupX: Int
-        val popupY: Int
+        val arrowOffset = if (showArrow) {
+            val arrowInset = resources.getDimensionPixelSize(R.dimen.tooltip_popup_arrow_inset)
+            val bubblePadding = resources.getDimensionPixelSize(R.dimen.tooltip_layout_margin_left)
+            (resources.getDimensionPixelSize(R.dimen.identity_popup_arrow_width) / 2) + arrowInset + bubblePadding
+        } else {
+            0
+        }
+        var popupX = 0
+        var popupY = 0
         val popupWidth: Int
         val anchorGravity: Int
         val contentGravity: Int
 
         when (alignment) {
             Alignment.ABOVE_ANCHOR_ARROW_LEFT -> {
-                popupLayout.findViewById<View>(R.id.arrow_bottom_left).isVisible = true
-                popupX = (originLocation[0] - arrowOffset).coerceAtLeast(0)
-                popupY = screenHeight - originLocation[1]
+                popupLayout.findViewById<View>(R.id.arrow_bottom_left).isVisible = showArrow
+                if (originLocation != null) {
+                    popupX = (originLocation[0] - arrowOffset).coerceAtLeast(0)
+                    popupY = screenHeight - originLocation[1]
+                }
                 popupWidth = (screenWidth - popupX - marginOnOtherEdge).coerceAtMost(maxWidth)
                 anchorGravity = Gravity.LEFT or Gravity.BOTTOM
                 contentGravity = Gravity.LEFT
             }
 
             Alignment.ABOVE_ANCHOR_ARROW_RIGHT -> {
-                popupLayout.findViewById<View>(R.id.arrow_bottom_right).isVisible = true
-                popupX = (originLocation[0] + arrowOffset).coerceAtMost(screenWidth)
-                popupY = screenHeight - originLocation[1]
+                popupLayout.findViewById<View>(R.id.arrow_bottom_right).isVisible = showArrow
+                if (originLocation != null) {
+                    popupX = (originLocation[0] + arrowOffset).coerceAtMost(screenWidth)
+                    popupY = screenHeight - originLocation[1]
+                }
                 popupWidth = (popupX - marginOnOtherEdge).coerceAtMost(maxWidth)
                 popupX -= popupWidth
                 anchorGravity = Gravity.LEFT or Gravity.BOTTOM
@@ -178,18 +192,22 @@ constructor(
             }
 
             Alignment.BELOW_ANCHOR_ARROW_LEFT -> {
-                popupLayout.findViewById<View>(R.id.arrow_top_left).isVisible = true
-                popupX = (originLocation[0] - arrowOffset).coerceAtLeast(0)
-                popupY = originLocation[1]
+                popupLayout.findViewById<View>(R.id.arrow_top_left).isVisible = showArrow
+                if (originLocation != null) {
+                    popupX = (originLocation[0] - arrowOffset).coerceAtLeast(0)
+                    popupY = originLocation[1]
+                }
                 popupWidth = (screenWidth - popupX - marginOnOtherEdge).coerceAtMost(maxWidth)
                 anchorGravity = Gravity.LEFT or Gravity.TOP
                 contentGravity = Gravity.LEFT
             }
 
             Alignment.BELOW_ANCHOR_ARROW_RIGHT -> {
-                popupLayout.findViewById<View>(R.id.arrow_top_right).isVisible = true
-                popupX = (originLocation[0] + arrowOffset).coerceAtMost(screenWidth)
-                popupY = originLocation[1]
+                popupLayout.findViewById<View>(R.id.arrow_top_right).isVisible = showArrow
+                if (originLocation != null) {
+                    popupX = (originLocation[0] + arrowOffset).coerceAtMost(screenWidth)
+                    popupY = originLocation[1]
+                }
                 popupWidth = (popupX - marginOnOtherEdge).coerceAtMost(maxWidth)
                 popupX -= popupWidth
                 anchorGravity = Gravity.LEFT or Gravity.TOP
@@ -208,8 +226,12 @@ constructor(
             return
         }
         try {
-            showAtLocation(anchor, anchorGravity, popupX, popupY)
-        } catch (e: BadTokenException) {
+            if (originLocation != null) {
+                showAtLocation(anchor, anchorGravity, popupX, popupY)
+            } else {
+                showAsDropDown(anchor)
+            }
+        } catch (_: BadTokenException) {
             return
         }
 
@@ -232,7 +254,7 @@ constructor(
         }
     }
 
-    private fun isForeverDismissed(): Boolean {
+    fun isForeverDismissed(): Boolean {
         if (preferenceKey == 0) {
             return false
         }

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

@@ -18,6 +18,7 @@ class OverrideOneTimeHintsUseCase(
             setOneTimeDialogShown("note_group_hint", dismiss)
             setOneTimeDialogShown("individual_confirm", dismiss)
             setTooltipPopupDismissed(R.string.preferences__tooltip_emoji_reactions_shown, dismiss)
+            setTooltipPopupDismissed(R.string.preferences__tooltip_availability_status_shown, dismiss)
             setTooltipPopupDismissed(R.string.preferences__tooltip_export_id_shown, dismiss)
             setTooltipPopupDismissed(R.string.preferences__tooltip_audio_selector_hint, dismiss)
             setTooltipPopupDismissed(R.string.preferences__tooltip_gc_camera, dismiss)

+ 8 - 5
app/src/main/java/ch/threema/app/usecases/availabilitystatus/UpdateUserAvailabilityStatusUseCase.kt

@@ -1,9 +1,10 @@
 package ch.threema.app.usecases.availabilitystatus
 
+import ch.threema.app.BuildConfig
+import ch.threema.app.availabilitystatus.withTrimmedDescription
 import ch.threema.app.multidevice.MultiDeviceManager
 import ch.threema.app.preference.service.PreferenceService
 import ch.threema.app.tasks.ReflectUserAvailabilityStatusTask
-import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.DispatcherProvider
 import ch.threema.app.work.workproperties.WorkPropertiesClient
 import ch.threema.base.utils.getThreemaLogger
@@ -22,20 +23,22 @@ class UpdateUserAvailabilityStatusUseCase(
 ) {
 
     suspend fun call(availabilityStatus: AvailabilityStatus): Result<Unit> {
-        if (!ConfigUtils.supportsAvailabilityStatus()) {
+        if (!BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             logger.error("Cannot update the users availability status because this feature is not supported by the build")
             return Result.failure(
                 exception = IllegalStateException("Availability status feature not supported"),
             )
         }
 
+        val availabilityStatusTrimmed = availabilityStatus.withTrimmedDescription()
+
         return withContext(dispatcherProvider.io) {
-            if (preferenceService.getAvailabilityStatus() == availabilityStatus) {
+            if (preferenceService.getAvailabilityStatus() == availabilityStatusTrimmed) {
                 return@withContext Result.success(Unit)
             }
 
             // 1. Call user-properties endpoint
-            workPropertiesClient.updateAvailabilityStatus(availabilityStatus)
+            workPropertiesClient.updateAvailabilityStatus(availabilityStatusTrimmed)
                 .onFailure { throwable ->
                     logger.error("Work properties api failure", throwable)
                     // 2. If 1. failed: Abort
@@ -43,7 +46,7 @@ class UpdateUserAvailabilityStatusUseCase(
                 }
 
             // 3. Persist status locally
-            preferenceService.setAvailabilityStatus(availabilityStatus)
+            preferenceService.setAvailabilityStatus(availabilityStatusTrimmed)
 
             // 4. Create a persistent task that reflects the users status value at the time of execution
             if (multiDeviceManager.isMultiDeviceActive) {

+ 2 - 2
app/src/main/java/ch/threema/app/usecases/availabilitystatus/WatchAllContactAvailabilityStatusesUseCase.kt

@@ -1,8 +1,8 @@
 package ch.threema.app.usecases.availabilitystatus
 
+import ch.threema.app.BuildConfig
 import ch.threema.app.listeners.ContactListener
 import ch.threema.app.managers.ListenerManager
-import ch.threema.app.utils.ConfigUtils
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.common.DispatcherProvider
 import ch.threema.data.datatypes.AvailabilityStatus
@@ -38,7 +38,7 @@ class WatchAllContactAvailabilityStatusesUseCase(
      *  Every exception that's not occurring inside the [ContactListener] will flow downstream.
      */
     fun call(): Flow<Map<IdentityString, AvailabilityStatus>> {
-        if (!ConfigUtils.supportsAvailabilityStatus()) {
+        if (!BuildConfig.AVAILABILITY_STATUS_ENABLED) {
             return flowOf(emptyMap())
         }
         return callbackFlow {

+ 0 - 4
app/src/main/java/ch/threema/app/utils/ConfigUtils.java

@@ -1595,8 +1595,4 @@ public class ConfigUtils {
     private static AppRestrictions getAppRestrictions() {
         return KoinJavaComponent.get(AppRestrictions.class);
     }
-
-    public static boolean supportsAvailabilityStatus() {
-        return isWorkBuild() && BuildConfig.AVAILABILITY_STATUS_ENABLED;
-    }
 }

+ 1 - 1
app/src/main/java/ch/threema/app/voip/groupcall/GroupCallManagerImpl.kt

@@ -1081,7 +1081,7 @@ class GroupCallManagerImpl(
     private fun purgeRunningCalls(groupId: LocalGroupId, peek: PeekResult) {
         GroupCallThreadUtil.assertDispatcherThread()
 
-        if (!peek.isJoined && !peek.isPeekFailed && (peek.isHttpNotFound || isAbandonedCall(peek))) {
+        if (!peek.isJoined && (peek.isHttpNotFound || isAbandonedCall(peek))) {
             val callId = peek.call.callId
             logger.debug("Remove call {}", callId)
             removeRunningCall(callId)

+ 6 - 0
app/src/main/java/ch/threema/app/workers/WorkSyncWorker.kt

@@ -23,6 +23,7 @@ import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.asynctasks.AddOrUpdateWorkContactBackgroundTask
 import ch.threema.app.di.awaitAppFullyReadyWithTimeout
+import ch.threema.app.multidevice.MultiDeviceManager
 import ch.threema.app.notifications.NotificationChannels
 import ch.threema.app.notifications.NotificationIDs
 import ch.threema.app.preference.service.PreferenceService
@@ -48,6 +49,7 @@ import ch.threema.domain.models.WorkVerificationLevel
 import ch.threema.domain.protocol.api.APIConnector
 import ch.threema.domain.protocol.api.work.WorkData
 import ch.threema.domain.stores.IdentityStore
+import ch.threema.domain.taskmanager.TaskManager
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.Duration.Companion.seconds
 import okhttp3.OkHttpClient
@@ -72,6 +74,8 @@ class WorkSyncWorker(
     private val identityStore: IdentityStore by inject()
     private val contactModelRepository: ContactModelRepository by inject()
     private val appRestrictions: AppRestrictions by inject()
+    private val taskManager: TaskManager by inject()
+    private val multiDeviceManager: MultiDeviceManager by inject()
 
     companion object {
         private const val EXTRA_FORCE_UPDATE = "FORCE_UPDATE"
@@ -268,6 +272,8 @@ class WorkSyncWorker(
                     workContact = workContact,
                     myIdentity = userService.identity!!,
                     contactModelRepository = contactModelRepository,
+                    taskManager = taskManager,
+                    multiDeviceManager = multiDeviceManager,
                 ).runSynchronously()
             }
             .map(ContactModel::identity)

+ 1 - 0
app/src/main/java/ch/threema/data/models/BaseModel.kt

@@ -121,6 +121,7 @@ abstract class BaseModel<TData, TReflectionTask : Task<*, TaskCodec>?>(
                 mutableData.value = updatedData
                 updateDatabase(updatedData)
                 if (reflectUpdateTask != null && multiDeviceManager.isMultiDeviceActive) {
+                    @Suppress("DeferredResultUnused")
                     taskManager.schedule(reflectUpdateTask)
                 }
                 updatedData

+ 24 - 9
app/src/main/java/ch/threema/data/models/ContactModel.kt

@@ -241,23 +241,25 @@ class ContactModel(
         )
     }
 
-    fun setAvailabilityStatusFromLocal(availabilityStatus: AvailabilityStatus) {
+    /**
+     *  Update the contact's `availabilityStatus` value **without** reflecting the change
+     */
+    fun setAvailabilityStatusFromSync(availabilityStatus: AvailabilityStatus) {
         this.updateFields(
-            methodName = "setAvailabilityStatusFromLocal",
+            methodName = "setAvailabilityStatusFromSync",
             detectChanges = { originalData -> originalData.availabilityStatus != availabilityStatus },
             updateData = { originalData -> originalData.copy(availabilityStatus = availabilityStatus) },
             updateDatabase = ::updateDatabase,
             onUpdated = ::defaultOnUpdated,
-            reflectUpdateTask = ReflectContactSyncUpdateTask.ReflectAvailabilityStatusUpdate(
-                newAvailabilityStatus = availabilityStatus,
-                contactIdentity = identity,
-            ),
         )
     }
 
-    fun setAvailabilityStatusFromSync(availabilityStatus: AvailabilityStatus) {
+    /**
+     *  Persist the contact's `availabilityStatus` locally
+     */
+    fun persistAvailabilityStatus(availabilityStatus: AvailabilityStatus) {
         this.updateFields(
-            methodName = "setAvailabilityStatusFromSync",
+            methodName = "persistAvailabilityStatus",
             detectChanges = { originalData -> originalData.availabilityStatus != availabilityStatus },
             updateData = { originalData -> originalData.copy(availabilityStatus = availabilityStatus) },
             updateDatabase = ::updateDatabase,
@@ -760,7 +762,20 @@ class ContactModel(
     }
 
     /**
-     *  Update the contact's work-last-fill-sync-at and reflect the change.
+     *  Persist the contact's `workLastFullSyncAt` value locally
+     */
+    fun persistWorkLastFullSyncAt(workLastFullSyncAt: Instant) {
+        this.updateFields(
+            methodName = "persistWorkLastFullSyncAt",
+            detectChanges = { originalData -> originalData.workLastFullSyncAt != workLastFullSyncAt },
+            updateData = { originalData -> originalData.copy(workLastFullSyncAt = workLastFullSyncAt) },
+            updateDatabase = ::updateDatabase,
+            onUpdated = null,
+        )
+    }
+
+    /**
+     *  Update the contact's `workLastFullSyncAt` value and **reflect** the change
      */
     fun setWorkLastFullSyncFromLocal(workLastFullSyncAt: Instant) {
         this.updateFields(

+ 34 - 41
app/src/main/java/ch/threema/data/storage/SqliteDatabaseBackend.kt

@@ -10,8 +10,8 @@ import androidx.core.database.getLongOrNull
 import androidx.core.database.getStringOrNull
 import androidx.sqlite.db.SupportSQLiteDatabase
 import androidx.sqlite.db.SupportSQLiteQueryBuilder
+import ch.threema.app.BuildConfig
 import ch.threema.app.stores.IdentityProvider
-import ch.threema.app.utils.ConfigUtils
 import ch.threema.base.crypto.NaCl
 import ch.threema.base.utils.getThreemaLogger
 import ch.threema.data.datatypes.AvailabilityStatus
@@ -122,17 +122,15 @@ class SqliteDatabaseBackend(
      */
     override fun getAllContacts(): List<DbContact> {
         return try {
-            val contactIdentity = "${ContactModel.TABLE}.${ContactModel.COLUMN_IDENTITY}"
-            val availabilityStatusIdentity = "${DbAvailabilityStatus.TABLE}.${DbAvailabilityStatus.COLUMN_IDENTITY}"
-            val availabilityStatusCategory = "${DbAvailabilityStatus.TABLE}.${DbAvailabilityStatus.COLUMN_CATEGORY}"
-            val availabilityStatusDescription = "${DbAvailabilityStatus.TABLE}.${DbAvailabilityStatus.COLUMN_DESCRIPTION}"
             /*
              * SELECT contacts.*, contact_availability_status.category, contact_availability_status.description
-             * FROM contacts LEFT JOIN contact_availability_status ON contacts.identity = contact_availability_status.identity;
+             *  FROM contacts LEFT JOIN contact_availability_status
+             *  ON contacts.identity = contact_availability_status.identity;
              */
             val query = """
-                SELECT ${ContactModel.TABLE}.*, $availabilityStatusCategory, $availabilityStatusDescription
-                FROM ${ContactModel.TABLE} LEFT JOIN ${DbAvailabilityStatus.TABLE} ON $contactIdentity = $availabilityStatusIdentity;
+                SELECT ${ContactModel.TABLE}.*, $COLUMN_AVAILABILITY_STATUS_CATEGORY, $COLUMN_AVAILABILITY_STATUS_DESCRIPTION
+                FROM ${ContactModel.TABLE} LEFT JOIN ${DbAvailabilityStatus.TABLE}
+                ON $COLUMN_CONTACTS_IDENTITY = $COLUMN_AVAILABILITY_STATUS_IDENTITY;
             """.trimIndent()
             val cursor = databaseProvider.readableDatabase.query(query)
             val dbContacts = mutableListOf<DbContact>()
@@ -170,7 +168,7 @@ class SqliteDatabaseBackend(
                 conflictAlgorithm = SQLiteDatabase.CONFLICT_ROLLBACK,
                 values = contentValuesContact,
             )
-            if (ConfigUtils.supportsAvailabilityStatus()) {
+            if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
                 dbContact.availabilityStatusSet
                     ?.toDatabaseModel(dbContact.identity)
                     ?.let { dbAvailabilityStatusSet ->
@@ -185,26 +183,20 @@ class SqliteDatabaseBackend(
     }
 
     override fun getContactByIdentity(identity: IdentityString): DbContact? {
-        val contactIdentity = "${ContactModel.TABLE}.${ContactModel.COLUMN_IDENTITY}"
-        val availabilityStatusIdentity = "${DbAvailabilityStatus.TABLE}.${DbAvailabilityStatus.COLUMN_IDENTITY}"
-        val availabilityStatusCategory = "${DbAvailabilityStatus.TABLE}.${DbAvailabilityStatus.COLUMN_CATEGORY}"
-        val availabilityStatusDescription = "${DbAvailabilityStatus.TABLE}.${DbAvailabilityStatus.COLUMN_DESCRIPTION}"
         /*
          * SELECT contacts.*, contact_availability_status.category, contact_availability_status.description
-         * FROM contacts LEFT JOIN contact_availability_status ON contacts.identity = contact_availability_status.identity
-         * WHERE contacts.identity = ?;
+         *  FROM contacts LEFT JOIN contact_availability_status
+         *  ON contacts.identity = contact_availability_status.identity
+         *  WHERE contacts.identity = ?;
          */
         val query = """
-            SELECT ${ContactModel.TABLE}.*, $availabilityStatusCategory, $availabilityStatusDescription
-            FROM ${ContactModel.TABLE} LEFT JOIN ${DbAvailabilityStatus.TABLE} ON $contactIdentity = $availabilityStatusIdentity
-            WHERE $contactIdentity = ?;
+            SELECT ${ContactModel.TABLE}.*, $COLUMN_AVAILABILITY_STATUS_CATEGORY, $COLUMN_AVAILABILITY_STATUS_DESCRIPTION
+            FROM ${ContactModel.TABLE} LEFT JOIN ${DbAvailabilityStatus.TABLE}
+            ON $COLUMN_CONTACTS_IDENTITY = $COLUMN_AVAILABILITY_STATUS_IDENTITY
+            WHERE $COLUMN_CONTACTS_IDENTITY = ?;
         """.trimIndent()
-        val cursor = databaseProvider.readableDatabase.query(
-            /* query = */
-            query,
-            /* bindArgs = */
-            arrayOf(identity),
-        )
+        val bindArgs = arrayOf(identity)
+        val cursor = databaseProvider.readableDatabase.query(query, bindArgs)
         return cursor.use { cursor ->
             if (cursor.moveToFirst()) {
                 cursor.mapToDbContact()
@@ -219,26 +211,20 @@ class SqliteDatabaseBackend(
             return emptyList()
         }
         val placeholders = DatabaseUtil.makePlaceholders(identities.size)
-        val contactIdentity = "${ContactModel.TABLE}.${ContactModel.COLUMN_IDENTITY}"
-        val availabilityStatusIdentity = "${DbAvailabilityStatus.TABLE}.${DbAvailabilityStatus.COLUMN_IDENTITY}"
-        val availabilityStatusCategory = "${DbAvailabilityStatus.TABLE}.${DbAvailabilityStatus.COLUMN_CATEGORY}"
-        val availabilityStatusDescription = "${DbAvailabilityStatus.TABLE}.${DbAvailabilityStatus.COLUMN_DESCRIPTION}"
         /*
          * SELECT contacts.*, contact_availability_status.category, contact_availability_status.description
-         * FROM contacts LEFT JOIN contact_availability_status ON contacts.identity = contact_availability_status.identity
-         * WHERE contacts.identity IN (?, ...);
+         *  FROM contacts LEFT JOIN contact_availability_status
+         *  ON contacts.identity = contact_availability_status.identity
+         *  WHERE contacts.identity IN (?, ?, ?, ...);
          */
         val query = """
-            SELECT ${ContactModel.TABLE}.*, $availabilityStatusCategory, $availabilityStatusDescription
-            FROM ${ContactModel.TABLE} LEFT JOIN ${DbAvailabilityStatus.TABLE} ON $contactIdentity = $availabilityStatusIdentity
-            WHERE $contactIdentity IN ($placeholders);
+            SELECT ${ContactModel.TABLE}.*, $COLUMN_AVAILABILITY_STATUS_CATEGORY, $COLUMN_AVAILABILITY_STATUS_DESCRIPTION
+            FROM ${ContactModel.TABLE} LEFT JOIN ${DbAvailabilityStatus.TABLE}
+            ON $COLUMN_CONTACTS_IDENTITY = $COLUMN_AVAILABILITY_STATUS_IDENTITY
+            WHERE $COLUMN_CONTACTS_IDENTITY IN ($placeholders);
         """.trimIndent()
-        val cursor = databaseProvider.readableDatabase.query(
-            /* query = */
-            query,
-            /* bindArgs = */
-            identities.toTypedArray(),
-        )
+        val bindArgs = identities.toTypedArray()
+        val cursor = databaseProvider.readableDatabase.query(query, bindArgs)
         return cursor.use { usedCursor ->
             val results = mutableListOf<DbContact>()
             while (usedCursor.moveToNext()) {
@@ -397,7 +383,7 @@ class SqliteDatabaseBackend(
         // Since both these values are joined via LEFT JOIN, they actually can be null, although they are defined as NOT NULL in their dedicated table
         val availabilityStatus: AvailabilityStatus.Set? =
             if (
-                ConfigUtils.supportsAvailabilityStatus() &&
+                BuildConfig.AVAILABILITY_STATUS_ENABLED &&
                 availabilityStatusCategoryRaw != null &&
                 availabilityStatusDescription != null
             ) {
@@ -453,7 +439,7 @@ class SqliteDatabaseBackend(
                 whereClause = "${ContactModel.COLUMN_IDENTITY} = ?",
                 whereArgs = arrayOf(dbContact.identity),
             )
-            if (ConfigUtils.supportsAvailabilityStatus()) {
+            if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
                 updateAvailabilityStatus(
                     identity = dbContact.identity,
                     availabilityStatusSet = dbContact.availabilityStatusSet,
@@ -895,4 +881,11 @@ class SqliteDatabaseBackend(
             )
         }
     }
+
+    private companion object {
+        private const val COLUMN_CONTACTS_IDENTITY: String = ContactModel.TABLE + "." + ContactModel.COLUMN_IDENTITY
+        private const val COLUMN_AVAILABILITY_STATUS_IDENTITY: String = DbAvailabilityStatus.TABLE + "." + DbAvailabilityStatus.COLUMN_IDENTITY
+        private const val COLUMN_AVAILABILITY_STATUS_CATEGORY: String = DbAvailabilityStatus.TABLE + "." + DbAvailabilityStatus.COLUMN_CATEGORY
+        private const val COLUMN_AVAILABILITY_STATUS_DESCRIPTION: String = DbAvailabilityStatus.TABLE + "." + DbAvailabilityStatus.COLUMN_DESCRIPTION
+    }
 }

+ 6 - 0
app/src/main/java/ch/threema/storage/ColumnIndexCache.kt

@@ -2,6 +2,7 @@ package ch.threema.storage
 
 import android.database.Cursor
 import androidx.collection.SimpleArrayMap
+import ch.threema.app.BuildConfig
 
 class ColumnIndexCache {
     private val indexMap = SimpleArrayMap<String, Int>()
@@ -12,6 +13,11 @@ class ColumnIndexCache {
             if (cachedIndex != null) {
                 return cachedIndex
             }
+            if (BuildConfig.DEBUG) {
+                require(cursor.columnNames.count { it == columnName } <= 1) {
+                    "Duplicate column name '$columnName' found in cursor"
+                }
+            }
             val index = cursor.getColumnIndex(columnName)
             indexMap.put(columnName, index)
             return index

+ 164 - 74
app/src/main/java/ch/threema/storage/factories/ContactModelFactory.java

@@ -4,7 +4,6 @@ import android.content.ContentValues;
 import android.database.Cursor;
 import android.database.sqlite.SQLiteException;
 
-import org.jetbrains.annotations.NotNull;
 import org.slf4j.Logger;
 
 import java.util.ArrayList;
@@ -13,12 +12,14 @@ import java.util.List;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import ch.threema.app.BuildConfig;
 import ch.threema.app.stores.IdentityProvider;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.crypto.NaCl;
 
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
 
+import ch.threema.data.datatypes.AvailabilityStatus;
 import ch.threema.domain.models.IdentityState;
 import ch.threema.domain.models.IdentityType;
 import ch.threema.domain.models.VerificationLevel;
@@ -27,13 +28,25 @@ import ch.threema.storage.CursorHelper;
 import ch.threema.storage.DatabaseCreationProvider;
 import ch.threema.storage.DatabaseProvider;
 import ch.threema.storage.DatabaseUtil;
+import ch.threema.storage.DbAvailabilityStatus;
 import ch.threema.storage.QueryBuilder;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel;
 
 public class ContactModelFactory extends ModelFactory {
+
     private static final Logger logger = getThreemaLogger("ContactModelFactory");
 
+    public static final String COLUMN_CONTACTS_IDENTITY = ContactModel.TABLE + "." + ContactModel.COLUMN_IDENTITY;
+    public static final String COLUMN_CONTACTS_STATE = ContactModel.TABLE + "." + ContactModel.COLUMN_STATE;
+    public static final String COLUMN_CONTACTS_ACQUAINTANCE_LEVEL = ContactModel.TABLE + "." + ContactModel.COLUMN_ACQUAINTANCE_LEVEL;
+    public static final String COLUMN_CONTACTS_TYPING_INDICATORS = ContactModel.TABLE + "." + ContactModel.COLUMN_TYPING_INDICATORS;
+    public static final String COLUMN_CONTACTS_READ_RECEIPTS = ContactModel.TABLE + "." + ContactModel.COLUMN_READ_RECEIPTS;
+    private static final String COLUMN_CONTACTS_LOOKUP_KEY = ContactModel.TABLE + "." + ContactModel.COLUMN_ANDROID_CONTACT_LOOKUP_KEY;
+    private static final String COLUMN_AVAILABILITY_STATUS_IDENTITY = DbAvailabilityStatus.TABLE + "." + DbAvailabilityStatus.COLUMN_IDENTITY;
+    private static final String COLUMN_AVAILABILITY_STATUS_CATEGORY = DbAvailabilityStatus.TABLE + "." + DbAvailabilityStatus.COLUMN_CATEGORY;
+    private static final String COLUMN_AVAILABILITY_STATUS_DESCRIPTION = DbAvailabilityStatus.TABLE + "." + DbAvailabilityStatus.COLUMN_DESCRIPTION;
+
     @NonNull
     private final IdentityProvider identityProvider;
 
@@ -42,55 +55,116 @@ public class ContactModelFactory extends ModelFactory {
         @NonNull IdentityProvider identityProvider
     ) {
         super(databaseProvider, ContactModel.TABLE);
-
         this.identityProvider = identityProvider;
     }
 
-    public List<ContactModel> getAll() {
-        return convertList(
-            getReadableDatabase().query(getTableName(), null, null, null, null, null, null)
-        );
-    }
-
+    /**
+     * Select one contact by the given identity
+     *
+     * <pre>{@code
+     * SELECT contacts.*,
+     *        contact_availability_status.category,
+     *        contact_availability_status.description
+     * FROM contacts
+     * LEFT JOIN contact_availability_status
+     *        ON contacts.identity = contact_availability_status.identity
+     * WHERE contacts.identity = ?;
+     * }</pre>
+     */
     @Nullable
     public ContactModel getByIdentity(@NonNull String identity) {
-        return this.getFirstOrNull(
-            ContactModel.COLUMN_IDENTITY + "=?",
-            identity
-        );
+        final String query =
+            "SELECT " + ContactModel.TABLE + ".*, " + COLUMN_AVAILABILITY_STATUS_CATEGORY + ", " + COLUMN_AVAILABILITY_STATUS_DESCRIPTION +
+                " FROM " + ContactModel.TABLE + " LEFT JOIN " + DbAvailabilityStatus.TABLE +
+                " ON " + COLUMN_CONTACTS_IDENTITY + " = " + COLUMN_AVAILABILITY_STATUS_IDENTITY +
+                " WHERE " + COLUMN_CONTACTS_IDENTITY + " = ?;";
+        final Object[] bindArgs = new String[]{identity};
+        final @Nullable Cursor cursor = getReadableDatabase().query(query, bindArgs);
+        return getFirstOrNull(cursor);
     }
 
+    /**
+     * Select multiple contacts by the given identities
+     *
+     * <pre>{@code
+     * SELECT contacts.*,
+     *        contact_availability_status.category,
+     *        contact_availability_status.description
+     * FROM contacts
+     * LEFT JOIN contact_availability_status
+     *        ON contacts.identity = contact_availability_status.identity
+     * WHERE contacts.identity IN (?, ?, ?, ...);
+     * }</pre>
+     */
     @NonNull
     public List<ContactModel> getByIdentities(@NonNull List<String> identities) {
         if (identities.isEmpty()) {
             return new ArrayList<>();
         }
         final @NonNull String placeholders = DatabaseUtil.makePlaceholders(identities.size());
-        final @NonNull String selection = ContactModel.COLUMN_IDENTITY + " IN (" + placeholders + ")";
-        final @NonNull String[] selectionArgs = identities.toArray(new String[0]);
-        return convertList(
-            getReadableDatabase().query(getTableName(), null, selection, selectionArgs, null, null, null)
-        );
+        final String query =
+            "SELECT " + ContactModel.TABLE + ".*, " + COLUMN_AVAILABILITY_STATUS_CATEGORY + ", " + COLUMN_AVAILABILITY_STATUS_DESCRIPTION +
+                " FROM " + ContactModel.TABLE + " LEFT JOIN " + DbAvailabilityStatus.TABLE +
+                " ON " + COLUMN_CONTACTS_IDENTITY + " = " + COLUMN_AVAILABILITY_STATUS_IDENTITY +
+                " WHERE " + COLUMN_CONTACTS_IDENTITY + " IN (" + placeholders + ");";
+        final Object[] bindArgs = identities.toArray(String[]::new);
+        final @Nullable Cursor cursor = getReadableDatabase().query(query, bindArgs);
+        return convertList(cursor);
     }
 
+    /**
+     * Select one contact by the given lookupKey
+     *
+     * <pre>{@code
+     * SELECT contacts.*,
+     *        contact_availability_status.category,
+     *        contact_availability_status.description
+     * FROM contacts
+     * LEFT JOIN contact_availability_status
+     *        ON contacts.identity = contact_availability_status.identity
+     * WHERE contacts.androidContactId = ?;
+     * }</pre>
+     */
     @Nullable
     public ContactModel getByLookupKey(@NonNull String lookupKey) {
-        return getFirstOrNull(
-            ContactModel.COLUMN_ANDROID_CONTACT_LOOKUP_KEY + " =?",
-            lookupKey
-        );
+        final String query =
+            "SELECT " + ContactModel.TABLE + ".*, " + COLUMN_AVAILABILITY_STATUS_CATEGORY + ", " + COLUMN_AVAILABILITY_STATUS_DESCRIPTION +
+                " FROM " + ContactModel.TABLE + " LEFT JOIN " + DbAvailabilityStatus.TABLE +
+                " ON " + COLUMN_CONTACTS_IDENTITY + " = " + COLUMN_AVAILABILITY_STATUS_IDENTITY +
+                " WHERE " + COLUMN_CONTACTS_LOOKUP_KEY + " = ?;";
+        final Object[] bindArgs = new String[]{lookupKey};
+        final @Nullable Cursor cursor = getReadableDatabase().query(query, bindArgs);
+        return getFirstOrNull(cursor);
     }
 
+    /**
+     * Table join:
+     * <pre>{@code
+     * contacts
+     *   LEFT JOIN contact_availability_status
+     *          ON (contacts.identity = contact_availability_status.identity)
+     * }</pre>
+     */
     @NonNull
-    public List<ContactModel> convert(
+    public List<ContactModel> select(
         @NonNull QueryBuilder queryBuilder,
-        @Nullable String[] args,
-        @Nullable String orderBy
+        @Nullable String[] selectionArgs,
+        @Nullable String sortOrder
     ) {
-        queryBuilder.setTables(this.getTableName());
-        return convertList(
-            queryBuilder.query(getReadableDatabase(), null, null, args, null, null, orderBy)
+        final String tablesJoined =
+            ContactModel.TABLE + " LEFT JOIN " + DbAvailabilityStatus.TABLE +
+                " ON (" + COLUMN_CONTACTS_IDENTITY + " = " + COLUMN_AVAILABILITY_STATUS_IDENTITY + ")";
+        queryBuilder.setTables(tablesJoined);
+        final @Nullable Cursor cursor = queryBuilder.query(
+            getReadableDatabase(),
+            new String[] { ContactModel.TABLE + ".*", COLUMN_AVAILABILITY_STATUS_CATEGORY, COLUMN_AVAILABILITY_STATUS_DESCRIPTION },
+            null,
+            selectionArgs,
+            null,
+            null,
+            sortOrder
         );
+        return convertList(cursor);
     }
 
     @NonNull
@@ -101,7 +175,8 @@ public class ContactModelFactory extends ModelFactory {
         }
         try (cursor) {
             while (cursor.moveToNext()) {
-                final @NonNull ContactModel contactModel = convert(new CursorHelper(cursor, getColumnIndexCache()));
+                final @NonNull CursorHelper cursorHelper = new CursorHelper(cursor, getColumnIndexCache());
+                final @NonNull ContactModel contactModel = convert(cursorHelper);
                 results.add(contactModel);
             }
         } catch (SQLiteException e) {
@@ -112,49 +187,49 @@ public class ContactModelFactory extends ModelFactory {
 
     @NonNull
     private ContactModel convert(@NonNull CursorHelper cursorHelper) {
-        final @NonNull ContactModel[] cm = new ContactModel[1];
-        cursorHelper.current((CursorHelper.Callback) cursorHelper1 -> {
+        final @NonNull ContactModel[] contactModels = new ContactModel[1];
+        cursorHelper.current((CursorHelper.Callback) cHelper -> {
             ContactModel contactModel = ContactModel.createUnchecked(
-                cursorHelper1.getString(ContactModel.COLUMN_IDENTITY),
-                cursorHelper1.getBlob(ContactModel.COLUMN_PUBLIC_KEY)
+                cHelper.getString(ContactModel.COLUMN_IDENTITY),
+                cHelper.getBlob(ContactModel.COLUMN_PUBLIC_KEY)
             );
 
             contactModel
                 .setName(
-                    cursorHelper1.getString(ContactModel.COLUMN_FIRST_NAME),
-                    cursorHelper1.getString(ContactModel.COLUMN_LAST_NAME)
+                    cHelper.getString(ContactModel.COLUMN_FIRST_NAME),
+                    cHelper.getString(ContactModel.COLUMN_LAST_NAME)
                 )
-                .setPublicNickName(cursorHelper1.getString(ContactModel.COLUMN_PUBLIC_NICK_NAME))
-                .setState(IdentityState.valueOf(cursorHelper1.getString(ContactModel.COLUMN_STATE)))
-                .setAndroidContactLookupKey(cursorHelper1.getString(ContactModel.COLUMN_ANDROID_CONTACT_LOOKUP_KEY))
-                .setIsWork(cursorHelper1.getInt(ContactModel.COLUMN_IS_WORK) == 1)
+                .setPublicNickName(cHelper.getString(ContactModel.COLUMN_PUBLIC_NICK_NAME))
+                .setState(IdentityState.valueOf(cHelper.getString(ContactModel.COLUMN_STATE)))
+                .setAndroidContactLookupKey(cHelper.getString(ContactModel.COLUMN_ANDROID_CONTACT_LOOKUP_KEY))
+                .setIsWork(cHelper.getInt(ContactModel.COLUMN_IS_WORK) == 1)
                 .setIdentityType(
-                    cursorHelper1.getInt(ContactModel.COLUMN_TYPE) == 1
+                    cHelper.getInt(ContactModel.COLUMN_TYPE) == 1
                         ? IdentityType.WORK
                         : IdentityType.NORMAL
                 )
-                .setFeatureMask(cursorHelper1.getLong(ContactModel.COLUMN_FEATURE_MASK))
-                .setIdColorIndex(cursorHelper1.getInt(ContactModel.COLUMN_ID_COLOR_INDEX))
+                .setFeatureMask(cHelper.getLong(ContactModel.COLUMN_FEATURE_MASK))
+                .setIdColorIndex(cHelper.getInt(ContactModel.COLUMN_ID_COLOR_INDEX))
                 .setAcquaintanceLevel(
-                    cursorHelper1.getInt(ContactModel.COLUMN_ACQUAINTANCE_LEVEL) == 1
+                    cHelper.getInt(ContactModel.COLUMN_ACQUAINTANCE_LEVEL) == 1
                         ? AcquaintanceLevel.GROUP
                         : AcquaintanceLevel.DIRECT
                 )
-                .setLocalAvatarExpires(cursorHelper1.getDate(ContactModel.COLUMN_LOCAL_AVATAR_EXPIRES))
-                .setProfilePicBlobID(cursorHelper1.getBlob(ContactModel.COLUMN_PROFILE_PIC_BLOB_ID))
-                .setDateCreated(cursorHelper1.getDate(ContactModel.COLUMN_CREATED_AT))
-                .setLastUpdate(cursorHelper1.getDate(ContactModel.COLUMN_LAST_UPDATE))
-                .setIsRestored(cursorHelper1.getInt(ContactModel.COLUMN_IS_RESTORED) == 1)
-                .setArchived(cursorHelper1.getInt(ContactModel.COLUMN_IS_ARCHIVED) == 1)
-                .setReadReceipts(cursorHelper1.getInt(ContactModel.COLUMN_READ_RECEIPTS))
-                .setTypingIndicators(cursorHelper1.getInt(ContactModel.COLUMN_TYPING_INDICATORS))
-                .setForwardSecurityState(cursorHelper1.getInt(ContactModel.COLUMN_FORWARD_SECURITY_STATE))
-                .setJobTitle(cursorHelper1.getString(ContactModel.COLUMN_JOB_TITLE))
-                .setDepartment(cursorHelper1.getString(ContactModel.COLUMN_DEPARTMENT))
-                .setNotificationTriggerPolicyOverride(cursorHelper1.getLong(ContactModel.COLUMN_NOTIFICATION_TRIGGER_POLICY_OVERRIDE));
+                .setLocalAvatarExpires(cHelper.getDate(ContactModel.COLUMN_LOCAL_AVATAR_EXPIRES))
+                .setProfilePicBlobID(cHelper.getBlob(ContactModel.COLUMN_PROFILE_PIC_BLOB_ID))
+                .setDateCreated(cHelper.getDate(ContactModel.COLUMN_CREATED_AT))
+                .setLastUpdate(cHelper.getDate(ContactModel.COLUMN_LAST_UPDATE))
+                .setIsRestored(cHelper.getInt(ContactModel.COLUMN_IS_RESTORED) == 1)
+                .setArchived(cHelper.getInt(ContactModel.COLUMN_IS_ARCHIVED) == 1)
+                .setReadReceipts(cHelper.getInt(ContactModel.COLUMN_READ_RECEIPTS))
+                .setTypingIndicators(cHelper.getInt(ContactModel.COLUMN_TYPING_INDICATORS))
+                .setForwardSecurityState(cHelper.getInt(ContactModel.COLUMN_FORWARD_SECURITY_STATE))
+                .setJobTitle(cHelper.getString(ContactModel.COLUMN_JOB_TITLE))
+                .setDepartment(cHelper.getString(ContactModel.COLUMN_DEPARTMENT))
+                .setNotificationTriggerPolicyOverride(cHelper.getLong(ContactModel.COLUMN_NOTIFICATION_TRIGGER_POLICY_OVERRIDE));
 
             // Convert state to enum
-            switch (cursorHelper1.getString(ContactModel.COLUMN_STATE)) {
+            switch (cHelper.getString(ContactModel.COLUMN_STATE)) {
                 case "INACTIVE":
                     contactModel.setState(IdentityState.INACTIVE);
                     break;
@@ -168,7 +243,7 @@ public class ContactModelFactory extends ModelFactory {
                     break;
             }
 
-            switch (cursorHelper1.getInt(ContactModel.COLUMN_VERIFICATION_LEVEL)) {
+            switch (cHelper.getInt(ContactModel.COLUMN_VERIFICATION_LEVEL)) {
                 case 1:
                     contactModel.verificationLevel = VerificationLevel.SERVER_VERIFIED;
                     break;
@@ -179,14 +254,36 @@ public class ContactModelFactory extends ModelFactory {
                     contactModel.verificationLevel = VerificationLevel.UNVERIFIED;
             }
 
-            cm[0] = contactModel;
+            // Availability status
+            if (BuildConfig.AVAILABILITY_STATUS_ENABLED) {
+                final @Nullable Integer availabilityStatusCategoryRaw = cHelper.getInt(DbAvailabilityStatus.COLUMN_CATEGORY);
+                final @Nullable String availabilityStatusDescription = cHelper.getString(DbAvailabilityStatus.COLUMN_DESCRIPTION);
+                // Since both these values are joined via LEFT JOIN, they actually can be null, although they are defined as NOT NULL in their
+                // dedicated table
+                if (availabilityStatusCategoryRaw != null && availabilityStatusDescription != null) {
+                    final @Nullable AvailabilityStatus availabilityStatus = AvailabilityStatus.fromDatabaseValues(
+                        availabilityStatusCategoryRaw,
+                        availabilityStatusDescription
+                    );
+                    if (availabilityStatus != null) {
+                        contactModel.setAvailabilityStatus(availabilityStatus);
+                    }
+                }
+            }
+
+            contactModels[0] = contactModel;
 
             return false;
         });
 
-        return cm[0];
+        return contactModels[0];
     }
 
+    /**
+     * <b>Caution:</b> This legacy implementation does <b>not</b> handle availability statuses. Meaning that when updating a contact model, any
+     * availability status will stay as is. When creating a contact, any defined availability status in the given model will be ignored.
+     * {@code SqliteDatabaseBackend} supports both use cases.
+     */
     public boolean createOrUpdate(@NonNull ContactModel contactModel) {
         if (TestUtil.isEmptyOrNull(contactModel.getIdentity())) {
             logger.error("try to create or update a contact model without identity");
@@ -205,7 +302,7 @@ public class ContactModelFactory extends ModelFactory {
             return false;
         }
 
-        Cursor cursor = getReadableDatabase().query(
+        final @Nullable Cursor cursor = getReadableDatabase().query(
             this.getTableName(),
             null,
             ContactModel.COLUMN_IDENTITY + "=?",
@@ -312,22 +409,15 @@ public class ContactModelFactory extends ModelFactory {
         );
     }
 
-    public int delete(@NonNull ContactModel contactModel) {
-        return getWritableDatabase().delete(
-            this.getTableName(),
-            ContactModel.COLUMN_IDENTITY + "=?",
-            new String[]{
-                contactModel.getIdentity()
-            }
-        );
-    }
-
     @Nullable
-    private ContactModel getFirstOrNull(@Nullable String selection, @Nullable String... selectionArgs) {
-        final @Nullable Cursor cursor = getReadableDatabase().query(getTableName(), null, selection, selectionArgs, null, null, null);
+    private ContactModel getFirstOrNull(final @Nullable Cursor cursor) {
+        if (cursor == null) {
+            return null;
+        }
         try (cursor) {
-            if (cursor != null && cursor.moveToFirst()) {
-                return convert(new CursorHelper(cursor, getColumnIndexCache()));
+            if (cursor.moveToFirst()) {
+                final @NonNull CursorHelper cursorHelper = new CursorHelper(cursor, getColumnIndexCache());
+                return convert(cursorHelper);
             }
         } catch (Exception e) {
             logger.error("Exception", e);
@@ -339,7 +429,7 @@ public class ContactModelFactory extends ModelFactory {
 
         @Override
         @NonNull
-        public String [] getCreationStatements() {
+        public String[] getCreationStatements() {
             return new String[]{
                 "CREATE TABLE `" + ContactModel.TABLE + "` (" +
                     "`" + ContactModel.COLUMN_IDENTITY + "` VARCHAR ," +

+ 14 - 0
app/src/main/java/ch/threema/storage/models/ContactModel.java

@@ -13,12 +13,15 @@ import java.lang.annotation.RetentionPolicy;
 import java.util.Date;
 
 import ch.threema.app.utils.TextUtil;
+import ch.threema.data.datatypes.AvailabilityStatus;
 import ch.threema.data.datatypes.ContactNameFormat;
 import ch.threema.data.datatypes.IdColor;
 import ch.threema.base.crypto.NaCl;
 import ch.threema.data.datatypes.NotificationTriggerPolicyOverride;
 import ch.threema.app.preference.service.PreferenceService;
+
 import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
+
 import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.ContactReceiverIdentifier;
 import ch.threema.domain.models.IdentityState;
@@ -126,6 +129,7 @@ public class ContactModel extends Contact implements ReceiverModel {
     private @Nullable String jobTitle;
     private @Nullable String department;
     private @Nullable Long notificationTriggerPolicyOverride;
+    private @NonNull AvailabilityStatus availabilityStatus = AvailabilityStatus.None.INSTANCE;
 
     private ContactModel(String identity, @NonNull byte[] publicKey) {
         super(identity, publicKey, VerificationLevel.UNVERIFIED);
@@ -484,6 +488,16 @@ public class ContactModel extends Contact implements ReceiverModel {
         return this;
     }
 
+    @NonNull
+    public AvailabilityStatus getAvailabilityStatus() {
+        return this.availabilityStatus;
+    }
+
+    public ContactModel setAvailabilityStatus(@NonNull AvailabilityStatus availabilityStatus) {
+        this.availabilityStatus = availabilityStatus;
+        return this;
+    }
+
     public Object[] getModifiedValueCandidates() {
         return new Object[]{
             this.getPublicKey(),

+ 2 - 9
app/src/main/res/layout/actionbar_compose_title.xml

@@ -20,15 +20,8 @@
             android:id="@+id/avatar_view"
             android:layout_width="@dimen/compose_title_avatar_size"
             android:layout_height="@dimen/compose_title_avatar_size"
-            android:contentDescription="@string/threema_contact" />
-
-        <ch.threema.app.availabilitystatus.AvailabilityStatusIconElevatedView
-            android:id="@+id/availability_status_avatar_icon"
-            android:layout_width="16dp"
-            android:layout_height="16dp"
-            android:layout_gravity="bottom|end"
-            android:visibility="gone"
-            tools:visibility="visible" />
+            android:contentDescription="@string/threema_contact"
+            tools:background="@drawable/ic_profile_filled" />
 
     </FrameLayout>
 

+ 14 - 2
app/src/main/res/layout/avatar_view.xml

@@ -7,7 +7,10 @@
     android:layout_height="match_parent"
     android:layout_alignParentLeft="true"
     android:layout_centerVertical="true"
-    android:clickable="false">
+    android:descendantFocusability="blocksDescendants"
+    android:clickable="false"
+    tools:layout_height="48dp"
+    tools:layout_width="48dp">
 
     <ImageView
         android:id="@+id/avatar"
@@ -18,7 +21,7 @@
         tools:srcCompat="@drawable/ic_profile_filled" />
 
     <ImageView
-        android:id="@+id/avatar_badge"
+        android:id="@+id/avatar_badge_work"
         android:layout_width="18dp"
         android:layout_height="18dp"
         android:layout_gravity="left|bottom"
@@ -27,4 +30,13 @@
         app:srcCompat="@drawable/ic_badge_work"
         tools:visibility="visible" />
 
+    <FrameLayout
+        android:id="@+id/avatar_badge_availability_status_container"
+        android:layout_width="16dp"
+        android:layout_height="16dp"
+        android:layout_gravity="right|bottom"
+        android:visibility="gone"
+        tools:background="@drawable/ic_availability_status_busy"
+        tools:visibility="visible" />
+
 </FrameLayout>

+ 15 - 5
app/src/main/res/layout/initial_avatar_view.xml

@@ -21,13 +21,23 @@
         android:id="@+id/avatar_initials"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:layout_marginBottom="1dp"
         android:layout_gravity="center"
+        android:layout_marginBottom="1dp"
+        android:clickable="false"
+        android:fontFamily="sans-serif-condensed"
         android:maxLines="1"
         android:textSize="23dp"
-        android:fontFamily="sans-serif-condensed"
-        android:text="AB"
-        android:clickable="false"
-        android:visibility="visible" />
+        android:visibility="visible"
+        tools:ignore="SpUsage"
+        tools:text="AB" />
+
+    <ch.threema.app.availabilitystatus.AvailabilityStatusIconElevatedView
+        android:id="@+id/avatar_badge_availability_status"
+        android:layout_width="16dp"
+        android:layout_height="16dp"
+        android:layout_gravity="right|bottom"
+        android:visibility="gone"
+        tools:background="@drawable/ic_availability_status_busy"
+        tools:visibility="visible" />
 
 </FrameLayout>

+ 2 - 1
app/src/main/res/layout/item_contact_list.xml

@@ -114,7 +114,8 @@
         android:visibility="visible"
         app:layout_constraintBottom_toBottomOf="parent"
         app:layout_constraintLeft_toLeftOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
+        app:layout_constraintTop_toTopOf="parent"
+        tools:background="@drawable/ic_profile_filled" />
 
     <ch.threema.app.ui.CheckableView
         android:id="@+id/check_box"

+ 0 - 8
app/src/main/res/layout/item_directory.xml

@@ -24,14 +24,6 @@
             android:clickable="false"
             android:contentDescription="@string/mime_contact" />
 
-        <ch.threema.app.availabilitystatus.AvailabilityStatusIconElevatedView
-            android:id="@+id/availability_status_avatar_icon"
-            android:layout_width="16dp"
-            android:layout_height="16dp"
-            android:layout_gravity="bottom|end"
-            android:visibility="gone"
-            tools:visibility="visible" />
-
     </FrameLayout>
 
     <RelativeLayout

+ 9 - 8
app/src/main/res/layout/popup_tooltip.xml

@@ -4,11 +4,12 @@
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"
-    android:layout_marginLeft="@dimen/tooltip_layout_margin_left"
+    android:paddingStart="@dimen/tooltip_layout_margin_left"
     android:layout_marginTop="2dp"
-    android:layout_marginRight="@dimen/tooltip_layout_margin_right"
-    android:layout_marginBottom="8dp"
+    android:paddingEnd="@dimen/tooltip_layout_margin_right"
+    android:paddingBottom="8dp"
     android:background="@android:color/transparent"
+    android:clipToPadding="false"
     android:elevation="0dp">
 
     <com.google.android.material.card.MaterialCardView
@@ -17,7 +18,7 @@
         android:layout_height="wrap_content"
         android:layout_marginTop="12dp"
         android:layout_marginBottom="12dp"
-        app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
+        app:cardBackgroundColor="?attr/colorSurfaceContainer"
         app:cardElevation="6dp"
         app:contentPaddingBottom="6dp"
         app:contentPaddingLeft="12dp"
@@ -89,7 +90,7 @@
         android:layout_marginLeft="@dimen/tooltip_popup_arrow_inset"
         android:importantForAccessibility="no"
         android:visibility="gone"
-        app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
+        app:cardBackgroundColor="?attr/colorSurfaceContainer"
         app:cardElevation="6dp"
         app:shapeAppearance="@style/Threema.ShapeAppearance.Arrow.Up"
         app:strokeWidth="0dp" />
@@ -102,7 +103,7 @@
         android:layout_marginRight="@dimen/tooltip_popup_arrow_inset"
         android:importantForAccessibility="no"
         android:visibility="gone"
-        app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
+        app:cardBackgroundColor="?attr/colorSurfaceContainer"
         app:cardElevation="6dp"
         app:shapeAppearance="@style/Threema.ShapeAppearance.Arrow.Up"
         app:strokeWidth="0dp" />
@@ -115,7 +116,7 @@
         android:layout_marginLeft="@dimen/tooltip_popup_arrow_inset"
         android:importantForAccessibility="no"
         android:visibility="gone"
-        app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
+        app:cardBackgroundColor="?attr/colorSurfaceContainer"
         app:cardElevation="6dp"
         app:shapeAppearance="@style/Threema.ShapeAppearance.Arrow.Down"
         app:strokeWidth="0dp" />
@@ -128,7 +129,7 @@
         android:layout_marginRight="@dimen/tooltip_popup_arrow_inset"
         android:importantForAccessibility="no"
         android:visibility="gone"
-        app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
+        app:cardBackgroundColor="?attr/colorSurfaceContainer"
         app:cardElevation="6dp"
         app:shapeAppearance="@style/Threema.ShapeAppearance.Arrow.Down"
         app:strokeWidth="0dp"

File diff suppressed because it is too large
+ 221 - 209
app/src/main/res/values-be-rBY/strings.xml


+ 45 - 37
app/src/main/res/values-be-rBY/voip_strings.xml

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="voip_title">Званок Threema</string>
+    <string name="voip_title">Выклік Threema</string>
     <string name="voip_hangup">Завяршыць</string>
     <string name="voip_toggle_mic">Пераключыць мікрафон</string>
     <string name="voip_toggle_speaker">Пераключыць дынамік</string>
@@ -9,41 +9,41 @@
     <string name="voip_switch_cam_rear">Пераключана на заднюю камеру</string>
     <string name="voip_toggle_video">Пераключыць рэжым відэа</string>
     <string name="voip_call_confirm">Хочаце патэлефанаваць карыстальніку %1$s?</string>
-    <string name="voip_error_call">Памылка падчас званка Threema</string>
-    <string name="voip_error_init_call">Памылка ініцыялізацыі званка</string>
-    <string name="voip_notification_title">Уваходны званок Threema</string>
+    <string name="voip_error_call">Памылка падчас выкліку Threema</string>
+    <string name="voip_error_init_call">Памылка ініцыялізацыі выкліку</string>
+    <string name="voip_notification_title">Уваходны выклік Threema</string>
     <string name="voip_notification_text">Выклікае %1$s</string>
     <!-- Shown when starting a call, before the peer device is ringing -->
     <string name="voip_status_initializing">Ініцыялізацыя</string>
     <!-- Shown when the callee has accepted the call and the connection is being established -->
     <string name="voip_status_connecting">Злучэнне</string>
     <!-- Shown when a call is being disconnected -->
-    <string name="voip_status_disconnecting">Раз\'яднанне</string>
+    <string name="voip_status_disconnecting">Адлучэнне</string>
     <!-- Shown when the device of the callee is ringing -->
-    <string name="voip_status_ringing">Званок…</string>
+    <string name="voip_status_ringing">Выклік…</string>
     <string name="voip_mic_enable">Уключыць мікрафон</string>
     <string name="voip_mic_disable">Адключыць мікрафон</string>
-    <string name="voip_checking_compatibility">Праверка магчымасці прыёму званкоў Threema кантактам…</string>
-    <string name="voip_incompatible">Гэты кантакт пакуль не можа прымаць званкі Threema.</string>
-    <string name="voip_call_status_unavailable">Адрасат званка недаступны</string>
-    <string name="voip_call_status_rejected">Званок адхілены</string>
-    <string name="voip_call_status_busy">Адрасат званка заняты</string>
-    <string name="voip_call_status_busy_short">Занята</string>
-    <string name="voip_call_status_disabled">Званкі Threema адключаныя адрасатам</string>
-    <string name="voip_call_status_missed">Прапушч. званок</string>
-    <string name="voip_call_finished_outbox">Выходны званок</string>
-    <string name="voip_call_finished_inbox">Уваходны званок</string>
-    <string name="voip_call_status_aborted">Званок перапынены</string>
+    <string name="voip_checking_compatibility">Праверка магчымасці прыёму выклікаў Threema кантактам…</string>
+    <string name="voip_incompatible">Гэты кантакт пакуль не можа прымаць выклікі Threema.</string>
+    <string name="voip_call_status_unavailable">Атрымальнік выкліку недаступны</string>
+    <string name="voip_call_status_rejected">Выклік адхілены</string>
+    <string name="voip_call_status_busy">Атрымальнік выкліку заняты</string>
+    <string name="voip_call_status_busy_short">Заняты(а)</string>
+    <string name="voip_call_status_disabled">Выклікі Threema адключаныя атрымальнікам</string>
+    <string name="voip_call_status_missed">Прапушчаны выклік</string>
+    <string name="voip_call_finished_outbox">Выходны выклік</string>
+    <string name="voip_call_finished_inbox">Уваходны выклік</string>
+    <string name="voip_call_status_aborted">Выклік перапынены</string>
     <string name="voip_return_call">Ператэлефанаваць</string>
     <string name="voip_accept">Прыняць</string>
     <string name="voip_reject">Адхіліць</string>
-    <string name="voip_speakerphone">Гучная сувязь</string>
+    <string name="voip_speakerphone">Дынамік</string>
     <string name="voip_wired_headset">Гарнітура</string>
     <string name="voip_earpiece">Тэлефон</string>
     <string name="voip_bluetooth">Bluetooth</string>
     <string name="voip_none">Недаступна</string>
-    <string name="voip_call_finished">Званок Threema завершаны</string>
-    <string name="voip_another_call">Не ўдаецца злучыцца. Здзяйсняецца іншы званок Threema.</string>
+    <string name="voip_call_finished">Выклік Threema скончаны</string>
+    <string name="voip_another_call">Не ўдаецца злучыцца. Здзяйсняецца іншы выклік Threema.</string>
     <string name="voip_prefs_title_aec">Рэхакампенсацыя</string>
     <string name="voip_prefs_aec_sw">Праграмная рэхакампенсацыя</string>
     <string name="voip_prefs_aec_hw">Апаратная рэхакампенсацыя</string>
@@ -53,19 +53,25 @@
     <string name="voip_prefs_video_codec_no_h264hip">Адключыць H264-HiP</string>
     <string name="voip_prefs_video_codec_sw">Праграмныя кодэкі</string>
     <string name="voip_connection_failed">Не ўдалося ўсталяваць злучэнне.</string>
-    <string name="prefs_voip_reject_incoming_calls_title">Адхіляць званкі з мабільных</string>
-    <string name="prefs_voip_reject_incoming_calls_summary">Адхіляць уваходныя званкі з мабільных, калі здзяйсняецца званок Threema.</string>
+    <string name="voip_call_disconnected">Выклік завершаны</string>
+    <string name="prefs_voip_reject_incoming_calls_title">Адхіляць выклікі з мабільных</string>
+    <string name="prefs_voip_reject_incoming_calls_summary">Адхіляць уваходныя выклікі з мабільных, калі здзяйсняецца выклік Threema.</string>
     <string name="voip_contact_not_found">Па гэтым нумары не знойдзены кантакт Threema.</string>
-    <string name="voip_another_pstn_call">Не ўдаецца запусціць званок. Выконваецца звычайны тэлефонны званок.</string>
-    <string name="voip_call_status_off_hours">Званок у непрац. час</string>
-    <string name="voip_peer_video_disabled">Відэазванкі адключаны субяседнікам.</string>
+    <string name="voip_another_pstn_call">Не ўдаецца запусціць выклік. Выконваецца звычайны тэлефонны выклік.</string>
+    <string name="voip_call_status_off_hours">Выклік у непрацоўны час</string>
+    <string name="voip_peer_video_disabled">Відэавыклікі адключаны субяседнікам.</string>
+    <string name="voip_error_processing_call_anser">Адбылася памылка падчас апрацоўкі адказу на выклік</string>
+    <string name="voip_error_peer_connection">Памылка аднарангавага злучэння</string>
+    <string name="voip_error_determine_common_video_quality_profile">Не ўдалося вызначыць агульны профіль якасці відэа</string>
+    <string name="voip_audio_focus_lost">Страчаны аўдыяфокус</string>
+    <string name="voip_could_not_initialize">Не ўдалося ініцыялізаваць выклік</string>
     <!-- WebRTC debugger -->
     <string name="voip_prefs_webrtc_debug">Дыягностыка WebRTC</string>
     <string name="voip_prefs_webrtc_debug_summary">Запусціце гэты сродак для адладкі непаладак з наладай злучэння для галасавой сувязі</string>
     <string name="voip_webrtc_debug">Дыягностыка WebRTC</string>
-    <string name="voip_webrtc_debug_intro">Націсніце кнопку \"Пуск\" для запуску тэста</string>
-    <string name="voip_webrtc_debug_start">Пуск</string>
-    <string name="voip_webrtc_debug_done">Гатова. Калі ў вас узнікаюць непаладкі з наладамі злучэння для званкоў, адпраўце даныя ў службу падтрымкі Threema.</string>
+    <string name="voip_webrtc_debug_intro">Націсніце кнопку \"Пачаць\" для запуску тэста.</string>
+    <string name="voip_webrtc_debug_start">Пачаць</string>
+    <string name="voip_webrtc_debug_done">Гатова. Калі ў вас узнікаюць непаладкі з наладамі злучэння для выклікаў, адпраўце даныя ў службу падтрымкі Threema.</string>
     <string name="voip_webrtc_debug_copied">Даныя скапіяваны ў буфер абмену.</string>
     <string name="voip_webrtc_debug_copy_clipboard">Капіяваць у буфер абмену</string>
     <!-- Group Calls -->
@@ -77,27 +83,29 @@
     <string name="voip_gc_waiting_for_participants">Чакаем удзельнікаў</string>
     <string name="voip_gc_participant_avatar_description">Відарыс профілю ўдзельніка</string>
     <string name="voip_gc_participant_mute_status_description">Статус адключэння гуку ўдзельніка</string>
+    <string name="voip_gc_participant_screen_share_status_description">Удзельнік дэманструе экран</string>
     <string name="voip_gc_notification_new_call_public">Пачаты новы супольны выклік</string>
-    <string name="voip_gc_open_call">Адкрыты</string>
+    <string name="voip_gc_open_call">Адкрыць</string>
     <string name="voip_gc_call_started">Пачаўся супольны выклік</string>
     <string name="voip_gc_call_ended">Супольны выклік скончыўся</string>
     <string name="voip_gc_in_call">У супольным выкліку</string>
     <string name="voip_gc_sfu_not_available">Сервер недаступны, паўтарыце спробу пазней</string>
-    <string name="voip_gc_call_error">Не атрымалася пачаць супольны выклік</string>
+    <string name="voip_gc_call_error">Выклік спынены з-за памылкі</string>
     <string name="voip_gc_call_start_error">Не атрымалася пачаць супольны выклік</string>
     <string name="voip_gc_call_already_ended">Супольны выклік ужо скончыўся</string>
+    <string name="voip_gc_screen_sharing_not_supported">Паказ экрана зараз даступная толькі на камп’ютарах.</string>
     <string name="voip_gc_call_full_generic">Дасягнуты максімум: больш удзельнікаў не можа далучыцца да гэтага супольнага выкліку.</string>
     <string name="voip_gc_call_full_n">Дасягнута максімальная колькасць удзельнікаў: %1$d, вы не можаце далучыцца да гэтага супольнага выкліку.</string>
     <plurals name="n_participants_in_call">
         <item quantity="one">%d удзельнік</item>
-        <item quantity="few">удзельнікаў</item>
-        <item quantity="many">удзельнікаў</item>
-        <item quantity="other">%d удзельнікі</item>
+        <item quantity="few">%d удзельнікі</item>
+        <item quantity="many">%d удзельнікаў</item>
+        <item quantity="other">%d удзельнікаў</item>
     </plurals>
     <plurals name="n_members_dont_support_group_calls">
-        <item quantity="one">Заўвага: адзін удзельнік не можа ўдзельнічаць у супольных выкліках.</item>
-        <item quantity="few">Заўвага: некалькі удзельнікаў не можа ўдзельнічаць у супольных выкліках.</item>
-        <item quantity="many">Заўвага: некалькі удзельнікаў не можа ўдзельнічаць у супольных выкліках.</item>
-        <item quantity="other">Заўвага: %d удзельнікі не могуць удзельнічаць у супольных выкліках.</item>
+        <item quantity="one">Увага: Адзін удзельнік не можа ўдзельнічаць у групавых выкліках.</item>
+        <item quantity="few">Увага: %d удзельнікі не могуць удзельнічаць у групавых выкліках.</item>
+        <item quantity="many">Увага: %d удзельнікаў не могуць удзельнічаць у групавых выкліках.</item>
+        <item quantity="other">Увага: %d удзельнікаў не могуць удзельнічаць у групавых выкліках.</item>
     </plurals>
 </resources>

+ 12 - 12
app/src/main/res/values-be-rBY/webclient_strings.xml

@@ -9,7 +9,7 @@
     <string name="webclient_no_sessions_found">Каб падлучыцца, спампуйце праграму для камп’ютара (<b>https://threema.com/download</b>) або адкрыйце вэб-кліент (<b>%s</b>) на сваім камп’ютары і націсніце кнопку ніжэй, каб прасканаваць QR-код на экране.</string>
     <string name="webclient_session_rename">Перайменаваць сеанс</string>
     <string name="webclient_session_label">Новая назва</string>
-    <string name="webclient_session_start">Пачаць сесію</string>
+    <string name="webclient_session_start">Пачаць сеанс</string>
     <string name="webclient_session_stop">Спыніць сеанс</string>
     <string name="webclient_persistent">пастаянны</string>
     <string name="webclient_disposable">разавы</string>
@@ -18,27 +18,27 @@
     <string name="webclient_welcome_title">Перапісвайцеся са свайго ПК!</string>
     <string name="webclient_welcome_explain">Праграма для камп’ютара і вэб-кліент дазваляюць вам перапісвацца з вашага ПК або ноўтбука, забяспечваючы поўны доступ да ўсіх вашых кантактаў, відарысаў і чатаў, нават мінулых размоў.\n\n<b>Уся камунікацыя паміж тэлефонам і ПК цалкам абаронена скразным шыфраваннем</b> і выкарыстоўвае прамое злучэнне, калі тэлефон і ПК знаходзяцца ў адной сетцы.\n\nЗвярніце ўвагу: праграма для камп’ютара / вэб-кліент можа павялічыць расход зараду батарэі падчас працы. Вы можаце ўключыць ці выключыць гэтую функцыю ў любы час.</string>
     <string name="webclient_launch">Запусціць праграму для ПК / вэб-кліента</string>
-    <string name="webclient_qr_scan_message">Калі ласка, адскануйце QR-код, які адлюстроўваецца на кампутары.</string>
+    <string name="webclient_qr_scan_message">Калі ласка, адскануйце QR-код, які адлюстроўваецца на камп’ютары.</string>
     <string name="webclient_invalid_qr_code">Няправільны QR-код</string>
-    <string name="webclient_new_connection_toast">Сесія праграмы для ПК/вэб-кліента запушч.</string>
+    <string name="webclient_new_connection_toast">Сеанс праграмы для ПК / вэб-кліента запушчаны.</string>
     <string name="webclient_protocol_error">Памылка пратакола</string>
     <string name="webclient_protocol_version_to_old">Ваша праграма не падтрымлівае гэтую версію вэб-кліента. Калі ласка, абнавіце Threema для Android да апошняй версіі.</string>
     <string name="webclient_protocol_version_too_new_selfhosted">Ваша праграма не падтрымлівае гэтую версію вэб-кліента. Калі ласка, абнавіце Threema для Android да апошняй версіі або папрасіце адміністратара вашага вэб-кліента абнавіць яго да апошняй версіі.</string>
-    <string name="webclient_protocol_version_too_new_threema">Ваша праграма не падтрымлівае дадзеную версію праграмы для ПК / вэб-кліента. Выкарыстоўвайце навейшую версію праграмы для ПК / вэб-кліента.</string>
+    <string name="webclient_protocol_version_too_new_threema">Ваша праграма не падтрымлівае гэтую версію праграмы для ПК / вэб-кліента. Выкарыстоўвайце апошнюю версію праграмы для ПК / вэб-кліента.</string>
     <string name="webclient_session_already_exists">Сеанс для гэтага QR-кода ўжо існуе. Калі ласка, перазапусціце праграму або перазагрузіце старонку вэб-кліента і паспрабуйце зноў.</string>
-    <string name="webclient_really_start_webclient_by_payload_body">Хочаце запусціць гэтую сесію?</string>
-    <string name="webclient_cannot_restore">Не ўдаецца аднавіць сесію</string>
-    <string name="webclient_disabled">Праграма для ПК/вэб-в. не актываваная</string>
-    <string name="webclient_cannot_start">Не ўдаецца запусціць сесію</string>
+    <string name="webclient_really_start_webclient_by_payload_body">Хочаце запусціць гэты сеанс?</string>
+    <string name="webclient_cannot_restore">Не ўдаецца аднавіць сеанс</string>
+    <string name="webclient_disabled">Праграма для ПК / вэб-кліент не ўключаны</string>
+    <string name="webclient_cannot_start">Не ўдаецца запусціць сеанс</string>
     <string name="webclient_constrained_by_mdm">Сервер не ўхвалены адміністратарам.</string>
     <string name="webclient_clear_all_sessions">Ачысціць усе сеансы</string>
     <string name="webclient_clear_all_sessions_confirm">Вы ўпэўнены, што хочаце спыніць і выдаліць усе сеансы?</string>
-    <string name="webclient_prefs_debug_tool_summary">Запусціце гэты сродак для адладкі непаладак з настройкай злучэння з праграмай для ПК або вэб-кліентам</string>
+    <string name="webclient_prefs_debug_tool_summary">Запусціце гэты інструмент для адладкі праблем пры ўсталяванні злучэння з настольнай праграмай ці вэб-кліентам.</string>
     <string name="webclient_diagnostics">Дыягностыка для Desktop/Web</string>
     <string name="webclient_diagnostics_start">Пачаць</string>
-    <string name="webclient_diagnostics_intro">Націсніце кнопку \"Пуск\" для запуску тэсту</string>
-    <string name="webclient_diagnostics_done">Гатова! Калі ў вас узнікаюць непаладкі з наладжваннем злучэння з праграмай для ПК або вэб-кліентам, адпраўце дадзеныя ў службу падтрымкі Threema.</string>
+    <string name="webclient_diagnostics_intro">Націсніце кнопку «Пачаць», каб запусціць тэст.</string>
+    <string name="webclient_diagnostics_done">Гатова! Калі ў вас узнікаюць непаладкі з наладжваннем злучэння з праграмай для ПК або вэб-кліентам, адпраўце даныя ў службу падтрымкі Threema.</string>
     <string name="webclient_scanned_md_linking_qr_code_title">Памылковы QR-код</string>
     <!-- ${app_name_desktop} is a placeholder for the Desktop app's name, e.g. "Threema". ${md_link_device} is a placeholder for the name of a UI element. They must be left as they are and not translated. -->
-    <string name="webclient_scanned_md_linking_qr_code_message">QR-код, які вы прасканавалі, належыць новай праграме для камп’ютара (бэта).\n\nВярніцеся ў ”Галоўнае меню > &gt; ${app_name_desktop} 2.0длякампутара(бэта)” і абярыце ”${md_link_device}”, каб падлучыцца да гэтай праграмы.</string>
+    <string name="webclient_scanned_md_linking_qr_code_message">QR-код, які вы прасканавалі, належыць новай праграме для камп’ютара (бэта).\n\nВярніцеся ў «Галоўнае меню &gt; ${app_name_desktop} 2.0 для камп’ютара (бэта)» і абярыце «${md_link_device}», каб звязацца з гэтай праграмай для камп’ютара.</string>
 </resources>

+ 3 - 0
app/src/main/res/values-bg/strings.xml

@@ -1669,6 +1669,7 @@
     <!-- Text of the notification which is shown when DualLock (a.k.a. "Remote Secret") is active. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">Достъпът Ви до това приложение е блокиран от Вашия администратор.\n\nМоля, свържете се с администратора за повече информация.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">${remote_secret} токенът не може да бъде извлечен. Моля, проверете интернет връзката Ви и опитайте отново.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1689,6 +1690,8 @@
     <string name="remote_secret_deactivating">Деактивиране ${remote_secret}</string>
     <!-- Error message shown fullscreen when deactivating the DualLock (a.k.a. "Remote Secret") feature failed. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secret_deactivating_failed">Деактивацията на ${remote_secret} е неуспешна. Моля, проверете интернет връзката Ви и опитайте отново.</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d контакт</item>
         <item quantity="other">%d контакти</item>

+ 3 - 0
app/src/main/res/values-ca/strings.xml

@@ -1296,6 +1296,7 @@ Si esteu canviant a un dispositiu nou, desinstal·leu o desactiveu %s al disposi
     <!-- Description of the notification channel which is used when DualLock (a.k.a. "Remote Secret") is active. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Text of the notification which is shown when DualLock (a.k.a. "Remote Secret") is active. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Shown as the title of an info dialog when DualLock (a.k.a. "Remote Secret") has been activated. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1306,6 +1307,8 @@ Si esteu canviant a un dispositiu nou, desinstal·leu o desactiveu %s al disposi
     <!-- Error message shown fullscreen when activating the DualLock (a.k.a. "Remote Secret") feature failed. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Shown on loading indicator while the DualLock (a.k.a. "Remote Secret") feature is being deactivated. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Error message shown fullscreen when deactivating the DualLock (a.k.a. "Remote Secret") feature failed. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contacte</item>
         <item quantity="other">%d contactes</item>

+ 9 - 0
app/src/main/res/values-cs/strings.xml

@@ -1702,6 +1702,8 @@ přátelům vás automaticky najít, pokud vás mají v adresáři svého telef
     <string name="remote_secret_notification_text">${remote_secret} je aktivní. Klepnutím zjistíte více.</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">Přístup k této aplikaci byl zablokován vaším administrátorem.\n\nPro více informací prosím kontaktujte vašeho administrátora.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
+    <string name="remote_secrets_close_app">Zavřít aplikaci</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">Token ${remote_secret} se nepodařilo načíst. Zkontrolujte prosím stav vašeho připojení k internetu a zkuste to znovu.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1751,6 +1753,13 @@ přátelům vás automaticky najít, pokud vás mají v adresáři svého telef
     <string name="edit_availability_status_modal_optional_message_description">Nastavte vlastní zprávu o stavu, když je vybrán stav Zaneprázdněn nebo Nedostupný.</string>
     <string name="edit_availability_status_modal_optional_message_too_long">Text je příliš dlouhý, zkraťte jej prosím</string>
     <string name="edit_availability_status_did_change">Stav dostupnosti byl změněn</string>
+    <string name="tooltip_title_availability_status">Stav dostupnosti</string>
+    <string name="tooltip_text_availability_status_in_chat">Zjistěte, zda ten, komu se chystáte napsat, není zaneprázdněný nebo nedostupný. Ve svém profilu můžete nastavit vlastní stav.</string>
+    <string name="tooltip_text_availability_status_in_profile">Nastavte si svůj stav, který se má zobrazit, když jste zaneprázdněni nebo nedostupní.</string>
+    <string name="view_full_availability_status_modal_title">Stav dostupnosti</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
+    <string name="accessibility_view_full_availability_status">Klepněte pro zobrazení úplného stavu dostupnosti</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d kontakt</item>
         <item quantity="few">%d kontakty</item>

+ 7 - 0
app/src/main/res/values-de/strings.xml

@@ -1780,6 +1780,13 @@
     <string name="edit_availability_status_modal_optional_message_description">Wenn «Beschäftigt» oder «Abwesend» ausgewählt ist, können Sie einen eigenen Status definieren.</string>
     <string name="edit_availability_status_modal_optional_message_too_long">Text zu lang, bitte kürzen Sie ihn.</string>
     <string name="edit_availability_status_did_change">Verfügbarkeitsstatus geändert</string>
+    <string name="tooltip_title_availability_status">Verfügbarkeitsstatus</string>
+    <string name="tooltip_text_availability_status_in_chat">Sehen Sie vor dem Senden einer Nachricht, ob jemand beschäftigt oder abwesend ist. Ihren eigenen Status können Sie im Profil festlegen.</string>
+    <string name="tooltip_text_availability_status_in_profile">Legen Sie Ihren Status fest, damit andere sehen, wenn Sie beschäftigt oder abwesend sind.</string>
+    <string name="view_full_availability_status_modal_title">Verfügbarkeitsstatus</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
+    <string name="accessibility_view_full_availability_status">Tippen, um den Verfügbarkeitsstatus ganz anzuzeigen</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d Kontakt</item>
         <item quantity="other">%d Kontakte</item>

+ 15 - 6
app/src/main/res/values-es/strings.xml

@@ -1538,7 +1538,7 @@ almacenado.</string>
     <string name="problemsolver_explain_notifications">Las notificaciones están completamente desactivadas para esta app. No se le informará de mensajes ni llamadas entrantes, y es posible que la app no funcione como se espera. Para solucionarlo, active \"Todas las notificaciones\".</string>
     <string name="problemsolver_explain_background_data">La app no puede utilizar datos móviles cuando esté en segundo plano. No se le informará de mensajes entrantes a menos que abra activamente la app. Para solucionarlo, active \"Datos en segundo plano\".</string>
     <string name="problemsolver_explain_background">El uso en segundo plano de la app está restringido. No recibirá notificaciones y muchas funciones no funcionarán como se espera. Para solucionarlo, pulse \"Uso de la batería de la app\" y seleccione \"Sin restricciones\".</string>
-    <string name="problemsolver_explain_debug_log_enabled">Activó el registro de depuración hace algún tiempo. Si ya no necesita registrar eventos e información de red, considere la posibilidad de desactivar el registro nuevamente.</string>
+    <string name="problemsolver_explain_debug_log_enabled">Hace tiempo que activó el registro de depuración. Si ya no necesita registrar eventos e información de red, considere desactivar el registro de nuevo.</string>
     <string name="problemsolver_explain_debug_log_force_enabled">${app_name} está registrando actualmente eventos e información de red en un archivo, ya que ha creado el archivo «%s». Si ya no necesita el registro de eventos, considere eliminar el archivo.</string>
     <string name="problemsolver_info_text">Los nombres de algunas de las opciones de configuración del sistema mencionados podrían variar en función de la versión de Android y del fabricante del dispositivo.</string>
     <string name="problemsolver_title_app_battery_usgae_optimized">Uso de la batería de la app establecido como \"optimizado\".</string>
@@ -1577,7 +1577,7 @@ almacenado.</string>
     <string name="error_reporting_summary_always_send">Enviar automáticamente informes de errores a ${company_name} cuando se detecte un error crítico</string>
     <!-- ${company_name} is a placeholder for the name of the company that operates the app, e.g. "Threema". It must be left as-is and not translated. -->
     <string name="error_reporting_summary_always_ask">Preguntar siempre si se debe enviar un informe de error a ${company_name}</string>
-    <string name="error_detected_dialog_title">Enviar un informe anónimo</string>
+    <string name="error_detected_dialog_title">Enviar informe anónimo</string>
     <!-- ${app_name_short} is a placeholder for the app's name, e.g. "Threema". It must be left as-is and not translated. -->
     <string name="crash_detected_dialog_text">Parece que ${app_name_short} se ha bloqueado recientemente. ¿Desea enviar un informe de error anónimo?</string>
     <string name="crash_detected_dialog_remember_choice">Recordar mi elección</string>
@@ -1604,7 +1604,7 @@ almacenado.</string>
     <string name="md_drop_device_failed_dialog_message">Compruebe la conexión a internet e inténtelo de nuevo.</string>
     <string name="md_drop_device_failed_dialog_button_close">Cerrar</string>
     <!-- Message shown in dialog when the user starts configuring a backup, but we detected that there might be a problem and thus offer a workaround option to choose from -->
-    <string name="backup_path_selection_message">Se ha detectado un problema en potencia al seleccionar la carpeta. Si continúa este problema, puede guardar la copia de seguridad en la carpeta «Documentos» en lugar de elegir una carpeta personalizada.</string>
+    <string name="backup_path_selection_message">Se ha detectado un posible problema al seleccionar la carpeta. Si continúa este problema, puede guardar la copia de seguridad en la carpeta «Documentos» en lugar de elegir una carpeta personalizada.</string>
     <!-- Button label on dialog when configuring a backup, shown on dialog that lets the user choose how they want to select the backup folder. This option lets them use the regular file picker. -->
     <string name="backup_path_selection_default">Seleccionar carpeta personalizada</string>
     <!-- Button label on dialog when configuring a backup, shown on dialog that lets the user choose how they want to select the backup folder. This option lets them bypass the file picker and use the default Documents folder instead -->
@@ -1703,9 +1703,11 @@ almacenado.</string>
     <!-- Description of the notification channel which is used when DualLock (a.k.a. "Remote Secret") is active. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secret_notification_channel_description">Cuando ${remote_secret} está activo, aparece una notificación persistente.</string>
     <!-- Text of the notification which is shown when DualLock (a.k.a. "Remote Secret") is active. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
-    <string name="remote_secret_notification_text">${remote_secret} está activo. Toca para  más información.</string>
+    <string name="remote_secret_notification_text">${remote_secret} está activo. Toque para obtener más información.</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">Su administrador ha bloqueado el acceso a esta aplicación.\n\nPor favor, póngase en contacto con su administrador para más información.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
+    <string name="remote_secrets_close_app">Cerrar app</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">No se ha podido recoger la ficha ${remote_secret}. Por favor, compruebe su conexión a Internet e inténtelo de nuevo.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1745,8 +1747,8 @@ almacenado.</string>
     <string name="referral_invitation_message_content">Hola, pensé que esto podría serte útil: Threema Work es una excelente opción para la comunicación empresarial segura. Aquí tienes el enlace de prueba: %1$s</string>
     <string name="prefs_voip_abort_on_bluetooth_disconnect_title">Finalizar la llamada cuando se desconecta el Bluetooth</string>
     <string name="prefs_voip_abort_on_bluetooth_disconnect_summary_on">La llamada finaliza cuando se desconecta el auricular Bluetooth durante la misma.</string>
-    <string name="prefs_voip_abort_on_bluetooth_disconnect_summary_off">La llamada continúa aunque se desconecte el auricular Bluetooth durante la misma. Nota: esto podría impedir que funcione el botón de colgar de los auriculares.</string>
-    <string name="failed_to_load_conversations">No se pudieron cargar los chats.</string>
+    <string name="prefs_voip_abort_on_bluetooth_disconnect_summary_off">La llamada continúa aunque se desconecte el auricular Bluetooth durante la misma. Nota: Esto podría impedir que funcione el botón de colgar en los auriculares.</string>
+    <string name="failed_to_load_conversations">No se han podido cargar los chats.</string>
     <string name="contact_support">Póngase en contacto con el servicio de asistencia</string>
     <string name="edit_availability_status_modal_title">Estado de disponibilidad</string>
     <string name="edit_availability_status_modal_subtitle">Todos en su empresa podrán ver este estado.</string>
@@ -1755,6 +1757,13 @@ almacenado.</string>
     <string name="edit_availability_status_modal_optional_message_description">Establezca un mensaje de estado personalizado cuando se seleccione Ocupado o No disponible.</string>
     <string name="edit_availability_status_modal_optional_message_too_long">El texto es demasiado largo, acórtelo.</string>
     <string name="edit_availability_status_did_change">Estado de disponibilidad cambiado</string>
+    <string name="tooltip_title_availability_status">Estado de disponibilidad</string>
+    <string name="tooltip_text_availability_status_in_chat">Vea si alguien está ocupado o no disponible antes de enviarle un mensaje. Puede establecer su propio estado en su perfil.</string>
+    <string name="tooltip_text_availability_status_in_profile">Establezca su estado para mostrar cuando está ocupado o no disponible.</string>
+    <string name="view_full_availability_status_modal_title">Estado de disponibilidad</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
+    <string name="accessibility_view_full_availability_status">Toque para ver el estado de disponibilidad completo</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contacto</item>
         <item quantity="other">%d contactos</item>

+ 5 - 5
app/src/main/res/values-es/voip_strings.xml

@@ -60,11 +60,11 @@
     <string name="voip_another_pstn_call">No se puede iniciar la llamada porque hay una llamada normal en curso.</string>
     <string name="voip_call_status_off_hours">Llamada fuera de horario</string>
     <string name="voip_peer_video_disabled">La otra parte ha desactivado las videollamadas</string>
-    <string name="voip_error_processing_call_anser">Se produjo un error al procesar la respuesta a la llamada</string>
-    <string name="voip_error_peer_connection">Error de conexión entre pares</string>
-    <string name="voip_error_determine_common_video_quality_profile">No se pudo determinar el perfil de calidad de vídeo común</string>
-    <string name="voip_audio_focus_lost">Se ha perdido el enfoque de audio</string>
-    <string name="voip_could_not_initialize">No se pudo inicializar la llamada</string>
+    <string name="voip_error_processing_call_anser">Se ha producido un error al procesar la respuesta a la llamada.</string>
+    <string name="voip_error_peer_connection">Error de conexión entre pares.</string>
+    <string name="voip_error_determine_common_video_quality_profile">No se ha podido determinar el perfil de calidad de vídeo común.</string>
+    <string name="voip_audio_focus_lost">Se ha perdido el enfoque de audio.</string>
+    <string name="voip_could_not_initialize">No se ha podido inicializar la llamada.</string>
     <!-- WebRTC debugger -->
     <string name="voip_prefs_webrtc_debug">Diagnóstico de WebRTC</string>
     <string name="voip_prefs_webrtc_debug_summary">Inicia esta herramienta para depurar problemas al establecer una conexión de llamada de voz.</string>

+ 15 - 6
app/src/main/res/values-fr/strings.xml

@@ -1562,7 +1562,7 @@ Veuillez saisir une question pour votre enquête.</string>
     <string name="group_chats">Discussions de groupe</string>
     <string name="edit_history_file_no_caption">Pas de légende</string>
     <string name="prefs_title_error_reporting">Signalement des erreurs</string>
-    <string name="prefs_send_error_reports">Envoyer des signalements anonymes</string>
+    <string name="prefs_send_error_reports">Envoyer des rapports anonymes</string>
     <string name="error_reporting_option_never_send">Jamais</string>
     <string name="error_reporting_option_always_send">Toujours</string>
     <string name="error_reporting_option_always_ask">Demander à chaque fois</string>
@@ -1572,9 +1572,9 @@ Veuillez saisir une question pour votre enquête.</string>
     <string name="error_reporting_summary_always_send">Envoyer automatiquement des rapports d\'erreur à ${company_name} lorsqu\'une erreur critique est détectée</string>
     <!-- ${company_name} is a placeholder for the name of the company that operates the app, e.g. "Threema". It must be left as-is and not translated. -->
     <string name="error_reporting_summary_always_ask">Demander systématiquement s\'il faut envoyer un rapport d\'erreur à ${company_name}</string>
-    <string name="error_detected_dialog_title">Envoyer un signalement anonyme</string>
+    <string name="error_detected_dialog_title">Envoyer un rapport anonyme</string>
     <!-- ${app_name_short} is a placeholder for the app's name, e.g. "Threema". It must be left as-is and not translated. -->
-    <string name="crash_detected_dialog_text">Il semblerait que ${app_name_short} ait récemment planté. Souhaitez-vous soumettre un rapport d\'erreur anonyme ?</string>
+    <string name="crash_detected_dialog_text">Il semblerait que ${app_name_short} ait récemment planté. Souhaitez-vous envoyer un rapport d\'erreur anonyme ?</string>
     <string name="crash_detected_dialog_remember_choice">Se souvenir de mon choix</string>
     <string name="crash_detected_dialog_send_button">Envoyer</string>
     <!-- Shown in the advanced settings, shows the user their own Device's uniquely generated ID, which is used for sending error reports -->
@@ -1680,7 +1680,7 @@ Veuillez saisir une question pour votre enquête.</string>
     <!-- Error message (only relevant for OnPrem) if the preset server url is different from the one provided by the activation link. -->
     <string name="error_preset_onprem_url_mismatch_intent">Le lien d’activation n’est pas valide. Veuillez contacter votre administrateur.</string>
     <!-- Warning message displayed on screen where the user can export a debug log file to share with Support. ${company_name} is a placeholder for the company name and should be kept as-is, not translated. -->
-    <string name="export_logs_screen_warning">Pour des raisons de confidentialité, ne partagez le fichier de journal qu\'avec l\'équipe d\'assistance de ${company_name} et uniquement si on vous le demande.</string>
+    <string name="export_logs_screen_warning">Pour des raisons de confidentialité, ne partagez le fichier journal qu\'avec l\'équipe d\'assistance de ${company_name} et uniquement si on vous le demande.</string>
     <string name="work_intro_screen_work_title">COMMUNICATION HAUTEMENT SÉCURISÉE POUR LES ENTREPRISES</string>
     <string name="work_intro_screen_work_subtitle">Threema Work est la solution de communication sécurisée pour les entreprises : chiffrement de bout en bout, conforme au RGPD et développée en Suisse.</string>
     <string name="work_intro_screen_work_sign_in_button_label">Se connecter maintenant</string>
@@ -1701,6 +1701,8 @@ Veuillez saisir une question pour votre enquête.</string>
     <string name="remote_secret_notification_text">${remote_secret} est actif. Appuyez pour en savoir plus.</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">L’accès à cette application a été bloqué par votre administrateur.\n\nVeuillez le contacter pour obtenir plus d’informations.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
+    <string name="remote_secrets_close_app">Fermer l\'application</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">Le jeton ${remote_secret} n’a pas pu être récupéré. Veuillez vérifier votre connexion Internet et réessayer.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1740,8 +1742,8 @@ Veuillez saisir une question pour votre enquête.</string>
     <string name="referral_invitation_message_content">Bonjour, je pense que cela pourrait vous être utile : Threema Work est une excellente option pour sécuriser les communications professionnelles. Voici votre lien d’essai :%1$s</string>
     <string name="prefs_voip_abort_on_bluetooth_disconnect_title">Mettre fin à l\'appel lorsque le Bluetooth est déconnecté</string>
     <string name="prefs_voip_abort_on_bluetooth_disconnect_summary_on">L\'appel prend fin lorsque le casque Bluetooth est déconnecté pendant un appel.</string>
-    <string name="prefs_voip_abort_on_bluetooth_disconnect_summary_off">L\'appel se poursuit même si le casque Bluetooth est déconnecté pendant un appel. Remarque : Ceci peut empêcher le bouton de raccrochage du casque de fonctionner.</string>
-    <string name="failed_to_load_conversations">Les conversations n\'ont pas pu être chargées.</string>
+    <string name="prefs_voip_abort_on_bluetooth_disconnect_summary_off">L\'appel se poursuit même si le casque Bluetooth est déconnecté pendant un appel. Remarque : ceci peut empêcher le bouton de raccrochage du casque de fonctionner.</string>
+    <string name="failed_to_load_conversations">Échec du chargement des conversations.</string>
     <string name="contact_support">Contacter l\'assistance</string>
     <string name="edit_availability_status_modal_title">Statut de disponibilité</string>
     <string name="edit_availability_status_modal_subtitle">Ce statut est visible par tous dans votre entreprise.</string>
@@ -1750,6 +1752,13 @@ Veuillez saisir une question pour votre enquête.</string>
     <string name="edit_availability_status_modal_optional_message_description">Définissez un message de statut personnalisé lorsque Occupé ou Indisponible est sélectionné.</string>
     <string name="edit_availability_status_modal_optional_message_too_long">Texte trop long, veuillez le raccourcir</string>
     <string name="edit_availability_status_did_change">Statut de disponibilité modifié</string>
+    <string name="tooltip_title_availability_status">Statut de disponibilité</string>
+    <string name="tooltip_text_availability_status_in_chat">Voyez si quelqu\'un est occupé ou indisponible avant de lui envoyer un message. Vous pouvez définir votre propre statut dans votre profil.</string>
+    <string name="tooltip_text_availability_status_in_profile">Définissez votre statut pour montrer quand vous êtes occupé(e) ou indisponible.</string>
+    <string name="view_full_availability_status_modal_title">Statut de disponibilité</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
+    <string name="accessibility_view_full_availability_status">Appuyez pour voir le statut de disponibilité complet</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contact</item>
         <item quantity="other">%d contacts</item>

+ 1 - 1
app/src/main/res/values-fr/voip_strings.xml

@@ -64,7 +64,7 @@
     <string name="voip_error_peer_connection">Erreur de connexion entre pairs</string>
     <string name="voip_error_determine_common_video_quality_profile">Impossible de déterminer un profil de qualité vidéo commun</string>
     <string name="voip_audio_focus_lost">Le contrôle audio a été perdu</string>
-    <string name="voip_could_not_initialize">Impossible d\'initialiser l\'appel</string>
+    <string name="voip_could_not_initialize">Échec de l\'initialisation de l\'appel</string>
     <!-- WebRTC debugger -->
     <string name="voip_prefs_webrtc_debug">Diagnostics WebRTC</string>
     <string name="voip_prefs_webrtc_debug_summary">Lancez cet outil pour déboguer les problèmes de configuration d\'une connexion d\'appel vocal</string>

+ 9 - 0
app/src/main/res/values-gsw/strings.xml

@@ -1695,6 +1695,8 @@
     <string name="remote_secret_notification_text">${remote_secret} isch aktiv. Tipped Sie da für meh Infos.</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">De Zuegriff uf die App isch vo Ihrem Administrator gsperrt worde.\n\nBitte wändet Sie sich a Ihre Administrator.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
+    <string name="remote_secrets_close_app">App schlüsse</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">S’${remote_secret}-Token hät nöd chöne abgruefe werde. Bitte prüefed Sie Ihri Internetverbindig und versueched Sie’s nomal.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1744,6 +1746,13 @@
     <string name="edit_availability_status_modal_optional_message_description">Wenn «Beschäftigt» oder «Abwesend» usgwählt isch, chönd Sie en eigene Status definiere.</string>
     <string name="edit_availability_status_modal_optional_message_too_long">Text isch z’lang, bitte chürzed Sie ihn.</string>
     <string name="edit_availability_status_did_change">Verfüegbarkeitsstatus gänderet</string>
+    <string name="tooltip_title_availability_status">Verfüegbarkeitsstatus</string>
+    <string name="tooltip_text_availability_status_in_chat">Erfahred Sie vorem Schicke vonere Nachricht, öb öpper beschäftigt oder abwesend isch. Ihre eiget Status chönd Sie im Profil festlegge.</string>
+    <string name="tooltip_text_availability_status_in_profile">Legged Sie Ihre Status fest, damit anderi gsehnd, wenn Sie beschäftigt oder abwesend sind.</string>
+    <string name="view_full_availability_status_modal_title">Verfüegbarkeitsstatus</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
+    <string name="accessibility_view_full_availability_status">Tippe, zum de Verfüegbarkeitsstatus ganz z’zeige</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d Kontakt</item>
         <item quantity="other">%d Kontäkt</item>

+ 3 - 0
app/src/main/res/values-hu/strings.xml

@@ -1286,6 +1286,7 @@ Ha új eszközre vált, kérjük, távolítsa el vagy deaktiválja a %s-t a rég
     <!-- Description of the notification channel which is used when DualLock (a.k.a. "Remote Secret") is active. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Text of the notification which is shown when DualLock (a.k.a. "Remote Secret") is active. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Shown as the title of an info dialog when DualLock (a.k.a. "Remote Secret") has been activated. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1296,6 +1297,8 @@ Ha új eszközre vált, kérjük, távolítsa el vagy deaktiválja a %s-t a rég
     <!-- Error message shown fullscreen when activating the DualLock (a.k.a. "Remote Secret") feature failed. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Shown on loading indicator while the DualLock (a.k.a. "Remote Secret") feature is being deactivated. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Error message shown fullscreen when deactivating the DualLock (a.k.a. "Remote Secret") feature failed. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d névjegyek</item>
         <item quantity="other">%d névjegyek</item>

+ 16 - 7
app/src/main/res/values-it/strings.xml

@@ -1123,7 +1123,7 @@
         <p>Per maggiori dettagli, vai su "Menu > Threema 2.0 per Desktop (Beta)".</p>
         ]]></string>
     <string name="tap_to_start">Tocca qui per iniziare %s adesso.</string>
-    <string name="notification_title_restart">È necessario riavviare il sistema.</string>
+    <string name="notification_title_restart">Riavvio necessario</string>
     <string name="two_years">2 anni</string>
     <string name="backup_data_no_permission">Impossibile scrivere in questa directory. Sceglierne un\'altra.</string>
     <string name="prefs_sum_show_unread_badge">Mostra un badge indicante il numero di messaggi non letti vicino all\'icona dei messaggi</string>
@@ -1548,7 +1548,7 @@
     <string name="problemsolver_explain_notifications">Poiché le notifiche sono completamente disattivate non sarai informato di nuovi messaggi o chiamate e l\'app potrebbe non funzionare come previsto. Per risolvere il problema, attiva l\'opzione \"Tutte le notifiche\".</string>
     <string name="problemsolver_explain_background_data">Poiché l\'utilizzo dei dati mobili in background è disattivato non riceverai notifiche di nuovi messaggi o chiamate, a meno che non apri attivamente l\'app. Per risolvere il problema, attiva l\'opzione \"Dati in background\".</string>
     <string name="problemsolver_explain_background">Poiché l\'uso dell\'app in background è limitato, non riceverai notifiche e l\'app funzionerà solo parzialmente o non come previsto. Per risolvere il problema, tocca \"Utilizzo batteria app\" e seleziona \"Senza restrizioni\".</string>
-    <string name="problemsolver_explain_debug_log_enabled">Qualche tempo fa hai attivato il registro di debug. Se non hai più bisogno di registrare eventi e informazioni di rete, valuta la possibilità di disabilitare nuovamente la registrazione.</string>
+    <string name="problemsolver_explain_debug_log_enabled">Tempo fa hai attivato il registro di debug. Se non hai più bisogno di registrare eventi e informazioni di rete, valuta la possibilità di disabilitarlo nuovamente.</string>
     <string name="problemsolver_explain_debug_log_force_enabled">${app_name} sta attualmente registrando eventi e informazioni di rete in un file perché hai creato il file \"%s\". Se non hai più bisogno di registrare i dati, valuta la possibilità di eliminare il file.</string>
     <string name="problemsolver_info_text">Le denominazioni di alcune delle opzioni nelle impostazioni di sistema menzionate possono variare a seconda della versione di Android e del produttore del dispositivo.</string>
     <string name="problemsolver_title_app_battery_usgae_optimized">Utilizzo batteria app \"ottimizzato\"</string>
@@ -1584,14 +1584,14 @@
     <!-- ${company_name} is a placeholder for the name of the company that operates the app, e.g. "Threema". It must be left as-is and not translated. -->
     <string name="error_reporting_summary_never_send">Non inviare mai segnalazioni di errore a ${company_name}</string>
     <!-- ${company_name} is a placeholder for the name of the company that operates the app, e.g. "Threema". It must be left as-is and not translated. -->
-    <string name="error_reporting_summary_always_send">Invia automaticamente i report degli errori a ${company_name} quando viene rilevato un errore critico.</string>
+    <string name="error_reporting_summary_always_send">Invia automaticamente i report degli errori a ${company_name} quando viene rilevato un errore critico</string>
     <!-- ${company_name} is a placeholder for the name of the company that operates the app, e.g. "Threema". It must be left as-is and not translated. -->
     <string name="error_reporting_summary_always_ask">Chiedi ogni volta se inviare una segnalazione di errore a ${company_name}</string>
     <string name="error_detected_dialog_title">Invia segnalazione anonima</string>
     <!-- ${app_name_short} is a placeholder for the app's name, e.g. "Threema". It must be left as-is and not translated. -->
-    <string name="crash_detected_dialog_text">Sembra che ${app_name_short} si sia bloccato di recente. Desideri inviare una segnalazione di errore anonima?</string>
+    <string name="crash_detected_dialog_text">${app_name_short} è andato in crash ultimamente. Desideri inviare una segnalazione di errore anonima?</string>
     <string name="crash_detected_dialog_remember_choice">Ricorda la mia scelta</string>
-    <string name="crash_detected_dialog_send_button">Inviare</string>
+    <string name="crash_detected_dialog_send_button">Invia</string>
     <!-- Shown in the advanced settings, shows the user their own Device's uniquely generated ID, which is used for sending error reports -->
     <string name="prefs_error_reporting_id">ID dispositivo anonimo</string>
     <!-- The %s is a placeholder for an ID. It must be left as-is. -->
@@ -1679,7 +1679,7 @@
     <!-- Error message when linking of a new device failed due to an invalid contact. %1$s will be replaced with the threema identity of the affected contact. -->
     <string name="device_linking_error_invalid_contact_body">Il contatto con identità %1$s non è memorizzato correttamente in questo dispositivo e impedisce il collegamento del nuovo dispositivo. Contatta il supporto (\"Menu principale &gt; Aiuto\").</string>
     <string name="device_linking_error_invalid_timestamp_title">Data non valida</string>
-    <string name="device_linking_error_invalid_timestamp_body">È stata rilevata una data non valida. Si prega di contattare l\'assistenza (“Main menu &gt; Help”).</string>
+    <string name="device_linking_error_invalid_timestamp_body">È stata rilevata una data non valida. Contatta l\'assistenza (\"Menu principale &gt; Aiuto\").</string>
     <string name="work_directory_title">Directory azienda</string>
     <!-- Instruction text for the user directory search -->
     <string name="work_directory_search">Cerca nella directory</string>
@@ -1716,6 +1716,8 @@
     <string name="remote_secret_notification_text">${remote_secret} è attivo. Tocca per saperne di più.</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">L\'accesso a questa app è stato bloccato dal tuo amministratore.\n\nPer ulteriori informazioni, contatta il tuo amministratore.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
+    <string name="remote_secrets_close_app">Chiudi l\'app</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">Impossibile recuperare il token ${remote_secret}. Controlla la tua connessione Internet e riprova.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1753,7 +1755,7 @@
     <string name="referral_share_invitation_link">Condividi il link dell\'invito</string>
     <string name="referral_view_terms_and_conditions">Consulta i Termini e condizioni</string>
     <string name="referral_invitation_message_content">Ciao, ho pensato che questo potesse esserti utile: Threema Work è un\'ottima soluzione per comunicazioni aziendali sicure. Ecco il link per il test: %1$s</string>
-    <string name="prefs_voip_abort_on_bluetooth_disconnect_title">Termina la chiamata quando il Bluetooth viene disconnesso.</string>
+    <string name="prefs_voip_abort_on_bluetooth_disconnect_title">Termina la chiamata quando il Bluetooth viene disconnesso</string>
     <string name="prefs_voip_abort_on_bluetooth_disconnect_summary_on">La chiamata termina quando l\'auricolare Bluetooth viene disconnesso durante una conversazione.</string>
     <string name="prefs_voip_abort_on_bluetooth_disconnect_summary_off">La chiamata continua anche se l\'auricolare Bluetooth viene disconnesso durante una conversazione. Nota: ciò potrebbe impedire il funzionamento del pulsante di fine chiamata sulle cuffie.</string>
     <string name="failed_to_load_conversations">Impossibile caricare le chat.</string>
@@ -1765,6 +1767,13 @@
     <string name="edit_availability_status_modal_optional_message_description">Imposta un messaggio di stato personalizzato quando sono selezionati Occupato o Non disponibile.</string>
     <string name="edit_availability_status_modal_optional_message_too_long">Il testo è troppo lungo, accorcialo</string>
     <string name="edit_availability_status_did_change">Stato di disponibilità cambiato</string>
+    <string name="tooltip_title_availability_status">Stato di disponibilità</string>
+    <string name="tooltip_text_availability_status_in_chat">Controlla se qualcuno è occupato o non disponibile prima di inviargli un messaggio. Puoi impostare il tuo stato nel tuo profilo.</string>
+    <string name="tooltip_text_availability_status_in_profile">Imposta il tuo stato per indicare quando hai un impegno o non sei disponibile.</string>
+    <string name="view_full_availability_status_modal_title">Stato di disponibilità</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
+    <string name="accessibility_view_full_availability_status">Tocca per visualizzare lo stato di disponibilità completo</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contatto</item>
         <item quantity="other">%d contatti</item>

+ 1 - 1
app/src/main/res/values-it/voip_strings.xml

@@ -60,7 +60,7 @@
     <string name="voip_another_pstn_call">Impossibile avviare la chiamata. È ancora in corso una telefonata normale.</string>
     <string name="voip_call_status_off_hours">Chiamate fuori orario</string>
     <string name="voip_peer_video_disabled">Le videochiamate sono state disattivate dall\'altra persona.</string>
-    <string name="voip_error_processing_call_anser">Si è verificato un errore durante l\'elaborazione della risposta alla chiamata.</string>
+    <string name="voip_error_processing_call_anser">Si è verificato un errore durante l\'elaborazione della risposta alla chiamata</string>
     <string name="voip_error_peer_connection">Errore di connessione peer</string>
     <string name="voip_error_determine_common_video_quality_profile">Impossibile determinare il profilo di qualità del video comune</string>
     <string name="voip_audio_focus_lost">Messa a fuoco audio persa</string>

+ 5 - 0
app/src/main/res/values-ja/strings.xml

@@ -112,6 +112,7 @@
     <string name="set_nickname_title">ユーザー名を設定</string>
     <string name="ok">OK</string>
     <string name="cancel">キャンセル</string>
+    <string name="save">保存</string>
     <string name="copy_message_action">コピー</string>
     <string name="delete_contact_action">連絡先を削除する</string>
     <string name="delete_multiple_contact_action">連絡先を削除する</string>
@@ -341,6 +342,7 @@ https://shop.threema.ch/retrieve_keys]]></string>
     <string name="can_not_send_no_group_members">空のグループにメッセージを送信することはできません</string>
     <string name="you_are_not_a_member_of_this_group">あなたはこのグループのメンバーではありません</string>
     <string name="title_public_nickname">ユーザー名</string>
+    <string name="title_availability_status">状態</string>
     <string name="prefs_sum_excluded_sync_identities">連絡先を同期する際に指定の ID を除外します</string>
     <string name="prefs_title_excluded_sync_identities">除外リスト</string>
     <string name="synchronize_contact">接続する端末の連絡先</string>
@@ -1519,6 +1521,7 @@ https://myid.threema.ch/revoke に入力することで ID を削除すること
     <!-- Description of the notification channel which is used when DualLock (a.k.a. "Remote Secret") is active. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Text of the notification which is shown when DualLock (a.k.a. "Remote Secret") is active. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Shown as the title of an info dialog when DualLock (a.k.a. "Remote Secret") has been activated. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1529,6 +1532,8 @@ https://myid.threema.ch/revoke に入力することで ID を削除すること
     <!-- Error message shown fullscreen when activating the DualLock (a.k.a. "Remote Secret") feature failed. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Shown on loading indicator while the DualLock (a.k.a. "Remote Secret") feature is being deactivated. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Error message shown fullscreen when deactivating the DualLock (a.k.a. "Remote Secret") feature failed. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
     <plurals name="contacts_counter_label">
         <item quantity="other">%d 連絡先</item>
     </plurals>

+ 3 - 0
app/src/main/res/values-nl-rNL/strings.xml

@@ -1697,6 +1697,7 @@ Wanneer u ${app_name_short} Safe gebruikt, kunt u uw ${app_name_short}-ID, conta
     <string name="remote_secret_notification_text">${remote_secret} is actief. Tik om meer te weten te komen.</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">De toegang tot deze app is geblokkeerd door uw beheerder.\n\nNeem contact op met uw beheerder voor meer informatie.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">Het ${remote_secret}-token kon niet worden opgehaald. Controleer uw internetverbinding en probeer het opnieuw.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1739,6 +1740,8 @@ Wanneer u ${app_name_short} Safe gebruikt, kunt u uw ${app_name_short}-ID, conta
     <string name="prefs_voip_abort_on_bluetooth_disconnect_summary_off">Het gesprek wordt voortgezet wanneer de Bluetooth-headset tijdens een gesprek wordt losgekoppeld. Let op: De ophangknop op de headset kan mogelijk niet meer werken.</string>
     <string name="failed_to_load_conversations">Chats konden niet worden geladen.</string>
     <string name="contact_support">Neem contact op met ondersteuning</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contactpersoon</item>
         <item quantity="other">%d contactpersonen</item>

+ 3 - 0
app/src/main/res/values-no/strings.xml

@@ -1484,6 +1484,7 @@ Om du bytter til en ny enhet, vennligst avinstaller eller deaktiver %s på den g
     <!-- Description of the notification channel which is used when DualLock (a.k.a. "Remote Secret") is active. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Text of the notification which is shown when DualLock (a.k.a. "Remote Secret") is active. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Shown as the title of an info dialog when DualLock (a.k.a. "Remote Secret") has been activated. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1494,6 +1495,8 @@ Om du bytter til en ny enhet, vennligst avinstaller eller deaktiver %s på den g
     <!-- Error message shown fullscreen when activating the DualLock (a.k.a. "Remote Secret") feature failed. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Shown on loading indicator while the DualLock (a.k.a. "Remote Secret") feature is being deactivated. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Error message shown fullscreen when deactivating the DualLock (a.k.a. "Remote Secret") feature failed. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d kontakt</item>
         <item quantity="other">%d kontakter</item>

+ 3 - 0
app/src/main/res/values-pl/strings.xml

@@ -1704,6 +1704,7 @@ podanie wyłącznie imienia lub pseudonimu. Jeśli nie ustawisz pseudonimu, będ
     <string name="remote_secret_notification_text">Funkcja ${remote_secret} jest aktywna. Stuknij, aby dowiedzieć się więcej.</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">Administrator zablokował dostęp do tej aplikacji.\n\nAby uzyskać więcej informacji, skontaktuj się z tą osobą.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">Nie udało się pobrać tokena ${remote_secret}. Sprawdź swoje połączenie internetowe i spróbuj ponownie.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1746,6 +1747,8 @@ podanie wyłącznie imienia lub pseudonimu. Jeśli nie ustawisz pseudonimu, będ
     <string name="prefs_voip_abort_on_bluetooth_disconnect_summary_off">Połączenie jest kontynuowane, nawet jeśli zestaw słuchawkowy Bluetooth zostanie rozłączony w trakcie rozmowy. Uwaga: Może to spowodować, że przycisk zakończenia połączenia na zestawie słuchawkowym nie będzie działał.</string>
     <string name="failed_to_load_conversations">Nie udało się wczytać czatów.</string>
     <string name="contact_support">Skontaktuj się z obsługą</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d kontakt</item>
         <item quantity="few">%d kontakty</item>

+ 3 - 0
app/src/main/res/values-pt-rBR/strings.xml

@@ -1697,6 +1697,7 @@ Por favor, insira uma pergunta para a sua enquete.</string>
     <string name="remote_secret_notification_text">O ${remote_secret} está ativo. Toque para saber mais.</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">O acesso a este aplicativo foi bloqueado pelo seu administrador.\n\nEntre em contato com seu administrador para obter mais informações.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">Não foi possível obter o token ${remote_secret}. Verifique sua conexão com a internet e tente novamente.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1739,6 +1740,8 @@ Por favor, insira uma pergunta para a sua enquete.</string>
     <string name="prefs_voip_abort_on_bluetooth_disconnect_summary_off">A chamada continua quando o fone de ouvido Bluetooth é desconectado durante uma chamada. Observação: isso pode impedir o funcionamento do botão de desligar no fone de ouvido.</string>
     <string name="failed_to_load_conversations">Não foi possível carregar os chats.</string>
     <string name="contact_support">Falar com o suporte</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contato</item>
         <item quantity="other">%d contatos</item>

+ 9 - 0
app/src/main/res/values-ru/strings.xml

@@ -1701,6 +1701,8 @@
     <string name="remote_secret_notification_text">${remote_secret} активирован. Нажмите, чтобы узнать больше.</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">Доступ к приложению заблокирован вашим администратором.\n\nСвяжитесь с ним для получения дополнительной информации.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
+    <string name="remote_secrets_close_app">Закрыть приложение</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">Невозможно получить метку ${remote_secret}. Проверьте подключение к интернету и повторите попытку.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1750,6 +1752,13 @@
     <string name="edit_availability_status_modal_optional_message_description">Установите своё сообщение для статусов «Занят» или «Недоступен».</string>
     <string name="edit_availability_status_modal_optional_message_too_long">Текст слишком длинный, сократите его</string>
     <string name="edit_availability_status_did_change">Статус доступности изменён</string>
+    <string name="tooltip_title_availability_status">Статус доступности</string>
+    <string name="tooltip_text_availability_status_in_chat">Прежде чем отправлять сообщение, узнайте, занят и доступен ли собеседник. Вы можете установить свой статус в профиле.</string>
+    <string name="tooltip_text_availability_status_in_profile">Установите свой статус, чтобы показать, когда вы заняты или недоступны.</string>
+    <string name="view_full_availability_status_modal_title">Статус доступности</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
+    <string name="accessibility_view_full_availability_status">Нажмите, чтобы просмотреть полный статус доступности</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d контакт</item>
         <item quantity="few">%d контакта</item>

+ 3 - 0
app/src/main/res/values-sk/strings.xml

@@ -1660,6 +1660,7 @@ Vykonajte prosím zálohú vašich údajov vhodnou metódou.</string>
     <!-- Text of the notification which is shown when DualLock (a.k.a. "Remote Secret") is active. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">Prístup k tejto aplikácii bol blokovaný správcom.\n\nPre viac informácií kontaktujte správcu.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">Token ${remote_secret} sa nepodarilo načítať. Skontrolujte pripojenie k internetu a skúste to znova.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1678,6 +1679,8 @@ Vykonajte prosím zálohú vašich údajov vhodnou metódou.</string>
     <string name="remote_secret_deactivating">Deaktivácia ${remote_secret}</string>
     <!-- Error message shown fullscreen when deactivating the DualLock (a.k.a. "Remote Secret") feature failed. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secret_deactivating_failed">Deaktivácia ${remote_secret} zlyhala. Skontrolujte pripojenie k internetu a skúste to znova.</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d kontakt</item>
         <item quantity="few">%d kontakty</item>

+ 21 - 0
app/src/main/res/values-tr/strings.xml

@@ -22,6 +22,7 @@
     <!-- ${app_name_short} is a placeholder for the app's name, e.g. "Threema". It must be left as-is and not translated. -->
     <string name="enter_id_hint">${app_name_short} Kimliğinizi girin</string>
     <string name="account_links">Bağlı hesaplar</string>
+    <string name="title_set_availability_status">Müsaitlik durumu</string>
     <string name="menu_settings">Ayarlar</string>
     <!-- ${app_name} is a placeholder for the app's name, it must be kept as-is, not translated. -->
     <string name="menu_about">${app_name} Hakkında</string>
@@ -112,6 +113,7 @@
     <string name="set_nickname_title">Takma isim ayarla</string>
     <string name="ok">Tamam</string>
     <string name="cancel">İptal</string>
+    <string name="save">Kaydet</string>
     <string name="copy_message_action">Kopyala</string>
     <string name="delete_contact_action">Sil</string>
     <string name="delete_multiple_contact_action">Kişileri sil</string>
@@ -349,6 +351,7 @@ tekrar denemeden önce girdiğiniz numaranın doğru olduğundan ve mobil ağa b
     <string name="can_not_send_no_group_members">Boş bir gruba mesaj gönderemezsiniz</string>
     <string name="you_are_not_a_member_of_this_group">Bu grubun üyesi değilsiniz</string>
     <string name="title_public_nickname">Takma isim</string>
+    <string name="title_availability_status">Durum</string>
     <string name="prefs_sum_excluded_sync_identities">Kişiler eşitlenirken burada listelenen kimlikler yok sayılacak.</string>
     <string name="prefs_title_excluded_sync_identities">Hariç listesi</string>
     <string name="synchronize_contact">Adres defteri eşitlemesi</string>
@@ -1666,9 +1669,11 @@ sunucularımıza güvenli bir şekilde iletildi. Kişisel anahtar hiçbir zaman
     <string name="work_directory_empty_view_text">Şirketinizin ${app_name_short} kullanıcıları dizininde arama yapmaya başlamak için lütfen en az 3 karakterlik bir ad girin.</string>
     <!-- Message that is shown when the user wants to share an invalid file. -->
     <string name="invalid_file_cannot_be_shared">Bu dosya geçersiz ve paylaşılamaz</string>
+    <string name="availability_status_none">Durum Yok</string>
     <!-- Status (only relevant for Work), e.g. as shown on their profile, indicating that the person is currently not available, i.e. out-of-office -->
     <string name="availability_status_unavailable">Kullanılamıyor</string>
     <!-- Status (only relevant for Work), e.g. as shown on their profile, indicating that the person is currently busy-->
+    <string name="availability_status_busy">Meşgul</string>
     <!-- Error message (only relevant for OnPrem) if the preset server url is different from the one provided by the activation link. -->
     <string name="error_preset_onprem_url_mismatch_intent">Aktivasyon bağlantısı geçersiz. Lütfen yöneticinizle iletişime geçin.</string>
     <!-- Warning message displayed on screen where the user can export a debug log file to share with Support. ${company_name} is a placeholder for the company name and should be kept as-is, not translated. -->
@@ -1693,6 +1698,8 @@ sunucularımıza güvenli bir şekilde iletildi. Kişisel anahtar hiçbir zaman
     <string name="remote_secret_notification_text">${remote_secret} etkin durumda. Daha fazla bilgi edinmek için dokunun.</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">Bu uygulamaya erişim yöneticiniz tarafından engellendi.\n\nDaha fazla bilgi için lütfen yöneticinizle iletişime geçin.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
+    <string name="remote_secrets_close_app">Uygulamayı Kapat</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">${remote_secret} belirteci alınamadı. Lütfen internet bağlantınızı kontrol edip tekrar deneyin.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1735,6 +1742,20 @@ sunucularımıza güvenli bir şekilde iletildi. Kişisel anahtar hiçbir zaman
     <string name="prefs_voip_abort_on_bluetooth_disconnect_summary_off">Görüşme sırasında Bluetooth kulaklığın bağlantısı kesilse bile görüşme devam eder. Not: Bu durum, kulaklıktaki kapatma düğmesinin çalışmasını engelleyebilir.</string>
     <string name="failed_to_load_conversations">Sohbetler yüklenemedi.</string>
     <string name="contact_support">Desteğe başvurun</string>
+    <string name="edit_availability_status_modal_title">Müsaitlik durumu</string>
+    <string name="edit_availability_status_modal_subtitle">Bu durum şirketinizdeki herkes tarafından görülebilir.</string>
+    <string name="edit_availability_status_modal_optional_message_label">Özel Metin (İsteğe bağlı)</string>
+    <string name="edit_availability_status_modal_optional_message_placeholder">Bir toplantıda</string>
+    <string name="edit_availability_status_modal_optional_message_description">Meşgul veya Müsait Değil seçeneği seçildiğinde özel bir durum mesajı ayarlayın.</string>
+    <string name="edit_availability_status_modal_optional_message_too_long">Metin çok uzun, lütfen kısaltın</string>
+    <string name="edit_availability_status_did_change">Kullanılabilirlik durumu değişti</string>
+    <string name="tooltip_title_availability_status">Müsaitlik durumu</string>
+    <string name="tooltip_text_availability_status_in_chat">Birine mesaj atmadan önce meşgul veya uygun olmadığını kontrol edin. Kendi durumunuzu profilinizde ayarlayabilirsiniz.</string>
+    <string name="tooltip_text_availability_status_in_profile">Meşgul veya müsait olmadığınızda durumunuzu gösterin.</string>
+    <string name="view_full_availability_status_modal_title">Müsaitlik durumu</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
+    <string name="accessibility_view_full_availability_status">Tam uygunluk durumuna bakmak için dokunun</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d kişi</item>
         <item quantity="other">%d kişi</item>

+ 9 - 0
app/src/main/res/values-uk/strings.xml

@@ -1733,6 +1733,8 @@
     <string name="remote_secret_notification_text">Ввімкнено ${remote_secret}. Натисніть, щоб дізнатися більше.</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">Доступ до цього додатка заблоковано адміністратором.\n\nЩоб дізнатися більше, зверніться до свого адміністратора.</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
+    <string name="remote_secrets_close_app">Закрити додаток</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">Не вдалося завантажити маркер ${remote_secret}. Перевірте інтернет-з\'єднання і повторіть спробу.</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1782,6 +1784,13 @@
     <string name="edit_availability_status_modal_optional_message_description">Налаштуйте власне повідомлення для статусів \"Не турбувати\" та \"Не на зв\'язку\".</string>
     <string name="edit_availability_status_modal_optional_message_too_long">Текст задовгий, скоротіть його</string>
     <string name="edit_availability_status_did_change">Статус доступності змінено</string>
+    <string name="tooltip_title_availability_status">Статус доступності</string>
+    <string name="tooltip_text_availability_status_in_chat">Перевірте, чи зайнята людина та чи на зв\'язку вона, перш ніж надсилати їй повідомлення. Ви можете встановити власний статус у своєму профілі.</string>
+    <string name="tooltip_text_availability_status_in_profile">Установіть статус, щоб інші бачили, коли ви зайняті або не на зв\'язку.</string>
+    <string name="view_full_availability_status_modal_title">Статус доступності</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
+    <string name="accessibility_view_full_availability_status">Натисніть, щоб переглянути повний статус доступності</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d контакт</item>
         <item quantity="few">%d контакти</item>

+ 9 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -1723,6 +1723,8 @@ ${app_name_short} 支持的所有表情符号。</string>
     <string name="remote_secret_notification_text">${remote_secret} 已启用。点击了解更多信息。</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">您的管理员已阻止访问此应用程序。\n\n请联系您的管理员以获取更多信息。</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
+    <string name="remote_secrets_close_app">关闭应用</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">无法获取 ${remote_secret} 令牌,请检查您的互联网连接并重试。</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1772,6 +1774,13 @@ ${app_name_short} 支持的所有表情符号。</string>
     <string name="edit_availability_status_modal_optional_message_description">当选择为 “忙碌中” 或 “离线” 时,设置自定义状态消息。</string>
     <string name="edit_availability_status_modal_optional_message_too_long">文本太长,请缩短</string>
     <string name="edit_availability_status_did_change">在线状态已更改</string>
+    <string name="tooltip_title_availability_status">在线状态</string>
+    <string name="tooltip_text_availability_status_in_chat">在发送消息到对方之前,先查看对方状态是否 “忙碌中” 或 “离线”。你可以在个人资料中设置自己的状态。</string>
+    <string name="tooltip_text_availability_status_in_profile">设置您的状态,向其他人显示您是 “忙碌中” 或 “离线”。</string>
+    <string name="view_full_availability_status_modal_title">在线状态</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
+    <string name="accessibility_view_full_availability_status">点击来查看完整的在线状态</string>
     <plurals name="contacts_counter_label">
         <item quantity="other">%d个联系人</item>
     </plurals>

+ 10 - 1
app/src/main/res/values-zh-rTW/strings.xml

@@ -1723,6 +1723,8 @@ ${app_name_short} 支援的所有表情符號。</string>
     <string name="remote_secret_notification_text">${remote_secret} 已啟用。按一下以了解更多資訊。</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that their access has been blocked by an admin. Only used for OnPrem builds. -->
     <string name="remote_secrets_blocked_by_admin">您的管理員已阻止存取此應用程式。\n\n請聯絡您的管理員以獲取更多資訊。</string>
+    <!-- Label on button, shown when the app is blocked with the DualLock (a.k.a. "Remote Secret") feature and the user has no other option than to close the app -->
+    <string name="remote_secrets_close_app">關閉應用程式</string>
     <!-- Message shown fullscreen when the app is opened, to inform the user that the DualLock (a.k.a. "Remote Secret") needed to unlock the app could not be fetched due to a network issue. Shown above a "Retry" button. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
     <string name="remote_secrets_failed_to_fetch">無法取得 ${remote_secret} 權杖,請檢查您的網路連線並再試一次。</string>
     <!-- Shown on loading spinner after app is started while DualLock (a.k.a. "Remote Secret") is fetched from the server to unlock the app. Only used for OnPrem builds. ${remote_secret} is a placeholder for a feature name, e.g. "DualLock". It must be left as-is and not translated. -->
@@ -1769,9 +1771,16 @@ ${app_name_short} 支援的所有表情符號。</string>
     <string name="edit_availability_status_modal_subtitle">此狀態對貴公司所有人都可見。</string>
     <string name="edit_availability_status_modal_optional_message_label">自訂文字(可選)</string>
     <string name="edit_availability_status_modal_optional_message_placeholder">開會中</string>
-    <string name="edit_availability_status_modal_optional_message_description">當選取為「忙碌」或「離線」時,設定自訂狀態訊息。</string>
+    <string name="edit_availability_status_modal_optional_message_description">當選取為「忙碌」或「離線」時,設定自訂狀態訊息。</string>
     <string name="edit_availability_status_modal_optional_message_too_long">文字過長,請縮短</string>
     <string name="edit_availability_status_did_change">在線狀態已變更</string>
+    <string name="tooltip_title_availability_status">在線狀態</string>
+    <string name="tooltip_text_availability_status_in_chat">在傳送訊息至對方之前,先檢視對方狀態是否「忙碌中」或「離線」。你可以在個人檔案中設定自己的狀態。</string>
+    <string name="tooltip_text_availability_status_in_profile">設定您的狀態,向其他人顯示您是「忙碌中」或「離線」。</string>
+    <string name="view_full_availability_status_modal_title">在線狀態</string>
+    <!-- A hint text that will be played by the android screen reader in accessibility mode. It explains the effect of a click action. -->
+    <!-- Clicking will open a new modal view that displays the full length of a contact's availability status description. -->
+    <string name="accessibility_view_full_availability_status">按一下以檢視完整的在線狀態</string>
     <plurals name="contacts_counter_label">
         <item quantity="other">%d位聯絡人</item>
     </plurals>

+ 2 - 2
app/src/main/res/values/colors.xml

@@ -134,11 +134,11 @@
     <color name="availability_status_icon_none">@color/brand_grey_700</color>
     <color name="availability_status_container_none">@color/brand_grey_200</color>
 
-    <color name="availability_status_icon_unavailable">#B91C1C</color>
+    <color name="availability_status_icon_unavailable">#C0392B</color>
     <color name="availability_status_container_unavailable">#FECACA</color>
     <color name="availability_status_on_container_unavailable">#000000</color>
 
-    <color name="availability_status_icon_busy">#F6A421</color>
+    <color name="availability_status_icon_busy">#D68910</color>
     <color name="availability_status_container_busy">#FFEAA3</color>
     <color name="availability_status_on_container_busy">#000000</color>
 </resources>

+ 1 - 1
app/src/main/res/values/dimens.xml

@@ -240,7 +240,7 @@
     <dimen name="tooltip_max_width">350dp</dimen>
     <dimen name="tooltip_margin_on_other_edge">40dp</dimen>
     <dimen name="tooltip_popup_arrow_inset">10dp</dimen>
-    <dimen name="tooltip_layout_margin_left">2dp</dimen>
+    <dimen name="tooltip_layout_margin_left">8dp</dimen>
     <dimen name="tooltip_layout_margin_right">8dp</dimen>
     <dimen name="tooltip_title_size">15sp</dimen>
     <dimen name="tooltip_text_size">15sp</dimen>

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