Threema před 9 měsíci
rodič
revize
d48d5825e6
51 změnil soubory, kde provedl 1127 přidání a 711 odebrání
  1. 2 3
      .github/pull_request_template.md
  2. 2 2
      CONTRIBUTING.md
  3. 3 3
      README.md
  4. 6 12
      app/build.gradle
  5. 133 2
      app/src/androidTest/java/ch/threema/data/repositories/EmojiReactionsRepositoryTest.kt
  6. 2 2
      app/src/libre/play/release-notes/de/default.txt
  7. 2 2
      app/src/libre/play/release-notes/en-US/default.txt
  8. 8 2
      app/src/main/java/ch/threema/app/activities/MessageDetailsActivity.kt
  9. 19 9
      app/src/main/java/ch/threema/app/activities/MessageDetailsViewModel.kt
  10. 46 42
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  11. 340 335
      app/src/main/java/ch/threema/app/adapters/GroupCallParticipantsAdapter.kt
  12. 13 3
      app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateWorkContactBackgroundTask.kt
  13. 73 0
      app/src/main/java/ch/threema/app/compose/common/HintText.kt
  14. 1 2
      app/src/main/java/ch/threema/app/compose/common/interop/ComposeJavaBridge.kt
  15. 53 27
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewActivity.kt
  16. 2 6
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewFragment.kt
  17. 4 4
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsPopup.kt
  18. 16 6
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsViewModel.kt
  19. 4 0
      app/src/main/java/ch/threema/app/emojis/EmojiUtil.java
  20. 17 7
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  21. 23 26
      app/src/main/java/ch/threema/app/routines/UpdateAppLogoRoutine.java
  22. 2 2
      app/src/main/java/ch/threema/app/services/AvatarCacheServiceImpl.java
  23. 17 4
      app/src/main/java/ch/threema/app/services/MessageServiceImpl.java
  24. 3 3
      app/src/main/java/ch/threema/app/services/notification/NotificationActionService.java
  25. 0 1
      app/src/main/java/ch/threema/app/voip/activities/GroupCallActivity.kt
  26. 3 2
      app/src/main/java/ch/threema/app/voip/viewmodel/GroupCallViewModel.kt
  27. 82 57
      app/src/main/java/ch/threema/app/workers/WorkSyncWorker.kt
  28. 3 2
      app/src/main/java/ch/threema/data/ModelCache.kt
  29. 34 0
      app/src/main/java/ch/threema/data/models/ContactModel.kt
  30. 0 2
      app/src/main/java/ch/threema/data/models/GroupModel.kt
  31. 75 12
      app/src/main/java/ch/threema/data/repositories/EmojiReactionsRepository.kt
  32. 1 1
      app/src/main/java/ch/threema/data/storage/EmojiReactionsDao.kt
  33. 21 28
      app/src/main/java/ch/threema/data/storage/EmojiReactionsDaoImpl.kt
  34. 9 6
      app/src/main/java/ch/threema/storage/models/data/media/FileDataModel.java
  35. 12 5
      app/src/main/res/drawable/ic_profile.xml
  36. 1 1
      app/src/main/res/layout/activity_emojireactions_overview.xml
  37. 62 61
      app/src/main/res/layout/item_group_call_participant_list.xml
  38. 2 2
      app/src/main/res/values-cs/strings.xml
  39. 4 2
      app/src/main/res/values-de/strings.xml
  40. 2 2
      app/src/main/res/values-fr/strings.xml
  41. 3 3
      app/src/main/res/values-gsw/strings.xml
  42. 2 2
      app/src/main/res/values-nl-rNL/strings.xml
  43. 2 2
      app/src/main/res/values-pl/strings.xml
  44. 2 2
      app/src/main/res/values-pt-rBR/strings.xml
  45. 2 2
      app/src/main/res/values-ru/strings.xml
  46. 2 2
      app/src/main/res/values-tr/strings.xml
  47. 3 3
      app/src/main/res/values-uk/strings.xml
  48. 3 3
      app/src/main/res/values-zh-rCN/strings.xml
  49. 3 3
      app/src/main/res/values-zh-rTW/strings.xml
  50. 2 0
      app/src/main/res/values/strings.xml
  51. 1 1
      domain/src/main/java/ch/threema/domain/protocol/csp/messages/GroupReactionMessage.kt

+ 2 - 3
.github/pull_request_template.md

@@ -1,9 +1,8 @@
 ## Description
 ## Description
 
 
 <!-- Please describe your change. If the change concerns the user interface,
 <!-- Please describe your change. If the change concerns the user interface,
-please include screenshots. Note that improvements to translation strings
-should be submitted through OneSky, see CONTRIBUTING.md or README.md for
-more information. -->
+please include screenshots. For improvements to translation strings
+please see CONTRIBUTING.md or README.md -->
 
 
 ## Checklist
 ## Checklist
 
 

+ 2 - 2
CONTRIBUTING.md

@@ -13,6 +13,6 @@ team through [threema.ch/support](https://threema.ch/support).
 
 
 ## Translations
 ## Translations
 
 
-We manage our app translations through OneSky. If you're interested in
+We manage our app translations on Crowdin. If you’re interested in
 improving translations, or if you would like to translate Threema to a new
 improving translations, or if you would like to translate Threema to a new
-language, please sign up at <https://threema.oneskyapp.com/collaboration/>.
+language, please contact us at `support at threema dot ch`.

+ 3 - 3
README.md

@@ -245,14 +245,14 @@ We accept GitHub pull requests. Please refer to
 for more information on how to contribute.
 for more information on how to contribute.
 
 
 Note that translation fixes should not be contributed through GitHub but
 Note that translation fixes should not be contributed through GitHub but
-through OneSky, see next section.
+on Crowdin, see next section.
 
 
 
 
 ## <a name="translating"></a>Translating
 ## <a name="translating"></a>Translating
 
 
-We manage our app translations through OneSky. If you’re interested in
+We manage our app translations on Crowdin. If you’re interested in
 improving translations, or if you would like to translate Threema to a new
 improving translations, or if you would like to translate Threema to a new
-language, please sign up at <https://threema.oneskyapp.com/collaboration/>.
+language, please contact us at `support at threema dot ch`.
 
 
 
 
 ## <a name="license"></a>License
 ## <a name="license"></a>License

+ 6 - 12
app/build.gradle

@@ -19,14 +19,14 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 // version codes
 // version codes
 
 
 // Only use the scheme "<major>.<minor>.<patch>" for the app_version
 // Only use the scheme "<major>.<minor>.<patch>" for the app_version
-def app_version = "5.8.0"
+def app_version = "5.8.1"
 
 
 // beta_suffix with leading dash (e.g. `-beta1`)
 // beta_suffix with leading dash (e.g. `-beta1`)
 // should be one of (alpha|beta|rc) and an increasing number or empty for a regular release.
 // should be one of (alpha|beta|rc) and an increasing number or empty for a regular release.
 // Note: in nightly builds this will be overwritten with a nightly version "-n12345"
 // Note: in nightly builds this will be overwritten with a nightly version "-n12345"
 def beta_suffix = ""
 def beta_suffix = ""
 
 
-def defaultVersionCode = 1043
+def defaultVersionCode = 1050
 
 
 /**
 /**
  * Return the git hash, if git is installed.
  * Return the git hash, if git is installed.
@@ -158,8 +158,8 @@ android {
         buildConfigField "boolean", "MD_SYNC_DISTRIBUTION_LISTS", "false"
         buildConfigField "boolean", "MD_SYNC_DISTRIBUTION_LISTS", "false"
         buildConfigField "boolean", "EDIT_MESSAGES_ENABLED", "true"
         buildConfigField "boolean", "EDIT_MESSAGES_ENABLED", "true"
         buildConfigField "boolean", "DELETE_MESSAGES_ENABLED", "true"
         buildConfigField "boolean", "DELETE_MESSAGES_ENABLED", "true"
-        buildConfigField "boolean", "EMOJI_REACTIONS_ENABLED", "false"
-        buildConfigField "boolean", "EMOJI_REACTIONS_WEB_ENABLED", "false"
+        buildConfigField "boolean", "EMOJI_REACTIONS_ENABLED", "true"
+        buildConfigField "boolean", "EMOJI_REACTIONS_WEB_ENABLED", "true"
 
 
         // config fields for action URLs / deep links
         // config fields for action URLs / deep links
         buildConfigField "String", "uriScheme", "\"threema\""
         buildConfigField "String", "uriScheme", "\"threema\""
@@ -281,9 +281,6 @@ android {
             buildConfigField "boolean", "MD_ENABLED", "true"
             buildConfigField "boolean", "MD_ENABLED", "true"
 
 
             buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
             buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
-
-            buildConfigField "boolean", "EMOJI_REACTIONS_ENABLED", "true"
-            buildConfigField "boolean", "EMOJI_REACTIONS_WEB_ENABLED", "true"
         }
         }
         sandbox_work {
         sandbox_work {
             versionName "${app_version}k${beta_suffix}"
             versionName "${app_version}k${beta_suffix}"
@@ -318,8 +315,6 @@ android {
             buildConfigField "String", "actionUrl", "\"work.test.threema.ch\""
             buildConfigField "String", "actionUrl", "\"work.test.threema.ch\""
 
 
             buildConfigField "boolean", "MD_ENABLED", "true"
             buildConfigField "boolean", "MD_ENABLED", "true"
-            buildConfigField "boolean", "EMOJI_REACTIONS_ENABLED", "true"
-            buildConfigField "boolean", "EMOJI_REACTIONS_WEB_ENABLED", "true"
 
 
             manifestPlaceholders = [
             manifestPlaceholders = [
                 uriScheme: "threemawork",
                 uriScheme: "threemawork",
@@ -359,6 +354,8 @@ android {
             buildConfigField "String", "uriScheme", "\"threemaonprem\""
             buildConfigField "String", "uriScheme", "\"threemaonprem\""
             buildConfigField "String", "actionUrl", "\"onprem.threema.ch\""
             buildConfigField "String", "actionUrl", "\"onprem.threema.ch\""
 
 
+            buildConfigField "boolean", "EMOJI_REACTIONS_WEB_ENABLED", "false"
+
             manifestPlaceholders = [
             manifestPlaceholders = [
                 uriScheme   : "threemaonprem",
                 uriScheme   : "threemaonprem",
                 actionUrl   : "onprem.threema.ch",
                 actionUrl   : "onprem.threema.ch",
@@ -394,9 +391,6 @@ android {
 
 
             buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
             buildConfigField "String", "BLOB_MIRROR_SERVER_URL", "\"https://blob-mirror-{deviceGroupIdPrefix4}.test.threema.ch/{deviceGroupIdPrefix8}\""
 
 
-            buildConfigField "boolean", "EMOJI_REACTIONS_ENABLED", "true"
-            buildConfigField "boolean", "EMOJI_REACTIONS_WEB_ENABLED", "true"
-
             // config fields for action URLs / deep links
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemablue\""
             buildConfigField "String", "uriScheme", "\"threemablue\""
             buildConfigField "String", "actionUrl", "\"blue.threema.ch\""
             buildConfigField "String", "actionUrl", "\"blue.threema.ch\""

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

@@ -24,21 +24,31 @@ package ch.threema.data.repositories
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestTaskManager
 import ch.threema.app.TestTaskManager
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.ThreemaApplication
+import ch.threema.data.ModelTypeCache
 import ch.threema.data.TestDatabaseService
 import ch.threema.data.TestDatabaseService
+import ch.threema.data.models.EmojiReactionData
+import ch.threema.data.models.EmojiReactionsModel
+import ch.threema.data.repositories.EmojiReactionsRepository.ReactionMessageIdentifier
 import ch.threema.data.storage.EmojiReactionsDao
 import ch.threema.data.storage.EmojiReactionsDao
 import ch.threema.data.storage.EmojiReactionsDaoImpl
 import ch.threema.data.storage.EmojiReactionsDaoImpl
 import ch.threema.domain.helpers.UnusedTaskCodec
 import ch.threema.domain.helpers.UnusedTaskCodec
-import ch.threema.protobuf.d2d.sync.group
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.AbstractMessageModel
+import ch.threema.storage.models.DistributionListMessageModel
 import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageType
 import ch.threema.storage.models.MessageType
 import org.junit.Assert
 import org.junit.Assert
 import org.junit.Before
 import org.junit.Before
 import org.junit.Test
 import org.junit.Test
+import java.util.Date
 import java.util.UUID
 import java.util.UUID
+import kotlin.test.assertContentEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.fail
 
 
 class EmojiReactionsRepositoryTest {
 class EmojiReactionsRepositoryTest {
+    private lateinit var testCoreServiceManager: TestCoreServiceManager
     private lateinit var databaseService: TestDatabaseService
     private lateinit var databaseService: TestDatabaseService
     private lateinit var emojiReactionsRepository: EmojiReactionsRepository
     private lateinit var emojiReactionsRepository: EmojiReactionsRepository
     private lateinit var emojiReactionDao: EmojiReactionsDao
     private lateinit var emojiReactionDao: EmojiReactionsDao
@@ -46,7 +56,7 @@ class EmojiReactionsRepositoryTest {
     @Before
     @Before
     fun before() {
     fun before() {
         databaseService = TestDatabaseService()
         databaseService = TestDatabaseService()
-        val testCoreServiceManager = TestCoreServiceManager(
+        testCoreServiceManager = TestCoreServiceManager(
             version = ThreemaApplication.getAppVersion(),
             version = ThreemaApplication.getAppVersion(),
             databaseService = databaseService,
             databaseService = databaseService,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
             preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
@@ -164,6 +174,127 @@ class EmojiReactionsRepositoryTest {
         Assert.assertEquals("⛵", groupReaction.emojiSequence)
         Assert.assertEquals("⛵", groupReaction.emojiSequence)
     }
     }
 
 
+    @Test
+    fun testContactAndGroupReactionsNotMixedUpWhenRemoved() {
+        val reactionSequence = "⛵"
+
+        val contactMessage = MessageModel().enrich()
+        val groupMessage = GroupMessageModel().enrich()
+
+        databaseService.messageModelFactory.create(contactMessage)
+        databaseService.groupMessageModelFactory.create(groupMessage)
+
+        Assert.assertEquals(1, contactMessage.id)
+        Assert.assertEquals(1, groupMessage.id)
+
+        contactMessage.assertEmojiReactionSize(0)
+        groupMessage.assertEmojiReactionSize(0)
+
+        emojiReactionsRepository.createEntry(contactMessage, "ABCD1234", reactionSequence)
+
+        contactMessage.assertEmojiReactionSize(1)
+        groupMessage.assertEmojiReactionSize(0)
+
+        emojiReactionsRepository.createEntry(groupMessage, "ABCD1234", reactionSequence)
+
+        contactMessage.assertEmojiReactionSize(1)
+        groupMessage.assertEmojiReactionSize(1)
+
+        emojiReactionsRepository.removeEntry(contactMessage, "ABCD1234", reactionSequence)
+
+        contactMessage.assertEmojiReactionSize(0)
+        groupMessage.assertEmojiReactionSize(1)
+
+        emojiReactionsRepository.removeEntry(groupMessage, "ABCD1234", reactionSequence)
+
+        contactMessage.assertEmojiReactionSize(0)
+        groupMessage.assertEmojiReactionSize(0)
+    }
+
+    @Test
+    fun testEmojiReactionsModelCaching() {
+
+        val testEmojiCache = ModelTypeCache<ReactionMessageIdentifier, EmojiReactionsModel>()
+
+        val contactMessage = MessageModel().enrich()
+        databaseService.messageModelFactory.create(contactMessage)
+
+        // Test successful creation of reaction-message-identifier
+        val reactionMessageIdentifier = ReactionMessageIdentifier.fromMessageModel(contactMessage)
+        assertNotNull(reactionMessageIdentifier)
+
+        // Test unsuccessful creation of reaction-message-identifier
+        val reactionMessageIdentifierNull = ReactionMessageIdentifier.fromMessageModel(
+            messageModel = DistributionListMessageModel()
+        )
+        assertNull(reactionMessageIdentifierNull)
+
+        // Test reading empty cache
+        var cachedEntry: EmojiReactionsModel? = testEmojiCache.get(reactionMessageIdentifier)
+        assertNull(cachedEntry)
+
+        // Test reading empty cache but creating entity
+        val emojiReactionData = EmojiReactionData(
+            contactMessage.id,
+            senderIdentity = "ABCD1234",
+            emojiSequence = "⛵",
+            reactedAt = Date()
+        )
+        val emojiReactionsModel = EmojiReactionsModel(
+            data = listOf(emojiReactionData),
+            coreServiceManager = testCoreServiceManager
+        )
+        cachedEntry = testEmojiCache.getOrCreate(reactionMessageIdentifier) { emojiReactionsModel }
+        assertContentEquals(listOf(emojiReactionData), cachedEntry!!.data.value)
+
+        // Test should read the cached value
+        testEmojiCache.getOrCreate(reactionMessageIdentifier) {
+            fail("Should not call this miss() function")
+        }
+        assertNotNull(testEmojiCache.get(reactionMessageIdentifier))
+
+        // Test removing from cache
+        val removedEmojiReactionsModel = testEmojiCache.remove(reactionMessageIdentifier)
+        assertContentEquals(listOf(emojiReactionData), removedEmojiReactionsModel!!.data.value)
+        assertNull(testEmojiCache.get(reactionMessageIdentifier))
+    }
+
+    @Test
+    fun testCacheCollision() {
+
+        // arrange
+        val testEmojiCache = ModelTypeCache<ReactionMessageIdentifier, EmojiReactionsModel>()
+        val contactMessageId = 1
+        val groupMessageId = 1
+        val reactionMessageIdentifierContact = ReactionMessageIdentifier(
+            messageId = contactMessageId,
+            messageType = ReactionMessageIdentifier.TargetMessageType.ONE_TO_ONE
+        )
+        val reactionMessageIdentifierGroup = ReactionMessageIdentifier(
+            messageId = groupMessageId,
+            messageType = ReactionMessageIdentifier.TargetMessageType.GROUP
+        )
+        // Add only the emoji reaction of the 1:1 message to the cache
+        val emojiReactionDataForContactMessage = EmojiReactionData(
+            messageId = contactMessageId,
+            senderIdentity = "ABCD1234",
+            emojiSequence = "⛵",
+            reactedAt = Date()
+        )
+        val emojiReactionsModelContact = EmojiReactionsModel(
+            data = listOf(emojiReactionDataForContactMessage),
+            coreServiceManager = testCoreServiceManager
+        )
+
+        val cachedEntryContact = testEmojiCache.getOrCreate(reactionMessageIdentifierContact) { emojiReactionsModelContact }
+
+        assertContentEquals(listOf(emojiReactionDataForContactMessage), cachedEntryContact!!.data.value)
+        assertNull(testEmojiCache.get(reactionMessageIdentifierGroup))
+
+        testEmojiCache.remove(reactionMessageIdentifierGroup)
+        assertNotNull(testEmojiCache.get(reactionMessageIdentifierContact))
+    }
+
     private fun AbstractMessageModel.assertEmojiReactionSize(expectedSize: Int) {
     private fun AbstractMessageModel.assertEmojiReactionSize(expectedSize: Int) {
         val actualSize = emojiReactionDao.findAllByMessage(this).size
         val actualSize = emojiReactionDao.findAllByMessage(this).size
 
 

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

@@ -1,2 +1,2 @@
-- Überarbeitete Darstellung der Zustimmen-/Ablehnen-Icons (in Vorbereitung auf kommende Emoji-Reaktionen)
-- Behebung eines Fehlers bzgl. dem Nachrichten-Status von Chats mit verpassten Anrufen
+- Neu: Auf Nachrichten kann mit Emoji reagiert werden
+- Behebung eines Fehlers bezüglich Profilbildern in Gruppenanrufen

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

@@ -1,2 +1,2 @@
-- Overhauled appearance of the agree/disagree icons (in preparation for the upcoming emoji reactions feature)
-- Fixed a bug concerning the message status of chats containing missed calls
+- New: You can now react to messages with emoji
+- Fixed a bug concerning profile pictures in group calls

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

@@ -37,11 +37,13 @@ import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.ComposeView
 import androidx.compose.ui.platform.ComposeView
 import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
 import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.viewmodel.compose.viewModel
 import androidx.lifecycle.viewmodel.compose.viewModel
 import ch.threema.app.R
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.compose.common.HintText
 import ch.threema.app.compose.edithistory.EditHistoryList
 import ch.threema.app.compose.edithistory.EditHistoryList
 import ch.threema.app.compose.edithistory.EditHistoryViewModel
 import ch.threema.app.compose.edithistory.EditHistoryViewModel
 import ch.threema.app.compose.message.CompleteMessageBubble
 import ch.threema.app.compose.message.CompleteMessageBubble
@@ -77,7 +79,6 @@ class MessageDetailsActivity : ThreemaToolbarActivity(), DialogClickListener {
         MessageDetailsViewModel.provideFactory(
         MessageDetailsViewModel.provideFactory(
             IntentDataUtil.getAbstractMessageId(intent),
             IntentDataUtil.getAbstractMessageId(intent),
             IntentDataUtil.getAbstractMessageType(intent),
             IntentDataUtil.getAbstractMessageType(intent),
-            serviceManager.userService.identity,
         )
         )
     }
     }
 
 
@@ -170,7 +171,6 @@ class MessageDetailsActivity : ThreemaToolbarActivity(), DialogClickListener {
     }
     }
 
 
     private fun initScreenContent() {
     private fun initScreenContent() {
-
         val editHistoryComposeView = findViewById<ComposeView>(R.id.message_details_compose_view)
         val editHistoryComposeView = findViewById<ComposeView>(R.id.message_details_compose_view)
         editHistoryComposeView.setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed)
         editHistoryComposeView.setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed)
 
 
@@ -207,6 +207,12 @@ class MessageDetailsActivity : ThreemaToolbarActivity(), DialogClickListener {
                                     isTextSelectable = true,
                                     isTextSelectable = true,
                                     textSelectionCallback = textSelectionCallback
                                     textSelectionCallback = textSelectionCallback
                                 )
                                 )
+                                if (uiState.hasReactions) {
+                                    Spacer(modifier = Modifier.height(2.dp))
+                                    HintText(
+                                        text = stringResource(R.string.emoji_reactions_message_details_hint),
+                                    )
+                                }
                             }
                             }
                             else -> Unit
                             else -> Unit
                         }
                         }

+ 19 - 9
app/src/main/java/ch/threema/app/activities/MessageDetailsViewModel.kt

@@ -31,6 +31,7 @@ import ch.threema.app.ThreemaApplication
 import ch.threema.app.services.MessageService
 import ch.threema.app.services.MessageService
 import ch.threema.app.utils.QuoteUtil
 import ch.threema.app.utils.QuoteUtil
 import ch.threema.app.utils.StateBitmapUtil
 import ch.threema.app.utils.StateBitmapUtil
+import ch.threema.data.repositories.EmojiReactionsRepository
 import ch.threema.domain.protocol.csp.messages.fs.ForwardSecurityMode
 import ch.threema.domain.protocol.csp.messages.fs.ForwardSecurityMode
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.DistributionListMessageModel
 import ch.threema.storage.models.DistributionListMessageModel
@@ -45,7 +46,7 @@ import java.util.Date
 class MessageDetailsViewModel(
 class MessageDetailsViewModel(
     savedStateHandle: SavedStateHandle,
     savedStateHandle: SavedStateHandle,
     messageService: MessageService,
     messageService: MessageService,
-    private val myIdentity: String,
+    private val emojiReactionsRepository: EmojiReactionsRepository,
 ) : StateFlowViewModel() {
 ) : StateFlowViewModel() {
 
 
     companion object {
     companion object {
@@ -56,16 +57,16 @@ class MessageDetailsViewModel(
         fun provideFactory(
         fun provideFactory(
             messageId: Int,
             messageId: Int,
             messageType: String?,
             messageType: String?,
-            myIdentity: String,
         ) = viewModelFactory {
         ) = viewModelFactory {
             initializer {
             initializer {
+                val serviceManager = ThreemaApplication.requireServiceManager()
                 MessageDetailsViewModel(
                 MessageDetailsViewModel(
                     this.createSavedStateHandle().apply {
                     this.createSavedStateHandle().apply {
                         set(MESSAGE_ID, messageId)
                         set(MESSAGE_ID, messageId)
                         set(MESSAGE_TYPE, messageType)
                         set(MESSAGE_TYPE, messageType)
                     },
                     },
-                    ThreemaApplication.requireServiceManager().messageService,
-                    myIdentity,
+                    serviceManager.messageService,
+                    serviceManager.modelRepositories.emojiReaction,
                 )
                 )
             }
             }
         }
         }
@@ -76,13 +77,21 @@ class MessageDetailsViewModel(
         val messageType = checkNotNull(savedStateHandle[MESSAGE_TYPE]) as String
         val messageType = checkNotNull(savedStateHandle[MESSAGE_TYPE]) as String
         val message = messageService.getMessageModelFromId(messageId, messageType)
         val message = messageService.getMessageModelFromId(messageId, messageType)
         MutableStateFlow(
         MutableStateFlow(
-            ChatMessageDetailsUiState(message.toUiModel(myIdentity), true)
+            ChatMessageDetailsUiState(
+                message = message.toUiModel(),
+                hasReactions = message.hasReactions(),
+                shouldMarkupText = true,
+            )
         )
         )
     }
     }
-    val uiState: StateFlow<ChatMessageDetailsUiState> = _uiState.stateInViewModel(initialValue = _uiState.value)
+    val uiState: StateFlow<ChatMessageDetailsUiState> =
+        _uiState.stateInViewModel(initialValue = _uiState.value)
+
+    private fun AbstractMessageModel.hasReactions(): Boolean =
+        emojiReactionsRepository.safeGetReactionsByMessage(this).isNotEmpty()
 
 
     fun refreshMessage(updatedMessage: AbstractMessageModel) {
     fun refreshMessage(updatedMessage: AbstractMessageModel) {
-        _uiState.update { it.copy(message = updatedMessage.toUiModel(myIdentity)) }
+        _uiState.update { it.copy(message = updatedMessage.toUiModel()) }
     }
     }
 
 
     fun markupText(value: Boolean) {
     fun markupText(value: Boolean) {
@@ -92,7 +101,8 @@ class MessageDetailsViewModel(
 
 
 data class ChatMessageDetailsUiState(
 data class ChatMessageDetailsUiState(
     val message: MessageUiModel,
     val message: MessageUiModel,
-    val shouldMarkupText: Boolean
+    val hasReactions: Boolean,
+    val shouldMarkupText: Boolean,
 )
 )
 
 
 // TODO(ANDR-3195): Move MessageModel mappings from ChatMessageDetailsViewModel to data models
 // TODO(ANDR-3195): Move MessageModel mappings from ChatMessageDetailsViewModel to data models
@@ -146,7 +156,7 @@ data class MessageDetailsUiModel(
     }
     }
 }
 }
 
 
-fun AbstractMessageModel.toUiModel(myIdentity: String) = MessageUiModel(
+fun AbstractMessageModel.toUiModel() = MessageUiModel(
     uid = this.uid,
     uid = this.uid,
     text = QuoteUtil.getMessageBody(this, false) ?: "",
     text = QuoteUtil.getMessageBody(this, false) ?: "",
     createdAt = this.createdAt,
     createdAt = this.createdAt,

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

@@ -188,18 +188,18 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     private final List<Integer> tabs = new ArrayList<>(NUM_FRAGMENTS);
     private final List<Integer> tabs = new ArrayList<>(NUM_FRAGMENTS);
     private boolean isInternallyForwardingMediaFiles = false;
     private boolean isInternallyForwardingMediaFiles = false;
 
 
-	private GroupService groupService;
-	private ContactService contactService;
-	private ConversationService conversationService;
-	private DistributionListService distributionListService;
-	private MessageService messageService;
-	private FileService fileService;
-	private UserService userService;
-	private APIConnector apiConnector;
-	private ContactModelRepository contactModelRepository;
-
-	@NonNull
-	private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
+    private GroupService groupService;
+    private ContactService contactService;
+    private ConversationService conversationService;
+    private DistributionListService distributionListService;
+    private MessageService messageService;
+    private FileService fileService;
+    private UserService userService;
+    private APIConnector apiConnector;
+    private ContactModelRepository contactModelRepository;
+
+    @NonNull
+    private final LazyProperty<BackgroundExecutor> backgroundExecutor = new LazyProperty<>(BackgroundExecutor::new);
 
 
     private final Runnable copyExternalFilesRunnable = new Runnable() {
     private final Runnable copyExternalFilesRunnable = new Runnable() {
         @Override
         @Override
@@ -853,35 +853,35 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
         if (contactModel == null) {
         if (contactModel == null) {
             GenericProgressDialog.newInstance(R.string.creating_contact, R.string.please_wait).show(getSupportFragmentManager(), "pro");
             GenericProgressDialog.newInstance(R.string.creating_contact, R.string.please_wait).show(getSupportFragmentManager(), "pro");
 
 
-			backgroundExecutor.get().execute(
-				new BasicAddOrUpdateContactBackgroundTask(
-					identity,
-					ContactModel.AcquaintanceLevel.DIRECT,
-					userService.getIdentity(),
-					apiConnector,
-					contactModelRepository,
-					AddContactRestrictionPolicy.CHECK,
-					RecipientListBaseActivity.this,
-					null
-				) {
-					@Override
-					public void onFinished(ContactResult result) {
-						DialogUtil.dismissDialog(getSupportFragmentManager(), "pro", true);
-
-						ContactModel newContactModel = contactService.getByIdentity(identity);
-						if (newContactModel == null) {
-							View rootView = getWindow().getDecorView().findViewById(android.R.id.content);
-							Snackbar.make(rootView, R.string.contact_not_found, Snackbar.LENGTH_LONG).show();
-						} else {
-							prepareComposeIntent(new ArrayList<>(Collections.singletonList(newContactModel)), false);
-						}
-					}
-				}
-			);
-		} else {
-			prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)), false);
-		}
-	}
+            backgroundExecutor.get().execute(
+                new BasicAddOrUpdateContactBackgroundTask(
+                    identity,
+                    ContactModel.AcquaintanceLevel.DIRECT,
+                    userService.getIdentity(),
+                    apiConnector,
+                    contactModelRepository,
+                    AddContactRestrictionPolicy.CHECK,
+                    RecipientListBaseActivity.this,
+                    null
+                ) {
+                    @Override
+                    public void onFinished(ContactResult result) {
+                        DialogUtil.dismissDialog(getSupportFragmentManager(), "pro", true);
+
+                        ContactModel newContactModel = contactService.getByIdentity(identity);
+                        if (newContactModel == null) {
+                            View rootView = getWindow().getDecorView().findViewById(android.R.id.content);
+                            Snackbar.make(rootView, R.string.contact_not_found, Snackbar.LENGTH_LONG).show();
+                        } else {
+                            prepareComposeIntent(new ArrayList<>(Collections.singletonList(newContactModel)), false);
+                        }
+                    }
+                }
+            );
+        } else {
+            prepareComposeIntent(new ArrayList<>(Collections.singletonList(contactModel)), false);
+        }
+    }
 
 
     @Override
     @Override
     public boolean onCreateOptionsMenu(Menu menu) {
     public boolean onCreateOptionsMenu(Menu menu) {
@@ -1178,6 +1178,10 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
                                     if (originalMessageModel.getFileData().getRenderingType() == FileData.RENDERING_DEFAULT) {
                                     if (originalMessageModel.getFileData().getRenderingType() == FileData.RENDERING_DEFAULT) {
                                         mediaItem.setVideoSize(PreferenceService.VideoSize_SEND_AS_FILE);
                                         mediaItem.setVideoSize(PreferenceService.VideoSize_SEND_AS_FILE);
                                     }
                                     }
+                                } else if (originalMessageModel.getMessageContentsType() == MessageContentsType.VOICE_MESSAGE) {
+                                    mediaItem.setDurationMs(
+                                        originalMessageModel.getFileData().getDurationMs()
+                                    );
                                 }
                                 }
 
 
                                 mappedMediaItems.add(mediaItem);
                                 mappedMediaItems.add(mediaItem);
@@ -1594,7 +1598,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
         final Location location = new Location("");
         final Location location = new Location("");
         location.setLatitude(locationData.latitude);
         location.setLatitude(locationData.latitude);
         location.setLongitude(locationData.longitude);
         location.setLongitude(locationData.longitude);
-        location.setAccuracy((float)locationData.accuracyOrFallback);
+        location.setAccuracy((float) locationData.accuracyOrFallback);
         final @Nullable Poi poi = locationData.poi;
         final @Nullable Poi poi = locationData.poi;
         @Nullable String poiName = null;
         @Nullable String poiName = null;
         if (poi != null) {
         if (poi != null) {

+ 340 - 335
app/src/main/java/ch/threema/app/adapters/GroupCallParticipantsAdapter.kt

@@ -45,343 +45,348 @@ private val logger = LoggingUtil.getThreemaLogger("GroupCallParticipantsAdapter"
 
 
 @UiThread
 @UiThread
 class GroupCallParticipantsAdapter(
 class GroupCallParticipantsAdapter(
-	private val contactService: ContactService,
-	private val gutterPx: Int,
-	private val requestManager: RequestManager,
+    private val contactService: ContactService,
+    private val gutterPx: Int,
+    private val requestManager: RequestManager,
 ) : RecyclerView.Adapter<GroupCallParticipantsAdapter.GroupCallParticipantViewHolder>() {
 ) : RecyclerView.Adapter<GroupCallParticipantsAdapter.GroupCallParticipantViewHolder>() {
-	private val participants: MutableList<Participant> = mutableListOf()
-
-	private var localParticipantViewHolder: GroupCallParticipantViewHolder? = null
-	private val activeViewHolders: MutableSet<GroupCallParticipantViewHolder> = mutableSetOf()
-	private val viewHolders: MutableSet<GroupCallParticipantViewHolder> = mutableSetOf()
-
-	lateinit var eglBase: EglBase
-
-	var isPortrait = true
-		set(value) {
-			if (field != value) {
-				field = value
-				notifyItemRangeChanged(0, participants.size)
-			}
-		}
-	private val orientation: Orientation
-		get() = if (isPortrait) {
-			Orientation.PORTRAIT
-		} else {
-			Orientation.LANDSCAPE
-		}
-
-	private val frozenStateUpdates = CoroutineScope(Dispatchers.Main).launch {
-		delay(UPDATE_FROZEN_INTERVAL_MS * 4)
-
-		while (true) {
-			// Don't update frozen state for local participants or participants without active camera
-			// as it may be confusing if changes happen without user interaction
-			activeViewHolders.filter {
-				it.participant !is LocalParticipant && it.participant?.cameraActive ?: false
-			}.forEach {
-				it.videoView.updateFrozenState()
-			}
-			delay(UPDATE_FROZEN_INTERVAL_MS)
-		}
-	}
-
-	@UiThread
-	class GroupCallParticipantViewHolder(
-		eglBase: EglBase,
-		itemView: View,
-		val parent: ViewGroup
-	) : RecyclerView.ViewHolder(itemView) {
-
-		var isAttachedToWindow = false
-
-		val name: TextView = itemView.findViewById(R.id.participant_name)
-		val avatar: ImageView = itemView.findViewById(R.id.participant_avatar)
-		val info: ConstraintLayout = itemView.findViewById(R.id.participant_info)
-		var participant: Participant? = null
-		val videoView: ParticipantSurfaceViewRenderer = itemView.findViewById(R.id.video_view)
-
-		private val microphoneMuted: ImageView = itemView.findViewById(R.id.participant_microphone_muted)
-		private var subscribeCameraJob: Job? = null
-
-		private val eglBaseContext = eglBase.eglBaseContext
-
-		init {
-			videoView.setNumFramesNeeded(ENABLE_FRAMES_THRESHOLD, DISABLE_FRAMES_THRESHOLD)
-			videoView.setAvatarView(avatar)
-		}
-
-		private var detachSinkFn: DetachSinkFn? = null
-
-		@UiThread
-		internal fun updateCameraSubscription() {
-			participant?.let {
-				if (isAttachedToWindow && it.cameraActive) {
-					subscribeCamera()
-				} else {
-					unsubscribeCamera()
-				}
-			}
-		}
-
-		@UiThread
-		private fun subscribeCamera() {
-			cancelCameraSubscription()
-			logger.trace("Subscribe camera for participant={}", participant?.id)
-			participant?.let { participant ->
-				val subscribe = {
-					itemView.let {
-						it.post {
-							if (isAttachedToWindow) {
-								subscribeCamera(participant, it)
-							}
-						}
-					}
-				}
-				subscribeCameraJob = CoroutineScope(GroupCallThreadUtil.DISPATCHER).launch {
-					delay(CAMERA_SUBSCRIPTION_DELAY_MILLIS)
-					subscribe()
-				}
-			}
-		}
-
-		@UiThread
-		private fun subscribeCamera(participant: Participant, view: View) {
-			try {
-				videoView.init(eglBaseContext)
-				logger.debug("Subscribe camera with resolution {}x{}", view.width, view.height)
-				detachSinkFn = participant.subscribeCamera(videoView, view.width, view.height)
-				updateMirroring()
-				videoView.enableVideo()
-			} catch (e: RuntimeException) {
-				logger.error("Error subscribing camera", e)
-			}
-		}
-
-		@UiThread
-		private fun unsubscribeCamera() {
-			cancelCameraSubscription()
-			itemView.post {
-				participant?.unsubscribeCamera()
-				videoView.disableVideo()
-				detachSinkFn?.invoke()
-				detachSinkFn = null
-			}
-		}
-
-		@UiThread
-		fun updateMirroring() {
-			participant?.let {
-				itemView.post {
-					videoView.setMirror(it.mirrorRenderer)
-				}
-			}
-		}
-
-		@UiThread
-		fun updateCaptureState() {
-			logger.trace("UpdateCaptureState for {}", participant)
-			participant?.let {
-				itemView.post {
-					microphoneMuted.visibility = if (it.microphoneActive) {
-						View.GONE
-					} else {
-						View.VISIBLE
-					}
-				}
-
-				updateCameraSubscription()
-			}
-		}
-
-		@UiThread
-		internal fun cancelCameraSubscription() {
-			subscribeCameraJob = subscribeCameraJob?.let {
-				if (!it.isCompleted) {
-					val message = "Cancel camera subscription"
-					logger.trace(message)
-					it.cancel(message)
-				}
-				null
-			}
-		}
-	}
-
-	/**
-	 * Teardown the adapter when it will not be used anymore.
-	 *
-	 * This will release all video views and cancel pending camera subscriptions.
-	 */
-	@UiThread
-	fun teardown() {
-		viewHolders.forEach {
-			it.cancelCameraSubscription()
-			it.videoView.release()
-		}
-		frozenStateUpdates.cancel("releaseVideoViews")
-	}
-
-	@UiThread
-	fun setParticipants(participants: Set<Participant>) {
-		val remove = this.participants
-			.filter { it !in participants }
-
-		val add = participants.filter { it !in this.participants }
-
-		val previousCount = this.participants.size
-		val newCount = this.participants.size - remove.size + add.size
-
-		// Remove participants
-		remove
-			.forEach {
-				val index = this.participants.indexOf(it)
-				this.participants.removeAt(index)
-				notifyItemRemoved(index)
-			}
-
-		// Add participants
-		if (add.isNotEmpty()) {
-			logger.debug("Add {} new participants", add.size)
-			val firstNewPosition = this.participants.size
-			this.participants.addAll(add)
-			notifyItemRangeInserted(firstNewPosition, add.size)
-		}
-
-		updateViewHoldersDimensions(previousCount, newCount)
-	}
-
-	@UiThread
-	private fun updateViewHoldersDimensions(previousCount: Int, newCount: Int) {
-		logger.trace("### updateViewHoldersDimensions")
-		if (hasHeightChanged(previousCount, newCount)) {
-			logger.trace("Layout changed; update view holder heights.")
-			activeViewHolders.forEach {
-				it.itemView.layoutParams.height = getViewHeight(it.parent)
-				it.updateCameraSubscription()
-			}
-		}
-	}
-
-	@UiThread
-	fun updateMirroringForLocalParticipant() {
-		localParticipantViewHolder?.updateMirroring()
-	}
-
-	@UiThread
-	fun updateCaptureStates() {
-		activeViewHolders
-			.forEach { it.updateCaptureState() }
-	}
-
-	@UiThread
-	override fun onCreateViewHolder(
-		parent: ViewGroup,
-		viewType: Int
-	): GroupCallParticipantViewHolder {
-		val view = LayoutInflater.from(parent.context).inflate(R.layout.item_group_call_participant_list, parent, false)
-		return GroupCallParticipantViewHolder(eglBase, view, parent).also {
-			viewHolders.add(it)
-		}
-	}
-
-	@UiThread
-	override fun onViewAttachedToWindow(holder: GroupCallParticipantViewHolder) {
-		holder.isAttachedToWindow = true
-		holder.updateCaptureState()
-	}
-
-	@UiThread
-	override fun onViewDetachedFromWindow(holder: GroupCallParticipantViewHolder) {
-		holder.isAttachedToWindow = false
-		holder.updateCaptureState()
-	}
-
-	@UiThread
-	override fun onBindViewHolder(holder: GroupCallParticipantViewHolder, position: Int) {
-		val participant = participants[position]
-
-		holder.itemView.layoutParams = FrameLayout.LayoutParams(
-			ViewGroup.LayoutParams.MATCH_PARENT,
-			getViewHeight(holder.parent)
-		)
-
-		holder.participant = participant
-
-		if (participant is LocalParticipant) {
-			localParticipantViewHolder = holder
-		}
-		activeViewHolders.add(holder)
-
-		holder.name.text = participant.name
-
-		if (participant is NormalParticipant) {
-			contactService.loadAvatarIntoImage(
-                participant.contactModel,
-                holder.avatar,
-                AVATAR_OPTIONS,
-                requestManager
-            )
-		} else {
-			logger.warn("Unknown group call participant type bound: {}", participant.type)
-			holder.avatar.setImageResource(R.drawable.ic_person_outline)
-		}
-	}
-
-	@UiThread
-	override fun onViewRecycled(holder: GroupCallParticipantViewHolder) {
-		if (holder.participant is LocalParticipant) {
-			localParticipantViewHolder = null
-		}
-		activeViewHolders.remove(holder)
-	}
-
-	@UiThread
-	override fun getItemCount() = participants.size
-
-	@UiThread
-	private fun getViewHeight(parent: ViewGroup): Int {
-		val rows = getRowCount(participants.size, isPortrait)
-		val totalGutterPx = (rows + 1) * gutterPx
-		// `+ 1` to compensate "lost" pixels due to integer arithmetic
-		return (parent.measuredHeight - totalGutterPx) / rows + 1
-	}
-
-	@UiThread
-	private fun hasHeightChanged(previousCount: Int, newCount: Int): Boolean {
-		return STABLE_HEIGHT_RANGES[orientation]?.any { previousCount in it && newCount !in it } ?: true
-	}
-
-	@UiThread
-	private companion object {
-		val AVATAR_OPTIONS: AvatarOptions = AvatarOptions.Builder()
-			.setHighRes(true)
-			.toOptions()
-
-		private const val CAMERA_SUBSCRIPTION_DELAY_MILLIS = 800L
-
-		private const val ENABLE_FRAMES_THRESHOLD = 15
-		private const val DISABLE_FRAMES_THRESHOLD = 5
-		private const val UPDATE_FROZEN_INTERVAL_MS: Long = 5000
-
-		/**
-		 * A stable height range is - depending on orientation - the range of participants in a call
-		 * that won't affect the view holders height. If the number of participants changes from within one
-		 * range to another, the height of the view holder will change.
-		 */
-		val STABLE_HEIGHT_RANGES: Map<Orientation, List<IntRange>> = mapOf(
-			Orientation.LANDSCAPE to listOf(0..2, 3..Int.MAX_VALUE),
-			Orientation.PORTRAIT to listOf(0..1, 2..4, 5..Int.MAX_VALUE)
-		)
-
-		fun getRowCount(participants: Int, isPortrait: Boolean) = when {
-			participants in 0..1 -> 1
-			participants == 2 && !isPortrait -> 1
-			participants in 2..4 || !isPortrait -> 2
-			else -> 3
-		}
-	}
+    private val participants: MutableList<Participant> = mutableListOf()
+
+    private var localParticipantViewHolder: GroupCallParticipantViewHolder? = null
+    private val activeViewHolders: MutableSet<GroupCallParticipantViewHolder> = mutableSetOf()
+    private val viewHolders: MutableSet<GroupCallParticipantViewHolder> = mutableSetOf()
+
+    lateinit var eglBase: EglBase
+
+    var isPortrait = true
+        set(value) {
+            if (field != value) {
+                field = value
+                notifyItemRangeChanged(0, participants.size)
+            }
+        }
+    private val orientation: Orientation
+        get() = if (isPortrait) {
+            Orientation.PORTRAIT
+        } else {
+            Orientation.LANDSCAPE
+        }
+
+    private val frozenStateUpdates = CoroutineScope(Dispatchers.Main).launch {
+        delay(UPDATE_FROZEN_INTERVAL_MS * 4)
+
+        while (true) {
+            // Don't update frozen state for local participants or participants without active camera
+            // as it may be confusing if changes happen without user interaction
+            activeViewHolders.filter {
+                it.participant !is LocalParticipant && it.participant?.cameraActive ?: false
+            }.forEach {
+                it.videoView.updateFrozenState()
+            }
+            delay(UPDATE_FROZEN_INTERVAL_MS)
+        }
+    }
+
+    @UiThread
+    class GroupCallParticipantViewHolder(
+        eglBase: EglBase,
+        itemView: View,
+        val parent: ViewGroup
+    ) : RecyclerView.ViewHolder(itemView) {
+
+        var isAttachedToWindow = false
+
+        val name: TextView = itemView.findViewById(R.id.participant_name)
+        val avatar: ImageView = itemView.findViewById(R.id.participant_avatar)
+        val info: ConstraintLayout = itemView.findViewById(R.id.participant_info)
+        var participant: Participant? = null
+        val videoView: ParticipantSurfaceViewRenderer = itemView.findViewById(R.id.video_view)
+
+        private val microphoneMuted: ImageView = itemView.findViewById(R.id.participant_microphone_muted)
+        private var subscribeCameraJob: Job? = null
+
+        private val eglBaseContext = eglBase.eglBaseContext
+
+        init {
+            videoView.setNumFramesNeeded(ENABLE_FRAMES_THRESHOLD, DISABLE_FRAMES_THRESHOLD)
+            videoView.setAvatarView(avatar)
+        }
+
+        private var detachSinkFn: DetachSinkFn? = null
+
+        @UiThread
+        internal fun updateCameraSubscription() {
+            participant?.let {
+                if (isAttachedToWindow && it.cameraActive) {
+                    subscribeCamera()
+                } else {
+                    unsubscribeCamera()
+                }
+            }
+        }
+
+        @UiThread
+        private fun subscribeCamera() {
+            cancelCameraSubscription()
+            logger.trace("Subscribe camera for participant={}", participant?.id)
+            participant?.let { participant ->
+                val subscribe = {
+                    itemView.let {
+                        it.post {
+                            if (isAttachedToWindow) {
+                                subscribeCamera(participant, it)
+                            }
+                        }
+                    }
+                }
+                subscribeCameraJob = CoroutineScope(GroupCallThreadUtil.DISPATCHER).launch {
+                    delay(CAMERA_SUBSCRIPTION_DELAY_MILLIS)
+                    subscribe()
+                }
+            }
+        }
+
+        @UiThread
+        private fun subscribeCamera(participant: Participant, view: View) {
+            try {
+                videoView.init(eglBaseContext)
+                logger.debug("Subscribe camera with resolution {}x{}", view.width, view.height)
+                detachSinkFn = participant.subscribeCamera(videoView, view.width, view.height)
+                updateMirroring()
+                videoView.enableVideo()
+            } catch (e: RuntimeException) {
+                logger.error("Error subscribing camera", e)
+            }
+        }
+
+        @UiThread
+        private fun unsubscribeCamera() {
+            cancelCameraSubscription()
+            itemView.post {
+                participant?.unsubscribeCamera()
+                videoView.disableVideo()
+                detachSinkFn?.invoke()
+                detachSinkFn = null
+            }
+        }
+
+        @UiThread
+        fun updateMirroring() {
+            participant?.let {
+                itemView.post {
+                    videoView.setMirror(it.mirrorRenderer)
+                }
+            }
+        }
+
+        @UiThread
+        fun updateCaptureState() {
+            logger.trace("UpdateCaptureState for {}", participant)
+            participant?.let {
+                itemView.post {
+                    microphoneMuted.visibility = if (it.microphoneActive) {
+                        View.GONE
+                    } else {
+                        View.VISIBLE
+                    }
+                }
+
+                updateCameraSubscription()
+            }
+        }
+
+        @UiThread
+        internal fun cancelCameraSubscription() {
+            subscribeCameraJob = subscribeCameraJob?.let {
+                if (!it.isCompleted) {
+                    val message = "Cancel camera subscription"
+                    logger.trace(message)
+                    it.cancel(message)
+                }
+                null
+            }
+        }
+    }
+
+    /**
+     * Teardown the adapter when it will not be used anymore.
+     *
+     * This will release all video views and cancel pending camera subscriptions.
+     */
+    @UiThread
+    fun teardown() {
+        viewHolders.forEach {
+            it.cancelCameraSubscription()
+            it.videoView.release()
+        }
+        frozenStateUpdates.cancel("releaseVideoViews")
+    }
+
+    @UiThread
+    fun setParticipants(updatedParticipants: Set<Participant>) {
+        val removedParticipants = this.participants.filter { it !in updatedParticipants }
+        val newlyAddedParticipants = updatedParticipants.filter { it !in this.participants }
+
+        val previousCount = this.participants.size
+        val newCount = updatedParticipants.size
+
+        val needsNewLayout = hasItemHeightChanged(previousCount, newCount)
+
+        if (needsNewLayout) {
+            this.participants.apply {
+                clear()
+                addAll(updatedParticipants)
+            }
+            notifyDataSetChanged()
+        } else {
+
+            // Remove participants
+            removedParticipants.forEach { participant ->
+                val index = this.participants.indexOf(participant)
+                this.participants.removeAt(index)
+                notifyItemRemoved(index)
+            }
+
+            // Add participants
+            if (newlyAddedParticipants.isNotEmpty()) {
+                logger.debug("Add {} new participants", newlyAddedParticipants.size)
+                val firstNewPosition = this.participants.size
+                this.participants.addAll(newlyAddedParticipants)
+                notifyItemRangeInserted(firstNewPosition, newlyAddedParticipants.size)
+            }
+        }
+    }
+
+    @UiThread
+    fun updateMirroringForLocalParticipant() {
+        localParticipantViewHolder?.updateMirroring()
+    }
+
+    @UiThread
+    fun updateCaptureStates() {
+        activeViewHolders.forEach(GroupCallParticipantViewHolder::updateCaptureState)
+    }
+
+    @UiThread
+    override fun onCreateViewHolder(
+        parent: ViewGroup,
+        viewType: Int
+    ): GroupCallParticipantViewHolder {
+        val view = LayoutInflater.from(parent.context).inflate(
+            /* resource = */ R.layout.item_group_call_participant_list,
+            /* root = */ parent,
+            /* attachToRoot = */ false
+        )
+        return GroupCallParticipantViewHolder(eglBase, view, parent).also(viewHolders::add)
+    }
+
+    @UiThread
+    override fun onViewAttachedToWindow(holder: GroupCallParticipantViewHolder) {
+        holder.isAttachedToWindow = true
+        holder.updateCaptureState()
+    }
+
+    @UiThread
+    override fun onViewDetachedFromWindow(holder: GroupCallParticipantViewHolder) {
+        holder.isAttachedToWindow = false
+        holder.updateCaptureState()
+    }
+
+    @UiThread
+    override fun onBindViewHolder(holder: GroupCallParticipantViewHolder, position: Int) {
+
+        val participant = participants[position]
+
+        val itemHeightPx = getViewHeight(holder.parent)
+        holder.avatar.layoutParams = FrameLayout.LayoutParams(
+            ViewGroup.LayoutParams.MATCH_PARENT,
+            itemHeightPx
+        )
+        holder.itemView.layoutParams = FrameLayout.LayoutParams(
+            ViewGroup.LayoutParams.MATCH_PARENT,
+            itemHeightPx
+        )
+
+        holder.participant = participant
+
+        if (participant is LocalParticipant) {
+            localParticipantViewHolder = holder
+        }
+
+        activeViewHolders.add(holder)
+
+        holder.name.text = participant.name
+
+        holder.avatar.post {
+            if (participant is NormalParticipant) {
+                contactService.loadAvatarIntoImage(
+                    participant.contactModel,
+                    holder.avatar,
+                    AVATAR_OPTIONS,
+                    requestManager
+                )
+            } else {
+                logger.warn("Unknown group call participant type bound: {}", participant.type)
+                holder.avatar.setImageResource(R.drawable.ic_person_outline)
+            }
+        }
+    }
+
+    @UiThread
+    override fun onViewRecycled(holder: GroupCallParticipantViewHolder) {
+        if (holder.participant is LocalParticipant) {
+            localParticipantViewHolder = null
+        }
+        activeViewHolders.remove(holder)
+    }
+
+    @UiThread
+    override fun getItemCount() = participants.size
+
+    @UiThread
+    private fun getViewHeight(parent: ViewGroup): Int {
+        val rows = getRowCount(participants.size, isPortrait)
+        val totalGutterPx = (rows + 1) * gutterPx
+        // `+ 1` to compensate "lost" pixels due to integer arithmetic
+        return (parent.measuredHeight - totalGutterPx) / rows + 1
+    }
+
+    @UiThread
+    private fun hasItemHeightChanged(previousCount: Int, newCount: Int): Boolean {
+        return STABLE_HEIGHT_RANGES[orientation]?.any { previousCount in it && newCount !in it } ?: true
+    }
+
+    @UiThread
+    private companion object {
+        val AVATAR_OPTIONS: AvatarOptions = AvatarOptions.Builder()
+            .setReturnPolicy(AvatarOptions.DefaultAvatarPolicy.DEFAULT_FALLBACK)
+            .setHighRes(true)
+            .toOptions()
+
+        private const val CAMERA_SUBSCRIPTION_DELAY_MILLIS = 800L
+
+        private const val ENABLE_FRAMES_THRESHOLD = 15
+        private const val DISABLE_FRAMES_THRESHOLD = 5
+        private const val UPDATE_FROZEN_INTERVAL_MS: Long = 5000
+
+        /**
+         * A stable height range is - depending on orientation - the range of participants in a call
+         * that won't affect the view holders height. If the number of participants changes from within one
+         * range to another, the height of the view holder will change.
+         */
+        val STABLE_HEIGHT_RANGES: Map<Orientation, List<IntRange>> = mapOf(
+            Orientation.LANDSCAPE to listOf(0..2, 3..Int.MAX_VALUE),
+            Orientation.PORTRAIT to listOf(0..1, 2..4, 5..Int.MAX_VALUE)
+        )
+
+        fun getRowCount(participants: Int, isPortrait: Boolean) = when {
+            participants in 0..1 -> 1
+            participants == 2 && !isPortrait -> 1
+            participants in 2..4 || !isPortrait -> 2
+            else -> 3
+        }
+    }
 }
 }
 
 
 private enum class Orientation {
 private enum class Orientation {
-	LANDSCAPE, PORTRAIT
+    LANDSCAPE, PORTRAIT
 }
 }

+ 13 - 3
app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateWorkContactBackgroundTask.kt

@@ -153,19 +153,29 @@ open class AddOrUpdateWorkContactBackgroundTask(
     }
     }
 
 
     private fun updateContact(contactModel: ContactModel) {
     private fun updateContact(contactModel: ContactModel) {
-        val data = contactModel.data.value ?: run {
+        val currentContactModelData: ContactModelData = contactModel.data.value ?: run {
             logger.error("Contact has already been deleted")
             logger.error("Contact has already been deleted")
             return
             return
         }
         }
 
 
         // Update first and last name if the contact is not synchronized
         // Update first and last name if the contact is not synchronized
         if (
         if (
-            data.androidContactLookupKey == null
+            currentContactModelData.androidContactLookupKey == null
             && (workContact.firstName != null || workContact.lastName != null)
             && (workContact.firstName != null || workContact.lastName != null)
         ) {
         ) {
             contactModel.setNameFromLocal(workContact.firstName ?: "", workContact.lastName ?: "")
             contactModel.setNameFromLocal(workContact.firstName ?: "", workContact.lastName ?: "")
         }
         }
 
 
+        // Update jobTitle if it changed
+        if (currentContactModelData.jobTitle != workContact.jobTitle) {
+            contactModel.setJobTitleFromLocal(workContact.jobTitle)
+        }
+
+        // Update department if it changed
+        if (currentContactModelData.department != workContact.department) {
+            contactModel.setDepartmentFromLocal(workContact.department)
+        }
+
         // Update work verification level
         // Update work verification level
         contactModel.setWorkVerificationLevelFromLocal(WorkVerificationLevel.WORK_SUBSCRIPTION_VERIFIED)
         contactModel.setWorkVerificationLevelFromLocal(WorkVerificationLevel.WORK_SUBSCRIPTION_VERIFIED)
 
 
@@ -173,7 +183,7 @@ open class AddOrUpdateWorkContactBackgroundTask(
         contactModel.setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT)
         contactModel.setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT)
 
 
         // Update verification level (except it would be a downgrade)
         // Update verification level (except it would be a downgrade)
-        if (data.verificationLevel == VerificationLevel.UNVERIFIED) {
+        if (currentContactModelData.verificationLevel == VerificationLevel.UNVERIFIED) {
             contactModel.setVerificationLevelFromLocal(VerificationLevel.SERVER_VERIFIED)
             contactModel.setVerificationLevelFromLocal(VerificationLevel.SERVER_VERIFIED)
         }
         }
     }
     }

+ 73 - 0
app/src/main/java/ch/threema/app/compose/common/HintText.kt

@@ -0,0 +1,73 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2025-2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.compose.common
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import ch.threema.app.compose.theme.AppTypography
+import ch.threema.app.compose.theme.ThreemaTheme
+
+@Composable
+fun HintText(text: String) {
+    Row(
+        modifier = Modifier.padding(
+            horizontal = 2.dp,
+            vertical = 4.dp,
+        ),
+        horizontalArrangement = Arrangement.spacedBy(4.dp),
+        verticalAlignment = Alignment.Top,
+    ) {
+        Icon(
+            modifier = Modifier.size(20.dp),
+            imageVector = Icons.Outlined.Info,
+            contentDescription = null,
+            tint = MaterialTheme.colorScheme.onSurface,
+        )
+       ThemedText(
+           modifier = Modifier.weight(1f),
+           text = text,
+           style = AppTypography.bodySmall,
+       )
+    }
+}
+
+@Preview
+@Composable
+private fun HintText_Preview() {
+    ThreemaTheme {
+        HintText(
+            text = "This is an informative text, it may span multiple lines if it " +
+                "is long enough.",
+        )
+    }
+}

+ 1 - 2
app/src/main/java/ch/threema/app/compose/common/interop/ComposeJavaBridge.kt

@@ -50,9 +50,8 @@ object ComposeJavaBridge {
     fun setEditModeMessageBubble(
     fun setEditModeMessageBubble(
         composeView: ComposeView,
         composeView: ComposeView,
         model: AbstractMessageModel,
         model: AbstractMessageModel,
-        myIdentity: String
     ) {
     ) {
-        val messageBubbleUiState = model.toUiModel(myIdentity)
+        val messageBubbleUiState = model.toUiModel()
         composeView.setContent {
         composeView.setContent {
             ThreemaTheme {
             ThreemaTheme {
                 MessageBubble(
                 MessageBubble(

+ 53 - 27
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewActivity.kt

@@ -22,15 +22,16 @@
 package ch.threema.app.emojireactions
 package ch.threema.app.emojireactions
 
 
 import android.annotation.SuppressLint
 import android.annotation.SuppressLint
-import android.content.res.Configuration.ORIENTATION_LANDSCAPE
+import android.graphics.Color
 import android.graphics.Typeface
 import android.graphics.Typeface
 import android.os.Build
 import android.os.Build
 import android.os.Bundle
 import android.os.Bundle
 import android.view.View
 import android.view.View
-import android.view.ViewTreeObserver
+import android.view.WindowInsets
+import android.view.WindowMetrics
 import android.widget.TextView
 import android.widget.TextView
-import android.graphics.Color
 import androidx.activity.viewModels
 import androidx.activity.viewModels
+import androidx.annotation.RequiresApi
 import androidx.constraintlayout.widget.ConstraintLayout
 import androidx.constraintlayout.widget.ConstraintLayout
 import androidx.coordinatorlayout.widget.CoordinatorLayout
 import androidx.coordinatorlayout.widget.CoordinatorLayout
 import androidx.core.content.ContextCompat
 import androidx.core.content.ContextCompat
@@ -50,6 +51,7 @@ import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.IntentDataUtil
 import ch.threema.app.utils.IntentDataUtil
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.data.models.EmojiReactionData
 import ch.threema.data.models.EmojiReactionData
+import ch.threema.data.repositories.EmojiReactionsRepository.ReactionMessageIdentifier
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.AbstractMessageModel
 import com.google.android.material.bottomsheet.BottomSheetBehavior
 import com.google.android.material.bottomsheet.BottomSheetBehavior
 import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
 import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
@@ -59,7 +61,6 @@ import com.google.android.material.tabs.TabLayout
 import com.google.android.material.tabs.TabLayoutMediator
 import com.google.android.material.tabs.TabLayoutMediator
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import org.slf4j.Logger
 import org.slf4j.Logger
-import kotlin.math.roundToInt
 
 
 private val logger: Logger = LoggingUtil.getThreemaLogger("EmojiReactionOverviewActivity")
 private val logger: Logger = LoggingUtil.getThreemaLogger("EmojiReactionOverviewActivity")
 
 
@@ -94,6 +95,7 @@ class EmojiReactionsOverviewActivity : ThreemaToolbarActivity() {
 
 
     @SuppressLint("ClickableViewAccessibility")
     @SuppressLint("ClickableViewAccessibility")
     override fun initActivity(savedInstanceState: Bundle?): Boolean {
     override fun initActivity(savedInstanceState: Bundle?): Boolean {
+
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
             overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0)
             overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0)
         }
         }
@@ -104,23 +106,22 @@ class EmojiReactionsOverviewActivity : ThreemaToolbarActivity() {
 
 
         messageModel = IntentDataUtil.getAbstractMessageModel(intent, messageService)
         messageModel = IntentDataUtil.getAbstractMessageModel(intent, messageService)
         messageModel?.let { message ->
         messageModel?.let { message ->
+
+            val reactionMessageIdentifier: ReactionMessageIdentifier = ReactionMessageIdentifier.fromMessageModel(message) ?: run {
+                logger.error("Closing emoji overview for unsupported message model type of {}", message::class.java.simpleName)
+                return false
+            }
+
             initialItem = intent.getStringExtra(EXTRA_INITIAL_EMOJI)
             initialItem = intent.getStringExtra(EXTRA_INITIAL_EMOJI)
 
 
             val emojiReactionsViewModel: EmojiReactionsViewModel by viewModels {
             val emojiReactionsViewModel: EmojiReactionsViewModel by viewModels {
                 EmojiReactionsViewModel.provideFactory(
                 EmojiReactionsViewModel.provideFactory(
-                    emojiReactionsRepository,
-                    messageService,
-                    messageId = message.uid
+                    emojiReactionsRepository = emojiReactionsRepository,
+                    messageService = messageService,
+                    reactionMessageIdentifier = reactionMessageIdentifier
                 )
                 )
             }
             }
 
 
-            lifecycleScope.launch {
-                repeatOnLifecycle(Lifecycle.State.STARTED) {
-                    emojiReactionsViewModel.emojiReactionsUiState
-                        .collect { uiState -> onUiStateChanged(uiState, emojiReactionsViewModel, message) }
-                }
-            }
-
             if (!super.initActivity(savedInstanceState)) {
             if (!super.initActivity(savedInstanceState)) {
                 finish()
                 finish()
                 return false
                 return false
@@ -131,6 +132,14 @@ class EmojiReactionsOverviewActivity : ThreemaToolbarActivity() {
 
 
             setupParentLayout()
             setupParentLayout()
             setupViewPager()
             setupViewPager()
+
+            lifecycleScope.launch {
+                repeatOnLifecycle(Lifecycle.State.STARTED) {
+                    emojiReactionsViewModel.emojiReactionsUiState
+                        .collect { uiState -> onUiStateChanged(uiState, emojiReactionsViewModel, message) }
+                }
+            }
+
         } ?: run {
         } ?: run {
             logger.error("No message model found")
             logger.error("No message model found")
             finish()
             finish()
@@ -232,18 +241,12 @@ class EmojiReactionsOverviewActivity : ThreemaToolbarActivity() {
                 BottomSheetBehavior.STATE_HIDDEN
                 BottomSheetBehavior.STATE_HIDDEN
             )
             )
         }
         }
-        parentLayout.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
-            override fun onGlobalLayout() {
-                parentLayout.viewTreeObserver.removeOnGlobalLayoutListener(this)
-                if (resources.configuration.orientation == ORIENTATION_LANDSCAPE) {
-                    bottomSheetBehavior.peekHeight = parentLayout.height / 2
-                } else {
-                    bottomSheetBehavior.peekHeight = (parentLayout.height * 0.4).roundToInt()
-                }
-                bottomSheetBehavior.maxHeight = parentLayout.height - ConfigUtils.getStatusBarHeight(this@EmojiReactionsOverviewActivity)
-                bottomSheetBehavior.state = STATE_COLLAPSED
-            }
-        })
+
+        determineAvailableDrawingHeight { availableHeight ->
+            bottomSheetBehavior.maxHeight = availableHeight
+            bottomSheetBehavior.peekHeight = (availableHeight * 0.4).toInt()
+            bottomSheetBehavior.state = STATE_COLLAPSED
+        }
     }
     }
 
 
     private fun setupViewPager() {
     private fun setupViewPager() {
@@ -306,7 +309,7 @@ class EmojiReactionsOverviewActivity : ThreemaToolbarActivity() {
     /**
     /**
      * Setup bottom sheet and set callback for state changes
      * Setup bottom sheet and set callback for state changes
      */
      */
-    private fun setupBottomSheet(bottomSheetLayout: ConstraintLayout) : BottomSheetBehavior<ConstraintLayout> {
+    private fun setupBottomSheet(bottomSheetLayout: ConstraintLayout): BottomSheetBehavior<ConstraintLayout> {
         val dragHandle = findViewById<View>(R.id.drag_handle)
         val dragHandle = findViewById<View>(R.id.drag_handle)
 
 
         val bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout)
         val bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout)
@@ -391,6 +394,29 @@ class EmojiReactionsOverviewActivity : ThreemaToolbarActivity() {
         }
         }
     }
     }
 
 
+    private fun determineAvailableDrawingHeight(onAvailable: (Int) -> Unit) {
+        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) {
+            onAvailable(determineAvailableDrawingHeightApi30Impl())
+        } else {
+            val statusBarHeight = ConfigUtils.getStatusBarHeight(this)
+            val navigationBarHeight = ConfigUtils.getNavigationBarHeight(this)
+            val screenHeightWithoutTopInsets = resources.displayMetrics.heightPixels - statusBarHeight + navigationBarHeight
+            onAvailable(screenHeightWithoutTopInsets.coerceAtLeast(0))
+        }
+    }
+
+    @RequiresApi(Build.VERSION_CODES.R)
+    private fun determineAvailableDrawingHeightApi30Impl(): Int {
+        val metrics: WindowMetrics = windowManager.currentWindowMetrics
+        val windowInsets: WindowInsets = metrics.getWindowInsets()
+        val insetsTop = windowInsets.getInsets(
+            WindowInsets.Type.statusBars() or WindowInsets.Type.displayCutout() or WindowInsets.Type.captionBar()
+        )
+        val insetsHeightTop = insetsTop.top + insetsTop.bottom
+
+        return (metrics.bounds.height() - insetsHeightTop).coerceAtLeast(0)
+    }
+
     companion object {
     companion object {
         const val EXTRA_INITIAL_EMOJI = "extra_initial_emoji" // emoji to show initially
         const val EXTRA_INITIAL_EMOJI = "extra_initial_emoji" // emoji to show initially
     }
     }

+ 2 - 6
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewFragment.kt

@@ -83,7 +83,6 @@ class EmojiReactionsOverviewFragment(val emojiSequence: String? = null, val mess
                 emojiReactionsViewModel.emojiReactionsUiState.collect { uiState ->
                 emojiReactionsViewModel.emojiReactionsUiState.collect { uiState ->
                     val emojiReactionsForSequence = uiState.emojiReactions.filter { it.emojiSequence == targetEmojiSequence }
                     val emojiReactionsForSequence = uiState.emojiReactions.filter { it.emojiSequence == targetEmojiSequence }
                     emojiReactionsOverviewListAdapter.submitList(emojiReactionsForSequence)
                     emojiReactionsOverviewListAdapter.submitList(emojiReactionsForSequence)
-                    emojiReactionsOverviewListAdapter.notifyDataSetChanged()
                 }
                 }
             }
             }
         }
         }
@@ -91,14 +90,11 @@ class EmojiReactionsOverviewFragment(val emojiSequence: String? = null, val mess
 
 
     override fun onRemoveClick(data: EmojiReactionData, position: Int) {
     override fun onRemoveClick(data: EmojiReactionData, position: Int) {
         val messageService = ThreemaApplication.requireServiceManager().messageService
         val messageService = ThreemaApplication.requireServiceManager().messageService
-        val messageModel = messageService.getGroupMessageModel(data.messageId) ?: messageService.getContactMessageModel(data.messageId)
-        val messageReceiver = messageService.getMessageReceiver(messageModel)
-
         CoroutineScope(Dispatchers.Default).launch {
         CoroutineScope(Dispatchers.Default).launch {
             messageService.sendEmojiReaction(
             messageService.sendEmojiReaction(
-                messageModel as AbstractMessageModel,
+                messageModel,
                 data.emojiSequence,
                 data.emojiSequence,
-                messageReceiver,
+                messageService.getMessageReceiver(messageModel),
                 false
                 false
             )
             )
         }
         }

+ 4 - 4
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsPopup.kt

@@ -74,10 +74,10 @@ class EmojiReactionsPopup(
     private val topReactions = arrayOf(
     private val topReactions = arrayOf(
         ReactionEntry(R.id.top_0, EmojiUtil.THUMBS_UP_SEQUENCE),
         ReactionEntry(R.id.top_0, EmojiUtil.THUMBS_UP_SEQUENCE),
         ReactionEntry(R.id.top_1, EmojiUtil.THUMBS_DOWN_SEQUENCE),
         ReactionEntry(R.id.top_1, EmojiUtil.THUMBS_DOWN_SEQUENCE),
-        ReactionEntry(R.id.top_2, "\u2764\uFE0F"),
-        ReactionEntry(R.id.top_3, "\uD83D\uDE02"),
-        ReactionEntry(R.id.top_4, "\uD83D\uDE2E"),
-        ReactionEntry(R.id.top_5, "\uD83D\uDE22")
+        ReactionEntry(R.id.top_2, EmojiUtil.HEART_SEQUENCE),
+        ReactionEntry(R.id.top_3, EmojiUtil.TEARS_OF_JOY_SEQUENCE),
+        ReactionEntry(R.id.top_4, EmojiUtil.CRYING_SEQUENCE),
+        ReactionEntry(R.id.top_5, EmojiUtil.FOLDED_HANDS_SEQUENCE),
     )
     )
 
 
     private val contactService: ContactService by lazy { ThreemaApplication.requireServiceManager().contactService }
     private val contactService: ContactService by lazy { ThreemaApplication.requireServiceManager().contactService }

+ 16 - 6
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsViewModel.kt

@@ -30,6 +30,8 @@ import ch.threema.app.services.MessageService
 import ch.threema.data.models.EmojiReactionData
 import ch.threema.data.models.EmojiReactionData
 import ch.threema.data.models.EmojiReactionsModel
 import ch.threema.data.models.EmojiReactionsModel
 import ch.threema.data.repositories.EmojiReactionsRepository
 import ch.threema.data.repositories.EmojiReactionsRepository
+import ch.threema.data.repositories.EmojiReactionsRepository.ReactionMessageIdentifier
+import ch.threema.data.repositories.EmojiReactionsRepository.ReactionMessageIdentifier.TargetMessageType
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.AbstractMessageModel
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.StateFlow
@@ -43,17 +45,19 @@ class EmojiReactionsViewModel(
     private var emojiReactionsModel: EmojiReactionsModel? = null
     private var emojiReactionsModel: EmojiReactionsModel? = null
 
 
     companion object {
     companion object {
-        private const val MESSAGE_UID = "messageUid"
+        private const val MESSAGE_ID = "messageId"
+        private const val MESSAGE_TYPE = "messageType"
 
 
         fun provideFactory(
         fun provideFactory(
             emojiReactionsRepository: EmojiReactionsRepository,
             emojiReactionsRepository: EmojiReactionsRepository,
             messageService: MessageService,
             messageService: MessageService,
-            messageId: String
+            reactionMessageIdentifier: ReactionMessageIdentifier
         ) = viewModelFactory {
         ) = viewModelFactory {
             initializer {
             initializer {
                 EmojiReactionsViewModel(
                 EmojiReactionsViewModel(
                     this.createSavedStateHandle().apply {
                     this.createSavedStateHandle().apply {
-                        set(MESSAGE_UID, messageId)
+                        set(MESSAGE_ID, reactionMessageIdentifier.messageId)
+                        set(MESSAGE_TYPE, reactionMessageIdentifier.messageType)
                     },
                     },
                     emojiReactionsRepository,
                     emojiReactionsRepository,
                     messageService
                     messageService
@@ -62,7 +66,12 @@ class EmojiReactionsViewModel(
         }
         }
     }
     }
 
 
-    private val messageUid = checkNotNull(savedStateHandle[MESSAGE_UID]) as String
+    private val reactionMessageIdentifier by lazy {
+        ReactionMessageIdentifier(
+            messageId = checkNotNull(savedStateHandle.get<Int>(MESSAGE_ID)),
+            messageType = checkNotNull(savedStateHandle.get<TargetMessageType>(MESSAGE_TYPE)),
+        )
+    }
 
 
     val emojiReactionsUiState: StateFlow<EmojiReactionsUiState> =
     val emojiReactionsUiState: StateFlow<EmojiReactionsUiState> =
         getMessageModel()?.let {
         getMessageModel()?.let {
@@ -73,8 +82,9 @@ class EmojiReactionsViewModel(
         } ?: MutableStateFlow(EmojiReactionsUiState(emptyList()))
         } ?: MutableStateFlow(EmojiReactionsUiState(emptyList()))
 
 
 
 
-    private fun getMessageModel(): AbstractMessageModel? {
-        return messageService.getGroupMessageModel(messageUid) ?: messageService.getContactMessageModel(messageUid)
+    private fun getMessageModel(): AbstractMessageModel? = when (reactionMessageIdentifier.messageType) {
+        TargetMessageType.ONE_TO_ONE -> messageService.getContactMessageModel(reactionMessageIdentifier.messageId)
+        TargetMessageType.GROUP -> messageService.getGroupMessageModel(reactionMessageIdentifier.messageId)
     }
     }
 
 
     data class EmojiReactionsUiState(
     data class EmojiReactionsUiState(

+ 4 - 0
app/src/main/java/ch/threema/app/emojis/EmojiUtil.java

@@ -39,6 +39,10 @@ public class EmojiUtil {
 	public static final String REPLACEMENT_CHARACTER = "\uFFFD";
 	public static final String REPLACEMENT_CHARACTER = "\uFFFD";
 	public static final String THUMBS_UP_SEQUENCE = "\uD83D\uDC4D";
 	public static final String THUMBS_UP_SEQUENCE = "\uD83D\uDC4D";
 	public static final String THUMBS_DOWN_SEQUENCE = "\uD83D\uDC4E";
 	public static final String THUMBS_DOWN_SEQUENCE = "\uD83D\uDC4E";
+	public static final String HEART_SEQUENCE = "\u2764\uFE0F"; // ❤️
+	public static final String TEARS_OF_JOY_SEQUENCE = "\uD83D\uDE02"; // 😂
+	public static final String CRYING_SEQUENCE = "\uD83D\uDE22"; // 😢
+	public static final String FOLDED_HANDS_SEQUENCE = "\uD83D\uDE4F"; // 🙏
 
 
 	private static final Set<String> THUMBS_UP_VARIANTS = Set.of(
 	private static final Set<String> THUMBS_UP_VARIANTS = Set.of(
 		"\ud83d\udc4d",
 		"\ud83d\udc4d",

+ 17 - 7
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -1395,6 +1395,12 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 		}
 	}
 	}
 
 
+    private void hideEmojiPopupIfShown() {
+        if (emojiReactionsPopup != null && emojiReactionsPopup.isShowing()) {
+            emojiReactionsPopup.dismiss();
+        }
+    }
+
 	private void showEmojiPicker() {
 	private void showEmojiPicker() {
 		logger.debug("Emoji button clicked");
 		logger.debug("Emoji button clicked");
 
 
@@ -3589,8 +3595,8 @@ public class ComposeMessageFragment extends Fragment implements
 						Toast.makeText(getContext().getApplicationContext(), R.string.quoted_message_deleted, Toast.LENGTH_SHORT).show();
 						Toast.makeText(getContext().getApplicationContext(), R.string.quoted_message_deleted, Toast.LENGTH_SHORT).show();
 					}
 					}
 				}
 				}
-			} else if (messageModel.getType() == MessageType.TEXT && !messageModel.isStatusMessage() && !messageModel.isDeleted()) {
-				showTextChatBubble(messageModel);
+			} else if ((messageModel.getType() == MessageType.TEXT && !messageModel.isStatusMessage()) || messageModel.isDeleted()) {
+				showMessageDetailScreen(messageModel);
 			}
 			}
 		}
 		}
 	}
 	}
@@ -4821,7 +4827,11 @@ public class ComposeMessageFragment extends Fragment implements
 			boolean canStarMessage = isSingleMessage && MessageUtil.canStarMessage(selectedMessages.get(0));
 			boolean canStarMessage = isSingleMessage && MessageUtil.canStarMessage(selectedMessages.get(0));
 
 
 			if (selectedMessages.stream().anyMatch(AbstractMessageModel::isDeleted)) {
 			if (selectedMessages.stream().anyMatch(AbstractMessageModel::isDeleted)) {
-				onlyShowItems(menu, R.id.menu_message_discard);
+                if (isSingleMessage) {
+                    onlyShowItems(menu, R.id.menu_message_discard, R.id.menu_info);
+                } else {
+                    onlyShowItems(menu, R.id.menu_message_discard);
+                }
 				return;
 				return;
 			}
 			}
 
 
@@ -5027,7 +5037,7 @@ public class ComposeMessageFragment extends Fragment implements
 				showQuotePopup(null);
 				showQuotePopup(null);
 				mode.finish();
 				mode.finish();
 			} else if (id == R.id.menu_info) {
 			} else if (id == R.id.menu_info) {
-				showTextChatBubble(selectedMessages.get(0));
+				showMessageDetailScreen(selectedMessages.get(0));
 				mode.finish();
 				mode.finish();
 			} else if (id == R.id.menu_message_star || id == R.id.menu_message_unstar) {
 			} else if (id == R.id.menu_message_star || id == R.id.menu_message_unstar) {
 				toggleStar(selectedMessages.get(0));
 				toggleStar(selectedMessages.get(0));
@@ -5127,8 +5137,7 @@ public class ComposeMessageFragment extends Fragment implements
 
 
 			ComposeJavaBridge.INSTANCE.setEditModeMessageBubble(
 			ComposeJavaBridge.INSTANCE.setEditModeMessageBubble(
                 editMessageBubbleComposeView,
                 editMessageBubbleComposeView,
-                messageModel,
-                userService.getIdentity()
+                messageModel
             );
             );
 			editMessageBubbleContainer.setVisibility(View.VISIBLE);
 			editMessageBubbleContainer.setVisibility(View.VISIBLE);
 
 
@@ -5260,7 +5269,7 @@ public class ComposeMessageFragment extends Fragment implements
         }
         }
 	}
 	}
 
 
-	private void showTextChatBubble(AbstractMessageModel messageModel) {
+	private void showMessageDetailScreen(AbstractMessageModel messageModel) {
 		Intent intent = new Intent(getContext(), MessageDetailsActivity.class);
 		Intent intent = new Intent(getContext(), MessageDetailsActivity.class);
 		IntentDataUtil.append(messageModel, intent);
 		IntentDataUtil.append(messageModel, intent);
 		activity.startActivity(intent);
 		activity.startActivity(intent);
@@ -5724,6 +5733,7 @@ public class ComposeMessageFragment extends Fragment implements
 		super.onConfigurationChanged(newConfig);
 		super.onConfigurationChanged(newConfig);
 
 
 		hideEmojiPickerIfShown();
 		hideEmojiPickerIfShown();
+        hideEmojiPopupIfShown();
 		EditTextUtil.hideSoftKeyboard(this.messageText);
 		EditTextUtil.hideSoftKeyboard(this.messageText);
 		dismissQuotePopup();
 		dismissQuotePopup();
 		dismissMentionPopup();
 		dismissMentionPopup();

+ 23 - 26
app/src/main/java/ch/threema/app/routines/UpdateAppLogoRoutine.java

@@ -51,12 +51,12 @@ import static android.provider.MediaStore.MEDIA_IGNORE_FILENAME;
 public class UpdateAppLogoRoutine implements Runnable {
 public class UpdateAppLogoRoutine implements Runnable {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("UpdateAppLogoRoutine");
 	private static final Logger logger = LoggingUtil.getThreemaLogger("UpdateAppLogoRoutine");
 
 
-	private FileService fileService;
+	private final FileService fileService;
 	private final PreferenceService preferenceService;
 	private final PreferenceService preferenceService;
 	private final String lightUrl;
 	private final String lightUrl;
 	private final String darkUrl;
 	private final String darkUrl;
 	private boolean running = false;
 	private boolean running = false;
-	private boolean forceUpdate = false;
+	private final boolean forceUpdate;
 
 
 	public UpdateAppLogoRoutine(FileService fileService,
 	public UpdateAppLogoRoutine(FileService fileService,
 	                            PreferenceService preferenceService,
 	                            PreferenceService preferenceService,
@@ -72,7 +72,7 @@ public class UpdateAppLogoRoutine implements Runnable {
 
 
 	@Override
 	@Override
 	public void run() {
 	public void run() {
-		logger.debug("start update app logo " + this.lightUrl + ", " + this.darkUrl);
+		logger.debug("start update app logo {}, {}", this.lightUrl, this.darkUrl);
 		this.running = true;
 		this.running = true;
 
 
 		//validate instances
 		//validate instances
@@ -82,8 +82,8 @@ public class UpdateAppLogoRoutine implements Runnable {
 			return;
 			return;
 		}
 		}
 
 
-		this.downloadLogo(this.lightUrl, ConfigUtils.THEME_LIGHT);
-		this.downloadLogo(this.darkUrl, ConfigUtils.THEME_DARK);
+		this.updateLogo(this.lightUrl, ConfigUtils.THEME_LIGHT);
+		this.updateLogo(this.darkUrl, ConfigUtils.THEME_DARK);
 		this.running = false;
 		this.running = false;
 	}
 	}
 
 
@@ -94,14 +94,13 @@ public class UpdateAppLogoRoutine implements Runnable {
 	}
 	}
 
 
 	private void clearLogo(@ConfigUtils.AppThemeSetting String theme) {
 	private void clearLogo(@ConfigUtils.AppThemeSetting String theme) {
+        logger.info("Clearing app logo for (forcedUpdate={}, theme={})", forceUpdate, theme);
 		this.fileService.saveAppLogo(null, theme);
 		this.fileService.saveAppLogo(null, theme);
 		this.preferenceService.clearAppLogo(theme);
 		this.preferenceService.clearAppLogo(theme);
 	}
 	}
 
 
-	private void downloadLogo(@Nullable String urlString, @ConfigUtils.AppThemeSetting String theme) {
-
-		logger.debug("Logo download forced = " + forceUpdate);
-
+	private void updateLogo(@Nullable String urlString, @ConfigUtils.AppThemeSetting String theme) {
+        logger.info("Update app logo (forcedUpdate={}, theme={})", forceUpdate, theme);
 		Date now = new Date();
 		Date now = new Date();
 		//get expires date
 		//get expires date
 
 
@@ -109,12 +108,12 @@ public class UpdateAppLogoRoutine implements Runnable {
 			this.clearLogo(theme);
 			this.clearLogo(theme);
 			return;
 			return;
 		}
 		}
-		//check expiry date only on force update
+		// Check expiry date only if update is not forced
 		if(!this.forceUpdate) {
 		if(!this.forceUpdate) {
 			Date expiresAt = this.preferenceService.getAppLogoExpiresAt(theme);
 			Date expiresAt = this.preferenceService.getAppLogoExpiresAt(theme);
 
 
 			if (expiresAt != null && now.before(expiresAt)) {
 			if (expiresAt != null && now.before(expiresAt)) {
-				logger.debug("Logo not expired");
+				logger.info("Logo not expired");
 				//do nothing!
 				//do nothing!
 				return;
 				return;
 			}
 			}
@@ -127,7 +126,7 @@ public class UpdateAppLogoRoutine implements Runnable {
 		Date tomorrow = tomorrowCalendar.getTime();
 		Date tomorrow = tomorrowCalendar.getTime();
 
 
 		try {
 		try {
-			logger.debug("Download " + urlString);
+			logger.info("Download {}", urlString);
 
 
 			URL url = new URL(urlString);
 			URL url = new URL(urlString);
 			HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
 			HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
@@ -139,45 +138,43 @@ public class UpdateAppLogoRoutine implements Runnable {
 				final int responseCode = connection.getResponseCode();
 				final int responseCode = connection.getResponseCode();
 				if (responseCode != HttpsURLConnection.HTTP_OK) {
 				if (responseCode != HttpsURLConnection.HTTP_OK) {
 					if (responseCode == HttpsURLConnection.HTTP_NOT_FOUND) {
 					if (responseCode == HttpsURLConnection.HTTP_NOT_FOUND) {
-						logger.debug("Logo not found");
-					}
+						logger.warn("Logo not found");
+					} else {
+                        logger.warn("Connection failed with response code {}", responseCode);
+                    }
 				} else {
 				} else {
-					//cool, save avatar
 					logger.debug("Logo found. Start download");
 					logger.debug("Logo found. Start download");
 
 
 					File temporaryFile = this.fileService.createTempFile(MEDIA_IGNORE_FILENAME, "appicon");
 					File temporaryFile = this.fileService.createTempFile(MEDIA_IGNORE_FILENAME, "appicon");
 					// this will be useful to display download percentage
 					// this will be useful to display download percentage
 					// might be -1: server did not report the length
 					// might be -1: server did not report the length
 					int fileLength = connection.getContentLength();
 					int fileLength = connection.getContentLength();
-					logger.debug("size: " + fileLength);
+					logger.debug("size: {}", fileLength);
 
 
 					// download the file
 					// download the file
 					try (InputStream input = connection.getInputStream()) {
 					try (InputStream input = connection.getInputStream()) {
 						Date expires = new Date(connection.getHeaderFieldDate("Expires", tomorrow.getTime()));
 						Date expires = new Date(connection.getHeaderFieldDate("Expires", tomorrow.getTime()));
-						logger.debug("expires " + expires);
+						logger.debug("expires {}", expires);
 
 
 						try (FileOutputStream output = new FileOutputStream(temporaryFile.getPath())) {
 						try (FileOutputStream output = new FileOutputStream(temporaryFile.getPath())) {
 							byte[] data = new byte[4096];
 							byte[] data = new byte[4096];
 							int count;
 							int count;
 
 
 							while ((count = input.read(data)) != -1) {
 							while ((count = input.read(data)) != -1) {
-								//write to file
 								output.write(data, 0, count);
 								output.write(data, 0, count);
 							}
 							}
 
 
-							logger.debug("Logo downloaded");
+							logger.info("Logo downloaded. Expires at {}.", expires);
 							output.close();
 							output.close();
 
 
-							//ok, save the app logo
 							this.setLogo(urlString, temporaryFile, expires, theme);
 							this.setLogo(urlString, temporaryFile, expires, theme);
 
 
-							//remove the temporary file
 							FileUtil.deleteFileOrWarn(temporaryFile, "temporary file", logger);
 							FileUtil.deleteFileOrWarn(temporaryFile, "temporary file", logger);
 
 
 						} catch (IOException x) {
 						} catch (IOException x) {
-							//failed to download
-							//do nothing an try again later
-							logger.error("Exception", x);
+							// Failed to download
+							// do nothing an try again later
+							logger.error("Download of app logo failed", x);
 						}
 						}
 					}
 					}
 				}
 				}
@@ -188,11 +185,11 @@ public class UpdateAppLogoRoutine implements Runnable {
 						errorStream.close();
 						errorStream.close();
 					}
 					}
 				} catch (IOException e) {
 				} catch (IOException e) {
-					// empty
+					// Ignored
 				}
 				}
 			}
 			}
 		} catch (Exception x) {
 		} catch (Exception x) {
-			logger.error("Exception", x);
+			logger.error("Update of app logo failed", x);
 		}
 		}
 
 
 	}
 	}

+ 2 - 2
app/src/main/java/ch/threema/app/services/AvatarCacheServiceImpl.java

@@ -232,7 +232,7 @@ final public class AvatarCacheServiceImpl implements AvatarCacheService {
 			}
 			}
 			return requestBuilder.submit().get();
 			return requestBuilder.submit().get();
 		} catch (ExecutionException | InterruptedException e) {
 		} catch (ExecutionException | InterruptedException e) {
-			logger.error("Error while getting avatar bitmap for configuration " + config, e);
+			logger.error("Error while getting avatar bitmap for configuration {}", config, e);
 			if (e instanceof InterruptedException) {
 			if (e instanceof InterruptedException) {
 				Thread.currentThread().interrupt();
 				Thread.currentThread().interrupt();
 			}
 			}
@@ -247,7 +247,7 @@ final public class AvatarCacheServiceImpl implements AvatarCacheService {
 		@NonNull ImageView view,
 		@NonNull ImageView view,
 		@NonNull RequestManager requestManager
 		@NonNull RequestManager requestManager
 	) {
 	) {
-		logger.debug("loading avatar for config {} hires = {}", config.state, config.options.highRes);
+		logger.debug("loading avatar for config {} highRes = {}", config.state, config.options.highRes);
 		try {
 		try {
 			RequestBuilder<Bitmap> requestBuilder = requestManager
 			RequestBuilder<Bitmap> requestBuilder = requestManager
 				.asBitmap()
 				.asBitmap()

+ 17 - 4
app/src/main/java/ch/threema/app/services/MessageServiceImpl.java

@@ -634,8 +634,13 @@ public class MessageServiceImpl implements MessageService {
 
 
 	@WorkerThread
 	@WorkerThread
 	@Override
 	@Override
-	public synchronized boolean sendEmojiReaction(@NonNull AbstractMessageModel message, @NonNull String emojiSequence, @NonNull MessageReceiver receiver, boolean markAsRead) throws ThreemaException {
-		logger.debug("Send emoji reaction to message {}", message.getApiMessageId());
+	public synchronized boolean sendEmojiReaction(
+        @NonNull AbstractMessageModel message,
+        @NonNull String emojiSequence,
+        @NonNull MessageReceiver receiver,
+        boolean markAsRead
+    ) throws ThreemaException {
+		logger.debug("Send emoji reaction to message {} (id={})", message.getApiMessageId(), message.getId());
 		logger.trace("Reaction: '{}'", emojiSequence);
 		logger.trace("Reaction: '{}'", emojiSequence);
 
 
         if (!EmojiUtil.isFullyQualifiedEmoji(emojiSequence)) {
         if (!EmojiUtil.isFullyQualifiedEmoji(emojiSequence)) {
@@ -650,7 +655,15 @@ public class MessageServiceImpl implements MessageService {
         final int reactionSupport = receiver.getEmojiReactionSupport();
         final int reactionSupport = receiver.getEmojiReactionSupport();
 
 
 		if (reactionSupport != MessageReceiver.Reactions_NONE) {
 		if (reactionSupport != MessageReceiver.Reactions_NONE) {
-			final String myIdentity = identityStore.getIdentity();
+
+            if (markAsRead) {
+                markAsRead(
+                    /* message */ message,
+                    /* silent */ true
+                );
+            }
+
+            final String myIdentity = identityStore.getIdentity();
 			List<EmojiReactionData> emojiReactionData = emojiReactionsRepository.safeGetReactionsByMessage(message);
 			List<EmojiReactionData> emojiReactionData = emojiReactionsRepository.safeGetReactionsByMessage(message);
 
 
 			Reaction.ActionCase actionCase = Reaction.ActionCase.APPLY;
 			Reaction.ActionCase actionCase = Reaction.ActionCase.APPLY;
@@ -706,7 +719,7 @@ public class MessageServiceImpl implements MessageService {
 					logger.error("Unable to send dec message");
 					logger.error("Unable to send dec message");
 				}
 				}
 			} else {
 			} else {
-				// TODO(ANDR-3492): Remove else branch and probably set method's return type to `void`
+				// TODO(ANDR-3585): Remove else branch and probably set method's return type to `void`
 				// unsupported emoji sequence (V1 to V2)
 				// unsupported emoji sequence (V1 to V2)
 				return false;
 				return false;
 			}
 			}

+ 3 - 3
app/src/main/java/ch/threema/app/services/notification/NotificationActionService.java

@@ -152,7 +152,7 @@ public class NotificationActionService extends IntentService {
 		lifetimeService.acquireConnection(TAG);
 		lifetimeService.acquireConnection(TAG);
 		try {
 		try {
 			messageService.sendEmojiReaction(messageModel, EmojiUtil.THUMBS_UP_SEQUENCE, messageReceiver, true);
 			messageService.sendEmojiReaction(messageModel, EmojiUtil.THUMBS_UP_SEQUENCE, messageReceiver, true);
-			notificationService.cancelConversationNotification(ConversationNotificationUtil.getUid(messageModel));
+            notificationService.cancelConversationNotification(ConversationNotificationUtil.getUid(messageModel));
 			showToast(R.string.message_acknowledged);
 			showToast(R.string.message_acknowledged);
 		} catch (Exception e) {
 		} catch (Exception e) {
 			logger.error("Failed to send emoji reaction", e);
 			logger.error("Failed to send emoji reaction", e);
@@ -165,8 +165,8 @@ public class NotificationActionService extends IntentService {
 		lifetimeService.acquireConnection(TAG);
 		lifetimeService.acquireConnection(TAG);
 		try {
 		try {
 			messageService.sendEmojiReaction(messageModel, EmojiUtil.THUMBS_DOWN_SEQUENCE, messageReceiver, true);
 			messageService.sendEmojiReaction(messageModel, EmojiUtil.THUMBS_DOWN_SEQUENCE, messageReceiver, true);
-			notificationService.cancelConversationNotification(ConversationNotificationUtil.getUid(messageModel));
-			showToast(R.string.message_declined);
+            notificationService.cancelConversationNotification(ConversationNotificationUtil.getUid(messageModel));
+            showToast(R.string.message_declined);
 		} catch (Exception e) {
 		} catch (Exception e) {
 			logger.error("Failed to send emoji reaction", e);
 			logger.error("Failed to send emoji reaction", e);
 		}
 		}

+ 0 - 1
app/src/main/java/ch/threema/app/voip/activities/GroupCallActivity.kt

@@ -655,7 +655,6 @@ class GroupCallActivity : ThreemaActivity(), GenericAlertDialog.DialogClickListe
 					adapter.updateCaptureStates()
 					adapter.updateCaptureStates()
 				}
 				}
 			}
 			}
-
 		}
 		}
 	}
 	}
 
 

+ 3 - 2
app/src/main/java/ch/threema/app/voip/viewmodel/GroupCallViewModel.kt

@@ -357,8 +357,9 @@ class GroupCallViewModel(application: Application) : AndroidViewModel(applicatio
 	@UiThread
 	@UiThread
 	private fun observeParticipants() {
 	private fun observeParticipants() {
 		viewModelScope.launch {
 		viewModelScope.launch {
-			callController.participants
-				.collect { eglBaseAndParticipants.value = callController.eglBase to it }
+			callController.participants.collect { participants ->
+                eglBaseAndParticipants.value = callController.eglBase to participants
+            }
 		}
 		}
 	}
 	}
 
 

+ 82 - 57
app/src/main/java/ch/threema/app/workers/WorkSyncWorker.kt

@@ -106,7 +106,12 @@ class WorkSyncWorker(private val context: Context, workerParameters: WorkerParam
 
 
             try {
             try {
                 val workManager = WorkManager.getInstance(context)
                 val workManager = WorkManager.getInstance(context)
-                val policy = if (WorkManagerUtil.shouldScheduleNewWorkManagerInstance(workManager, ThreemaApplication.WORKER_PERIODIC_WORK_SYNC, schedulePeriodMs)) {
+                val policy = if (WorkManagerUtil.shouldScheduleNewWorkManagerInstance(
+                        workManager,
+                        ThreemaApplication.WORKER_PERIODIC_WORK_SYNC,
+                        schedulePeriodMs
+                    )
+                ) {
                     ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
                     ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
                 } else {
                 } else {
                     ExistingPeriodicWorkPolicy.KEEP
                     ExistingPeriodicWorkPolicy.KEEP
@@ -125,13 +130,13 @@ class WorkSyncWorker(private val context: Context, workerParameters: WorkerParam
 
 
         private fun buildOneTimeWorkRequest(refreshRestrictionsOnly: Boolean, forceUpdate: Boolean, tag: String?): OneTimeWorkRequest {
         private fun buildOneTimeWorkRequest(refreshRestrictionsOnly: Boolean, forceUpdate: Boolean, tag: String?): OneTimeWorkRequest {
             val data = Data.Builder()
             val data = Data.Builder()
-                    .putBoolean(EXTRA_REFRESH_RESTRICTIONS_ONLY, refreshRestrictionsOnly)
-                    .putBoolean(EXTRA_FORCE_UPDATE, forceUpdate)
-                    .build()
+                .putBoolean(EXTRA_REFRESH_RESTRICTIONS_ONLY, refreshRestrictionsOnly)
+                .putBoolean(EXTRA_FORCE_UPDATE, forceUpdate)
+                .build()
 
 
             val builder = OneTimeWorkRequestBuilder<WorkSyncWorker>()
             val builder = OneTimeWorkRequestBuilder<WorkSyncWorker>()
-                    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
-                    .apply { setInputData(data) }
+                .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+                .apply { setInputData(data) }
 
 
             tag?.let {
             tag?.let {
                 builder.addTag(tag)
                 builder.addTag(tag)
@@ -245,11 +250,11 @@ class WorkSyncWorker(private val context: Context, workerParameters: WorkerParam
                 for (n in allContacts.indices) {
                 for (n in allContacts.indices) {
                     identities[n] = allContacts[n].identity
                     identities[n] = allContacts[n].identity
                 }
                 }
-                workData = apiConnector
-                        .fetchWorkData(
-                                credentials.username,
-                                credentials.password,
-                                identities)
+                workData = apiConnector.fetchWorkData(
+                    /* username = */ credentials.username,
+                    /* password = */ credentials.password,
+                    /* identities = */ identities
+                )
             } catch (e: Exception) {
             } catch (e: Exception) {
                 logger.error("Failed to fetch2 work data from API", e)
                 logger.error("Failed to fetch2 work data from API", e)
                 notificationService.cancelWorkSyncProgress()
                 notificationService.cancelWorkSyncProgress()
@@ -271,17 +276,18 @@ class WorkSyncWorker(private val context: Context, workerParameters: WorkerParam
             val fetchedWorkIdentities = workData.workContacts.map { it.threemaId }.toSet()
             val fetchedWorkIdentities = workData.workContacts.map { it.threemaId }.toSet()
 
 
             // Create or update work contacts
             // Create or update work contacts
-            val refreshedWorkIdentities = workData.workContacts.mapNotNull { workContact ->
-                AddOrUpdateWorkContactBackgroundTask(
-                    workContact,
-                    userService.identity,
-                    contactModelRepository,
-                ).runSynchronously()
-            }.map { it.identity }
+            val refreshedWorkIdentities = workData.workContacts
+                .mapNotNull { workContact ->
+                    AddOrUpdateWorkContactBackgroundTask(
+                        workContact = workContact,
+                        myIdentity = userService.identity,
+                        contactModelRepository = contactModelRepository,
+                    ).runSynchronously()
+                }.map { it.identity }
 
 
             val newWorkIdentities = refreshedWorkIdentities - existingWorkIdentities
             val newWorkIdentities = refreshedWorkIdentities - existingWorkIdentities
             newWorkContacts.addAll(
             newWorkContacts.addAll(
-                newWorkIdentities.mapNotNull { contactModelRepository.getByIdentity(it) }
+                newWorkIdentities.mapNotNull(contactModelRepository::getByIdentity)
             )
             )
 
 
             // Downgrade work contacts
             // Downgrade work contacts
@@ -297,30 +303,32 @@ class WorkSyncWorker(private val context: Context, workerParameters: WorkerParam
                 }
                 }
             }
             }
 
 
-            // update applogos
+            // update app-logos
             // start a new thread to lazy download the app icons
             // start a new thread to lazy download the app icons
             logger.trace("Updating app logos in new thread")
             logger.trace("Updating app logos in new thread")
-            Thread(UpdateAppLogoRoutine(
-                    this.fileService,
-                    this.preferenceService,
-                    workData.logoLight,
-                    workData.logoDark,
-                    forceUpdate
-            ), "UpdateAppIcon").start()
+            Thread(
+                UpdateAppLogoRoutine(
+                    /* fileService = */ this.fileService,
+                    /* preferenceService = */ this.preferenceService,
+                    /* lightUrl = */ workData.logoLight,
+                    /* darkUrl = */ workData.logoDark,
+                    /* forceUpdate = */ forceUpdate
+                ), "UpdateAppIcon"
+            ).start()
             preferenceService.customSupportUrl = workData.supportUrl
             preferenceService.customSupportUrl = workData.supportUrl
             if (workData.mdm.parameters != null) {
             if (workData.mdm.parameters != null) {
                 // Save the Mini-MDM Parameters to a local file
                 // Save the Mini-MDM Parameters to a local file
                 AppRestrictionService.getInstance()
                 AppRestrictionService.getInstance()
-                        .storeWorkMDMSettings(workData.mdm)
+                    .storeWorkMDMSettings(workData.mdm)
             }
             }
 
 
             // update work info
             // update work info
             UpdateWorkInfoRoutine(
             UpdateWorkInfoRoutine(
-                    context,
-                    apiConnector,
-                    identityStore,
-                    null,
-                    licenseService
+                /* context = */ context,
+                /* apiConnector = */ apiConnector,
+                /* identityStore = */ identityStore,
+                /* deviceService = */ null,
+                /* licenseService = */ licenseService
             ).run()
             ).run()
             preferenceService.workDirectoryEnabled = workData.directory.enabled
             preferenceService.workDirectoryEnabled = workData.directory.enabled
             preferenceService.workDirectoryCategories = workData.directory.categories
             preferenceService.workDirectoryCategories = workData.directory.categories
@@ -338,17 +346,19 @@ class WorkSyncWorker(private val context: Context, workerParameters: WorkerParam
 
 
         logger.info("Refreshing work data successfully finished")
         logger.info("Refreshing work data successfully finished")
 
 
-        return serviceManager?.let {
-            if (newWorkContacts.isEmpty() || ContactUpdateWorker.fetchAndUpdateContactModels(
-                contactModels = newWorkContacts,
-                apiConnector = apiConnector,
-                preferenceService = preferenceService,
-            )) {
-                return Result.success()
-            } else {
-                return Result.failure()
-            }
-        } ?: Result.success()
+        if (newWorkContacts.isEmpty()) {
+            return Result.success()
+        }
+        val wasSuccessful = ContactUpdateWorker.fetchAndUpdateContactModels(
+            contactModels = newWorkContacts,
+            apiConnector = apiConnector,
+            preferenceService = preferenceService,
+        )
+        return if (wasSuccessful) {
+            Result.success()
+        } else {
+            Result.failure()
+        }
     }
     }
 
 
     private fun resetRestrictions() {
     private fun resetRestrictions() {
@@ -362,7 +372,11 @@ class WorkSyncWorker(private val context: Context, workerParameters: WorkerParam
                 applyBooleanRestriction(editor, R.string.restriction__disable_screenshots, R.string.preferences__hide_screenshots) { it }
                 applyBooleanRestriction(editor, R.string.restriction__disable_screenshots, R.string.preferences__hide_screenshots) { it }
                 applyBooleanRestriction(editor, R.string.restriction__disable_save_to_gallery, R.string.preferences__save_media) { !it }
                 applyBooleanRestriction(editor, R.string.restriction__disable_save_to_gallery, R.string.preferences__save_media) { !it }
                 applyBooleanRestriction(editor, R.string.restriction__disable_message_preview, R.string.preferences__notification_preview) { !it }
                 applyBooleanRestriction(editor, R.string.restriction__disable_message_preview, R.string.preferences__notification_preview) { !it }
-                applyBooleanRestrictionMapToInt(editor, R.string.restriction__disable_send_profile_picture, R.string.preferences__profile_pic_release) {
+                applyBooleanRestrictionMapToInt(
+                    editor,
+                    R.string.restriction__disable_send_profile_picture,
+                    R.string.preferences__profile_pic_release
+                ) {
                     if (it) {
                     if (it) {
                         PreferenceService.PROFILEPIC_RELEASE_NOBODY
                         PreferenceService.PROFILEPIC_RELEASE_NOBODY
                     } else {
                     } else {
@@ -381,27 +395,38 @@ class WorkSyncWorker(private val context: Context, workerParameters: WorkerParam
 
 
     override fun getForegroundInfoAsync(): ListenableFuture<ForegroundInfo> {
     override fun getForegroundInfoAsync(): ListenableFuture<ForegroundInfo> {
         val notification = NotificationCompat.Builder(context, NotificationChannels.NOTIFICATION_CHANNEL_WORK_SYNC)
         val notification = NotificationCompat.Builder(context, NotificationChannels.NOTIFICATION_CHANNEL_WORK_SYNC)
-                .setSound(null)
-                .setSmallIcon(R.drawable.ic_sync_notification)
-                .setContentTitle(context.getString(R.string.wizard1_sync_work))
-                .setProgress(0, 0, true)
-                .setPriority(NotificationCompat.PRIORITY_LOW)
-                .setAutoCancel(true)
-                .setLocalOnly(true)
-                .setOnlyAlertOnce(true)
-                .setVisibility(NotificationCompat.VISIBILITY_SECRET)
-                .build()
+            .setSound(null)
+            .setSmallIcon(R.drawable.ic_sync_notification)
+            .setContentTitle(context.getString(R.string.wizard1_sync_work))
+            .setProgress(0, 0, true)
+            .setPriority(NotificationCompat.PRIORITY_LOW)
+            .setAutoCancel(true)
+            .setLocalOnly(true)
+            .setOnlyAlertOnce(true)
+            .setVisibility(NotificationCompat.VISIBILITY_SECRET)
+            .build()
 
 
         return Futures.immediateFuture(ForegroundInfo(ThreemaApplication.WORK_SYNC_NOTIFICATION_ID, notification))
         return Futures.immediateFuture(ForegroundInfo(ThreemaApplication.WORK_SYNC_NOTIFICATION_ID, notification))
     }
     }
 
 
-    private fun applyBooleanRestriction(editor: Editor, @StringRes restrictionKeyRes: Int, @StringRes settingKeyRes: Int, mapper: (Boolean) -> Boolean) {
+    private fun applyBooleanRestriction(
+        editor: Editor,
+        @StringRes restrictionKeyRes: Int,
+        @StringRes settingKeyRes: Int,
+        mapper: (Boolean) -> Boolean
+    ) {
         AppRestrictionUtil.getBooleanRestriction(context.getString(restrictionKeyRes))?.let {
         AppRestrictionUtil.getBooleanRestriction(context.getString(restrictionKeyRes))?.let {
             editor.putBoolean(context.getString(settingKeyRes), mapper(it))
             editor.putBoolean(context.getString(settingKeyRes), mapper(it))
         }
         }
     }
     }
 
 
-    private fun applyBooleanRestrictionMapToInt(editor: Editor, @StringRes restrictionKeyRes: Int, @StringRes settingKeyRes: Int, mapper: (Boolean) -> Int) {
+    @Suppress("SameParameterValue")
+    private fun applyBooleanRestrictionMapToInt(
+        editor: Editor,
+        @StringRes restrictionKeyRes: Int,
+        @StringRes settingKeyRes: Int,
+        mapper: (Boolean) -> Int
+    ) {
         AppRestrictionUtil.getBooleanRestriction(context.getString(restrictionKeyRes))?.let {
         AppRestrictionUtil.getBooleanRestriction(context.getString(restrictionKeyRes))?.let {
             editor.putInt(context.getString(settingKeyRes), mapper(it))
             editor.putInt(context.getString(settingKeyRes), mapper(it))
         }
         }

+ 3 - 2
app/src/main/java/ch/threema/data/ModelCache.kt

@@ -27,6 +27,7 @@ import ch.threema.data.models.EditHistoryListModel
 import ch.threema.data.models.EmojiReactionsModel
 import ch.threema.data.models.EmojiReactionsModel
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupIdentity
 import ch.threema.data.models.GroupModel
 import ch.threema.data.models.GroupModel
+import ch.threema.data.repositories.EmojiReactionsRepository
 
 
 /**
 /**
  * The model cache holds a [ModelTypeCache] for every model type.
  * The model cache holds a [ModelTypeCache] for every model type.
@@ -44,8 +45,8 @@ class ModelCache {
     // Edit history entries are identified by their reference to a message's uid
     // Edit history entries are identified by their reference to a message's uid
     val editHistory = ModelTypeCache<String, EditHistoryListModel>()
     val editHistory = ModelTypeCache<String, EditHistoryListModel>()
 
 
-    // Emoji reactions are uniquely identified by a message's uid
-    val emojiReaction = ModelTypeCache<String, EmojiReactionsModel>()
+    // Emoji reactions are uniquely identified by a composition of the message's id (int) and type
+    val emojiReaction = ModelTypeCache<EmojiReactionsRepository.ReactionMessageIdentifier, EmojiReactionsModel>()
 }
 }
 
 
 /**
 /**

+ 34 - 0
app/src/main/java/ch/threema/data/models/ContactModel.kt

@@ -392,6 +392,40 @@ class ContactModel(
         )
         )
     }
     }
 
 
+    /**
+     * Update the contact's jobTitle
+     *
+     * @throws ModelDeletedException if model is deleted.
+     */
+    // TODO(ANDR-3611): Reflect change to device group
+    fun setJobTitleFromLocal(jobTitle: String?) {
+        this.updateFields(
+            methodName = "setJobTitleFromLocal",
+            detectChanges = { originalData -> originalData.jobTitle != jobTitle },
+            updateData = { originalData -> originalData.copy(jobTitle = jobTitle) },
+            updateDatabase = ::updateDatabase,
+            onUpdated = ::defaultOnUpdated,
+            reflectUpdateTask = null,
+        )
+    }
+
+    /**
+     * Update the contact's department
+     *
+     * @throws ModelDeletedException if model is deleted.
+     */
+    // TODO(ANDR-3611): Reflect change to device group
+    fun setDepartmentFromLocal(department: String?) {
+        this.updateFields(
+            methodName = "setDepartmentFromLocal",
+            detectChanges = { originalData -> originalData.department != department },
+            updateData = { originalData -> originalData.copy(department = department) },
+            updateDatabase = ::updateDatabase,
+            onUpdated = ::defaultOnUpdated,
+            reflectUpdateTask = null,
+        )
+    }
+
     /**
     /**
      * Update the contact's acquaintance level.
      * Update the contact's acquaintance level.
      *
      *

+ 0 - 2
app/src/main/java/ch/threema/data/models/GroupModel.kt

@@ -21,8 +21,6 @@
 
 
 package ch.threema.data.models
 package ch.threema.data.models
 
 
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.asLiveData
 import ch.threema.app.managers.CoreServiceManager
 import ch.threema.app.managers.CoreServiceManager
 import ch.threema.app.utils.runtimeAssert
 import ch.threema.app.utils.runtimeAssert
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.base.utils.LoggingUtil

+ 75 - 12
app/src/main/java/ch/threema/data/repositories/EmojiReactionsRepository.kt

@@ -35,13 +35,14 @@ import ch.threema.data.storage.DbEmojiReaction
 import ch.threema.data.storage.EmojiReactionsDao
 import ch.threema.data.storage.EmojiReactionsDao
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.GroupMessageModel
 import ch.threema.storage.models.GroupMessageModel
+import ch.threema.storage.models.MessageModel
 import ch.threema.storage.models.MessageState
 import ch.threema.storage.models.MessageState
 import java.util.Date
 import java.util.Date
 
 
 private val logger = LoggingUtil.getThreemaLogger("EmojiReactionsRepository")
 private val logger = LoggingUtil.getThreemaLogger("EmojiReactionsRepository")
 
 
 class EmojiReactionsRepository(
 class EmojiReactionsRepository(
-    private val cache: ModelTypeCache<String, EmojiReactionsModel>,
+    private val cache: ModelTypeCache<ReactionMessageIdentifier, EmojiReactionsModel>,
     private val emojiReactionDao: EmojiReactionsDao,
     private val emojiReactionDao: EmojiReactionsDao,
     private val coreServiceManager: CoreServiceManager,
     private val coreServiceManager: CoreServiceManager,
 ) {
 ) {
@@ -56,7 +57,8 @@ class EmojiReactionsRepository(
     fun getReactionsByMessage(messageModel: AbstractMessageModel): EmojiReactionsModel? {
     fun getReactionsByMessage(messageModel: AbstractMessageModel): EmojiReactionsModel? {
         logger.debug("Loading emoji reactions for message {}", messageModel.id)
         logger.debug("Loading emoji reactions for message {}", messageModel.id)
         synchronized(cache) {
         synchronized(cache) {
-            return cache.getOrCreate(messageModel.uid) {
+            val reactionMessageIdentifier = ReactionMessageIdentifier.fromMessageModel(messageModel) ?: return null
+            return cache.getOrCreate(reactionMessageIdentifier) {
                 val dbReactions = emojiReactionDao.findAllByMessage(messageModel)
                 val dbReactions = emojiReactionDao.findAllByMessage(messageModel)
                     .map(DbEmojiReaction::toDataType)
                     .map(DbEmojiReaction::toDataType)
                 val allReactions = addAckDecReactions(messageModel, dbReactions.toMutableList())
                 val allReactions = addAckDecReactions(messageModel, dbReactions.toMutableList())
@@ -76,19 +78,34 @@ class EmojiReactionsRepository(
     fun deleteAllReactionsForMessage(messageModel: AbstractMessageModel) {
     fun deleteAllReactionsForMessage(messageModel: AbstractMessageModel) {
         logger.debug("Delete all for message with uid {}", messageModel.uid)
         logger.debug("Delete all for message with uid {}", messageModel.uid)
         emojiReactionDao.deleteAllByMessage(messageModel)
         emojiReactionDao.deleteAllByMessage(messageModel)
-        cache.get(messageModel.uid)?.clear()
-        cache.remove(messageModel.uid)
+        ReactionMessageIdentifier.fromMessageModel(messageModel)?.let { reactionMessageIdentifier ->
+            cache.get(reactionMessageIdentifier)?.clear()
+            cache.remove(reactionMessageIdentifier)
+        }
     }
     }
 
 
     /**
     /**
      * Add reactions from the old ack/dec system to an existing list of emoji reactions
      * Add reactions from the old ack/dec system to an existing list of emoji reactions
      */
      */
-    private fun addAckDecReactions(targetMessageModel: AbstractMessageModel, mutableEmojiReactions: MutableList<EmojiReactionData>): List<EmojiReactionData> {
+    private fun addAckDecReactions(
+        targetMessageModel: AbstractMessageModel,
+        mutableEmojiReactions: MutableList<EmojiReactionData>
+    ): List<EmojiReactionData> {
         if (targetMessageModel is GroupMessageModel) {
         if (targetMessageModel is GroupMessageModel) {
             mutableEmojiReactions += targetMessageModel.groupMessageStates?.mapNotNull { (identity, reaction) ->
             mutableEmojiReactions += targetMessageModel.groupMessageStates?.mapNotNull { (identity, reaction) ->
                 when (reaction) {
                 when (reaction) {
-                    MessageState.USERACK.toString() -> createEmojiReactionData(targetMessageModel, identity, emojiSequence = EmojiUtil.THUMBS_UP_SEQUENCE)
-                    MessageState.USERDEC.toString() -> createEmojiReactionData(targetMessageModel, identity, emojiSequence = EmojiUtil.THUMBS_DOWN_SEQUENCE)
+                    MessageState.USERACK.toString() -> createEmojiReactionData(
+                        targetMessageModel,
+                        identity,
+                        emojiSequence = EmojiUtil.THUMBS_UP_SEQUENCE
+                    )
+
+                    MessageState.USERDEC.toString() -> createEmojiReactionData(
+                        targetMessageModel,
+                        identity,
+                        emojiSequence = EmojiUtil.THUMBS_DOWN_SEQUENCE
+                    )
+
                     else -> null
                     else -> null
                 }
                 }
             } ?: emptyList()
             } ?: emptyList()
@@ -98,8 +115,18 @@ class EmojiReactionsRepository(
             val state = targetMessageModel.state
             val state = targetMessageModel.state
 
 
             when (state) {
             when (state) {
-                MessageState.USERACK -> mutableEmojiReactions += createEmojiReactionData(targetMessageModel, senderIdentity, emojiSequence = EmojiUtil.THUMBS_UP_SEQUENCE)
-                MessageState.USERDEC -> mutableEmojiReactions += createEmojiReactionData(targetMessageModel, senderIdentity, emojiSequence = EmojiUtil.THUMBS_DOWN_SEQUENCE)
+                MessageState.USERACK -> mutableEmojiReactions += createEmojiReactionData(
+                    messageModel = targetMessageModel,
+                    senderIdentity = senderIdentity,
+                    emojiSequence = EmojiUtil.THUMBS_UP_SEQUENCE
+                )
+
+                MessageState.USERDEC -> mutableEmojiReactions += createEmojiReactionData(
+                    messageModel = targetMessageModel,
+                    senderIdentity = senderIdentity,
+                    emojiSequence = EmojiUtil.THUMBS_DOWN_SEQUENCE
+                )
+
                 else -> {
                 else -> {
                     // ignore
                     // ignore
                 }
                 }
@@ -147,7 +174,9 @@ class EmojiReactionsRepository(
                     reactedAt = Date()
                     reactedAt = Date()
                 )
                 )
                 emojiReactionDao.create(reactionEntry, targetMessage)
                 emojiReactionDao.create(reactionEntry, targetMessage)
-                cache.get(targetMessage.uid)?.addEntry(reactionEntry.toDataType())
+                ReactionMessageIdentifier.fromMessageModel(targetMessage)?.let { reactionMessageIdentifier ->
+                    cache.get(reactionMessageIdentifier)?.addEntry(reactionEntry.toDataType())
+                }
             } catch (exception: SQLiteException) {
             } catch (exception: SQLiteException) {
                 throw EmojiReactionEntryCreateException(exception)
                 throw EmojiReactionEntryCreateException(exception)
             }
             }
@@ -181,7 +210,7 @@ class EmojiReactionsRepository(
                     reactedAt = Date()
                     reactedAt = Date()
                 )
                 )
 
 
-                emojiReactionDao.remove(reactionEntry)
+                emojiReactionDao.remove(reactionEntry, targetMessage)
 
 
                 // TODO(ANDR-3325): Remove ACK/DEC compatibility
                 // TODO(ANDR-3325): Remove ACK/DEC compatibility
                 val isThumbsUp = EmojiUtil.isThumbsUpEmoji(emojiSequence)
                 val isThumbsUp = EmojiUtil.isThumbsUpEmoji(emojiSequence)
@@ -202,12 +231,46 @@ class EmojiReactionsRepository(
                         messageService.clearMessageState(targetMessage)
                         messageService.clearMessageState(targetMessage)
                     }
                     }
                 }
                 }
-                cache.get(targetMessage.uid)?.removeEntry(reactionEntry.toDataType())
+                ReactionMessageIdentifier.fromMessageModel(targetMessage)?.let { reactionMessageIdentifier ->
+                    cache.get(reactionMessageIdentifier)?.removeEntry(reactionEntry.toDataType())
+                }
             } catch (exception: SQLiteException) {
             } catch (exception: SQLiteException) {
                 throw EmojiReactionEntryRemoveException(exception)
                 throw EmojiReactionEntryRemoveException(exception)
             }
             }
         }
         }
     }
     }
+
+    /**
+     *  We need to use this compound identifier object for emoji reactions because
+     *  the [AbstractMessageModel.id] is **not** unique across different message type db tables.
+     *
+     *  @param messageId Represents the local auto-incremented message row-id ([AbstractMessageModel.id])
+     *  @param messageType Is either [TargetMessageType.ONE_TO_ONE] or [TargetMessageType.GROUP]. Other existing
+     *  message types like for example distribution-list messages can not receive emoji reactions.
+     */
+    data class ReactionMessageIdentifier(
+        val messageId: Int,
+        val messageType: TargetMessageType
+    ) {
+
+        companion object {
+
+            /**
+             *  @return The reaction-message-identifier object or `null` if the concrete
+             *  type of [AbstractMessageModel] can not receive emoji reactions.
+             */
+            fun fromMessageModel(messageModel: AbstractMessageModel): ReactionMessageIdentifier? {
+                val messageType: TargetMessageType = when (messageModel) {
+                    is MessageModel -> TargetMessageType.ONE_TO_ONE
+                    is GroupMessageModel -> TargetMessageType.GROUP
+                    else -> return null
+                }
+                return ReactionMessageIdentifier(messageId = messageModel.id, messageType = messageType)
+            }
+        }
+
+        enum class TargetMessageType { ONE_TO_ONE, GROUP }
+    }
 }
 }
 
 
 class EmojiReactionEntryCreateException(e: Exception) :
 class EmojiReactionEntryCreateException(e: Exception) :

+ 1 - 1
app/src/main/java/ch/threema/data/storage/EmojiReactionsDao.kt

@@ -39,7 +39,7 @@ interface EmojiReactionsDao {
     /**
     /**
      * Remove an emoji reaction from the database
      * Remove an emoji reaction from the database
      */
      */
-    fun remove(entry: DbEmojiReaction)
+    fun remove(entry: DbEmojiReaction, messageModel: AbstractMessageModel)
 
 
     /**
     /**
      * Delete all reactions referred to by the specified message id
      * Delete all reactions referred to by the specified message id

+ 21 - 28
app/src/main/java/ch/threema/data/storage/EmojiReactionsDaoImpl.kt

@@ -47,36 +47,25 @@ class EmojiReactionsDaoImpl(
         contentValues.put(DbEmojiReaction.COLUMN_EMOJI_SEQUENCE, entry.emojiSequence)
         contentValues.put(DbEmojiReaction.COLUMN_EMOJI_SEQUENCE, entry.emojiSequence)
         contentValues.put(DbEmojiReaction.COLUMN_REACTED_AT, entry.reactedAt.time)
         contentValues.put(DbEmojiReaction.COLUMN_REACTED_AT, entry.reactedAt.time)
 
 
-        val table = when (messageModel) {
-            is MessageModel -> ContactEmojiReactionModelFactory.TABLE
-            is GroupMessageModel -> GroupEmojiReactionModelFactory.TABLE
-            else -> throw EmojiReactionEntryCreateException(
-                IllegalArgumentException("Cannot create reaction entry for message of class ${messageModel.javaClass.name}")
-            )
-        }
+        val table = getReactionTableForMessage(messageModel) ?: throw EmojiReactionEntryCreateException(
+            IllegalArgumentException("Cannot create reaction entry for message of class ${messageModel.javaClass.name}")
+        )
         sqlite.writableDatabase.insert(table, SQLiteDatabase.CONFLICT_ROLLBACK, contentValues)
         sqlite.writableDatabase.insert(table, SQLiteDatabase.CONFLICT_ROLLBACK, contentValues)
     }
     }
 
 
-    override fun remove(entry: DbEmojiReaction) {
-        var removedEntries = sqlite.writableDatabase.delete(
-                table = GroupEmojiReactionModelFactory.TABLE,
-                whereClause = "${DbEmojiReaction.COLUMN_MESSAGE_ID} = ? AND ${DbEmojiReaction.COLUMN_EMOJI_SEQUENCE} = ? AND ${DbEmojiReaction.COLUMN_SENDER_IDENTITY} = ?",
-                whereArgs = arrayOf(entry.messageId, entry.emojiSequence, entry.senderIdentity)
-            )
-        removedEntries += sqlite.writableDatabase.delete(
-                table = ContactEmojiReactionModelFactory.TABLE,
-                whereClause = "${DbEmojiReaction.COLUMN_MESSAGE_ID} = ? AND ${DbEmojiReaction.COLUMN_EMOJI_SEQUENCE} = ? AND ${DbEmojiReaction.COLUMN_SENDER_IDENTITY} = ?",
-                whereArgs = arrayOf(entry.messageId, entry.emojiSequence, entry.senderIdentity)
-            )
+    override fun remove(entry: DbEmojiReaction, messageModel: AbstractMessageModel) {
+        val table = getReactionTableForMessage(messageModel) ?: return
+
+        val removedEntries = sqlite.writableDatabase.delete(
+            table = table,
+            whereClause = "${DbEmojiReaction.COLUMN_MESSAGE_ID} = ? AND ${DbEmojiReaction.COLUMN_EMOJI_SEQUENCE} = ? AND ${DbEmojiReaction.COLUMN_SENDER_IDENTITY} = ?",
+            whereArgs = arrayOf(entry.messageId, entry.emojiSequence, entry.senderIdentity)
+        )
         logger.debug("{} entries removed for reaction {}", removedEntries, entry)
         logger.debug("{} entries removed for reaction {}", removedEntries, entry)
     }
     }
 
 
     override fun deleteAllByMessage(messageModel: AbstractMessageModel) {
     override fun deleteAllByMessage(messageModel: AbstractMessageModel) {
-        val table = when(messageModel) {
-            is GroupMessageModel -> GroupEmojiReactionModelFactory.TABLE
-            is MessageModel -> ContactEmojiReactionModelFactory.TABLE
-            else -> return
-        }
+        val table = getReactionTableForMessage(messageModel) ?: return
 
 
         val deletedEntries = sqlite.writableDatabase.delete(
         val deletedEntries = sqlite.writableDatabase.delete(
             table = table,
             table = table,
@@ -87,11 +76,7 @@ class EmojiReactionsDaoImpl(
     }
     }
 
 
     override fun findAllByMessage(messageModel: AbstractMessageModel): List<DbEmojiReaction> {
     override fun findAllByMessage(messageModel: AbstractMessageModel): List<DbEmojiReaction> {
-        val table = when (messageModel)  {
-            is GroupMessageModel -> GroupEmojiReactionModelFactory.TABLE
-            is MessageModel -> ContactEmojiReactionModelFactory.TABLE
-            else -> return emptyList()
-        }
+        val table = getReactionTableForMessage(messageModel) ?: return emptyList()
 
 
         val query = "SELECT * FROM $table WHERE ${DbEmojiReaction.COLUMN_MESSAGE_ID} = ? " +
         val query = "SELECT * FROM $table WHERE ${DbEmojiReaction.COLUMN_MESSAGE_ID} = ? " +
             "ORDER BY ${DbEmojiReaction.COLUMN_REACTED_AT} DESC"
             "ORDER BY ${DbEmojiReaction.COLUMN_REACTED_AT} DESC"
@@ -105,6 +90,14 @@ class EmojiReactionsDaoImpl(
         return cursor.use { getResult(it) }
         return cursor.use { getResult(it) }
     }
     }
 
 
+    private fun getReactionTableForMessage(messageModel: AbstractMessageModel): String? {
+        return when (messageModel) {
+            is GroupMessageModel -> GroupEmojiReactionModelFactory.TABLE
+            is MessageModel -> ContactEmojiReactionModelFactory.TABLE
+            else -> return null
+        }
+    }
+
     private fun getResult(cursor: Cursor) : MutableList<DbEmojiReaction> {
     private fun getResult(cursor: Cursor) : MutableList<DbEmojiReaction> {
         val result = mutableListOf<DbEmojiReaction>()
         val result = mutableListOf<DbEmojiReaction>()
         while (cursor.moveToNext()) {
         while (cursor.moveToNext()) {

+ 9 - 6
app/src/main/java/ch/threema/storage/models/data/media/FileDataModel.java

@@ -251,8 +251,10 @@ public class FileDataModel implements MediaMessageDataInterface {
     @Nullable
     @Nullable
     public Float getMetaDataFloat(String metaDataKey) {
     public Float getMetaDataFloat(String metaDataKey) {
         if (this.metaData != null && this.metaData.containsKey(metaDataKey)) {
         if (this.metaData != null && this.metaData.containsKey(metaDataKey)) {
-
-            Object value = this.metaData.get(metaDataKey);
+            final @Nullable Object value = this.metaData.get(metaDataKey);
+            if (value == null) {
+                return null;
+            }
             if (value instanceof Number) {
             if (value instanceof Number) {
                 if (value instanceof Double) {
                 if (value instanceof Double) {
                     return ((Double) value).floatValue();
                     return ((Double) value).floatValue();
@@ -293,18 +295,19 @@ public class FileDataModel implements MediaMessageDataInterface {
     }
     }
 
 
     /**
     /**
-     * Return the duration in MILLISECONDS as set in the metadata field.
-     * <p>
      * Note: Floats are converted to long integers. No rounding.
      * Note: Floats are converted to long integers. No rounding.
+     *
+     * @return The value in the meta-data-map for key {@code d} converted to milliseconds or {@code 0L} as fallback.
      */
      */
     public long getDurationMs() {
     public long getDurationMs() {
         try {
         try {
-            Float durationF = getMetaDataFloat(METADATA_KEY_DURATION);
+            @Nullable Float durationF = getMetaDataFloat(METADATA_KEY_DURATION);
             if (durationF != null) {
             if (durationF != null) {
                 durationF *= 1000F;
                 durationF *= 1000F;
                 return durationF.longValue();
                 return durationF.longValue();
             }
             }
-        } catch (Exception ignored) {
+        } catch (Exception exception) {
+            logger.warn("Ignored exception", exception);
         }
         }
         return 0L;
         return 0L;
     }
     }

+ 12 - 5
app/src/main/res/drawable/ic_profile.xml

@@ -1,6 +1,13 @@
-<vector android:height="24dp" android:tint="#000000"
-    android:viewportHeight="24" android:viewportWidth="24"
-    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM7.35,18.5C8.66,17.56 10.26,17 12,17s3.34,0.56 4.65,1.5C15.34,19.44 13.74,20 12,20S8.66,19.44 7.35,18.5zM18.14,17.12L18.14,17.12C16.45,15.8 14.32,15 12,15s-4.45,0.8 -6.14,2.12l0,0C4.7,15.73 4,13.95 4,12c0,-4.42 3.58,-8 8,-8s8,3.58 8,8C20,13.95 19.3,15.73 18.14,17.12z"/>
-    <path android:fillColor="@android:color/white" android:pathData="M12,6c-1.93,0 -3.5,1.57 -3.5,3.5S10.07,13 12,13s3.5,-1.57 3.5,-3.5S13.93,6 12,6zM12,11c-0.83,0 -1.5,-0.67 -1.5,-1.5S11.17,8 12,8s1.5,0.67 1.5,1.5S12.83,11 12,11z"/>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="#000000"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10s10,-4.48 10,-10S17.52,2 12,2zM7.35,18.5C8.66,17.56 10.26,17 12,17s3.34,0.56 4.65,1.5C15.34,19.44 13.74,20 12,20S8.66,19.44 7.35,18.5zM18.14,17.12L18.14,17.12C16.45,15.8 14.32,15 12,15s-4.45,0.8 -6.14,2.12l0,0C4.7,15.73 4,13.95 4,12c0,-4.42 3.58,-8 8,-8s8,3.58 8,8C20,13.95 19.3,15.73 18.14,17.12z" />
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M12,6c-1.93,0 -3.5,1.57 -3.5,3.5S10.07,13 12,13s3.5,-1.57 3.5,-3.5S13.93,6 12,6zM12,11c-0.83,0 -1.5,-0.67 -1.5,-1.5S11.17,8 12,8s1.5,0.67 1.5,1.5S12.83,11 12,11z" />
 </vector>
 </vector>

+ 1 - 1
app/src/main/res/layout/activity_emojireactions_overview.xml

@@ -33,7 +33,7 @@
             app:layout_constraintRight_toRightOf="parent"
             app:layout_constraintRight_toRightOf="parent"
             app:layout_constraintTop_toTopOf="parent" />
             app:layout_constraintTop_toTopOf="parent" />
 
 
-        <!-- TODO(ANDR-3492): Remove -->
+        <!-- TODO(ANDR-3585): Remove -->
         <com.google.android.material.card.MaterialCardView
         <com.google.android.material.card.MaterialCardView
             android:id="@+id/infobox"
             android:id="@+id/infobox"
             android:layout_width="match_parent"
             android:layout_width="match_parent"

+ 62 - 61
app/src/main/res/layout/item_group_call_participant_list.xml

@@ -1,74 +1,75 @@
 <?xml version="1.0" encoding="utf-8"?>
 <?xml version="1.0" encoding="utf-8"?>
 <com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
 <com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
-	xmlns:app="http://schemas.android.com/apk/res-auto"
-	android:layout_width="match_parent"
-	android:layout_height="match_parent"
-	xmlns:tools="http://schemas.android.com/tools"
-	app:strokeWidth="0dp"
-	app:shapeAppearanceOverlay="@style/Threema.GroupCallParticipantRoundedCorner">
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    app:shapeAppearanceOverlay="@style/Threema.GroupCallParticipantRoundedCorner"
+    app:strokeWidth="0dp">
 
 
-	<ch.threema.app.voip.groupcall.ParticipantSurfaceViewRenderer
-		android:id="@+id/video_view"
-		android:layout_width="match_parent"
-		android:layout_height="match_parent"
-		android:visibility="gone" />
+    <ch.threema.app.voip.groupcall.ParticipantSurfaceViewRenderer
+        android:id="@+id/video_view"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:visibility="gone" />
 
 
-	<ImageView
-		android:id="@+id/participant_avatar"
-		android:layout_width="match_parent"
-		android:layout_height="match_parent"
-		android:scaleType="centerCrop"
-		android:contentDescription="@string/voip_gc_participant_avatar_description" />
+    <ImageView
+        android:id="@+id/participant_avatar"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:contentDescription="@string/voip_gc_participant_avatar_description"
+        android:scaleType="centerCrop"
+        tools:src="@drawable/ic_profile" />
 
 
-	<androidx.constraintlayout.widget.ConstraintLayout
-		android:id="@+id/participant_info"
-		android:layout_width="match_parent"
-		android:layout_height="match_parent">
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:id="@+id/participant_info"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
 
 
-		<View
-			android:id="@+id/participant_gradient"
-			android:layout_width="match_parent"
-			android:layout_height="0dp"
-			android:background="@drawable/group_call_participant_info_background_gradient"
-			app:layout_constraintStart_toStartOf="parent"
-			app:layout_constraintBottom_toBottomOf="parent"
-			app:layout_constraintHeight_percent="0.25" />
+        <View
+            android:id="@+id/participant_gradient"
+            android:layout_width="match_parent"
+            android:layout_height="0dp"
+            android:background="@drawable/group_call_participant_info_background_gradient"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintHeight_percent="0.25"
+            app:layout_constraintStart_toStartOf="parent" />
 
 
-		<androidx.constraintlayout.widget.ConstraintLayout
-			android:layout_width="match_parent"
-			android:layout_height="match_parent"
-			android:paddingStart="@dimen/group_call_participant_margin"
-			android:paddingEnd="@dimen/group_call_participant_margin">
+        <androidx.constraintlayout.widget.ConstraintLayout
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:paddingStart="@dimen/group_call_participant_margin"
+            android:paddingEnd="@dimen/group_call_participant_margin">
 
 
-		<ImageView
-			android:id="@+id/participant_microphone_muted"
-			android:layout_width="@dimen/group_call_participant_mute_icon_size"
-			android:layout_height="@dimen/group_call_participant_mute_icon_size"
-			android:layout_marginEnd="8dp"
-			android:visibility="visible"
-			app:tint="@android:color/white"
-			app:layout_constraintStart_toStartOf="parent"
-			app:layout_constraintTop_toTopOf="@id/participant_name"
-			app:layout_constraintBottom_toBottomOf="@id/participant_name"
-			app:srcCompat="@drawable/ic_mic_off_outline"
-			android:contentDescription="@string/voip_gc_participant_mute_status_description" />
+            <ImageView
+                android:id="@+id/participant_microphone_muted"
+                android:layout_width="@dimen/group_call_participant_mute_icon_size"
+                android:layout_height="@dimen/group_call_participant_mute_icon_size"
+                android:contentDescription="@string/voip_gc_participant_mute_status_description"
+                android:visibility="visible"
+                app:layout_constraintBottom_toBottomOf="@id/participant_name"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="@id/participant_name"
+                app:srcCompat="@drawable/ic_mic_off_outline"
+                app:tint="@android:color/white" />
 
 
-		<TextView
-			android:id="@+id/participant_name"
-			android:layout_width="0dp"
-			android:layout_height="wrap_content"
-			android:textColor="#ffffff"
-			android:textSize="@dimen/group_call_participant_text_size"
-			android:layout_marginBottom="@dimen/group_call_participant_margin"
-			android:maxLines="1"
-			android:ellipsize="end"
-			app:layout_constraintStart_toEndOf="@+id/participant_microphone_muted"
-			app:layout_constraintEnd_toEndOf="parent"
-			app:layout_constraintBottom_toBottomOf="parent"
-			tools:text="Averyveryveryveryveryveryveryveryveryverylongnamesdfsdf" />
+            <TextView
+                android:id="@+id/participant_name"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_marginStart="@dimen/group_call_participant_margin"
+                android:layout_marginBottom="@dimen/group_call_participant_margin"
+                android:ellipsize="end"
+                android:maxLines="1"
+                android:textColor="#ffffff"
+                android:textSize="@dimen/group_call_participant_text_size"
+                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toEndOf="@+id/participant_microphone_muted"
+                tools:text="Averyveryveryveryveryveryveryveryveryverylongnamesdfsdf" />
 
 
-	</androidx.constraintlayout.widget.ConstraintLayout>
+        </androidx.constraintlayout.widget.ConstraintLayout>
 
 
-	</androidx.constraintlayout.widget.ConstraintLayout>
+    </androidx.constraintlayout.widget.ConstraintLayout>
 
 
 </com.google.android.material.card.MaterialCardView>
 </com.google.android.material.card.MaterialCardView>

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

@@ -474,7 +474,6 @@ http://www.7-zip.org nebo https://itunes.apple.com/us/app/the-unarchiver/id42542
     <string name="back">Zpět</string>
     <string name="back">Zpět</string>
     <string name="wearable_reply">Odpovědět</string>
     <string name="wearable_reply">Odpovědět</string>
     <string name="wearable_reply_label">Odpovědět %s</string>
     <string name="wearable_reply_label">Odpovědět %s</string>
-    <string name="message_acknowledged">Souhlas odeslán</string>
     <string name="push_disable_text">Jestliže budete pokračovat, bude použita služba „Threema Push“ namísto systémové služby push. Toto pomáhá redukovat vaši digitální stopu, ale vyžaduje to také, aby vaše zařízení bylo nakonfigurováno takovým způsobem, aby aplikace mohla stále běžet na pozadí. V případě potřeby kontaktujte podporu výrobce vašeho zařízení, aby vám pomohla s konfigurací, jestliže odpovídající možnosti nejsou k dispozici v nastavení zařízení.</string>
     <string name="push_disable_text">Jestliže budete pokračovat, bude použita služba „Threema Push“ namísto systémové služby push. Toto pomáhá redukovat vaši digitální stopu, ale vyžaduje to také, aby vaše zařízení bylo nakonfigurováno takovým způsobem, aby aplikace mohla stále běžet na pozadí. V případě potřeby kontaktujte podporu výrobce vašeho zařízení, aby vám pomohla s konfigurací, jestliže odpovídající možnosti nejsou k dispozici v nastavení zařízení.</string>
     <string name="ballot_intermediate_results_show">Zobrazovat průběžné výsledky</string>
     <string name="ballot_intermediate_results_show">Zobrazovat průběžné výsledky</string>
     <string name="converting_video">Zpracovávání videa</string>
     <string name="converting_video">Zpracovávání videa</string>
@@ -593,7 +592,8 @@ možné je obnovit.</string>
     <string name="permission_storage_required">Chcete‑li uložit či odeslat média, povolte aplikaci Threema oprávnění přístupu k úložišti.</string>
     <string name="permission_storage_required">Chcete‑li uložit či odeslat média, povolte aplikaci Threema oprávnění přístupu k úložišti.</string>
     <string name="permission_location_required">Chcete‑li odeslat polohu, povolte aplikaci Threema oprávnění přístupu k poloze vašeho zařízení.</string>
     <string name="permission_location_required">Chcete‑li odeslat polohu, povolte aplikaci Threema oprávnění přístupu k poloze vašeho zařízení.</string>
     <string name="permission_contacts_required">Chcete‑li odeslat kontakty, povolte aplikaci Threema oprávnění přístupu ke kontaktům.</string>
     <string name="permission_contacts_required">Chcete‑li odeslat kontakty, povolte aplikaci Threema oprávnění přístupu ke kontaktům.</string>
-    <string name="message_declined">Nesouhlas odeslán</string>
+    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">„Palec nahoru“ odeslán</string>
+    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">„Palec dolů“ odeslán</string>
     <string name="notifications_settings">Nastavení oznámení</string>
     <string name="notifications_settings">Nastavení oznámení</string>
     <string name="notifications_default">Výchozí nastavení</string>
     <string name="notifications_default">Výchozí nastavení</string>
     <string name="notifications_until">Do %s</string>
     <string name="notifications_until">Do %s</string>

+ 4 - 2
app/src/main/res/values-de/strings.xml

@@ -506,7 +506,6 @@
     <string name="back">Zurück</string>
     <string name="back">Zurück</string>
     <string name="wearable_reply">Antworten</string>
     <string name="wearable_reply">Antworten</string>
     <string name="wearable_reply_label">Antwort an %s</string>
     <string name="wearable_reply_label">Antwort an %s</string>
-    <string name="message_acknowledged">«Daumen hoch» gesendet</string>
     <string name="push_disable_text">Wenn Sie fortfahren, wird «Threema Push» anstelle des Push-Dienstes des Systems verwendet. Dies trägt dazu bei, Ihren digitalen Fussabdruck zu verringern, erfordert jedoch, dass Ihr Gerät so konfiguriert ist, dass die App im Hintergrund ausgeführt werden kann. Kontaktieren Sie ggf. den Support Ihres Geräteherstellers für Unterstützung bei der Konfiguration, falls die entsprechenden Optionen in den Geräte-Einstellungen nicht auffindbar sind.</string>
     <string name="push_disable_text">Wenn Sie fortfahren, wird «Threema Push» anstelle des Push-Dienstes des Systems verwendet. Dies trägt dazu bei, Ihren digitalen Fussabdruck zu verringern, erfordert jedoch, dass Ihr Gerät so konfiguriert ist, dass die App im Hintergrund ausgeführt werden kann. Kontaktieren Sie ggf. den Support Ihres Geräteherstellers für Unterstützung bei der Konfiguration, falls die entsprechenden Optionen in den Geräte-Einstellungen nicht auffindbar sind.</string>
     <string name="ballot_intermediate_results_show">Zwischenresultate zeigen</string>
     <string name="ballot_intermediate_results_show">Zwischenresultate zeigen</string>
     <string name="converting_video">Video wird bearbeitet</string>
     <string name="converting_video">Video wird bearbeitet</string>
@@ -632,7 +631,8 @@ sicheren Ort gesichert oder ausgedruckt haben.</string>
     <string name="permission_storage_required">Aktivieren Sie die Speicher-Berechtigung, um Medien zu sichern oder zu senden.</string>
     <string name="permission_storage_required">Aktivieren Sie die Speicher-Berechtigung, um Medien zu sichern oder zu senden.</string>
     <string name="permission_location_required">Aktivieren Sie die Standort-Berechtigung, um Ihre Position zu senden.</string>
     <string name="permission_location_required">Aktivieren Sie die Standort-Berechtigung, um Ihre Position zu senden.</string>
     <string name="permission_contacts_required">Aktivieren Sie die Kontakte-Berechtigung, um einen Kontakt zu senden.</string>
     <string name="permission_contacts_required">Aktivieren Sie die Kontakte-Berechtigung, um einen Kontakt zu senden.</string>
-    <string name="message_declined">«Daumen runter» gesendet</string>
+    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">«Daumen hoch» gesendet</string>
+    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">«Daumen runter» gesendet</string>
     <string name="notifications_settings">Benachrichtigungseinstellungen</string>
     <string name="notifications_settings">Benachrichtigungseinstellungen</string>
     <string name="notifications_default">Standardeinstellung</string>
     <string name="notifications_default">Standardeinstellung</string>
     <string name="notifications_until">Bis %s</string>
     <string name="notifications_until">Bis %s</string>
@@ -1655,6 +1655,8 @@ sicheren Ort gesichert oder ausgedruckt haben.</string>
     <string name="emoji_reactions_cannot_remove_body">%1$s verwendet eine App-Version, welche das Entfernen von Emoji-Reaktionen oder das Senden mehrerer Emoji-Reaktionen noch nicht unterstützt. 👍 und 👎 lassen sich weiterhin austauschen.</string>
     <string name="emoji_reactions_cannot_remove_body">%1$s verwendet eine App-Version, welche das Entfernen von Emoji-Reaktionen oder das Senden mehrerer Emoji-Reaktionen noch nicht unterstützt. 👍 und 👎 lassen sich weiterhin austauschen.</string>
     <string name="emoji_reactions_cannot_remove_group_body">Kein Gruppenmitglied verwendet eine Version der App, welche das Entfernen von Emoji-Reaktionen oder das Senden von mehr als einer Emoji-Reaktion unterstützt. Sie können aber 👍 mit 👎 ersetzen und umgekehrt.</string>
     <string name="emoji_reactions_cannot_remove_group_body">Kein Gruppenmitglied verwendet eine Version der App, welche das Entfernen von Emoji-Reaktionen oder das Senden von mehr als einer Emoji-Reaktion unterstützt. Sie können aber 👍 mit 👎 ersetzen und umgekehrt.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Ihre App-Version unterstützt das Entfernen von Emoji-Reaktionen oder das Senden mehrerer Emoji-Reaktionen noch nicht.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Ihre App-Version unterstützt das Entfernen von Emoji-Reaktionen oder das Senden mehrerer Emoji-Reaktionen noch nicht.</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">Diese Nachricht hat Reaktionen. Halten Sie im Chat ein Reaktions-Emoji gedrückt, um alle zu sehen.</string>
     <plurals name="contacts_counter_label">
     <plurals name="contacts_counter_label">
         <item quantity="one">%d Kontakt</item>
         <item quantity="one">%d Kontakt</item>
         <item quantity="other">%d Kontakte</item>
         <item quantity="other">%d Kontakte</item>

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

@@ -476,7 +476,6 @@ Veuillez saisir une question pour votre enquête.</string>
     <string name="back">Retour</string>
     <string name="back">Retour</string>
     <string name="wearable_reply">Répondre</string>
     <string name="wearable_reply">Répondre</string>
     <string name="wearable_reply_label">Répondre à %s</string>
     <string name="wearable_reply_label">Répondre à %s</string>
-    <string name="message_acknowledged">Envoi d’un « pouce en l’air »</string>
     <string name="push_disable_text">Si vous continuez, « Threema Push » sera utilisé au lieu du service Push du système. Cela aide à réduire votre empreinte numérique, mais nécessite une configuration de votre téléphone qui permet à l\'application de fonctionner en arrière-plan. Vous devriez peut-être contacter l\'assistance du fabricant de votre appareil pour obtenir de l\'aide concernant cette configuration si vous ne trouvez pas les options correspondantes dans les paramètres de l\'appareil.</string>
     <string name="push_disable_text">Si vous continuez, « Threema Push » sera utilisé au lieu du service Push du système. Cela aide à réduire votre empreinte numérique, mais nécessite une configuration de votre téléphone qui permet à l\'application de fonctionner en arrière-plan. Vous devriez peut-être contacter l\'assistance du fabricant de votre appareil pour obtenir de l\'aide concernant cette configuration si vous ne trouvez pas les options correspondantes dans les paramètres de l\'appareil.</string>
     <string name="ballot_intermediate_results_show">Montrer les résultats intermédiaires</string>
     <string name="ballot_intermediate_results_show">Montrer les résultats intermédiaires</string>
     <string name="converting_video">Traitement de la vidéo</string>
     <string name="converting_video">Traitement de la vidéo</string>
@@ -595,7 +594,8 @@ Veuillez saisir une question pour votre enquête.</string>
     <string name="permission_storage_required">Pour envoyer des médias, autorisez Threema à accéder à l\'espace de stockage.</string>
     <string name="permission_storage_required">Pour envoyer des médias, autorisez Threema à accéder à l\'espace de stockage.</string>
     <string name="permission_location_required">Pour envoyer un emplacement, autorisez Threema à accéder à votre position.</string>
     <string name="permission_location_required">Pour envoyer un emplacement, autorisez Threema à accéder à votre position.</string>
     <string name="permission_contacts_required">Pour envoyer des contacts, autorisez Threema à accéder aux contacts.</string>
     <string name="permission_contacts_required">Pour envoyer des contacts, autorisez Threema à accéder aux contacts.</string>
-    <string name="message_declined">Envoi d’un « pouce vers le bas »</string>
+    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">Envoi d’un « pouce en l’air »</string>
+    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">Envoi d’un « pouce vers le bas »</string>
     <string name="notifications_settings">Réglages des notifications</string>
     <string name="notifications_settings">Réglages des notifications</string>
     <string name="notifications_default">Réglages par défaut</string>
     <string name="notifications_default">Réglages par défaut</string>
     <string name="notifications_until">Jusqu\'à %s</string>
     <string name="notifications_until">Jusqu\'à %s</string>

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

@@ -106,7 +106,7 @@
     <string name="wizard2_phone_linking">Handynummere mitde ID verchnüpfe</string>
     <string name="wizard2_phone_linking">Handynummere mitde ID verchnüpfe</string>
     <string name="wizard3_nickname_hint">Nickname iigeh</string>
     <string name="wizard3_nickname_hint">Nickname iigeh</string>
     <string name="set_nickname_title">Nickname festlegge</string>
     <string name="set_nickname_title">Nickname festlegge</string>
-    <string name="ok">Ja eh</string>
+    <string name="ok">Aso guet</string>
     <string name="cancel">Abbräche</string>
     <string name="cancel">Abbräche</string>
     <string name="copy_message_action">Kopiere</string>
     <string name="copy_message_action">Kopiere</string>
     <string name="delete_contact_action">Kontakt lösche</string>
     <string name="delete_contact_action">Kontakt lösche</string>
@@ -473,7 +473,6 @@
     <string name="back">Zrugg</string>
     <string name="back">Zrugg</string>
     <string name="wearable_reply">Antworte</string>
     <string name="wearable_reply">Antworte</string>
     <string name="wearable_reply_label">Antwort a %s</string>
     <string name="wearable_reply_label">Antwort a %s</string>
-    <string name="message_acknowledged">«Duume ufe» gschickt</string>
     <string name="push_disable_text">Wenn Sie wiitermached, wird «Threema Push» statt de Push-Dienst vom System bruucht. Das treit dezue bii, Ihre digital Fuessabdruck chliner z’mache, defür muess Ihres Grät so konfiguriert sii, dass d’App im Hindergrund cha usgfüehrt werde. Kontaktiered Sie ggf. de Support vo Ihrem Grätehersteller für Unterstützig bide Konfiguration, falls di entsprächende Optione ide Gräteiistellige nöd uffindbar sind.</string>
     <string name="push_disable_text">Wenn Sie wiitermached, wird «Threema Push» statt de Push-Dienst vom System bruucht. Das treit dezue bii, Ihre digital Fuessabdruck chliner z’mache, defür muess Ihres Grät so konfiguriert sii, dass d’App im Hindergrund cha usgfüehrt werde. Kontaktiered Sie ggf. de Support vo Ihrem Grätehersteller für Unterstützig bide Konfiguration, falls di entsprächende Optione ide Gräteiistellige nöd uffindbar sind.</string>
     <string name="ballot_intermediate_results_show">Zwüscheresultat zeige</string>
     <string name="ballot_intermediate_results_show">Zwüscheresultat zeige</string>
     <string name="converting_video">Video wird bearbeitet</string>
     <string name="converting_video">Video wird bearbeitet</string>
@@ -590,7 +589,8 @@
     <string name="permission_storage_required">Aktiviered Sie d’Speicher-Berächtigung, zum Medie z’sichere oder z’schicke.</string>
     <string name="permission_storage_required">Aktiviered Sie d’Speicher-Berächtigung, zum Medie z’sichere oder z’schicke.</string>
     <string name="permission_location_required">Aktiviered Sie d’Standort-Berächtigung, zum Ihri Position z’schicke.</string>
     <string name="permission_location_required">Aktiviered Sie d’Standort-Berächtigung, zum Ihri Position z’schicke.</string>
     <string name="permission_contacts_required">Aktiviered Sie d’Kontakt-Berächtigung, zum en Kontakt z’schicke.</string>
     <string name="permission_contacts_required">Aktiviered Sie d’Kontakt-Berächtigung, zum en Kontakt z’schicke.</string>
-    <string name="message_declined">«Duume abe» gschickt</string>
+    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">«Duume ufe» gschickt</string>
+    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">«Duume abe» gschickt</string>
     <string name="notifications_settings">Benachrichtigungsiistellige</string>
     <string name="notifications_settings">Benachrichtigungsiistellige</string>
     <string name="notifications_default">Standardiistellig</string>
     <string name="notifications_default">Standardiistellig</string>
     <string name="notifications_until">Bis %s</string>
     <string name="notifications_until">Bis %s</string>

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

@@ -477,7 +477,6 @@ Voer een vraag in voor uw poll.</string>
     <string name="back">Terug</string>
     <string name="back">Terug</string>
     <string name="wearable_reply">Antwoorden</string>
     <string name="wearable_reply">Antwoorden</string>
     <string name="wearable_reply_label">Antwoorden aan %s</string>
     <string name="wearable_reply_label">Antwoorden aan %s</string>
-    <string name="message_acknowledged">\"Duimen omhoog\" verstuurd</string>
     <string name="push_disable_text">Als u doorgaat, wordt \"Threema Push\" gebruikt in plaats van de push-dienst van het systeem. Dit helpt bij het verminderen van uw digitale voetafdruk, maar uw telefoon moet wel geconfigureerd zijn zodat de app op de achtergrond kan draaien. Als u de overeenkomende opties niet kunt vinden in de apparaatinstellingen, kunt u contact opnemen met het ondersteuningsteam van de fabrikant voor hulp bij de configuratie.</string>
     <string name="push_disable_text">Als u doorgaat, wordt \"Threema Push\" gebruikt in plaats van de push-dienst van het systeem. Dit helpt bij het verminderen van uw digitale voetafdruk, maar uw telefoon moet wel geconfigureerd zijn zodat de app op de achtergrond kan draaien. Als u de overeenkomende opties niet kunt vinden in de apparaatinstellingen, kunt u contact opnemen met het ondersteuningsteam van de fabrikant voor hulp bij de configuratie.</string>
     <string name="ballot_intermediate_results_show">Tussentijdse resultaten tonen</string>
     <string name="ballot_intermediate_results_show">Tussentijdse resultaten tonen</string>
     <string name="converting_video">Video verwerken</string>
     <string name="converting_video">Video verwerken</string>
@@ -595,7 +594,8 @@ Voer een vraag in voor uw poll.</string>
     <string name="permission_storage_required">Geeft Threema toegang tot uw opslag om media te kunnen versturen.</string>
     <string name="permission_storage_required">Geeft Threema toegang tot uw opslag om media te kunnen versturen.</string>
     <string name="permission_location_required">Geef Threema toegang tot uw locatie om een locatie te kunnen versturen.</string>
     <string name="permission_location_required">Geef Threema toegang tot uw locatie om een locatie te kunnen versturen.</string>
     <string name="permission_contacts_required">Geef Threema leestoegang tot uw contactpersonen om contactpersonen te kunnen versturen.</string>
     <string name="permission_contacts_required">Geef Threema leestoegang tot uw contactpersonen om contactpersonen te kunnen versturen.</string>
-    <string name="message_declined">\"Duimen omlaag\" verzonden</string>
+    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">\"Duimen omhoog\" verstuurd</string>
+    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">\"Duimen omlaag\" verzonden</string>
     <string name="notifications_settings">Meldingsinstellingen</string>
     <string name="notifications_settings">Meldingsinstellingen</string>
     <string name="notifications_default">Standaardinstellingen</string>
     <string name="notifications_default">Standaardinstellingen</string>
     <string name="notifications_until">Tot %s</string>
     <string name="notifications_until">Tot %s</string>

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

@@ -477,7 +477,6 @@ Wprowadź pytanie do swojej ankiety.</string>
     <string name="back">Wstecz</string>
     <string name="back">Wstecz</string>
     <string name="wearable_reply">Odpowiedz</string>
     <string name="wearable_reply">Odpowiedz</string>
     <string name="wearable_reply_label">Odpowiedz %s</string>
     <string name="wearable_reply_label">Odpowiedz %s</string>
-    <string name="message_acknowledged">Wysłano „kciuk w górę”</string>
     <string name="push_disable_text">Jeśli będziesz kontynuować, zamiast systemowej usługi push będzie wykorzystywana usługa Threema Push. Pomaga to ograniczyć zużycie zasobów, ale wymaga określonej konfiguracji urządzenia, aby aplikacja działała w tle. Jeśli odpowiednia opcja nie jest dostępna w ustawieniach urządzenia, skontaktuj się z pomocą techniczną producenta urządzenia.</string>
     <string name="push_disable_text">Jeśli będziesz kontynuować, zamiast systemowej usługi push będzie wykorzystywana usługa Threema Push. Pomaga to ograniczyć zużycie zasobów, ale wymaga określonej konfiguracji urządzenia, aby aplikacja działała w tle. Jeśli odpowiednia opcja nie jest dostępna w ustawieniach urządzenia, skontaktuj się z pomocą techniczną producenta urządzenia.</string>
     <string name="ballot_intermediate_results_show">Pokaż średnie wyniki</string>
     <string name="ballot_intermediate_results_show">Pokaż średnie wyniki</string>
     <string name="converting_video">Przetwarzanie filmu</string>
     <string name="converting_video">Przetwarzanie filmu</string>
@@ -596,7 +595,8 @@ Wprowadź pytanie do swojej ankiety.</string>
     <string name="permission_storage_required">Aby wysłać media, zezwól Threema na dostęp do pamięci.</string>
     <string name="permission_storage_required">Aby wysłać media, zezwól Threema na dostęp do pamięci.</string>
     <string name="permission_location_required">Aby wysłać swoją lokalizację, zezwól Threema na dostęp do swojego położenia.</string>
     <string name="permission_location_required">Aby wysłać swoją lokalizację, zezwól Threema na dostęp do swojego położenia.</string>
     <string name="permission_contacts_required">Aby wysłać kontakty, zezwól Threema na odczytywanie danych kontaktowych.</string>
     <string name="permission_contacts_required">Aby wysłać kontakty, zezwól Threema na odczytywanie danych kontaktowych.</string>
-    <string name="message_declined">Wysłano „kciuk w dół”</string>
+    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">Wysłano „kciuk w górę”</string>
+    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">Wysłano „kciuk w dół”</string>
     <string name="notifications_settings">Ustawienia powiadomień</string>
     <string name="notifications_settings">Ustawienia powiadomień</string>
     <string name="notifications_default">Ustawienia domyślne</string>
     <string name="notifications_default">Ustawienia domyślne</string>
     <string name="notifications_until">Do %s</string>
     <string name="notifications_until">Do %s</string>

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

@@ -476,7 +476,6 @@ Por favor, insira uma pergunta para a sua enquete.</string>
     <string name="back">Voltar</string>
     <string name="back">Voltar</string>
     <string name="wearable_reply">Responder</string>
     <string name="wearable_reply">Responder</string>
     <string name="wearable_reply_label">Responder a %s</string>
     <string name="wearable_reply_label">Responder a %s</string>
-    <string name="message_acknowledged">“Polegar para cima” enviado</string>
     <string name="push_disable_text">Se continuar, o \"Push do Threema\" será usado em vez do serviço de push do sistema. Isso ajuda a reduzir sua pegada digital, mas requer que seu telefone seja configurado de modo a permitir que o aplicativo opere em segundo plano. É recomendado entrar em contato com o suporte do fabricante do seu dispositivo para auxílio com a configuração se as opções correspondentes não puderem ser encontradas nas configurações do dispositivo.</string>
     <string name="push_disable_text">Se continuar, o \"Push do Threema\" será usado em vez do serviço de push do sistema. Isso ajuda a reduzir sua pegada digital, mas requer que seu telefone seja configurado de modo a permitir que o aplicativo opere em segundo plano. É recomendado entrar em contato com o suporte do fabricante do seu dispositivo para auxílio com a configuração se as opções correspondentes não puderem ser encontradas nas configurações do dispositivo.</string>
     <string name="ballot_intermediate_results_show">Mostrar resultados intermediários</string>
     <string name="ballot_intermediate_results_show">Mostrar resultados intermediários</string>
     <string name="converting_video">Processando vídeo</string>
     <string name="converting_video">Processando vídeo</string>
@@ -595,7 +594,8 @@ Por favor, insira uma pergunta para a sua enquete.</string>
     <string name="permission_storage_required">Para enviar mídia, permita que o Threema acesse o armazenamento.</string>
     <string name="permission_storage_required">Para enviar mídia, permita que o Threema acesse o armazenamento.</string>
     <string name="permission_location_required">Para enviar uma localização, permita que o Threema acesse sua posição.</string>
     <string name="permission_location_required">Para enviar uma localização, permita que o Threema acesse sua posição.</string>
     <string name="permission_contacts_required">Para enviar contatos, permita que o Threema leia os contatos.</string>
     <string name="permission_contacts_required">Para enviar contatos, permita que o Threema leia os contatos.</string>
-    <string name="message_declined">“polegar para baixo” enviado</string>
+    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">“Polegar para cima” enviado</string>
+    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">“polegar para baixo” enviado</string>
     <string name="notifications_settings">Configurações de notificação</string>
     <string name="notifications_settings">Configurações de notificação</string>
     <string name="notifications_default">Configurações padrão</string>
     <string name="notifications_default">Configurações padrão</string>
     <string name="notifications_until">Até %s</string>
     <string name="notifications_until">Até %s</string>

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

@@ -475,7 +475,6 @@
     <string name="back">Назад</string>
     <string name="back">Назад</string>
     <string name="wearable_reply">Ответить</string>
     <string name="wearable_reply">Ответить</string>
     <string name="wearable_reply_label">Ответить %s</string>
     <string name="wearable_reply_label">Ответить %s</string>
-    <string name="message_acknowledged">«Одобрение» отправлено</string>
     <string name="push_disable_text">Если продолжить, вместо службы push-уведомлений системы будет использоваться функция Threema Push. Это помогает сократить «цифровой след», но требует настройки телефона таким образом, чтобы приложение могло работать в фоновом режиме. Если соответствующие параметры не удаётся найти в настройках устройства, попробуйте обратиться в службу поддержки производителя вашего устройства за помощью с настройкой.</string>
     <string name="push_disable_text">Если продолжить, вместо службы push-уведомлений системы будет использоваться функция Threema Push. Это помогает сократить «цифровой след», но требует настройки телефона таким образом, чтобы приложение могло работать в фоновом режиме. Если соответствующие параметры не удаётся найти в настройках устройства, попробуйте обратиться в службу поддержки производителя вашего устройства за помощью с настройкой.</string>
     <string name="ballot_intermediate_results_show">Показать промежуточные результаты</string>
     <string name="ballot_intermediate_results_show">Показать промежуточные результаты</string>
     <string name="converting_video">Обработка видео</string>
     <string name="converting_video">Обработка видео</string>
@@ -592,7 +591,8 @@
     <string name="permission_storage_required">Чтобы отправлять медиа, разрешите Threema доступ к хранилищу.</string>
     <string name="permission_storage_required">Чтобы отправлять медиа, разрешите Threema доступ к хранилищу.</string>
     <string name="permission_location_required">Чтобы отправлять местоположение, разрешите Threema доступ к службе геопозиционирования.</string>
     <string name="permission_location_required">Чтобы отправлять местоположение, разрешите Threema доступ к службе геопозиционирования.</string>
     <string name="permission_contacts_required">Чтобы отправлять контакты, разрешите Threema чтение контактов.</string>
     <string name="permission_contacts_required">Чтобы отправлять контакты, разрешите Threema чтение контактов.</string>
-    <string name="message_declined">«Неодобрение» отправлено</string>
+    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">«Одобрение» отправлено</string>
+    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">«Неодобрение» отправлено</string>
     <string name="notifications_settings">Настройки уведомлений</string>
     <string name="notifications_settings">Настройки уведомлений</string>
     <string name="notifications_default">Настройки по умолчанию</string>
     <string name="notifications_default">Настройки по умолчанию</string>
     <string name="notifications_until">До %s</string>
     <string name="notifications_until">До %s</string>

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

@@ -474,7 +474,6 @@ tekrar denemeden önce girdiğiniz numaranın doğru olduğundan ve mobil ağa b
     <string name="back">Geri</string>
     <string name="back">Geri</string>
     <string name="wearable_reply">Cevap</string>
     <string name="wearable_reply">Cevap</string>
     <string name="wearable_reply_label">%s adlı kişiye yanıt gönder</string>
     <string name="wearable_reply_label">%s adlı kişiye yanıt gönder</string>
-    <string name="message_acknowledged">\"Beğendi\" gönderildi</string>
     <string name="push_disable_text">Devam ederseniz, sistemin push servisi yerine “Threema Push” kullanılacaktır. Bu, dijital ayak izinizi azaltmaya yardımcı olur ancak telefonunuzun, uygulamanın arka planda çalışmasına izin verecek şekilde yapılandırılmasını gerektirir. Cihaz ayarlarında ilgili seçenekler bulunamıyorsa, yapılandırma konusunda yardım için cihaz üreticinizin desteğiyle iletişime geçmek isteyebilirsiniz.</string>
     <string name="push_disable_text">Devam ederseniz, sistemin push servisi yerine “Threema Push” kullanılacaktır. Bu, dijital ayak izinizi azaltmaya yardımcı olur ancak telefonunuzun, uygulamanın arka planda çalışmasına izin verecek şekilde yapılandırılmasını gerektirir. Cihaz ayarlarında ilgili seçenekler bulunamıyorsa, yapılandırma konusunda yardım için cihaz üreticinizin desteğiyle iletişime geçmek isteyebilirsiniz.</string>
     <string name="ballot_intermediate_results_show">Ara sonuçları göster</string>
     <string name="ballot_intermediate_results_show">Ara sonuçları göster</string>
     <string name="converting_video">Video işleniyor</string>
     <string name="converting_video">Video işleniyor</string>
@@ -591,7 +590,8 @@ tekrar denemeden önce girdiğiniz numaranın doğru olduğundan ve mobil ağa b
     <string name="permission_storage_required">Medyayı kaydetmek veya göndermek için, Threema\'nın depolama alanına erişmesine izin verin.</string>
     <string name="permission_storage_required">Medyayı kaydetmek veya göndermek için, Threema\'nın depolama alanına erişmesine izin verin.</string>
     <string name="permission_location_required">Lokasyon gönderebilmek için, Threema\'nın konumunuza erişmesine izin verin.</string>
     <string name="permission_location_required">Lokasyon gönderebilmek için, Threema\'nın konumunuza erişmesine izin verin.</string>
     <string name="permission_contacts_required">Kontaklarınızı gönderebilmek için, Threema\'nın kişilerinizi okumasına izin verin.</string>
     <string name="permission_contacts_required">Kontaklarınızı gönderebilmek için, Threema\'nın kişilerinizi okumasına izin verin.</string>
-    <string name="message_declined">\"Beğenmedi\" gönderildi</string>
+    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">\"Beğendi\" gönderildi</string>
+    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">\"Beğenmedi\" gönderildi</string>
     <string name="notifications_settings">Bildirim ayarları</string>
     <string name="notifications_settings">Bildirim ayarları</string>
     <string name="notifications_default">Varsayılan ayarlar</string>
     <string name="notifications_default">Varsayılan ayarlar</string>
     <string name="notifications_until">%s tarihine kadar</string>
     <string name="notifications_until">%s tarihine kadar</string>

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

@@ -494,7 +494,6 @@
     <string name="back">Назад</string>
     <string name="back">Назад</string>
     <string name="wearable_reply">Відповісти</string>
     <string name="wearable_reply">Відповісти</string>
     <string name="wearable_reply_label">Відповісти \"%s\"</string>
     <string name="wearable_reply_label">Відповісти \"%s\"</string>
-    <string name="message_acknowledged">\"Подобається\" надіслано</string>
     <string name="push_disable_text">Якщо продовжити, замість системного сервісу push-сповіщень використовуватиметься сервіс Threema Push. Він допомагає зменшити цифровий слід, але для його роботи потрібно дозволити додатку працювати у фоновому режимі. Якщо ви не знайшли відповідні параметри в налаштуваннях системи, зверніться за допомогою до служби підтримки виробника пристрою.</string>
     <string name="push_disable_text">Якщо продовжити, замість системного сервісу push-сповіщень використовуватиметься сервіс Threema Push. Він допомагає зменшити цифровий слід, але для його роботи потрібно дозволити додатку працювати у фоновому режимі. Якщо ви не знайшли відповідні параметри в налаштуваннях системи, зверніться за допомогою до служби підтримки виробника пристрою.</string>
     <string name="ballot_intermediate_results_show">Показувати попередні результати</string>
     <string name="ballot_intermediate_results_show">Показувати попередні результати</string>
     <string name="converting_video">Обробка відео</string>
     <string name="converting_video">Обробка відео</string>
@@ -619,7 +618,8 @@
     <string name="permission_storage_required">Щоб зберігати та надсилати медіафайли, надайте Threema доступ до сховища.</string>
     <string name="permission_storage_required">Щоб зберігати та надсилати медіафайли, надайте Threema доступ до сховища.</string>
     <string name="permission_location_required">Щоб надіслати розташування, надайте Threema доступ до геоданих.</string>
     <string name="permission_location_required">Щоб надіслати розташування, надайте Threema доступ до геоданих.</string>
     <string name="permission_contacts_required">Щоб надіслати контакти, надайте Threema доступ до них.</string>
     <string name="permission_contacts_required">Щоб надіслати контакти, надайте Threema доступ до них.</string>
-    <string name="message_declined">\"Не подобається\" надіслано</string>
+    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">\"Подобається\" надіслано</string>
+    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">\"Не подобається\" надіслано</string>
     <string name="notifications_settings">Налаштування сповіщень</string>
     <string name="notifications_settings">Налаштування сповіщень</string>
     <string name="notifications_default">Налаштування за умовчанням</string>
     <string name="notifications_default">Налаштування за умовчанням</string>
     <string name="notifications_until">До %s</string>
     <string name="notifications_until">До %s</string>
@@ -1521,7 +1521,7 @@
     <string name="permission_read_phone_state">Телефон</string>
     <string name="permission_read_phone_state">Телефон</string>
     <string name="permission_enable_in_settings_rationale">Увімкніть цей дозвіл у налаштуваннях. Виберіть \"Дозволи\" й увімкніть \"%s\".</string>
     <string name="permission_enable_in_settings_rationale">Увімкніть цей дозвіл у налаштуваннях. Виберіть \"Дозволи\" й увімкніть \"%s\".</string>
     <string name="group_name">Назва групи</string>
     <string name="group_name">Назва групи</string>
-    <string name="star_message">Позначити повідомлення зірочкою</string>
+    <string name="star_message">Додати зірочку</string>
     <string name="starred_message">Повідомлення із зірочкою</string>
     <string name="starred_message">Повідомлення із зірочкою</string>
     <string name="starred">Із зірочкою</string>
     <string name="starred">Із зірочкою</string>
     <string name="starred_messages">Повідомлення із зірочкою</string>
     <string name="starred_messages">Повідомлення із зірочкою</string>

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

@@ -489,7 +489,6 @@ http://www.7-zip.org 或 https://itunes.apple.com/us/app/the-unarchiver/id425424
     <string name="back">返回</string>
     <string name="back">返回</string>
     <string name="wearable_reply">回复</string>
     <string name="wearable_reply">回复</string>
     <string name="wearable_reply_label">回复 %s</string>
     <string name="wearable_reply_label">回复 %s</string>
-    <string name="message_acknowledged">已发送 “赞”</string>
     <string name="push_disable_text">如果继续,您将使用 “Threema Push” 代替系统内置的推送服务。虽然这能减少您的数字足迹,但您需要允许 “应用程序在后台运行” 才能顺利使用。如果在设备设置中找不到相应的选项,您可能需要联系设备制造商的支持以获取配置帮助。</string>
     <string name="push_disable_text">如果继续,您将使用 “Threema Push” 代替系统内置的推送服务。虽然这能减少您的数字足迹,但您需要允许 “应用程序在后台运行” 才能顺利使用。如果在设备设置中找不到相应的选项,您可能需要联系设备制造商的支持以获取配置帮助。</string>
     <string name="ballot_intermediate_results_show">显示中间结果</string>
     <string name="ballot_intermediate_results_show">显示中间结果</string>
     <string name="converting_video">正在处理影片</string>
     <string name="converting_video">正在处理影片</string>
@@ -612,7 +611,8 @@ Threema 支持的所有表情符号。</string>
     <string name="permission_storage_required">如要保存或发送媒体,请允许 Threema 访问存储权限。</string>
     <string name="permission_storage_required">如要保存或发送媒体,请允许 Threema 访问存储权限。</string>
     <string name="permission_location_required">如要发送位置,请允许 Threema 访问您的位置的权限。</string>
     <string name="permission_location_required">如要发送位置,请允许 Threema 访问您的位置的权限。</string>
     <string name="permission_contacts_required">如要发送联系人,请允许 Threema 读取联系人的权限。</string>
     <string name="permission_contacts_required">如要发送联系人,请允许 Threema 读取联系人的权限。</string>
-    <string name="message_declined">已发送 “踩”</string>
+    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">已发送 “赞”</string>
+    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">已发送 “踩”</string>
     <string name="notifications_settings">通知设置</string>
     <string name="notifications_settings">通知设置</string>
     <string name="notifications_default">默认设置</string>
     <string name="notifications_default">默认设置</string>
     <string name="notifications_until">直到%s</string>
     <string name="notifications_until">直到%s</string>
@@ -1433,7 +1433,7 @@ Threema ID。您将不会出现在朋友的联系人列表中。您确定要
     <string name="last_added_contact">最后添加</string>
     <string name="last_added_contact">最后添加</string>
     <string name="directory_explain_text">在公司目录中搜索任何尚未列在您的联系人列表中的员工。</string>
     <string name="directory_explain_text">在公司目录中搜索任何尚未列在您的联系人列表中的员工。</string>
     <string name="cannot_display_location">无法显示此位置。</string>
     <string name="cannot_display_location">无法显示此位置。</string>
-    <string name="draw_reply">随机回复</string>
+    <string name="draw_reply">绘制回复图片</string>
     <string name="drawing">绘画</string>
     <string name="drawing">绘画</string>
     <string name="background_color">背景颜色</string>
     <string name="background_color">背景颜色</string>
     <string name="send_video_muted">从视频中删除声音</string>
     <string name="send_video_muted">从视频中删除声音</string>

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

@@ -489,7 +489,6 @@ http://www.7-zip.org 或 https://itunes.apple.com/us/app/the-unarchiver/id425424
     <string name="back">返回</string>
     <string name="back">返回</string>
     <string name="wearable_reply">回覆</string>
     <string name="wearable_reply">回覆</string>
     <string name="wearable_reply_label">回覆 %s</string>
     <string name="wearable_reply_label">回覆 %s</string>
-    <string name="message_acknowledged">已發送「讚好」</string>
     <string name="push_disable_text">如果繼續,您將使用「Threema Push」代替系統內置的推播服務。雖然這能減少您的數碼足跡,但您需要允許「應用程式在背景執行」才能順利使用。如果在裝置設定中找不到相應的選項,您可能需要聯絡裝置製造商的支援以取得配置說明。</string>
     <string name="push_disable_text">如果繼續,您將使用「Threema Push」代替系統內置的推播服務。雖然這能減少您的數碼足跡,但您需要允許「應用程式在背景執行」才能順利使用。如果在裝置設定中找不到相應的選項,您可能需要聯絡裝置製造商的支援以取得配置說明。</string>
     <string name="ballot_intermediate_results_show">顯示中間結果</string>
     <string name="ballot_intermediate_results_show">顯示中間結果</string>
     <string name="converting_video">正在處理影片</string>
     <string name="converting_video">正在處理影片</string>
@@ -612,7 +611,8 @@ Threema 支援的所有表情符號。</string>
     <string name="permission_storage_required">如要儲存或傳送媒體,請允許 Threema 取用儲存權限。</string>
     <string name="permission_storage_required">如要儲存或傳送媒體,請允許 Threema 取用儲存權限。</string>
     <string name="permission_location_required">如要傳送位置,請允許 Threema 取用您的位置的權限。</string>
     <string name="permission_location_required">如要傳送位置,請允許 Threema 取用您的位置的權限。</string>
     <string name="permission_contacts_required">如要傳送聯絡人,請允許 Threema 讀取聯絡人的權限。</string>
     <string name="permission_contacts_required">如要傳送聯絡人,請允許 Threema 讀取聯絡人的權限。</string>
-    <string name="message_declined">已發送「不喜歡」</string>
+    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">已發送「讚好」</string>
+    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">已發送「不喜歡」</string>
     <string name="notifications_settings">通知設定</string>
     <string name="notifications_settings">通知設定</string>
     <string name="notifications_default">預設設定</string>
     <string name="notifications_default">預設設定</string>
     <string name="notifications_until">直至%s</string>
     <string name="notifications_until">直至%s</string>
@@ -1427,7 +1427,7 @@ Threema 支援的所有表情符號。</string>
     <string name="last_added_contact">最後新增</string>
     <string name="last_added_contact">最後新增</string>
     <string name="directory_explain_text">在公司目錄中搜尋任何尚未列在您的聯絡人清單中的員工。</string>
     <string name="directory_explain_text">在公司目錄中搜尋任何尚未列在您的聯絡人清單中的員工。</string>
     <string name="cannot_display_location">無法顯示此位置。</string>
     <string name="cannot_display_location">無法顯示此位置。</string>
-    <string name="draw_reply">隨機回覆</string>
+    <string name="draw_reply">繪製回覆圖片</string>
     <string name="drawing">繪圖</string>
     <string name="drawing">繪圖</string>
     <string name="background_color">背景顏色</string>
     <string name="background_color">背景顏色</string>
     <string name="send_video_muted">從影片中刪除音效</string>
     <string name="send_video_muted">從影片中刪除音效</string>

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

@@ -1649,6 +1649,8 @@
     <string name="emoji_reactions_cannot_remove_body">%1$s is using an app version that does not yet support removing emoji reactions or sending more than one emoji reaction. You can still replace 👍 with 👎 or vice versa.</string>
     <string name="emoji_reactions_cannot_remove_body">%1$s is using an app version that does not yet support removing emoji reactions or sending more than one emoji reaction. You can still replace 👍 with 👎 or vice versa.</string>
     <string name="emoji_reactions_cannot_remove_group_body">None of the group members is using a version of the app that supports removing emoji reactions or sending more than one emoji reaction. You can replace 👍 with 👎 or vice-versa.</string>
     <string name="emoji_reactions_cannot_remove_group_body">None of the group members is using a version of the app that supports removing emoji reactions or sending more than one emoji reaction. You can replace 👍 with 👎 or vice-versa.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Your app version does not yet support removing emoji reactions or sending more than one emoji reaction.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Your app version does not yet support removing emoji reactions or sending more than one emoji reaction.</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">This message has emoji reactions. In the chat, tap and hold any emoji bubble to view them all.</string>
     <plurals name="contacts_counter_label">
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contact</item>
         <item quantity="one">%d contact</item>
         <item quantity="few">%d contacts</item>
         <item quantity="few">%d contacts</item>

+ 1 - 1
domain/src/main/java/ch/threema/domain/protocol/csp/messages/GroupReactionMessage.kt

@@ -47,7 +47,7 @@ class GroupReactionMessage(payloadData: ReactionMessageData) : AbstractProtobufG
 
 
     override fun bumpLastUpdate() = false
     override fun bumpLastUpdate() = false
 
 
-    override fun flagSendPush() = false
+    override fun flagSendPush() = true
 
 
     override fun reflectSentUpdate() = false
     override fun reflectSentUpdate() = false
 }
 }