浏览代码

Version 5.8.0

Threema 9 月之前
父节点
当前提交
2f562c020b
共有 100 个文件被更改,包括 4250 次插入1309 次删除
  1. 12 3
      app/build.gradle
  2. 1 1
      app/src/androidTest/java/ch/threema/app/contacts/ReflectedContactSyncTaskTest.kt
  3. 238 0
      app/src/androidTest/java/ch/threema/app/emojis/EmojiUtilTest.kt
  4. 2 2
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupSetupTest.kt
  5. 13 8
      app/src/androidTest/java/ch/threema/app/processors/IncomingMessageProcessorTest.kt
  6. 4 1
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt
  7. 11 5
      app/src/androidTest/java/ch/threema/app/protocol/IdentityBlockedStepsTest.kt
  8. 142 0
      app/src/androidTest/java/ch/threema/app/services/BlockedIdentitiesServiceTest.kt
  9. 32 0
      app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt
  10. 1 1
      app/src/androidTest/java/ch/threema/app/utils/BundledMessagesSendStepsTest.kt
  11. 6 2
      app/src/androidTest/java/ch/threema/app/utils/GeoLocationUtilTest.kt
  12. 1 2
      app/src/androidTest/java/ch/threema/app/utils/TextUtilTest.java
  13. 1 1
      app/src/androidTest/java/ch/threema/data/TestDatabaseService.kt
  14. 15 0
      app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt
  15. 179 0
      app/src/androidTest/java/ch/threema/data/repositories/EmojiReactionsRepositoryTest.kt
  16. 2 4
      app/src/libre/play/release-notes/de/default.txt
  17. 2 4
      app/src/libre/play/release-notes/en-US/default.txt
  18. 9 1
      app/src/main/AndroidManifest.xml
  19. 1 4
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  20. 3 2
      app/src/main/java/ch/threema/app/actions/LocationMessageSendAction.java
  21. 58 0
      app/src/main/java/ch/threema/app/activities/BlockedIdentitiesActivity.kt
  22. 22 10
      app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java
  23. 57 0
      app/src/main/java/ch/threema/app/activities/ExcludedSyncIdentitiesActivity.kt
  24. 5 5
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  25. 5 2
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  26. 31 17
      app/src/main/java/ch/threema/app/activities/IdentityListActivity.java
  27. 1 1
      app/src/main/java/ch/threema/app/activities/MapActivity.java
  28. 23 14
      app/src/main/java/ch/threema/app/activities/MessageDetailsActivity.kt
  29. 25 67
      app/src/main/java/ch/threema/app/activities/MessageDetailsViewModel.kt
  30. 3 2
      app/src/main/java/ch/threema/app/activities/NotificationsActivity.java
  31. 9 5
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  32. 4 1
      app/src/main/java/ch/threema/app/activities/ThreemaToolbarActivity.java
  33. 3 1
      app/src/main/java/ch/threema/app/activities/ballot/BallotOverviewActivity.java
  34. 26 14
      app/src/main/java/ch/threema/app/activities/ballot/BallotWizardActivity.java
  35. 2 2
      app/src/main/java/ch/threema/app/activities/ballot/BallotWizardFragment0.java
  36. 3 0
      app/src/main/java/ch/threema/app/activities/wizard/WizardBackgroundActivity.java
  37. 5 1
      app/src/main/java/ch/threema/app/activities/wizard/WizardBackupRestoreActivity.java
  38. 4 1
      app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java
  39. 1 1
      app/src/main/java/ch/threema/app/activities/wizard/WizardFingerPrintActivity.java
  40. 68 42
      app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java
  41. 4 3
      app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java
  42. 12 11
      app/src/main/java/ch/threema/app/adapters/ContactListAdapter.java
  43. 0 3
      app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java
  44. 0 3
      app/src/main/java/ch/threema/app/adapters/MessageListAdapterItem.kt
  45. 1 12
      app/src/main/java/ch/threema/app/adapters/MessageListViewHolder.kt
  46. 5 4
      app/src/main/java/ch/threema/app/adapters/UserListAdapter.java
  47. 5 2
      app/src/main/java/ch/threema/app/adapters/WidgetViewsFactory.java
  48. 3 9
      app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java
  49. 19 29
      app/src/main/java/ch/threema/app/adapters/decorators/LocationChatAdapterDecorator.java
  50. 2 2
      app/src/main/java/ch/threema/app/asynctasks/MarkContactAsDeletedBackgroundTask.kt
  51. 4 1
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java
  52. 0 126
      app/src/main/java/ch/threema/app/compose/message/AckDecIndicator.kt
  53. 4 19
      app/src/main/java/ch/threema/app/compose/message/MessageBubble.kt
  54. 6 22
      app/src/main/java/ch/threema/app/compose/message/MessageStateIndicator.kt
  55. 0 2
      app/src/main/java/ch/threema/app/compose/theme/color/CustomColor.kt
  56. 1 3
      app/src/main/java/ch/threema/app/debug/PatternLibraryActivity.kt
  57. 2 1
      app/src/main/java/ch/threema/app/dialogs/BallotVoteDialog.java
  58. 0 357
      app/src/main/java/ch/threema/app/dialogs/MessageDetailDialog.java
  59. 256 0
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionGroup.kt
  60. 135 0
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsButton.kt
  61. 143 0
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsGridAdapter.java
  62. 398 0
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewActivity.kt
  63. 74 0
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewAdapter.kt
  64. 119 0
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewFragment.kt
  65. 120 0
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewListAdapter.kt
  66. 154 0
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsPickerActivity.kt
  67. 308 0
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsPopup.kt
  68. 83 0
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsViewModel.kt
  69. 116 0
      app/src/main/java/ch/threema/app/emojireactions/MoreReactionsButton.kt
  70. 96 0
      app/src/main/java/ch/threema/app/emojireactions/SelectEmojiButton.kt
  71. 1 1
      app/src/main/java/ch/threema/app/emojis/EmojiConversationTextView.java
  72. 1 1
      app/src/main/java/ch/threema/app/emojis/EmojiDrawable.java
  73. 1 1
      app/src/main/java/ch/threema/app/emojis/EmojiGridAdapter.java
  74. 37 2
      app/src/main/java/ch/threema/app/emojis/EmojiItemView.java
  75. 0 1
      app/src/main/java/ch/threema/app/emojis/EmojiListAdapter.kt
  76. 3 20
      app/src/main/java/ch/threema/app/emojis/EmojiManager.java
  77. 19 7
      app/src/main/java/ch/threema/app/emojis/EmojiMarkupUtil.java
  78. 69 12
      app/src/main/java/ch/threema/app/emojis/EmojiPagerAdapter.java
  79. 69 17
      app/src/main/java/ch/threema/app/emojis/EmojiPicker.java
  80. 137 135
      app/src/main/java/ch/threema/app/emojis/EmojiSearchWidget.kt
  81. 19 1
      app/src/main/java/ch/threema/app/emojis/EmojiTextView.java
  82. 77 0
      app/src/main/java/ch/threema/app/emojis/EmojiUtil.java
  83. 352 149
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  84. 5 4
      app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java
  85. 3 3
      app/src/main/java/ch/threema/app/fragments/MemberListFragment.java
  86. 3 2
      app/src/main/java/ch/threema/app/fragments/RecipientListFragment.java
  87. 1 1
      app/src/main/java/ch/threema/app/fragments/UserListFragment.java
  88. 1 1
      app/src/main/java/ch/threema/app/fragments/UserMemberListFragment.java
  89. 1 1
      app/src/main/java/ch/threema/app/fragments/WorkUserListFragment.java
  90. 1 1
      app/src/main/java/ch/threema/app/fragments/WorkUserMemberListFragment.java
  91. 0 1
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchActivity.kt
  92. 9 14
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchAdapter.java
  93. 45 16
      app/src/main/java/ch/threema/app/managers/ServiceManager.java
  94. 81 36
      app/src/main/java/ch/threema/app/messagereceiver/ContactMessageReceiver.java
  95. 19 7
      app/src/main/java/ch/threema/app/messagereceiver/DistributionListMessageReceiver.java
  96. 114 16
      app/src/main/java/ch/threema/app/messagereceiver/GroupMessageReceiver.java
  97. 37 12
      app/src/main/java/ch/threema/app/messagereceiver/MessageReceiver.java
  98. 3 3
      app/src/main/java/ch/threema/app/multidevice/linking/DeviceLinkingDataCollector.kt
  99. 2 1
      app/src/main/java/ch/threema/app/notifications/NotificationChannels.kt
  100. 40 7
      app/src/main/java/ch/threema/app/preference/SettingsPrivacyFragment.kt

+ 12 - 3
app/build.gradle

@@ -19,14 +19,14 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 // version codes
 
 // Only use the scheme "<major>.<minor>.<patch>" for the app_version
-def app_version = "5.7.0"
+def app_version = "5.8.0"
 
 // 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.
 // Note: in nightly builds this will be overwritten with a nightly version "-n12345"
 def beta_suffix = ""
 
-def defaultVersionCode = 1033
+def defaultVersionCode = 1043
 
 /**
  * Return the git hash, if git is installed.
@@ -158,7 +158,8 @@ android {
         buildConfigField "boolean", "MD_SYNC_DISTRIBUTION_LISTS", "false"
         buildConfigField "boolean", "EDIT_MESSAGES_ENABLED", "true"
         buildConfigField "boolean", "DELETE_MESSAGES_ENABLED", "true"
-        buildConfigField "boolean", "SHOW_TIMESTAMPS_AND_TECHNICAL_INFO_IN_MESSAGE_DETAILS", "false"
+        buildConfigField "boolean", "EMOJI_REACTIONS_ENABLED", "false"
+        buildConfigField "boolean", "EMOJI_REACTIONS_WEB_ENABLED", "false"
 
         // config fields for action URLs / deep links
         buildConfigField "String", "uriScheme", "\"threema\""
@@ -280,6 +281,9 @@ android {
             buildConfigField "boolean", "MD_ENABLED", "true"
 
             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 {
             versionName "${app_version}k${beta_suffix}"
@@ -314,6 +318,8 @@ android {
             buildConfigField "String", "actionUrl", "\"work.test.threema.ch\""
 
             buildConfigField "boolean", "MD_ENABLED", "true"
+            buildConfigField "boolean", "EMOJI_REACTIONS_ENABLED", "true"
+            buildConfigField "boolean", "EMOJI_REACTIONS_WEB_ENABLED", "true"
 
             manifestPlaceholders = [
                 uriScheme: "threemawork",
@@ -388,6 +394,9 @@ android {
 
             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
             buildConfigField "String", "uriScheme", "\"threemablue\""
             buildConfigField "String", "actionUrl", "\"blue.threema.ch\""

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

@@ -286,7 +286,7 @@ class ReflectedContactSyncTaskTest {
     private fun MdD2DSync.Contact.AcquaintanceLevel.convert(): AcquaintanceLevel =
         when (this) {
             MdD2DSync.Contact.AcquaintanceLevel.DIRECT -> AcquaintanceLevel.DIRECT
-            MdD2DSync.Contact.AcquaintanceLevel.GROUP -> AcquaintanceLevel.GROUP
+            MdD2DSync.Contact.AcquaintanceLevel.GROUP_OR_DELETED -> AcquaintanceLevel.GROUP
             MdD2DSync.Contact.AcquaintanceLevel.UNRECOGNIZED -> fail("Acquaintance level is unrecognized")
         }
 

+ 238 - 0
app/src/androidTest/java/ch/threema/app/emojis/EmojiUtilTest.kt

@@ -0,0 +1,238 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.emojis
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.LinkedList
+
+class EmojiUtilTest {
+    @Test
+    fun isFullyQualifiedEmoji_fullyQualifiedEmoji() {
+        val fullyQualifiedEmoji: MutableList<String> = LinkedList()
+        fullyQualifiedEmoji.add("\uD83D\uDE00") // 😀
+        fullyQualifiedEmoji.add("\uD83D\uDE00") // 😀
+        fullyQualifiedEmoji.add("☺\uFE0F") // ☺️
+        fullyQualifiedEmoji.add("\uD83E\uDEE5") // 🫥
+        fullyQualifiedEmoji.add("\uD83D\uDE35\u200D\uD83D\uDCAB") // 😵‍💫
+        fullyQualifiedEmoji.add("\uD83D\uDC80") // 💀
+        fullyQualifiedEmoji.add("\uD83D\uDC9A") // 💚
+        fullyQualifiedEmoji.add("\uD83D\uDD73\uFE0F") // 🕳️
+        fullyQualifiedEmoji.add("\uD83D\uDC4B\uD83C\uDFFD") // 👋🏽
+        fullyQualifiedEmoji.add("\uD83E\uDD1D\uD83C\uDFFE") // 🤝🏾
+        fullyQualifiedEmoji.add("\uD83E\uDEF1\uD83C\uDFFB\u200D\uD83E\uDEF2\uD83C\uDFFD") // 🫱🏻‍🫲🏽
+        fullyQualifiedEmoji.add("\uD83D\uDC71\uD83C\uDFFF") // 👱🏿
+        fullyQualifiedEmoji.add("\uD83D\uDEB6\u200D♂\uFE0F\u200D➡\uFE0F") // 🚶‍♂️‍➡️
+        fullyQualifiedEmoji.add("\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDBD") // 👨🏽‍🦽
+        fullyQualifiedEmoji.add("\uD83C\uDFC3\u200D♂\uFE0F\u200D➡\uFE0F") // 🏃‍♂️‍➡️
+        fullyQualifiedEmoji.add("\uD83E\uDDD7\uD83C\uDFFB") // 🧗🏻
+        fullyQualifiedEmoji.add("\uD83E\uDD38\uD83C\uDFFE\u200D♀\uFE0F") // 🤸🏾‍♀️
+        fullyQualifiedEmoji.add("\uD83E\uDDD1\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1\uD83C\uDFFB") // 🧑🏽‍🤝‍🧑🏻
+        fullyQualifiedEmoji.add("\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D\uD83D\uDC69\uD83C\uDFFC") // 👩🏾‍🤝‍👩🏼
+        fullyQualifiedEmoji.add("\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDD1D\u200D\uD83D\uDC68\uD83C\uDFFD") // 👨🏻‍🤝‍👨🏽
+        fullyQualifiedEmoji.add("\uD83E\uDDD1\uD83C\uDFFB\u200D❤\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF") // 🧑🏻‍❤️‍💋‍🧑🏿
+        fullyQualifiedEmoji.add("\uD83D\uDC69\uD83C\uDFFF\u200D❤\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE") // 👩🏿‍❤️‍💋‍👨🏾
+        fullyQualifiedEmoji.add("\uD83D\uDC91\uD83C\uDFFC") // 💑🏼
+        fullyQualifiedEmoji.add("\uD83D\uDC68\uD83C\uDFFD\u200D❤\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE") // 👨🏽‍❤️‍👨🏾
+        fullyQualifiedEmoji.add("\uD83D\uDC69\uD83C\uDFFD\u200D❤\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC") // 👩🏽‍❤️‍👩🏼
+        fullyQualifiedEmoji.add("\uD83E\uDDD1\u200D\uD83E\uDDD1\u200D\uD83E\uDDD2\u200D\uD83E\uDDD2") // 🧑‍🧑‍🧒‍🧒
+        fullyQualifiedEmoji.add("\uD83D\uDC29") // 🐩
+        fullyQualifiedEmoji.add("\uD83E\uDD9B") // 🦛
+        fullyQualifiedEmoji.add("\uD83D\uDC1F") // 🐟
+        fullyQualifiedEmoji.add("\uD83E\uDD52") // 🥒
+        fullyQualifiedEmoji.add("\uD83E\uDED5") // 🫕
+        fullyQualifiedEmoji.add("⛺")
+        fullyQualifiedEmoji.add("\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08") // 🏳️‍🌈
+        fullyQualifiedEmoji.add("\uD83C\uDFF3\uFE0F\u200D⚧\uFE0F") // 🏳️‍⚧️
+        fullyQualifiedEmoji.add("\uD83C\uDFF4\u200D☠\uFE0F") // 🏴‍☠️
+        fullyQualifiedEmoji.add("\uD83C\uDDE8\uD83C\uDDED") // 🇨🇭
+
+
+        fullyQualifiedEmoji.forEach { emojiSequence ->
+            assertTrue(EmojiUtil.isFullyQualifiedEmoji(emojiSequence))
+        }
+    }
+
+    @Test
+    fun isFullyQualifiedEmoji_unqualifiedEmoji() {
+        val unqualifiedEmoji: MutableList<String> = LinkedList()
+
+        unqualifiedEmoji.add("☺")
+        unqualifiedEmoji.add("☠")
+        unqualifiedEmoji.add("❤")
+        unqualifiedEmoji.add("\uD83D\uDD73") // 🕳
+        unqualifiedEmoji.add("\uD83D\uDC41\u200D\uD83D\uDDE8\uFE0F") // 👁‍🗨️
+        unqualifiedEmoji.add("\uD83D\uDC41\u200D\uD83D\uDDE8") // 👁‍🗨
+        unqualifiedEmoji.add("\uD83D\uDDE8") // 🗨
+        unqualifiedEmoji.add("\uD83D\uDDEF") // 🗯
+        unqualifiedEmoji.add("\uD83D\uDD75\u200D♀\uFE0F") // 🕵‍♀️
+        unqualifiedEmoji.add("\uD83D\uDD74") // 🕴
+        unqualifiedEmoji.add("\uD83C\uDFCC") // 🏌
+        unqualifiedEmoji.add("\uD83D\uDD78") // 🕸
+        unqualifiedEmoji.add("\uD83C\uDF36") // 🌶
+        unqualifiedEmoji.add("\uD83C\uDF7D") // 🍽
+        unqualifiedEmoji.add("\uD83D\uDDFA") // 🗺
+        unqualifiedEmoji.add("\uD83C\uDFD4") // 🏔
+        unqualifiedEmoji.add("\uD83C\uDF29") // 🌩
+        unqualifiedEmoji.add("\uD83C\uDF2B") // 🌫
+        unqualifiedEmoji.add("\uD83C\uDF2C") // 🌬
+        unqualifiedEmoji.add("♣")
+        unqualifiedEmoji.add("\uD83D\uDD8C") // 🖌
+        unqualifiedEmoji.add("\uD83D\uDDD2") // 🗒
+        unqualifiedEmoji.add("\uD83D\uDD87") // 🖇
+        unqualifiedEmoji.add("\uD83D\uDDDD") // 🗝
+        unqualifiedEmoji.add("⛏")
+        unqualifiedEmoji.add("✖")
+        unqualifiedEmoji.add("♾")
+        unqualifiedEmoji.add("⁉")
+        unqualifiedEmoji.add("♻")
+        unqualifiedEmoji.add("❇")
+        unqualifiedEmoji.add("®")
+        unqualifiedEmoji.add("™")
+        unqualifiedEmoji.add("0⃣")
+        unqualifiedEmoji.add("Ⓜ")
+        unqualifiedEmoji.add("\uD83C\uDFF3\u200D\uD83C\uDF08") // 🏳‍🌈
+        unqualifiedEmoji.add("\uD83C\uDFF3\u200D⚧") // 🏳‍⚧
+
+        unqualifiedEmoji.forEach { emojiSequence ->
+            assertFalse(EmojiUtil.isFullyQualifiedEmoji(emojiSequence))
+        }
+    }
+
+    @Test
+    fun isFullyQualifiedEmoji_minimallyQualifiedEmoji() {
+        val minimallyQualified: MutableList<String> = LinkedList()
+
+        minimallyQualified.add("\uD83D\uDE36\u200D\uD83C\uDF2B") // 😶‍🌫
+        minimallyQualified.add("\uD83D\uDE42\u200D↔") // 🙂‍↔
+        minimallyQualified.add("\uD83D\uDE42\u200D↕") // 🙂‍↕
+        minimallyQualified.add("\uD83D\uDC41\uFE0F\u200D\uD83D\uDDE8") // 👁️‍🗨
+        minimallyQualified.add("\uD83E\uDDDE\u200D♀") // 🧞‍♀
+        minimallyQualified.add("\uD83C\uDFC3\uD83C\uDFFE\u200D♀\u200D➡\uFE0F") // 🏃🏾‍♀‍➡️
+        minimallyQualified.add("\uD83C\uDFC3\uD83C\uDFFF\u200D♂\uFE0F\u200D➡") // 🏃🏿‍♂️‍➡
+        minimallyQualified.add("\uD83D\uDC6F\u200D♂") // 👯‍♂
+        minimallyQualified.add("\uD83C\uDFCA\u200D♀") // 🏊‍♀
+        minimallyQualified.add("\uD83E\uDD3D\uD83C\uDFFC\u200D♂") // 🤽🏼‍♂
+        minimallyQualified.add("\uD83E\uDD3D\uD83C\uDFFD\u200D♀") // 🤽🏽‍♀
+        minimallyQualified.add("\uD83D\uDC69\u200D❤\u200D\uD83D\uDC8B\u200D\uD83D\uDC68") // 👩‍❤‍💋‍👨
+        minimallyQualified.add("\uD83D\uDC69\uD83C\uDFFC\u200D❤\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB") // 👩🏼‍❤‍💋‍👨🏻
+        minimallyQualified.add("\uD83D\uDC68\uD83C\uDFFB\u200D❤\u200D\uD83D\uDC68\uD83C\uDFFD") // 👨🏻‍❤‍👨🏽
+        minimallyQualified.add("\uD83D\uDC68\uD83C\uDFFD\u200D❤\u200D\uD83D\uDC68\uD83C\uDFFE") // 👨🏽‍❤‍👨🏾
+        minimallyQualified.add("\uD83D\uDC69\u200D❤\u200D\uD83D\uDC69") // 👩‍❤‍👩
+        minimallyQualified.add("\uD83D\uDC69\uD83C\uDFFE\u200D❤\u200D\uD83D\uDC69\uD83C\uDFFC") // 👩🏾‍❤‍👩🏼
+        minimallyQualified.add("\uD83C\uDFF4\u200D☠") // 🏴‍☠
+
+        minimallyQualified.forEach { emojiSequence ->
+            assertFalse(EmojiUtil.isFullyQualifiedEmoji(emojiSequence))
+        }
+    }
+
+    @Test
+    fun isThumbsUpEmoji_thumbsUp() {
+        assertTrue(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4D")); // 👍
+        assertTrue(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4D\uD83C\uDFFB")); // 👍🏻
+        assertTrue(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4D\uD83C\uDFFC")); // 👍🏼
+        assertTrue(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4D\uD83C\uDFFD")); // 👍🏽
+        assertTrue(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4D\uD83C\uDFFE")); // 👍🏾
+        assertTrue(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4D\uD83C\uDFFF")); // 👍🏿
+    }
+
+    @Test
+    fun isThumbsUpEmoji_thumbsDown() {
+        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4E")); // 👎
+        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4E\uD83C\uDFFB")); // 👎🏻
+        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4E\uD83C\uDFFC")); // 👎🏼
+        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4E\uD83C\uDFFD")); // 👎🏽
+        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4E\uD83C\uDFFE")); // 👎🏾
+        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC4E\uD83C\uDFFF")); // 👎🏿
+    }
+
+    @Test
+    fun isThumbsUpEmoji_otherEmoji() {
+        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83E\uDD70")); // 🥰
+        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDC7E")); // 👾
+        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83D\uDCAA")); // 💪
+        assertFalse(EmojiUtil.isThumbsUpEmoji("\uD83E\uDEF1\uD83C\uDFFF\u200D\uD83E\uDEF2\uD83C\uDFFB")); // 🫱🏿‍🫲🏻
+        assertFalse(EmojiUtil.isThumbsUpEmoji("✊"));
+        assertFalse(EmojiUtil.isThumbsUpEmoji("✅"));
+        assertFalse(EmojiUtil.isThumbsUpEmoji("❌"));
+    }
+
+    @Test
+    fun isThumbsDownEmoji_thumbsDown() {
+        assertTrue(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4E")); // 👎
+        assertTrue(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4E\uD83C\uDFFB")); // 👎🏻
+        assertTrue(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4E\uD83C\uDFFC")); // 👎🏼
+        assertTrue(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4E\uD83C\uDFFD")); // 👎🏽
+        assertTrue(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4E\uD83C\uDFFE")); // 👎🏾
+        assertTrue(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4E\uD83C\uDFFF")); // 👎🏿
+    }
+
+    @Test
+    fun isThumbsDownEmoji_thumbsUp() {
+        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4D")); // 👍
+        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4D\uD83C\uDFFB")); // 👍🏻
+        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4D\uD83C\uDFFC")); // 👍🏼
+        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4D\uD83C\uDFFD")); // 👍🏽
+        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4D\uD83C\uDFFE")); // 👍🏾
+        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC4D\uD83C\uDFFF")); // 👍🏿
+    }
+
+    @Test
+    fun isThumbsDownEmoji_otherEmoji() {
+        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83E\uDD70")); // 🥰
+        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDC7E")); // 👾
+        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83D\uDCAA")); // 💪
+        assertFalse(EmojiUtil.isThumbsDownEmoji("\uD83E\uDEF1\uD83C\uDFFF\u200D\uD83E\uDEF2\uD83C\uDFFB")); // 🫱🏿‍🫲🏻
+        assertFalse(EmojiUtil.isThumbsDownEmoji("✊"));
+        assertFalse(EmojiUtil.isThumbsDownEmoji("✅"));
+        assertFalse(EmojiUtil.isThumbsDownEmoji("❌"));
+    }
+
+    @Test
+    fun isThumbsUpOrDownEmoji_thumbsUpDown() {
+        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4D")); // 👍
+        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4D\uD83C\uDFFB")); // 👍🏻
+        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4D\uD83C\uDFFC")); // 👍🏼
+        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4D\uD83C\uDFFD")); // 👍🏽
+        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4D\uD83C\uDFFE")); // 👍🏾
+        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4D\uD83C\uDFFF")); // 👍🏿
+        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4E")); // 👎
+        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4E\uD83C\uDFFB")); // 👎🏻
+        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4E\uD83C\uDFFC")); // 👎🏼
+        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4E\uD83C\uDFFD")); // 👎🏽
+        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4E\uD83C\uDFFE")); // 👎🏾
+        assertTrue(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC4E\uD83C\uDFFF")); // 👎🏿
+    }
+
+    @Test
+    fun isThumbsUpOrDownEmoji_otherEmoji() {
+        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("\uD83E\uDD70")); // 🥰
+        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDC7E")); // 👾
+        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("\uD83D\uDCAA")); // 💪
+        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("\uD83E\uDEF1\uD83C\uDFFF\u200D\uD83E\uDEF2\uD83C\uDFFB")); // 🫱🏿‍🫲🏻
+        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("✊"));
+        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("✅"));
+        assertFalse(EmojiUtil.isThumbsUpOrDownEmoji("❌"));
+    }
+}

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

@@ -146,8 +146,8 @@ class IncomingGroupSetupTest : GroupConversationListTest<GroupSetupMessage>() {
         // Assert initial group conversations
         assertGroupConversations(scenario, initialGroups)
 
-        serviceManager.blockedContactsService.add(contactA.identity)
-        serviceManager.blockedContactsService.add(contactB.identity)
+        serviceManager.blockedIdentitiesService.blockIdentity(contactA.identity)
+        serviceManager.blockedIdentitiesService.blockIdentity(contactB.identity)
 
         val setupTracker = GroupSetupTracker(
             newAGroup,

+ 13 - 8
app/src/androidTest/java/ch/threema/app/processors/IncomingMessageProcessorTest.kt

@@ -31,7 +31,7 @@ import ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGUSERACK
 import ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGUSERDEC
 import ch.threema.domain.protocol.csp.messages.AbstractMessage
 import ch.threema.domain.protocol.csp.messages.DeliveryReceiptMessage
-import ch.threema.domain.protocol.csp.messages.LocationMessage
+import ch.threema.domain.protocol.csp.messages.location.LocationMessage
 import ch.threema.domain.protocol.csp.messages.TextMessage
 import ch.threema.domain.protocol.csp.messages.TypingIndicatorMessage
 import ch.threema.domain.protocol.csp.messages.ballot.BallotData
@@ -41,6 +41,7 @@ import ch.threema.domain.protocol.csp.messages.ballot.BallotId
 import ch.threema.domain.protocol.csp.messages.ballot.BallotVote
 import ch.threema.domain.protocol.csp.messages.ballot.PollSetupMessage
 import ch.threema.domain.protocol.csp.messages.ballot.PollVoteMessage
+import ch.threema.domain.protocol.csp.messages.location.LocationMessageData
 import junit.framework.TestCase.assertEquals
 import junit.framework.TestCase.assertTrue
 import kotlinx.coroutines.test.runTest
@@ -62,12 +63,16 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
 
     @Test
     fun testIncomingLocationMessage() = runTest {
-        assertSuccessfulMessageProcessing(LocationMessage().also {
-            it.longitude = 0.0
-            it.longitude = 0.0
-        }.enrich(), contactA)
-
-        assertSuccessfulMessageProcessing(LocationMessage().enrich(), contactA)
+        val locationMessageData = LocationMessageData(
+            latitude = 0.0,
+            longitude = 0.0,
+            accuracy = null,
+            poi = null
+        )
+        assertSuccessfulMessageProcessing(
+            message = LocationMessage(locationMessageData = locationMessageData).enrich(),
+            fromContact = contactA
+        )
     }
 
     @Test
@@ -221,7 +226,7 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
         )
 
         val expectDeliveryReceiptSent = message.sendAutomaticDeliveryReceipt()
-                && !message.hasFlags(ProtocolDefines.MESSAGE_FLAG_NO_DELIVERY_RECEIPTS)
+            && !message.hasFlags(ProtocolDefines.MESSAGE_FLAG_NO_DELIVERY_RECEIPTS)
         if (expectDeliveryReceiptSent) {
             val deliveryReceiptMessage = sentMessagesInsideTask.poll()
             if (deliveryReceiptMessage is DeliveryReceiptMessage) {

+ 4 - 1
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt

@@ -402,7 +402,10 @@ open class MessageProcessorProvider {
         )
 
         // Unblock contacts
-        serviceManager.blockedContactsService.removeAll()
+        val blockedIdentitiesService = serviceManager.blockedIdentitiesService
+        blockedIdentitiesService.getAllBlockedIdentities().forEach { blockedIdentity ->
+            blockedIdentitiesService.unblockIdentity(blockedIdentity)
+        }
     }
 
     private fun setTaskManager(taskManager: TaskManager) {

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

@@ -25,8 +25,8 @@ import ch.threema.app.DangerousTest
 import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.TestTaskManager
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.services.BlockedIdentitiesService
 import ch.threema.app.services.GroupService
-import ch.threema.app.services.IdListService
 import ch.threema.app.services.PreferenceService
 import ch.threema.app.services.PreferenceServiceImpl
 import ch.threema.app.testutils.TestHelpers
@@ -63,7 +63,7 @@ class IdentityBlockedStepsTest {
     private lateinit var groupService: GroupService
     private lateinit var blockUnknownPreferenceService: PreferenceService
     private lateinit var noBlockPreferenceService: PreferenceService
-    private lateinit var blockedContactsService: IdListService
+    private lateinit var blockedIdentitiesService: BlockedIdentitiesService
 
     private val myContact = TestHelpers.TEST_CONTACT
     private val knownContact = TestContact("12345678")
@@ -89,12 +89,15 @@ class IdentityBlockedStepsTest {
         contactModelRepository = ModelRepositories(coreServiceManager).contacts
         contactStore = serviceManager.contactStore
         groupService = serviceManager.groupService
-        blockedContactsService = serviceManager.blockedContactsService
-        blockedContactsService.add(explicitlyBlockedContact.identity)
+        blockedIdentitiesService = serviceManager.blockedIdentitiesService
+        blockedIdentitiesService.blockIdentity(explicitlyBlockedContact.identity)
 
         blockUnknownPreferenceService = object : PreferenceServiceImpl(
             ThreemaApplication.getAppContext(),
             serviceManager.preferenceStore,
+            coreServiceManager.taskManager,
+            coreServiceManager.multiDeviceManager,
+            coreServiceManager.nonceFactory,
         ) {
             override fun isBlockUnknown(): Boolean {
                 return true
@@ -104,6 +107,9 @@ class IdentityBlockedStepsTest {
         noBlockPreferenceService = object : PreferenceServiceImpl(
             ThreemaApplication.getAppContext(),
             serviceManager.preferenceStore,
+            coreServiceManager.taskManager,
+            coreServiceManager.multiDeviceManager,
+            coreServiceManager.nonceFactory,
         ) {
             override fun isBlockUnknown(): Boolean {
                 return false
@@ -211,7 +217,7 @@ class IdentityBlockedStepsTest {
         contactModelRepository,
         contactStore,
         groupService,
-        blockedContactsService,
+        blockedIdentitiesService,
         preferenceService,
     )
 

+ 142 - 0
app/src/androidTest/java/ch/threema/app/services/BlockedIdentitiesServiceTest.kt

@@ -0,0 +1,142 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.services
+
+import ch.threema.app.TestMultiDeviceManager
+import ch.threema.app.TestNonceStore
+import ch.threema.app.TestTaskManager
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.listeners.ContactListener
+import ch.threema.app.managers.ListenerManager
+import ch.threema.app.stores.PreferenceStore
+import ch.threema.base.crypto.NonceFactory
+import ch.threema.domain.helpers.ServerAckTaskCodec
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class BlockedIdentitiesServiceTest {
+
+    private val multiDeviceManager = TestMultiDeviceManager(
+        isMultiDeviceActive = false
+    )
+
+    private val taskManager = TestTaskManager(ServerAckTaskCodec())
+
+    private val preferenceService = PreferenceServiceImpl(
+        ThreemaApplication.getAppContext(),
+        PreferenceStore(
+            ThreemaApplication.getAppContext(),
+            ThreemaApplication.getMasterKey()
+        ),
+        taskManager,
+        multiDeviceManager,
+        NonceFactory(TestNonceStore()),
+    )
+
+    private val blockedIdentitiesService: BlockedIdentitiesService = BlockedIdentitiesServiceImpl(
+        preferenceService,
+        multiDeviceManager,
+        ThreemaApplication.requireServiceManager().taskCreator,
+    )
+
+    private val onModified = ArrayDeque<String>()
+
+    init {
+        ListenerManager.contactListeners.add(object : ContactListener {
+            override fun onModified(identity: String) {
+                onModified.addLast(identity)
+            }
+        })
+    }
+
+    @BeforeTest
+    fun initListener() {
+        blockedIdentitiesService.persistBlockedIdentities(emptySet())
+        onModified.clear()
+        // Assert that initially no identities are blocked
+        assertTrue { blockedIdentitiesService.getAllBlockedIdentities().isEmpty() }
+    }
+
+    @Test
+    fun testBlockIdentity() {
+        blockedIdentitiesService.blockIdentity("ABCDEFGH")
+        blockedIdentitiesService.blockIdentity("TESTTEST")
+
+        assertTrue { blockedIdentitiesService.isBlocked("ABCDEFGH") }
+        assertTrue { blockedIdentitiesService.isBlocked("TESTTEST") }
+
+        assertTrue { onModified.removeFirst() == "ABCDEFGH" }
+        assertTrue { onModified.removeFirst() == "TESTTEST" }
+    }
+
+    @Test
+    fun testUnblockIdentity() {
+        blockedIdentitiesService.blockIdentity("ABCDEFGH")
+        blockedIdentitiesService.blockIdentity("TESTTEST")
+        blockedIdentitiesService.unblockIdentity("ABCDEFGH")
+
+        assertTrue { !blockedIdentitiesService.isBlocked("ABCDEFGH") }
+        assertTrue { blockedIdentitiesService.isBlocked("TESTTEST") }
+
+        assertTrue { onModified.removeFirst() == "ABCDEFGH" }
+        assertTrue { onModified.removeFirst() == "TESTTEST" }
+        assertTrue { onModified.removeFirst() == "ABCDEFGH" }
+    }
+
+    @Test
+    fun testPersistIdentities() {
+        blockedIdentitiesService.persistBlockedIdentities(setOf("ABCDEFGH", "12345678"))
+
+        assertEquals(
+            setOf("ABCDEFGH", "12345678"),
+            setOf(onModified.removeFirst(), onModified.removeFirst())
+        )
+
+        blockedIdentitiesService.persistBlockedIdentities(setOf("ABCDEFGH", "TESTTEST"))
+
+        assertEquals(
+            setOf("12345678", "TESTTEST"),
+            setOf(onModified.removeFirst(), onModified.removeFirst())
+        )
+        assertTrue { onModified.isEmpty() }
+    }
+
+    @Test
+    fun testToggle() {
+        blockedIdentitiesService.toggleBlocked("12345678")
+
+        assertTrue { blockedIdentitiesService.isBlocked("12345678") }
+        assertEquals("12345678", onModified.removeFirst())
+
+        blockedIdentitiesService.toggleBlocked("12345678")
+
+        assertFalse { blockedIdentitiesService.isBlocked("12345678") }
+        assertEquals("12345678", onModified.removeFirst())
+
+        assertTrue { onModified.isEmpty() }
+    }
+
+}

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

@@ -457,6 +457,38 @@ class PersistableTasksTest {
         )
     }
 
+    @Test
+    fun testReflectUnknownContactPolicyUpdate() {
+        assertValidEncoding(
+            ReflectSettingsSyncTask.ReflectUnknownContactPolicySyncUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectSettingsSyncTask.ReflectUnknownContactPolicySyncUpdate.ReflectUnknownContactPolicySyncUpdateData\"}"
+        )
+    }
+
+    @Test
+    fun testReflectReadReceiptPolicySyncUpdate() {
+        assertValidEncoding(
+            ReflectSettingsSyncTask.ReflectReadReceiptPolicySyncUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectSettingsSyncTask.ReflectReadReceiptPolicySyncUpdate.ReadReceiptPolicySyncUpdateData\"}"
+        )
+    }
+
+    @Test
+    fun testReflectTypingIndicatorPolicySyncUpdate() {
+        assertValidEncoding(
+            ReflectSettingsSyncTask.ReflectTypingIndicatorPolicySyncUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectSettingsSyncTask.ReflectTypingIndicatorPolicySyncUpdate.ReflectTypingIndicatorPolicySyncUpdateData\"}"
+        )
+    }
+
+    @Test
+    fun testReflectBlockedIdentitiesSyncUpdate() {
+        assertValidEncoding(
+            ReflectSettingsSyncTask.ReflectBlockedIdentitiesSyncUpdate::class.java,
+            "{\"type\":\"ch.threema.app.tasks.ReflectSettingsSyncTask.ReflectBlockedIdentitiesSyncUpdate.ReflectBlockedIdentitiesSyncUpdateData\"}"
+        )
+    }
+
     private fun addTestIdentity() = runBlocking {
         val identity = "01234567"
         if (serviceManager.modelRepositories.contacts.getByIdentity(identity) != null) {

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

@@ -50,7 +50,7 @@ class BundledMessagesSendStepsTest : MessageProcessorProvider() {
             serviceManager.modelRepositories.contacts,
             serviceManager.groupService,
             serviceManager.nonceFactory,
-            serviceManager.blockedContactsService,
+            serviceManager.blockedIdentitiesService,
             serviceManager.preferenceService,
             serviceManager.multiDeviceManager,
         )

+ 6 - 2
app/src/androidTest/java/ch/threema/app/utils/GeoLocationUtilTest.kt

@@ -39,13 +39,17 @@ class GeoLocationUtilTest {
         assertEquals(expected.latitude, actual?.latitude)
         assertEquals(expected.longitude, actual?.longitude)
         assertEquals(expected.poi, actual?.poi)
-        assertEquals(expected.address, actual?.address)
         assertEquals(expected.accuracy, actual?.accuracy)
     }
 
     @Test
     fun testGetLocationFromUri() {
-        val latLong1234 = LocationDataModel(12.0, 34.0, 0, "", "")
+        val latLong1234 = LocationDataModel(
+            latitude = 12.0,
+            longitude = 34.0,
+            accuracy = 0.0,
+            poi = null
+        )
         expectLocationData(latLong1234, "geo:12,34;abcd=efg")
         expectLocationData(latLong1234, "geo:12.0,34.00;a=b;c=d")
         expectLocationData(latLong1234, "geo:12.0,34.0?q=12.0,34.0")

+ 1 - 2
app/src/androidTest/java/ch/threema/app/utils/TextUtilTest.java

@@ -36,7 +36,7 @@ import static junit.framework.Assert.assertTrue;
 /**
  * Ensure the Call SDP does not contain any "funny" easter eggs such as silly header extensions
  * that are not encrypted and contain sensitive information.
- *
+ * <p>
  * This may need updating from time to time, so if it breaks, you will have to do some
  * research on what changed and why.
  */
@@ -68,5 +68,4 @@ public class TextUtilTest {
 		assertTrue(TextUtil.checkBadPassword(context, "apples123"));
 		assertFalse(TextUtil.checkBadPassword(context, "kajsdlfkjalskdjflkajsdfl"));
 	}
-
 }

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

@@ -28,7 +28,7 @@ import ch.threema.storage.DatabaseServiceNew
 /**
  * An in-memory database used in android tests.
  */
-class TestDatabaseService() : DatabaseServiceNew(
+class TestDatabaseService: DatabaseServiceNew(
     ApplicationProvider.getApplicationContext(),
     null,
     "test-database-key",

+ 15 - 0
app/src/androidTest/java/ch/threema/data/repositories/ContactModelRepositoryTest.kt

@@ -42,6 +42,7 @@ import ch.threema.storage.models.ContactModel.AcquaintanceLevel
 import ch.threema.testhelpers.nonSecureRandomArray
 import ch.threema.testhelpers.randomIdentity
 import com.neilalexander.jnacl.NaCl
+import junit.framework.TestCase.assertNotNull
 import kotlinx.coroutines.runBlocking
 import org.junit.Assert.assertThrows
 import org.junit.Before
@@ -407,6 +408,20 @@ class ContactModelRepositoryTest(private val contactModelData: ContactModelData)
         taskCodecMd.transactionCommitCount = 0
     }
 
+    @Test
+    fun updateNickname() {
+        // Create contact using "old model"
+        val identity = randomIdentity()
+        databaseService.contactModelFactory.createOrUpdate(ContactModel(identity, nonSecureRandomArray(32)))
+
+        // Fetch model
+        val model: ch.threema.data.models.ContactModel? = contactModelRepository.getByIdentity(identity)
+        assertNotNull(model)
+        assertEquals(null, model!!.data.value?.nickname)
+        model.setNicknameFromSync("testnick")
+        assertEquals("testnick", model.data.value?.nickname)
+    }
+
     private fun assertContentEquals(expected: ContactModelData?, actual: ContactModelData?) {
         if (expected == null && actual == null) {
             return

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

@@ -0,0 +1,179 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.data.repositories
+
+import ch.threema.app.TestCoreServiceManager
+import ch.threema.app.TestTaskManager
+import ch.threema.app.ThreemaApplication
+import ch.threema.data.TestDatabaseService
+import ch.threema.data.storage.EmojiReactionsDao
+import ch.threema.data.storage.EmojiReactionsDaoImpl
+import ch.threema.domain.helpers.UnusedTaskCodec
+import ch.threema.protobuf.d2d.sync.group
+import ch.threema.storage.models.AbstractMessageModel
+import ch.threema.storage.models.GroupMessageModel
+import ch.threema.storage.models.MessageModel
+import ch.threema.storage.models.MessageType
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import java.util.UUID
+
+class EmojiReactionsRepositoryTest {
+    private lateinit var databaseService: TestDatabaseService
+    private lateinit var emojiReactionsRepository: EmojiReactionsRepository
+    private lateinit var emojiReactionDao: EmojiReactionsDao
+
+    @Before
+    fun before() {
+        databaseService = TestDatabaseService()
+        val testCoreServiceManager = TestCoreServiceManager(
+            version = ThreemaApplication.getAppVersion(),
+            databaseService = databaseService,
+            preferenceStore = ThreemaApplication.requireServiceManager().preferenceStore,
+            taskManager = TestTaskManager(UnusedTaskCodec())
+        )
+
+        emojiReactionsRepository = ModelRepositories(testCoreServiceManager).emojiReaction
+        emojiReactionDao = EmojiReactionsDaoImpl(databaseService)
+    }
+
+    @Test
+    fun testEmojiReactionForeignKeyConstraint() {
+        val contactMessage = MessageModel().enrich()
+
+        Assert.assertThrows(EmojiReactionEntryCreateException::class.java) {
+            emojiReactionsRepository.createEntry(contactMessage, "ABCDEFGH", "\uD83C\uDFC8")
+        }
+
+        databaseService.messageModelFactory.create(contactMessage)
+
+        contactMessage.assertEmojiReactionSize(0)
+
+        contactMessage.body = "reacted"
+
+        emojiReactionsRepository.createEntry(contactMessage, "ABCDEFGH", "⚽")
+        databaseService.messageModelFactory.update(contactMessage)
+
+        contactMessage.assertEmojiReactionSize(1)
+
+        databaseService.messageModelFactory.delete(contactMessage)
+
+        contactMessage.assertEmojiReactionSize(0)
+    }
+
+    @Test
+    fun testGroupEmojiReactionForeignKeyConstraint() {
+        val groupMessage = GroupMessageModel().enrich()
+
+        Assert.assertThrows(EmojiReactionEntryCreateException::class.java) {
+            emojiReactionsRepository.createEntry(groupMessage, "ABCDEFGH", "⚾")
+        }
+
+        databaseService.groupMessageModelFactory.create(groupMessage)
+
+        groupMessage.assertEmojiReactionSize(0)
+
+        groupMessage.body = "Reacted"
+
+        emojiReactionsRepository.createEntry(groupMessage, "ABCDEFGH", "⚽")
+        databaseService.groupMessageModelFactory.update(groupMessage)
+
+        groupMessage.assertEmojiReactionSize(1)
+
+        databaseService.groupMessageModelFactory.delete(groupMessage)
+
+        groupMessage.assertEmojiReactionSize(0)
+    }
+
+    @Test
+    fun testEmojiReactionUniqueness() {
+        val message = MessageModel().enrich()
+        databaseService.messageModelFactory.create(message)
+
+        message.assertEmojiReactionSize(0)
+        message.body = "reacted"
+
+        emojiReactionsRepository.createEntry(message, "ABCDEFGH", "⚽")
+        databaseService.messageModelFactory.update(message)
+
+        message.assertEmojiReactionSize(1)
+
+        Assert.assertThrows(EmojiReactionEntryCreateException::class.java) {
+            emojiReactionsRepository.createEntry(message, "ABCDEFGH", "⚽")
+        }
+
+        message.assertEmojiReactionSize(1)
+
+        val reactions = emojiReactionsRepository.getReactionsByMessage(message)
+        Assert.assertNotNull(reactions)
+
+        val reaction = reactions!!.data.value!![0]
+        Assert.assertEquals("⚽", reaction.emojiSequence)
+
+        databaseService.messageModelFactory.delete(message)
+    }
+
+    @Test
+    fun testContactAndGroupReactionsNotMixedUp() {
+        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", "⚾")
+
+        contactMessage.assertEmojiReactionSize(1)
+        groupMessage.assertEmojiReactionSize(0)
+
+        emojiReactionsRepository.createEntry(groupMessage, "ABCD1234", "⛵")
+
+        contactMessage.assertEmojiReactionSize(1)
+        groupMessage.assertEmojiReactionSize(1)
+
+        val contactReaction = emojiReactionsRepository.getReactionsByMessage(contactMessage)!!.data.value!![0]
+        val groupReaction = emojiReactionsRepository.getReactionsByMessage(groupMessage)!!.data.value!![0]
+
+        Assert.assertEquals("⚾", contactReaction.emojiSequence)
+        Assert.assertEquals("⛵", groupReaction.emojiSequence)
+    }
+
+    private fun AbstractMessageModel.assertEmojiReactionSize(expectedSize: Int) {
+        val actualSize = emojiReactionDao.findAllByMessage(this).size
+
+        Assert.assertEquals(expectedSize, actualSize)
+    }
+
+    private fun <T : AbstractMessageModel> T.enrich(text: String = "Text"): T {
+        type = MessageType.TEXT
+        uid = UUID.randomUUID().toString()
+        body = text
+        return this
+    }
+}

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

@@ -1,4 +1,2 @@
-- Behebung eines Fehlers der zu einem Crash auf einigen Motorola Geräten führt, wenn ein Chat geöffnet wird
-- Behebung eines Fehlers bei der Darstellung von Sprachnachrichten auf Tablets
-- Änderungen bei der Verarbeitung von Nachrichten als Vorbereitung für Multi Device auf Android
-- Weniger Statusnachrichten bei Umfragen
+- Überarbeitete Darstellung der Zustimmen-/Ablehnen-Icons (in Vorbereitung auf kommende Emoji-Reaktionen)
+- Behebung eines Fehlers bzgl. dem Nachrichten-Status von Chats mit verpassten Anrufen

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

@@ -1,4 +1,2 @@
-- Fixed a bug that crashes the app on some motorola devices when a chat is opened
-- Fixed a bug regarding the display of voice messages on tablets
-- Changes during message processing in preparation of multi device for android
-- Reduce number of status messages when using polls
+- 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

+ 9 - 1
app/src/main/AndroidManifest.xml

@@ -498,7 +498,7 @@
 			android:theme="@style/Theme.Threema.WithToolbar"
 			android:configChanges="uiMode" />
 		<activity
-			android:name=".activities.BlockedContactsActivity"
+			android:name=".activities.BlockedIdentitiesActivity"
 			android:theme="@style/Theme.Threema.WithToolbar"
 			android:configChanges="uiMode" />
 		<activity
@@ -893,6 +893,14 @@
 		<activity
 			android:name=".debug.PatternLibraryActivity"
 			android:theme="@style/Theme.Threema.Translucent" />
+        <activity
+            android:name="ch.threema.app.emojireactions.EmojiReactionsOverviewActivity"
+            android:theme="@style/Theme.Threema.Translucent"
+            android:windowSoftInputMode="adjustResize"/>
+        <activity
+            android:name="ch.threema.app.emojireactions.EmojiReactionsPickerActivity"
+            android:theme="@style/Theme.Threema.Translucent"
+            android:windowSoftInputMode="adjustResize"/>
 
 		<!-- services -->
 		<service

+ 1 - 4
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -675,7 +675,7 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
          */
         try {
             if (getMasterKey() != null && !getMasterKey().isProtected()) {
-                if (serviceManager != null && serviceManager.getPreferenceService().getWizardRunning()) {
+                if (serviceManager != null && serviceManager.getNotificationPreferenceService().getWizardRunning()) {
                     getMasterKey().setPassphrase(null);
                 }
             }
@@ -1480,9 +1480,6 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 						final ConversationService conversationService = serviceManager.getConversationService();
 						final ContactService contactService = serviceManager.getContactService();
 
-                        // Remove contact from cache
-                        contactService.removeFromCache(identity);
-
                         // Refresh conversation cache
                         conversationService.updateContactConversation(modifiedContactModel);
                         conversationService.refresh(modifiedContactModel);

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

@@ -26,6 +26,7 @@ import android.location.Location;
 import org.slf4j.Logger;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.messagereceiver.MessageReceiver;
@@ -61,7 +62,7 @@ public class LocationMessageSendAction extends SendAction {
 	public boolean sendLocationMessage(
 		final MessageReceiver[] allReceivers,
 		final Location location,
-		final String poiName,
+		final @Nullable String poiName,
 		final ActionHandler actionHandler
 	) {
 		if (actionHandler == null) {
@@ -123,7 +124,7 @@ public class LocationMessageSendAction extends SendAction {
 	private void sendSingleMessage(
 		final MessageReceiver messageReceiver,
 		final @NonNull Location location,
-		final String poiName,
+		final @Nullable String poiName,
 		final @NonNull ActionHandler actionHandler
 	) {
 		if (messageReceiver == null) {

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

@@ -0,0 +1,58 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2014-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.activities
+
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+
+class BlockedIdentitiesActivity : IdentityListActivity() {
+    private val identityList: IdentityList? by lazy {
+        val blockedIdentitiesService =
+            ThreemaApplication.getServiceManager()?.blockedIdentitiesService ?: return@lazy null
+
+        object : IdentityList {
+            override fun getAll(): Set<String> {
+                return blockedIdentitiesService.getAllBlockedIdentities()
+            }
+
+            override fun addIdentity(identity: String) {
+                blockedIdentitiesService.blockIdentity(identity)
+            }
+
+            override fun removeIdentity(identity: String) {
+                blockedIdentitiesService.unblockIdentity(identity)
+            }
+        }
+    }
+
+    override fun getIdentityListHandle(): IdentityList? {
+        return identityList
+    }
+
+    override fun getBlankListText(): String {
+        return this.getString(R.string.prefs_sum_blocked_contacts)
+    }
+
+    override fun getTitleText(): String {
+        return this.getString(R.string.prefs_title_blocked_contacts)
+    }
+}

+ 22 - 10
app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java

@@ -83,6 +83,7 @@ import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
 import ch.threema.app.listeners.GroupListener;
 import ch.threema.app.managers.ListenerManager;
+import ch.threema.app.services.BlockedIdentitiesService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.DeadlineListService;
 import ch.threema.app.services.GroupService;
@@ -143,7 +144,8 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	private ContactService contactService;
 	private ContactModelRepository contactModelRepository;
 	private GroupService groupService;
-	private IdListService blockedContactsService, profilePicRecipientsService;
+    private BlockedIdentitiesService blockedIdentitiesService;
+	private IdListService profilePicRecipientsService;
 	private DeadlineListService hiddenChatsListService;
 	private VoipStateService voipStateService;
 	private DeleteContactServices deleteContactServices;
@@ -328,7 +330,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			this.contactService = serviceManager.getContactService();
 			modelRepositories = serviceManager.getModelRepositories();
 			contactModelRepository = modelRepositories.getContacts();
-			this.blockedContactsService = serviceManager.getBlockedContactsService();
+			this.blockedIdentitiesService = serviceManager.getBlockedIdentitiesService();
 			this.profilePicRecipientsService = serviceManager.getProfilePicRecipientsService();
 			this.groupService = serviceManager.getGroupService();
 			this.hiddenChatsListService = serviceManager.getHiddenChatsListService();
@@ -698,9 +700,9 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	private void updateVoipCallMenuItem(final Boolean newState) {
 		if (callItem != null) {
 			if (
-				ContactUtil.canReceiveVoipMessages(contact, blockedContactsService)
+				ContactUtil.canReceiveVoipMessages(contact, blockedIdentitiesService)
 					&& ConfigUtils.isCallsEnabled()) {
-				logger.debug("updateVoipMenu newState " + newState);
+				logger.debug("updateVoipMenu newState {}", newState);
 
 				callItem.setVisible(newState != null ? newState : voipStateService.getCallState().isIdle());
 			} else {
@@ -730,8 +732,8 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		} else if (id == R.id.menu_threema_call) {
 			VoipUtil.initiateCall(this, contact, false, null);
 		} else if (id == R.id.action_block_contact) {
-			if (this.blockedContactsService != null && this.blockedContactsService.has(this.identity)) {
-				blockContact();
+			if (this.blockedIdentitiesService != null && this.blockedIdentitiesService.isBlocked(this.identity)) {
+				unblockContact();
 			} else {
 				GenericAlertDialog.newInstance(R.string.block_contact, R.string.really_block_contact, R.string.yes, R.string.no).show(getSupportFragmentManager(), DIALOG_TAG_CONFIRM_BLOCK);
 			}
@@ -763,14 +765,24 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	}
 
 	private void blockContact() {
-		if (this.blockedContactsService != null) {
-			this.blockedContactsService.toggle(this, this.contact);
-		}
+		if (this.blockedIdentitiesService != null) {
+			this.blockedIdentitiesService.blockIdentity(identity, this);
+		} else {
+            logger.error("Could not block contact as the service is null");
+        }
 	}
 
+    private void unblockContact() {
+        if (this.blockedIdentitiesService != null) {
+            this.blockedIdentitiesService.unblockIdentity(identity, null);
+        } else {
+            logger.error("Could not unblock contact as the service is null");
+        }
+    }
+
 	private void updateBlockMenu() {
 		if (this.blockMenuItem != null) {
-			if (blockedContactsService != null && blockedContactsService.has(this.identity)) {
+			if (blockedIdentitiesService != null && blockedIdentitiesService.isBlocked(this.identity)) {
 				blockMenuItem.setTitle(R.string.unblock_contact);
 			} else {
 				blockMenuItem.setTitle(R.string.block_contact);

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

@@ -0,0 +1,57 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2013-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.activities
+
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+
+class ExcludedSyncIdentitiesActivity : IdentityListActivity() {
+    private val identityList: IdentityList? by lazy {
+        val listService = ThreemaApplication.getServiceManager()?.excludedSyncIdentitiesService ?: return@lazy null
+
+        object : IdentityList {
+            override fun getAll(): Set<String> {
+                return listService.all?.toSet() ?: setOf()
+            }
+
+            override fun addIdentity(identity: String) {
+                listService.add(identity)
+            }
+
+            override fun removeIdentity(identity: String) {
+                listService.remove(identity)
+            }
+        }
+    }
+
+    override fun getIdentityListHandle(): IdentityList? {
+        return identityList
+    }
+
+    override fun getBlankListText(): String {
+        return this.getString(R.string.prefs_sum_excluded_sync_identities)
+    }
+
+    override fun getTitleText(): String {
+        return this.getString(R.string.prefs_title_excluded_sync_identities)
+    }
+}

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

@@ -93,8 +93,8 @@ import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
 import ch.threema.app.listeners.GroupListener;
 import ch.threema.app.managers.ListenerManager;
+import ch.threema.app.services.BlockedIdentitiesService;
 import ch.threema.app.services.DeviceService;
-import ch.threema.app.services.IdListService;
 import ch.threema.app.services.group.GroupInviteService;
 import ch.threema.app.ui.AvatarEditView;
 import ch.threema.app.ui.GroupDetailViewModel;
@@ -150,7 +150,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 	private GroupInviteService groupInviteService;
 	private DeviceService deviceService;
-	private IdListService blockedContactsService;
+	private BlockedIdentitiesService blockedIdentitiesService;
 	private GroupCallManager groupCallManager;
 
 	private GroupModel groupModel;
@@ -324,7 +324,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		// services
 		try {
 			this.deviceService = serviceManager.getDeviceService();
-			this.blockedContactsService = serviceManager.getBlockedContactsService();
+			this.blockedIdentitiesService = serviceManager.getBlockedIdentitiesService();
 			this.groupInviteService = serviceManager.getGroupInviteService();
 			this.groupCallManager = serviceManager.getGroupCallManager();
 		} catch (ThreemaException e) {
@@ -333,7 +333,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			return;
 		}
 
-		if (this.deviceService == null || this.blockedContactsService == null) {
+		if (this.deviceService == null || this.blockedIdentitiesService == null) {
 			finish();
 			return;
 		}
@@ -1167,7 +1167,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			items.add(new SelectorDialogItem(String.format(getString(R.string.chat_with), shortName), R.drawable.ic_chat_bubble));
 			optionsMap.add(SELECTOR_OPTION_CHAT);
 
-			if (ContactUtil.canReceiveVoipMessages(contactModel, blockedContactsService)
+			if (ContactUtil.canReceiveVoipMessages(contactModel, blockedIdentitiesService)
 				&& ConfigUtils.isCallsEnabled()
 			) {
 				items.add(new SelectorDialogItem(String.format(getString(R.string.call_with), shortName), R.drawable.ic_phone_locked_outline));

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

@@ -132,6 +132,7 @@ import ch.threema.app.services.DeviceService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.MessageService;
+import ch.threema.app.services.NotificationPreferenceService;
 import ch.threema.app.services.PassphraseService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.ThreemaPushService;
@@ -230,6 +231,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private APIConnector apiConnector;
 	private LockAppService lockAppService;
 	private PreferenceService preferenceService;
+    private NotificationPreferenceService notificationPreferenceService;
 	private ConversationService conversationService;
 	private GroupCallManager groupCallManager;
 
@@ -1062,6 +1064,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		if (serviceManager != null) {
 			this.userService = this.serviceManager.getUserService();
 			this.preferenceService = this.serviceManager.getPreferenceService();
+            this.notificationPreferenceService = serviceManager.getNotificationPreferenceService();
 			this.notificationService = serviceManager.getNotificationService();
 			this.lockAppService = serviceManager.getLockAppService();
 			try {
@@ -1123,8 +1126,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		checkApp();
 
 		// start wizard if necessary
-		if (preferenceService.getWizardRunning() || !userService.hasIdentity()) {
-			logger.debug("Missing identity. Wizard running? " + preferenceService.getWizardRunning());
+		if (notificationPreferenceService.getWizardRunning() || !userService.hasIdentity()) {
+			logger.debug("Missing identity. Wizard running: {}", notificationPreferenceService.getWizardRunning());
 
 			if (userService.hasIdentity()) {
 				startActivity(new Intent(this, WizardBaseActivity.class));

+ 31 - 17
app/src/main/java/ch/threema/app/activities/IdentityListActivity.java

@@ -28,6 +28,13 @@ import android.view.Menu;
 import android.view.MenuItem;
 import android.view.ViewGroup;
 
+import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.view.ActionMode;
@@ -36,14 +43,6 @@ import androidx.recyclerview.widget.DefaultItemAnimator;
 import androidx.recyclerview.widget.DividerItemDecoration;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
-
-import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
-
-import java.util.Arrays;
-import java.util.Iterator;
-import java.util.List;
-import java.util.stream.Collectors;
-
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.IdentityListAdapter;
@@ -51,7 +50,6 @@ import ch.threema.app.dialogs.TextEntryDialog;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.services.ContactService;
-import ch.threema.app.services.IdListService;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.EmptyView;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
@@ -67,7 +65,24 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
     private EmptyRecyclerView recyclerView;
     private ContactService contactService;
 
-    abstract protected IdListService getIdentityListService();
+    public interface IdentityList {
+        /**
+         * Get all identities.
+         */
+        Set<String> getAll();
+
+        /**
+         * Add an identity.
+         */
+        void addIdentity(@NonNull String identity);
+
+        /**
+         * Remove an identity.
+         */
+        void removeIdentity(@NonNull String identity);
+    }
+
+    abstract protected IdentityList getIdentityListHandle();
 
     @Override
     public void onCreate(Bundle savedInstanceState) {
@@ -133,12 +148,12 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
     protected abstract String getTitleText();
 
     private void updateListAdapter() {
-        if(this.getIdentityListService() == null) {
+        if(this.getIdentityListHandle() == null) {
             //do nothing;
             return;
         }
 
-        List<IdentityListAdapter.Entity> identityList = Arrays.stream(this.getIdentityListService().getAll())
+        List<IdentityListAdapter.Entity> identityList = this.getIdentityListHandle().getAll().stream()
             .map(IdentityListAdapter.Entity::new)
             .collect(Collectors.toList());
 
@@ -207,13 +222,12 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
     }
 
     private void excludeIdentity(String identity) {
-        if(this.getIdentityListService() == null) {
+        if(this.getIdentityListHandle() == null) {
             return;
         }
 
         //add identity to list!
-        this.getIdentityListService()
-            .add(identity);
+        this.getIdentityListHandle().addIdentity(identity);
 
         fireOnModifiedContact(identity);
 
@@ -221,12 +235,12 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
     }
 
     private void removeIdentity(String identity) {
-        if(this.getIdentityListService() == null) {
+        if(this.getIdentityListHandle() == null) {
             return;
         }
 
         //remove identity from list!
-        this.getIdentityListService().remove(identity);
+        this.getIdentityListHandle().removeIdentity(identity);
 
         fireOnModifiedContact(identity);
 

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

@@ -189,7 +189,7 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 		} else if (intent.getData() != null) {
 			LocationDataModel locationData = GeoLocationUtil.getLocationDataFromGeoUri(intent.getData());
 			if (locationData != null) {
-				markerPosition = new LatLng(locationData.getLatitude(), locationData.getLongitude());
+				markerPosition = new LatLng(locationData.latitude, locationData.longitude);
 				isShowingExternalLocation = true;
 			} else {
 				Toast.makeText(this, R.string.cannot_display_location, Toast.LENGTH_LONG).show();

+ 23 - 14
app/src/main/java/ch/threema/app/activities/MessageDetailsActivity.kt

@@ -40,7 +40,6 @@ import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLif
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.viewmodel.compose.viewModel
-import ch.threema.app.BuildConfig
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.compose.edithistory.EditHistoryList
@@ -59,6 +58,7 @@ import ch.threema.app.utils.IntentDataUtil
 import ch.threema.app.utils.LinkifyUtil
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.storage.models.AbstractMessageModel
+import ch.threema.storage.models.MessageType
 import com.google.android.material.appbar.MaterialToolbar
 import org.slf4j.Logger
 
@@ -192,24 +192,35 @@ class MessageDetailsActivity : ThreemaToolbarActivity(), DialogClickListener {
                     isOutbox = messageModel.isOutbox,
                     shouldMarkupText = uiState.shouldMarkupText,
                     headerContent = {
-                        Column {
-                            Spacer(modifier = Modifier.height(16.dp))
-                            CompleteMessageBubble(
-                                message = messageModel,
-                                shouldMarkupText = uiState.shouldMarkupText,
-                                isTextSelectable = true,
-                                textSelectionCallback = textSelectionCallback
-                            )
+                        when (messageModel.type) {
+                            MessageType.TEXT,
+                            MessageType.FILE,
+                            MessageType.LOCATION,
+                            MessageType.IMAGE,
+                            MessageType.VIDEO,
+                            MessageType.VOICEMESSAGE,
+                            -> Column {
+                                Spacer(modifier = Modifier.height(16.dp))
+                                CompleteMessageBubble(
+                                    message = messageModel,
+                                    shouldMarkupText = uiState.shouldMarkupText,
+                                    isTextSelectable = true,
+                                    textSelectionCallback = textSelectionCallback
+                                )
+                            }
+                            else -> Unit
                         }
                     },
-                    footerContent = if (BuildConfig.SHOW_TIMESTAMPS_AND_TECHNICAL_INFO_IN_MESSAGE_DETAILS) {
-                        {
-                            Column {
+                    footerContent = {
+                        Column {
+                            if (messageModel.messageTimestampsUiModel.hasProperties()) {
                                 Spacer(modifier = Modifier.height(8.dp))
                                 MessageTimestampsListBox(
                                     messageTimestampsUiModel = messageModel.messageTimestampsUiModel,
                                     isOutbox = messageModel.isOutbox
                                 )
+                            }
+                            if (messageModel.messageDetailsUiModel.hasProperties()) {
                                 Spacer(modifier = Modifier.height(16.dp))
                                 MessageDetailsListBox(
                                     messageDetailsUiModel = messageModel.messageDetailsUiModel,
@@ -218,8 +229,6 @@ class MessageDetailsActivity : ThreemaToolbarActivity(), DialogClickListener {
                                 Spacer(modifier = Modifier.height(24.dp))
                             }
                         }
-                    } else {
-                        {}
                     }
                 )
             }

+ 25 - 67
app/src/main/java/ch/threema/app/activities/MessageDetailsViewModel.kt

@@ -103,37 +103,13 @@ data class MessageUiModel(
     val editedAt: Date?,
     val isDeleted: Boolean,
     val isOutbox: Boolean,
-    val ackUiModel: AckUiModel?,
     @DrawableRes val deliveryIconRes: Int?,
     @StringRes val deliveryIconContentDescriptionRes: Int?,
     val messageTimestampsUiModel: MessageTimestampsUiModel,
-    val messageDetailsUiModel: MessageDetailsUiModel
+    val messageDetailsUiModel: MessageDetailsUiModel,
+    val type: MessageType?,
 )
 
-enum class UserReaction {
-    REACTED,
-    NONE,
-}
-
-enum class ContactAckDecState {
-    ACK,
-    DEC,
-    NONE,
-}
-
-class GroupAckDecState(val count: Int, val userReaction: UserReaction)
-
-sealed interface AckUiModel
-
-data class ContactAckUiModel(
-    val ackDecState: ContactAckDecState,
-) : AckUiModel
-
-data class GroupAckUiModel(
-    val ackState: GroupAckDecState,
-    val decState: GroupAckDecState,
-) : AckUiModel
-
 data class MessageTimestampsUiModel(
     val createdAt: Date? = null,
     val sentAt: Date? = null,
@@ -143,14 +119,32 @@ data class MessageTimestampsUiModel(
     val modifiedAt: Date? = null,
     val editedAt: Date? = null,
     val deletedAt: Date? = null
-)
+) {
+   fun hasProperties(): Boolean {
+        return createdAt != null
+            || sentAt != null
+            || receivedAt != null
+            || deliveredAt != null
+            || readAt != null
+            || modifiedAt != null
+            || editedAt != null
+            || deletedAt != null
+    }
+}
 
 data class MessageDetailsUiModel(
     val messageId: String? = null,
     val mimeType: String? = null,
     val fileSizeInBytes: Long? = null,
-    val pfsState: ForwardSecurityMode? = null
-)
+    val pfsState: ForwardSecurityMode? = null,
+) {
+    fun hasProperties(): Boolean {
+        return messageId != null
+            || mimeType != null
+            || fileSizeInBytes != null
+            || pfsState != null
+    }
+}
 
 fun AbstractMessageModel.toUiModel(myIdentity: String) = MessageUiModel(
     uid = this.uid,
@@ -161,47 +155,11 @@ fun AbstractMessageModel.toUiModel(myIdentity: String) = MessageUiModel(
     isOutbox = this.isOutbox,
     deliveryIconRes = StateBitmapUtil.getInstance()?.getStateDrawable(this.state),
     deliveryIconContentDescriptionRes = StateBitmapUtil.getInstance()?.getStateDescription(this.state),
-    ackUiModel = this.getAckUiModel(myIdentity),
     messageTimestampsUiModel = this.toMessageTimestampsUiModel(),
-    messageDetailsUiModel = this.toMessageDetailsUiModel()
+    messageDetailsUiModel = this.toMessageDetailsUiModel(),
+    type = this.type,
 )
 
-/**
- *  @return Only returns an instance of [AckUiModel] if there is at least one ack/dec for this [AbstractMessageModel].
- */
-private fun AbstractMessageModel.getAckUiModel(myIdentity: String): AckUiModel? = let {
-    if (this is GroupMessageModel) {
-        val ackStates = groupMessageStates?.filter { groupMessageState -> groupMessageState.value == MessageState.USERACK.name }
-        val decStates = groupMessageStates?.filter { groupMessageState -> groupMessageState.value == MessageState.USERDEC.name }
-
-        val ackUserReaction = if (ackStates?.containsKey(myIdentity) == true) {
-            UserReaction.REACTED
-        } else {
-            UserReaction.NONE
-        }
-        val decUserReaction = if (decStates?.containsKey(myIdentity) == true) {
-            UserReaction.REACTED
-        } else {
-            UserReaction.NONE
-        }
-        return@let if (ackUserReaction != UserReaction.NONE || decUserReaction != UserReaction.NONE) {
-            GroupAckUiModel(
-                ackState = GroupAckDecState(ackStates?.size ?: 0, ackUserReaction),
-                decState = GroupAckDecState(decStates?.size ?: 0, decUserReaction),
-            )
-        } else {
-            null
-        }
-    } else {
-        val contactAckDecState = when (this.state) {
-            MessageState.USERACK -> ContactAckDecState.ACK
-            MessageState.USERDEC -> ContactAckDecState.DEC
-            else -> return@let null
-        }
-        ContactAckUiModel(contactAckDecState)
-    }
-}
-
 fun AbstractMessageModel?.toMessageTimestampsUiModel(): MessageTimestampsUiModel {
     if (this == null) {
         return MessageTimestampsUiModel()

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

@@ -63,6 +63,7 @@ import ch.threema.app.services.DeadlineListService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.RingtoneService;
+import ch.threema.app.services.ServicesConstants;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.LogUtil;
@@ -307,7 +308,7 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 
 		// SOUND
 		if (ringtoneService.hasCustomRingtone(uid)) {
-			if (selectedRingtone == null || selectedRingtone.toString() == null || selectedRingtone.toString().equals("null")) {
+			if (selectedRingtone == null || selectedRingtone.toString().equals(ServicesConstants.PREFERENCES_NULL)) {
 				// silent ringtone selected
 				radioSoundNone.setChecked(true);
 				textSoundCustom.setEnabled(true);
@@ -404,7 +405,7 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 
 	protected void pickRingtone(String uniqueId) {
 		Uri existingUri = this.ringtoneService.getRingtoneFromUniqueId(uniqueId);
-		if (existingUri != null && existingUri.getPath().equals("null")) {
+		if (existingUri != null && existingUri.getPath() != null && existingUri.getPath().equals(ServicesConstants.PREFERENCES_NULL)) {
 			existingUri = null;
 		}
 		if (existingUri == null && backupSoundCustom != null) {

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

@@ -133,6 +133,7 @@ import ch.threema.base.utils.LoggingUtil;
 import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.domain.protocol.csp.messages.file.FileData;
+import ch.threema.domain.protocol.csp.messages.location.Poi;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.DistributionListModel;
@@ -1591,11 +1592,14 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
     @WorkerThread
     private void sendLocationMessage(final MessageReceiver[] messageReceivers, LocationDataModel locationData) {
         final Location location = new Location("");
-        location.setLatitude(locationData.getLatitude());
-        location.setLongitude(locationData.getLongitude());
-        location.setAccuracy(locationData.getAccuracy());
-
-        final String poiName = locationData.getPoi();
+        location.setLatitude(locationData.latitude);
+        location.setLongitude(locationData.longitude);
+        location.setAccuracy((float)locationData.accuracyOrFallback);
+        final @Nullable Poi poi = locationData.poi;
+        @Nullable String poiName = null;
+        if (poi != null) {
+            poiName = poi.getName();
+        }
 
         LocationMessageSendAction.getInstance()
             .sendLocationMessage(messageReceivers, location, poiName, new SendAction.ActionHandler() {

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

@@ -49,6 +49,7 @@ import ch.threema.app.activities.wizard.WizardIntroActivity;
 import ch.threema.app.emojis.EmojiPicker;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.LockAppService;
+import ch.threema.app.services.NotificationPreferenceService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConnectionIndicatorUtil;
@@ -71,6 +72,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 	protected ServiceManager serviceManager;
 	protected LockAppService lockAppService;
 	protected PreferenceService preferenceService;
+    private NotificationPreferenceService notificationPreferenceService;
 	protected ServerConnection connection;
 
 	@Override
@@ -135,6 +137,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 
 			lockAppService = serviceManager.getLockAppService();
 			preferenceService = serviceManager.getPreferenceService();
+            notificationPreferenceService = serviceManager.getNotificationPreferenceService();
 		}
 	}
 
@@ -158,7 +161,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 			finish();
 		}
 
-		if (preferenceService != null && preferenceService.getWizardRunning()) {
+		if (notificationPreferenceService != null && notificationPreferenceService.getWizardRunning()) {
 			startActivity(new Intent(this, WizardIntroActivity.class));
 			return false;
 		}

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

@@ -66,6 +66,8 @@ import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.domain.models.MessageId;
+import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.storage.models.ballot.BallotModel;
 
 public class BallotOverviewActivity extends ThreemaToolbarActivity implements ListView.OnItemClickListener, GenericAlertDialog.DialogClickListener, SelectorDialog.SelectorDialogClickListener {
@@ -451,7 +453,7 @@ public class BallotOverviewActivity extends ThreemaToolbarActivity implements Li
 		if (tag.equals(DIALOG_TAG_BALLOT_DELETE)) {
 			removeSelectedBallotsDo((SparseBooleanArray) data);
 		} else if (tag.equals(ThreemaApplication.CONFIRM_TAG_CLOSE_BALLOT)) {
-			BallotUtil.closeBallot(this, (BallotModel) data, ballotService);
+			BallotUtil.closeBallot(this, (BallotModel) data, ballotService, new MessageId(), TriggerSource.LOCAL);
 		}
 	}
 

+ 26 - 14
app/src/main/java/ch/threema/app/activities/ballot/BallotWizardActivity.java

@@ -28,12 +28,6 @@ import android.os.Bundle;
 import android.view.View;
 import android.widget.Toast;
 
-import androidx.annotation.NonNull;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentManager;
-import androidx.fragment.app.FragmentStatePagerAdapter;
-import androidx.viewpager.widget.ViewPager;
-
 import com.google.android.material.button.MaterialButton;
 
 import org.slf4j.Logger;
@@ -42,6 +36,12 @@ import java.lang.ref.WeakReference;
 import java.util.ArrayList;
 import java.util.List;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.fragment.app.FragmentStatePagerAdapter;
+import androidx.viewpager.widget.ViewPager;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ThreemaActivity;
@@ -59,7 +59,10 @@ import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.domain.models.MessageId;
 import ch.threema.domain.protocol.api.APIConnector;
+import ch.threema.domain.protocol.csp.messages.ballot.BallotId;
+import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.storage.models.ballot.BallotChoiceModel;
 import ch.threema.storage.models.ballot.BallotModel;
 
@@ -80,7 +83,7 @@ public class BallotWizardActivity extends ThreemaActivity {
 	private MessageReceiver<?> receiver;
 
 	private final List<BallotChoiceModel> ballotChoiceModelList = new ArrayList<>();
-	private String ballotTitle;
+	private String ballotDescription;
 	private BallotModel.Type ballotType;
 	private BallotModel.Assessment ballotAssessment;
 
@@ -90,7 +93,16 @@ public class BallotWizardActivity extends ThreemaActivity {
 	private final Runnable createBallotRunnable = new Runnable() {
 		@Override
 		public void run() {
-			BallotUtil.createBallot(receiver, ballotTitle, ballotType, ballotAssessment, ballotChoiceModelList);
+            BallotUtil.createBallot(
+                receiver,
+                ballotDescription,
+                ballotType,
+                ballotAssessment,
+                ballotChoiceModelList,
+                new BallotId(),
+                new MessageId(),
+                TriggerSource.LOCAL
+            );
 		}
 	};
 
@@ -210,7 +222,7 @@ public class BallotWizardActivity extends ThreemaActivity {
 	}
 
 	private boolean checkTitle() {
-		if (TestUtil.isEmptyOrNull(this.ballotTitle)) {
+		if (TestUtil.isEmptyOrNull(this.ballotDescription)) {
 			BallotWizardCallback callback = (BallotWizardCallback) this.fragmentList.get(0).get();
 			if (callback != null) {
 				callback.onMissingTitle();
@@ -245,8 +257,8 @@ public class BallotWizardActivity extends ThreemaActivity {
 		pager.setCurrentItem(0);
 	}
 
-	public void setBallotTitle(String title) {
-		this.ballotTitle = title != null ? title.trim() : title;
+	public void setBallotDescription(@Nullable String description) {
+		this.ballotDescription = description != null ? description.trim() : null;
 	}
 
 	public void setBallotType(BallotModel.Type ballotType) {
@@ -261,8 +273,8 @@ public class BallotWizardActivity extends ThreemaActivity {
 		return this.ballotChoiceModelList;
 	}
 
-	public String getBallotTitle() {
-		return this.ballotTitle;
+	public String getBallotDescription() {
+		return this.ballotDescription;
 	}
 
 	public BallotModel.Type getBallotType() {
@@ -352,7 +364,7 @@ public class BallotWizardActivity extends ThreemaActivity {
 
 	private void copyFrom(BallotModel ballotModel) {
 		if(ballotModel != null && this.requiredInstances()) {
-			this.ballotTitle = ballotModel.getName();
+			this.ballotDescription = ballotModel.getName();
 			this.ballotType = ballotModel.getType();
 			this.ballotAssessment = ballotModel.getAssessment();
 

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

@@ -68,7 +68,7 @@ public class BallotWizardFragment0 extends BallotWizardFragment implements Ballo
 		this.editText.addTextChangedListener(new TextWatcher(){
 			public void afterTextChanged(Editable s) {
 				if( getBallotActivity() != null) {
-					getBallotActivity().setBallotTitle(editText.getText().toString());
+					getBallotActivity().setBallotDescription(editText.getText().toString());
 				}
 				if (s != null && s.length() > 0) {
 					textInputLayout.setError(null);
@@ -116,7 +116,7 @@ public class BallotWizardFragment0 extends BallotWizardFragment implements Ballo
 	public void updateView() {
 		if (getBallotActivity() != null) {
 			ViewUtil.showAndSet(this.editText,
-					this.getBallotActivity().getBallotTitle());
+					this.getBallotActivity().getBallotDescription());
 		}
 		if(this.getBallotActivity() != null) {
 			ViewUtil.showAndSet(this.typeCheckbox,

+ 3 - 0
app/src/main/java/ch/threema/app/activities/wizard/WizardBackgroundActivity.java

@@ -33,6 +33,7 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ThreemaAppCompatActivity;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.FileService;
+import ch.threema.app.services.NotificationPreferenceService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.utils.TestUtil;
@@ -43,6 +44,7 @@ public abstract class WizardBackgroundActivity extends ThreemaAppCompatActivity
 
 	protected ServiceManager serviceManager;
 	protected PreferenceService preferenceService;
+    protected NotificationPreferenceService notificationPreferenceService;
 	protected UserService userService;
 	protected FileService fileService;
 
@@ -99,6 +101,7 @@ public abstract class WizardBackgroundActivity extends ThreemaAppCompatActivity
 		serviceManager = ThreemaApplication.getServiceManager();
 		if (serviceManager != null) {
 			this.preferenceService = serviceManager.getPreferenceService();
+            this.notificationPreferenceService = serviceManager.getNotificationPreferenceService();
 			try {
 				this.userService = serviceManager.getUserService();
 				this.fileService = serviceManager.getFileService();

+ 5 - 1
app/src/main/java/ch/threema/app/activities/wizard/WizardBackupRestoreActivity.java

@@ -55,6 +55,8 @@ import ch.threema.app.dialogs.PasswordEntryDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.FileService;
+import ch.threema.app.services.NotificationPreferenceService;
+import ch.threema.app.services.NotificationPreferenceServiceImpl;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
@@ -82,6 +84,7 @@ public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implem
 	private FileService fileService;
 	private UserService userService;
 	private PreferenceService preferenceService;
+    private NotificationPreferenceService notificationPreferenceService;
 	private File backupFile;
 	private String backupPassword;
 
@@ -137,6 +140,7 @@ public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implem
 				fileService = serviceManager.getFileService();
 				userService = serviceManager.getUserService();
 				preferenceService = serviceManager.getPreferenceService();
+                notificationPreferenceService = serviceManager.getNotificationPreferenceService();
 			}
 		} catch (Exception e) {
 			logger.error("Exception ", e);
@@ -365,7 +369,7 @@ public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implem
 
 	private void startNextWizard() {
 		if (this.userService.hasIdentity()) {
-			this.preferenceService.setWizardRunning(true);
+			this.notificationPreferenceService.setWizardRunning(true);
 			startActivity(new Intent(this, WizardBaseActivity.class));
 			overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 			finish();

+ 4 - 1
app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java

@@ -68,6 +68,7 @@ import ch.threema.app.fragments.wizard.WizardFragment4;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
 import ch.threema.app.services.LocaleService;
+import ch.threema.app.services.NotificationPreferenceService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.SynchronizeContactsService;
 import ch.threema.app.services.UserService;
@@ -143,6 +144,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
     private UserService userService;
     private LocaleService localeService;
     private PreferenceService preferenceService;
+    private NotificationPreferenceService notificationPreferenceService;
     private ThreemaSafeService threemaSafeService;
     private APIConnector apiConnector;
     private ContactModelRepository contactModelRepository;
@@ -222,6 +224,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
                 userService = serviceManager.getUserService();
                 localeService = serviceManager.getLocaleService();
                 preferenceService = serviceManager.getPreferenceService();
+                notificationPreferenceService = serviceManager.getNotificationPreferenceService();
                 threemaSafeService = serviceManager.getThreemaSafeService();
                 apiConnector = serviceManager.getAPIConnector();
                 contactModelRepository = serviceManager.getModelRepositories().getContacts();
@@ -936,7 +939,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
                     return;
                 }
 
-                preferenceService.setWizardRunning(false);
+                notificationPreferenceService.setWizardRunning(false);
                 preferenceService.setLatestVersion(WizardBaseActivity.this);
 
                 // Flush conversation cache (after a restore) to ensure that the conversation list

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

@@ -112,7 +112,7 @@ public class WizardFingerPrintActivity extends WizardBackgroundActivity implemen
 						userService.createIdentity(bytes);
 						preferenceService.resetIDBackupCount();
 						preferenceService.setLastIDBackupReminderDate(new Date());
-						preferenceService.setWizardRunning(true);
+						notificationPreferenceService.setWizardRunning(true);
 					}
 				} catch (final ThreemaException e) {
 					logger.error("Exception", e);

+ 68 - 42
app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java

@@ -24,7 +24,6 @@ package ch.threema.app.adapters;
 import static ch.threema.domain.protocol.csp.messages.file.FileData.RENDERING_DEFAULT;
 
 import android.content.Context;
-import android.content.res.ColorStateList;
 import android.text.TextUtils;
 import android.util.SparseIntArray;
 import android.view.LayoutInflater;
@@ -48,8 +47,6 @@ import androidx.media3.session.MediaController;
 import com.google.android.material.shape.ShapeAppearanceModel;
 import com.google.common.util.concurrent.ListenableFuture;
 
-import org.slf4j.Logger;
-
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.List;
@@ -75,7 +72,7 @@ import ch.threema.app.adapters.decorators.VoipStatusDataChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.AnimatedImageDrawableDecorator;
 import ch.threema.app.cache.ThumbnailCache;
 import ch.threema.app.collections.Functional;
-import ch.threema.app.collections.IPredicateNonNull;
+import ch.threema.app.emojireactions.EmojiReactionGroup;
 import ch.threema.app.emojis.EmojiMarkupUtil;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.ContactService;
@@ -94,7 +91,8 @@ import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.QuoteUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.models.EmojiReactionData;
+import ch.threema.data.repositories.EmojiReactionsRepository;
 import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
@@ -102,8 +100,7 @@ import ch.threema.storage.models.DateSeparatorMessageModel;
 import ch.threema.storage.models.FirstUnreadMessageModel;
 import ch.threema.storage.models.MessageType;
 
-public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("ComposeMessageAdapter");
+public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> implements EmojiReactionGroup.OnEmojiReactionGroupClickListener {
 	public static final int MIN_CONSTRAINT_LENGTH = 2;
 
 	private final List<AbstractMessageModel> values;
@@ -127,6 +124,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	private final Context context;
 	private final ShapeAppearanceModel shapeAppearanceModelReceiveTop, shapeAppearanceModelReceiveMiddle, shapeAppearanceModelReceiveBottom, shapeAppearanceModelSendTop, shapeAppearanceModelSendMiddle, shapeAppearanceModelSendBottom, shapeAppearanceModelSingle;
 	private final LayoutInflater layoutInflater;
+	private final EmojiReactionsRepository emojiReactionsRepository;
 
 	@Retention(RetentionPolicy.SOURCE)
 	@IntDef({
@@ -195,27 +193,32 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		void avatarClick(View view, int position, AbstractMessageModel messageModel);
 		void onSearchResultsUpdate(int searchResultsIndex, int searchResultsSize, int queryLength);
 		void onSearchInProgress(boolean inProgress);
+		void onEmojiReactionClick(@Nullable String emojiSequence, @Nullable AbstractMessageModel messageModel);
+		void onEmojiReactionLongClick(@Nullable String emojiSequence, @Nullable AbstractMessageModel messageModel);
+		void onSelectButtonClick(@Nullable AbstractMessageModel messageModel);
+		void onMoreReactionsButtonClick(@Nullable AbstractMessageModel messageModel);
 	}
 
 	public ComposeMessageAdapter(
-			Context context,
-			MessagePlayerService messagePlayerService,
-			List<AbstractMessageModel> values,
-			UserService userService,
-			ContactService contactService,
-			FileService fileService,
-			MessageService messageService,
-			BallotService ballotService,
-			PreferenceService preferenceService,
-			DownloadService downloadService,
-			LicenseService licenseService,
-			MessageReceiver messageReceiver,
-			ListView listView,
-			ThumbnailCache<?> thumbnailCache,
-			int thumbnailWidth,
-			Fragment fragment,
-			int unreadMessagesCount,
-			ListenableFuture<MediaController> mediaControllerFuture) {
+		Context context,
+		MessagePlayerService messagePlayerService,
+		List<AbstractMessageModel> values,
+		UserService userService,
+		ContactService contactService,
+		FileService fileService,
+		MessageService messageService,
+		BallotService ballotService,
+		PreferenceService preferenceService,
+		DownloadService downloadService,
+		LicenseService<?> licenseService,
+		EmojiReactionsRepository emojiReactionsRepository,
+		MessageReceiver<?> messageReceiver,
+		ListView listView,
+		ThumbnailCache<?> thumbnailCache,
+		int thumbnailWidth,
+		Fragment fragment,
+		int unreadMessagesCount,
+		ListenableFuture<MediaController> mediaControllerFuture) {
 		super(context, R.layout.conversation_list_item_send, values);
 
 		this.context = context;
@@ -250,6 +253,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				maxQuoteTextLength,
 				mediaControllerFuture
 		);
+		this.emojiReactionsRepository = emojiReactionsRepository;
 
 		int cornerRadius = context.getResources().getDimensionPixelSize(R.dimen.chat_bubble_border_radius),
 			cornerRadiusSharp = context.getResources().getDimensionPixelSize(R.dimen.chat_bubble_border_radius_sharp);
@@ -518,14 +522,10 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 					holder.readOnContainer = itemView.findViewById(R.id.read_on_container);
 					holder.readOnButton = itemView.findViewById(R.id.read_on_button);
 					holder.audioMessageIcon = itemView.findViewById(R.id.audio_message_icon);
-					holder.groupAckContainer = itemView.findViewById(R.id.groupack_container);
-					holder.groupAckThumbsUpCount = itemView.findViewById(R.id.groupack_thumbsup_count);
-					holder.groupAckThumbsDownCount = itemView.findViewById(R.id.groupack_thumbsdown_count);
-					holder.groupAckThumbsUpImage = itemView.findViewById(R.id.groupack_thumbsup);
-					holder.groupAckThumbsDownImage = itemView.findViewById(R.id.groupack_thumbsdown);
 					holder.tapToResend = itemView.findViewById(R.id.tap_to_resend);
 					holder.starredIcon = itemView.findViewById(R.id.star_icon);
 					holder.editedText = itemView.findViewById(R.id.edited_text);
+					holder.emojiReactionGroup = itemView.findViewById(R.id.emoji_reactions);
 				}
 				itemView.setTag(holder);
 			}
@@ -597,6 +597,22 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			}
 		}
 
+		if (holder.emojiReactionGroup != null) {
+			List<EmojiReactionData> reactions = emojiReactionsRepository.safeGetReactionsByMessage(messageModel);
+			if (!reactions.isEmpty()) {
+				final EmojiReactionGroup group = holder.emojiReactionGroup;
+				if (false) {
+					group.setMessageModel(decoratorHelper.getMessageReceiver(), messageModel, reactions);
+				} else {
+					group.post(() -> group.setMessageModel(decoratorHelper.getMessageReceiver(), messageModel, reactions));
+				}
+				holder.emojiReactionGroup.setOnEmojiReactionGroupClickListener(this);
+				holder.emojiReactionGroup.setVisibility(View.VISIBLE);
+			} else {
+				holder.emojiReactionGroup.setVisibility(View.GONE);
+			}
+		}
+
 		if (convListFilter != null && convListFilter.getHighlightMatches()) {
 			/* show matches in decorator */
 			decorator.setFilter(convListFilter.getFilterString());
@@ -918,12 +934,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				resultMapIndex = resultMap.size() - 1;
 				searchUpdate();
 				if (!TextUtils.isEmpty(filterString)) {
-					listView.postDelayed(new Runnable() {
-						@Override
-						public void run() {
-							listView.setSelection(positionOfLastMatch);
-						}
-					}, 500);
+					listView.postDelayed(() -> listView.setSelection(positionOfLastMatch), 500);
 				}
 			} else if (positionOfLastMatch != AbsListView.INVALID_POSITION) {
 				if (listView != null) {
@@ -1103,12 +1114,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 
 		if(c > 0 && c == this.getCount()) {
 			//nothing deleted, search!
-			AbstractMessageModel newObject = Functional.select(this.values, new IPredicateNonNull<AbstractMessageModel>() {
-				@Override
-				public boolean apply(@NonNull AbstractMessageModel o) {
-					return o.getId() == object.getId();
-				}
-			});
+			AbstractMessageModel newObject = Functional.select(this.values, o -> o.getId() == object.getId());
 
 			if(newObject != null) {
 				super.remove(newObject);
@@ -1191,4 +1197,24 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			}
 		}
 	}
+
+	@Override
+	public void onEmojiReactionClick(@Nullable AbstractMessageModel messageModel, @Nullable String emojiSequence) {
+		onClickListener.onEmojiReactionClick(emojiSequence, messageModel);
+	}
+
+	@Override
+	public void onEmojiReactionLongClick(@Nullable AbstractMessageModel messageModel, @Nullable String emojiSequence) {
+		onClickListener.onEmojiReactionLongClick(emojiSequence, messageModel);
+	}
+
+	@Override
+	public void onSelectButtonClick(@Nullable AbstractMessageModel messageModel) {
+		onClickListener.onSelectButtonClick(messageModel);
+	}
+
+	@Override
+    public void onMoreReactionsButtonClick(@Nullable AbstractMessageModel messageModel) {
+		onClickListener.onMoreReactionsButtonClick(messageModel);
+	}
 }

+ 4 - 3
app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java

@@ -55,6 +55,7 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.PublicKeyDialog;
 import ch.threema.app.glide.AvatarOptions;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.services.BlockedIdentitiesService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.IdListService;
@@ -95,7 +96,7 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 	private GroupService groupService;
 	private PreferenceService preferenceService;
 	private IdListService excludeFromSyncListService;
-	private IdListService blockedContactsService;
+	private BlockedIdentitiesService blockedIdentitiesService;
 	private final @NonNull ch.threema.data.models.ContactModel contactModel;
 	private final @NonNull ContactModelData contactModelData;
 	private final List<GroupModel> values;
@@ -236,7 +237,7 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
             this.contactService = serviceManager.getContactService();
             this.groupService = serviceManager.getGroupService();
             this.excludeFromSyncListService = serviceManager.getExcludedSyncIdentitiesService();
-            this.blockedContactsService = serviceManager.getBlockedContactsService();
+            this.blockedIdentitiesService = serviceManager.getBlockedIdentitiesService();
             this.preferenceService = serviceManager.getPreferenceService();
         } catch (Exception e) {
             logger.error("Failed to set up services", e);
@@ -291,7 +292,7 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			String identityAdditional = null;
 			switch (this.contactModelData.activityState) {
 				case ACTIVE:
-					if (blockedContactsService.has(contactModelData.identity)) {
+					if (blockedIdentitiesService.isBlocked(contactModelData.identity)) {
 						identityAdditional = context.getString(R.string.blocked);
 					}
 					break;

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

@@ -60,8 +60,8 @@ import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.emojis.EmojiTextView;
 import ch.threema.app.glide.AvatarOptions;
+import ch.threema.app.services.BlockedIdentitiesService;
 import ch.threema.app.services.ContactService;
-import ch.threema.app.services.IdListService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.ui.AvatarListItemUtil;
 import ch.threema.app.ui.CheckableConstraintLayout;
@@ -80,7 +80,8 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 
     private final ContactService contactService;
     private final PreferenceService preferenceService;
-    private final IdListService blockedContactsService;
+    @NonNull
+    private final BlockedIdentitiesService blockedIdentitiesService;
 
     public static final int VIEW_TYPE_NORMAL = 0;
     public static final int VIEW_TYPE_RECENTLY_ADDED = 1;
@@ -116,7 +117,7 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
         @NonNull List<ContactModel> values,
         ContactService contactService,
         PreferenceService preferenceService,
-        IdListService blockedContactsService,
+        BlockedIdentitiesService blockedIdentitiesService,
         AvatarListener avatarListener,
         @NonNull RequestManager requestManager
     ) {
@@ -126,7 +127,7 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
         this.ovalues = this.values;
         this.contactService = contactService;
         this.preferenceService = preferenceService;
-        this.blockedContactsService = blockedContactsService;
+        this.blockedIdentitiesService = blockedIdentitiesService;
         this.avatarListener = avatarListener;
         this.inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
 
@@ -398,16 +399,16 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
         );
         AdapterUtil.styleContact(holder.contactTextBottomRight, contactModel);
 
-		if (holder.verificationLevelView != null) {
-			holder.verificationLevelView.setVerificationLevel(
-				contactModel.verificationLevel,
-				contactModel.getWorkVerificationLevel()
-			);
-		}
+        if (holder.verificationLevelView != null) {
+            holder.verificationLevelView.setVerificationLevel(
+                contactModel.verificationLevel,
+                contactModel.getWorkVerificationLevel()
+            );
+        }
 
         ViewUtil.show(
             holder.blockedContactView,
-            blockedContactsService != null && blockedContactsService.has(contactModel.getIdentity())
+            blockedIdentitiesService.isBlocked(contactModel.getIdentity())
         );
 
         if (viewType == VIEW_TYPE_RECENTLY_ADDED) {

+ 0 - 3
app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java

@@ -42,7 +42,6 @@ import java.util.Map;
 import androidx.annotation.IntDef;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-import androidx.core.content.ContextCompat;
 import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -170,8 +169,6 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 
 		messageListItemParams = new MessageListViewHolder.MessageListItemParams(
 			ConfigUtils.getColorFromAttribute(context, R.attr.colorOnSurface),
-			ContextCompat.getColor(context, R.color.material_green),
-			ContextCompat.getColor(context, R.color.material_orange),
 			ConfigUtils.getColorFromAttribute(context, android.R.attr.colorBackground),
 			ConfigUtils.isTabletLayout(),
 			emojiMarkupUtil,

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

@@ -32,7 +32,6 @@ import ch.threema.app.utils.NameUtil
 import ch.threema.app.utils.TestUtil
 import ch.threema.storage.models.ConversationModel
 import ch.threema.storage.models.GroupModel
-import ch.threema.storage.models.MessageState
 import ch.threema.storage.models.MessageType
 
 /**
@@ -120,8 +119,6 @@ class MessageListAdapterItem(
             subject
         }
     }
-    val latestMessageIsAck = latestMessage != null && latestMessage.state == MessageState.USERACK
-    val latestMessageIsDec = latestMessage != null && latestMessage.state == MessageState.USERDEC
 
     val latestMessageGroupMemberName =
         if (isGroupConversation && latestMessage != null && latestMessage.type != MessageType.GROUP_CALL_STATUS && TestUtil.isBlankOrNull(getDraft())) {

+ 1 - 12
app/src/main/java/ch/threema/app/adapters/MessageListViewHolder.kt

@@ -84,10 +84,6 @@ class MessageListViewHolder(
             @ColorInt
             val regularColor: Int,
             @ColorInt
-            val ackColor: Int,
-            @ColorInt
-            val decColor: Int,
-            @ColorInt
             val backgroundColor: Int,
             val isTablet: Boolean,
             val emojiMarkupUtil: EmojiMarkupUtil,
@@ -388,14 +384,7 @@ class MessageListViewHolder(
                     messageListAdapterItem.isGroupConversation -> strings.groups
                     else -> strings.distributionLists
                 }
-                deliveryView.setColorFilter(
-                    when {
-                        messageListAdapterItem.isGroupConversation -> params.regularColor
-                        messageListAdapterItem.latestMessageIsAck -> params.ackColor
-                        messageListAdapterItem.latestMessageIsDec -> params.decColor
-                        else -> params.regularColor
-                    }
-                )
+                deliveryView.setColorFilter(params.regularColor)
             } else {
                 if (messageListAdapterItem.latestMessage != null) {
                     // In case there is a latest message but no icon is set, we need to get the

+ 5 - 4
app/src/main/java/ch/threema/app/adapters/UserListAdapter.java

@@ -43,6 +43,7 @@ import androidx.annotation.Nullable;
 import ch.threema.app.R;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.messagereceiver.MessageReceiver;
+import ch.threema.app.services.BlockedIdentitiesService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.DeadlineListService;
 import ch.threema.app.services.IdListService;
@@ -70,7 +71,7 @@ public class UserListAdapter extends FilterableListAdapter {
 	private final List<ContactModel> originalValues;
 	private UserListFilter userListFilter;
 	private final ContactService contactService;
-	private final IdListService blockedContactsService;
+	private final BlockedIdentitiesService blockedIdentitiesService;
 	private final DeadlineListService hiddenChatsListService;
 	private final FilterResultsListener filterResultsListener;
 	private final @NonNull RequestManager requestManager;
@@ -81,7 +82,7 @@ public class UserListAdapter extends FilterableListAdapter {
 		@Nullable final List<String> preselectedIdentities,
 		final List<Integer> checkedItems,
 		ContactService contactService,
-		IdListService blockedContactsService,
+		BlockedIdentitiesService blockedIdentitiesService,
 		DeadlineListService hiddenChatsListService,
 		PreferenceService preferenceService,
 		FilterResultsListener filterResultsListener,
@@ -92,7 +93,7 @@ public class UserListAdapter extends FilterableListAdapter {
 		this.context = context;
 		this.contactService = contactService;
 		this.hiddenChatsListService = hiddenChatsListService;
-		this.blockedContactsService = blockedContactsService;
+		this.blockedIdentitiesService = blockedIdentitiesService;
 		this.filterResultsListener = filterResultsListener;
 		this.requestManager = requestManager;
 
@@ -168,7 +169,7 @@ public class UserListAdapter extends FilterableListAdapter {
 
 		ViewUtil.show(
 				holder.blockedView,
-				blockedContactsService != null && blockedContactsService.has(contactModel.getIdentity())
+				blockedIdentitiesService != null && blockedIdentitiesService.isBlocked(contactModel.getIdentity())
 		);
 
 		holder.verificationLevelView.setVerificationLevel(

+ 5 - 2
app/src/main/java/ch/threema/app/adapters/WidgetViewsFactory.java

@@ -43,6 +43,7 @@ import ch.threema.app.services.DistributionListService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.MessageService;
+import ch.threema.app.services.NotificationPreferenceService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.utils.MessageUtil;
 import ch.threema.base.ThreemaException;
@@ -62,6 +63,7 @@ public class WidgetViewsFactory implements RemoteViewsService.RemoteViewsFactory
 	private DistributionListService distributionListService;
 	private LockAppService lockAppService;
 	private PreferenceService preferenceService;
+    private NotificationPreferenceService notificationPreferenceService;
 	private MessageService messageService;
 	private DeadlineListService hiddenChatsListService;
 	private List<ConversationModel> conversations;
@@ -81,6 +83,7 @@ public class WidgetViewsFactory implements RemoteViewsService.RemoteViewsFactory
 				this.messageService = serviceManager.getMessageService();
 				this.lockAppService = serviceManager.getLockAppService();
 				this.preferenceService = serviceManager.getPreferenceService();
+                this.notificationPreferenceService = serviceManager.getNotificationPreferenceService();
 				this.hiddenChatsListService = serviceManager.getHiddenChatsListService();
 			} catch (ThreemaException e) {
 				logger.debug("no conversationservice");
@@ -153,7 +156,7 @@ public class WidgetViewsFactory implements RemoteViewsService.RemoteViewsFactory
 	public int getCount() {
 		if (lockAppService != null &&
 				!lockAppService.isLocked() &&
-				preferenceService.isShowMessagePreview() &&
+				notificationPreferenceService.isShowMessagePreview() &&
 				conversations != null) {
 			return conversations.size();
 		} else {
@@ -180,7 +183,7 @@ public class WidgetViewsFactory implements RemoteViewsService.RemoteViewsFactory
 				Bundle extras = new Bundle();
 				String uniqueId = conversationModel.getReceiver().getUniqueIdString();
 
-				if (this.lockAppService != null && !this.lockAppService.isLocked() && preferenceService.isShowMessagePreview()) {
+				if (this.lockAppService != null && !this.lockAppService.isLocked() && notificationPreferenceService.isShowMessagePreview()) {
 					sender = conversationModel.getReceiver().getDisplayName();
 
 					if (conversationModel.isContactConversation()) {

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

@@ -48,7 +48,6 @@ import java.util.Map;
 
 import ch.threema.app.R;
 import ch.threema.app.cache.ThumbnailCache;
-import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.DownloadService;
@@ -274,8 +273,7 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
         try {
             actionModeStatus = (ActionModeStatus) helper.getFragment();
         } catch (ClassCastException e) {
-            throw new ClassCastException(context.toString()
-                + " must implement ActionModeStatus");
+            throw new ClassCastException(context + " must implement ActionModeStatus");
         }
     }
 
@@ -440,10 +438,6 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
                 stateBitmapUtil.setStateDrawable(getContext(), messageModel, viewHolder.deliveredIndicator, null);
             }
 
-            if (viewHolder.groupAckContainer != null) {
-                stateBitmapUtil.setGroupAckCount(messageModel, viewHolder);
-            }
-
             if (viewHolder.editedText != null) {
                 viewHolder.editedText.setVisibility(messageModel.getEditedAt() != null ? View.VISIBLE : View.GONE);
             }
@@ -454,7 +448,7 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
         }
     }
 
-    public Spannable highlightMatches(CharSequence fullText, String filterText) {
+    public Spannable highlightMatches(@Nullable CharSequence fullText, @Nullable String filterText) {
         return TextUtil.highlightMatches(getContext(), fullText, filterText, true, false);
     }
 
@@ -615,7 +609,7 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
             holder.bodyTextView.setText(formatTextString(caption, filterString));
 
             LinkifyUtil.getInstance().linkify(
-                (ComposeMessageFragment) helper.getFragment(),
+                helper.getFragment(),
                 holder.bodyTextView,
                 getMessageModel(),
                 true,

+ 19 - 29
app/src/main/java/ch/threema/app/adapters/decorators/LocationChatAdapterDecorator.java

@@ -28,14 +28,12 @@ import android.view.View;
 import android.widget.TextView;
 import android.widget.Toast;
 
-import org.slf4j.Logger;
-
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import ch.threema.app.R;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.GeoLocationUtil;
 import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.app.utils.TestUtil;
-import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.data.LocationDataModel;
@@ -43,7 +41,6 @@ import ch.threema.storage.models.data.LocationDataModel;
 import static android.view.View.GONE;
 
 public class LocationChatAdapterDecorator extends ChatAdapterDecorator {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("LocationChatAdapterDecorator");
 
 	public LocationChatAdapterDecorator(Context context, AbstractMessageModel messageModel, Helper helper) {
 		super(context, messageModel, helper);
@@ -51,8 +48,8 @@ public class LocationChatAdapterDecorator extends ChatAdapterDecorator {
 
 	@SuppressLint("StaticFieldLeak")
 	@Override
-	protected void configureChatMessage(final ComposeMessageHolder holder, final int position) {
-		final LocationDataModel location = this.getMessageModel().getLocationData();
+	protected void configureChatMessage(@NonNull final ComposeMessageHolder holder, final int position) {
+		final LocationDataModel locationDataModel = this.getMessageModel().getLocationData();
 
 		TextView addressLine = holder.bodyTextView;
 
@@ -77,9 +74,9 @@ public class LocationChatAdapterDecorator extends ChatAdapterDecorator {
 			holder.secondaryTextView.setVisibility(GONE);
 		}
 
-		if(!TestUtil.isEmptyOrNull(location.getPoi())) {
-			if(holder.bodyTextView != null) {
-				holder.bodyTextView.setText(highlightMatches(location.getPoi(), filterString));
+		if (locationDataModel.poiNameOrNull != null) {
+			if (holder.bodyTextView != null) {
+				holder.bodyTextView.setText(highlightMatches(locationDataModel.poiNameOrNull, filterString));
 				holder.bodyTextView.setWidth(this.getThumbnailWidth());
 				holder.bodyTextView.setVisibility(View.VISIBLE);
 			}
@@ -87,20 +84,18 @@ public class LocationChatAdapterDecorator extends ChatAdapterDecorator {
 		}
 
 		if(addressLine != null) {
-			String address = location.getAddress();
-			if (address == null) {
+			final @Nullable String poiAddress = locationDataModel.poiAddressOrNull;
+            if (poiAddress != null) {
+                addressLine.setText(highlightMatches(poiAddress, filterString));
+                addressLine.setWidth(this.getThumbnailWidth());
+                addressLine.setVisibility(View.VISIBLE);
+            } else {
 				GeoLocationUtil geoLocation = new GeoLocationUtil(addressLine);
-				Location l = new Location("X");
-				l.setLatitude(location.getLatitude());
-				l.setLongitude(location.getLongitude());
-				l.setAccuracy(location.getAccuracy());
-				geoLocation.updateAddressAndModel(getContext(), l);
-			}
-
-			if (address != null) {
-				addressLine.setText(highlightMatches(address, filterString));
-				addressLine.setWidth(this.getThumbnailWidth());
-				addressLine.setVisibility(View.VISIBLE);
+				Location location = new Location("X");
+				location.setLatitude(locationDataModel.latitude);
+				location.setLongitude(locationDataModel.longitude);
+				location.setAccuracy((long) locationDataModel.accuracyOrFallback);
+				geoLocation.updateAddressAndModel(getContext(), location);
 			}
 		}
 
@@ -118,12 +113,7 @@ public class LocationChatAdapterDecorator extends ChatAdapterDecorator {
 		}
 
 		if (!GeoLocationUtil.viewLocation(getContext(), messageModel.getLocationData())) {
-			RuntimeUtil.runOnUiThread(new Runnable() {
-				@Override
-				public void run() {
-					Toast.makeText(getContext(), "Feature not available due to firmware error", Toast.LENGTH_LONG).show();
-				}
-			});
+			RuntimeUtil.runOnUiThread(() -> Toast.makeText(getContext(), "Feature not available due to firmware error", Toast.LENGTH_LONG).show());
 		}
 	}
 }

+ 2 - 2
app/src/main/java/ch/threema/app/asynctasks/MarkContactAsDeletedBackgroundTask.kt

@@ -207,7 +207,7 @@ open class MarkContactAsDeletedBackgroundTask(
 
         // Remove the old contact model from the cache to reduce the risk to it being stored to the
         // database.
-        deleteContactServices.contactService.removeFromCache(identity)
+        deleteContactServices.contactService.invalidateCache(identity)
 
         // Cancel notifications
         deleteContactServices.notificationService.cancel(identity)
@@ -276,7 +276,7 @@ open class DeleteAllContactsBackgroundTask(
      * This should only be called after the contact was successfully removed from the database.
      */
     private fun cleanContactLeftovers(identity: String) {
-        deleteContactServices.contactService.removeFromCache(identity)
+        deleteContactServices.contactService.invalidateCache(identity)
         deleteContactServices.conversationService.delete(identity)
 
         val uniqueIdString = ContactUtil.getUniqueIdString(identity)

+ 4 - 1
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java

@@ -75,6 +75,7 @@ import ch.threema.app.notifications.NotificationChannels;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.FileService;
+import ch.threema.app.services.NotificationPreferenceService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.utils.BackupUtils;
@@ -147,6 +148,7 @@ public class RestoreService extends Service {
 	private UserService userService;
 	private DatabaseServiceNew databaseServiceNew;
 	private PreferenceService preferenceService;
+    private NotificationPreferenceService notificationPreferenceService;
 	private PowerManager.WakeLock wakeLock;
 	private NotificationManagerCompat notificationManagerCompat;
 	private NonceFactory nonceFactory;
@@ -300,6 +302,7 @@ public class RestoreService extends Service {
 			conversationService = serviceManager.getConversationService();
 			userService = serviceManager.getUserService();
 			preferenceService = serviceManager.getPreferenceService();
+            notificationPreferenceService = serviceManager.getNotificationPreferenceService();
 			nonceFactory = serviceManager.getNonceFactory();
 		} catch (Exception e) {
 			logger.error("Could not instantiate all required services", e);
@@ -1900,7 +1903,7 @@ public class RestoreService extends Service {
 		cancelPersistentNotification();
 
 		if (restoreSuccess && userService.hasIdentity()) {
-			preferenceService.setWizardRunning(true);
+			notificationPreferenceService.setWizardRunning(true);
 
 			showRestoreSuccessNotification();
 

+ 0 - 126
app/src/main/java/ch/threema/app/compose/message/AckDecIndicator.kt

@@ -1,126 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 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.message
-
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import ch.threema.app.R
-import ch.threema.app.activities.ContactAckDecState
-import ch.threema.app.activities.GroupAckDecState
-import ch.threema.app.activities.UserReaction
-import ch.threema.app.compose.theme.AppTypography
-import ch.threema.app.compose.theme.customColorScheme
-
-@Composable
-fun ContactAckDecIndicator(ackDecState: ContactAckDecState) {
-    Row(verticalAlignment = Alignment.CenterVertically) {
-        when (ackDecState) {
-            ContactAckDecState.ACK -> AckIndicator(true)
-            ContactAckDecState.DEC -> DecIndicator(true)
-            ContactAckDecState.NONE -> Unit
-        }
-    }
-}
-
-@Composable
-fun GroupAckDecIndicator(
-    ackState: GroupAckDecState,
-    decState: GroupAckDecState,
-) {
-    Row(verticalAlignment = Alignment.CenterVertically) {
-        if (ackState.count > 0) {
-            AckIndicator(ackState.userReaction == UserReaction.REACTED)
-            Spacer(modifier = Modifier.width(2.dp))
-            AckDecCountLabel(ackState.count, MaterialTheme.customColorScheme.ackTint)
-        }
-        Spacer(modifier = Modifier.width(2.dp))
-        if (decState.count > 0) {
-            DecIndicator(decState.userReaction == UserReaction.REACTED)
-            Spacer(modifier = Modifier.width(2.dp))
-            AckDecCountLabel(decState.count, MaterialTheme.customColorScheme.decTint)
-        }
-    }
-}
-
-@Composable
-private fun AckIndicator(filled: Boolean) {
-    val resource = if (filled) {
-        R.drawable.ic_thumb_up_filled
-    } else {
-        R.drawable.ic_thumb_up_grey600_24dp
-    }
-    Icon(
-        modifier = Modifier
-            .padding(bottom = 1.dp)
-            .size(16.dp),
-        painter = painterResource(resource),
-        tint = MaterialTheme.customColorScheme.ackTint,
-        contentDescription = stringResource(R.string.cd_ack_icon)
-    )
-}
-
-@Composable
-private fun DecIndicator(filled: Boolean) {
-    val resource = if (filled) {
-        R.drawable.ic_thumb_down_filled
-    } else {
-        R.drawable.ic_thumb_down_grey600_24dp
-    }
-    Icon(
-        modifier = Modifier
-            .size(16.dp),
-        painter = painterResource(resource),
-        tint = MaterialTheme.customColorScheme.decTint,
-        contentDescription = stringResource(R.string.cd_dec_icon)
-    )
-}
-
-@Composable
-private fun AckDecCountLabel(count: Int, color: Color) {
-    Text(
-        modifier = stringResource(R.string.cd_ack_dec_group_count).let { Modifier.semantics { it.format(count) } },
-        text = count.toString(),
-        style = AppTypography.bodySmall,
-        color = color,
-    )
-}
-
-@Composable
-@Preview
-private fun GroupAckDecIndicatorPreview() {
-    GroupAckDecIndicator(GroupAckDecState(4, UserReaction.REACTED), GroupAckDecState(3, UserReaction.NONE))
-}

+ 4 - 19
app/src/main/java/ch/threema/app/compose/message/MessageBubble.kt

@@ -49,18 +49,13 @@ import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import ch.threema.app.R
-import ch.threema.app.activities.AckUiModel
-import ch.threema.app.activities.GroupAckDecState
-import ch.threema.app.activities.GroupAckUiModel
 import ch.threema.app.activities.MessageUiModel
-import ch.threema.app.activities.UserReaction
 import ch.threema.app.compose.common.ThemedText
 import ch.threema.app.compose.common.interop.InteropEmojiConversationTextView
 import ch.threema.app.compose.theme.AppTypography
 import ch.threema.app.compose.theme.customColorScheme
 import ch.threema.app.ui.CustomTextSelectionCallback
 import ch.threema.app.utils.LocaleUtil
-import ch.threema.data.models.EditHistoryEntryData
 import java.util.Date
 
 @Composable
@@ -153,8 +148,7 @@ fun CompleteMessageBubble(
                     isOutbox = message.isOutbox,
                     deliveryIconRes = message.deliveryIconRes,
                     deliveryIconContentDescriptionRes = message.deliveryIconContentDescriptionRes,
-                    contentColor = contentColor,
-                    ackUiModel = message.ackUiModel
+                    contentColor = contentColor
                 )
             }
         )
@@ -190,8 +184,7 @@ fun MessageBubbleFooter(
     isOutbox: Boolean,
     @DrawableRes deliveryIconRes: Int? = null,
     @StringRes deliveryIconContentDescriptionRes: Int? = null,
-    contentColor: Color,
-    ackUiModel: AckUiModel? = null
+    contentColor: Color
 ) {
     Row(
         modifier = Modifier
@@ -208,12 +201,6 @@ fun MessageBubbleFooter(
             )
         }
         Spacer(modifier = Modifier.weight(1f))
-        if (ackUiModel != null && !isOutbox) {
-            Spacer(modifier = Modifier.size(8.dp))
-            MessageStateIndicator(
-                ackUiModel = ackUiModel,
-            )
-        }
         date?.let {
             Spacer(modifier = Modifier.size(4.dp))
             val formattedDate = LocaleUtil.formatTimeStampString(LocalContext.current, it.time, true)
@@ -224,10 +211,9 @@ fun MessageBubbleFooter(
                 color = contentColor
             )
         }
-        if ((ackUiModel != null || deliveryIconRes != null) && isOutbox) {
+        if (deliveryIconRes != null && isOutbox) {
             Spacer(modifier = Modifier.size(8.dp))
             MessageStateIndicator(
-                ackUiModel = ackUiModel,
                 deliveryIconRes = deliveryIconRes,
                 deliveryIconContentDescriptionRes = deliveryIconContentDescriptionRes,
                 deliveryIndicatorTintColor = contentColor
@@ -249,8 +235,7 @@ private fun MessageBubblePreview() {
                 date = Date(),
                 isOutbox = true,
                 deliveryIconRes = R.drawable.ic_mark_read,
-                contentColor = contentColor,
-                ackUiModel = GroupAckUiModel(GroupAckDecState(2, UserReaction.REACTED), GroupAckDecState(1, UserReaction.NONE)),
+                contentColor = contentColor
             )
         }
     )

+ 6 - 22
app/src/main/java/ch/threema/app/compose/message/MessageStateIndicator.kt

@@ -25,34 +25,18 @@ import androidx.annotation.DrawableRes
 import androidx.annotation.StringRes
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.graphics.Color
-import ch.threema.app.activities.AckUiModel
-import ch.threema.app.activities.ContactAckUiModel
-import ch.threema.app.activities.GroupAckUiModel
 
 @Composable
 fun MessageStateIndicator(
-    ackUiModel: AckUiModel? = null,
     @DrawableRes deliveryIconRes: Int? = null,
     @StringRes deliveryIconContentDescriptionRes: Int? = null,
     deliveryIndicatorTintColor: Color? = null
 ) {
-    when (ackUiModel) {
-        is ContactAckUiModel -> {
-            ContactAckDecIndicator(ackUiModel.ackDecState)
-        }
-        is GroupAckUiModel -> {
-            GroupAckDecIndicator(ackUiModel.ackState, ackUiModel.decState)
-        }
-
-        null -> {
-            if (deliveryIconRes != null && deliveryIconContentDescriptionRes != null) {
-                DeliveryIndicator(
-                    deliveryIconRes = deliveryIconRes,
-                    deliveryIconContentDescriptionRes = deliveryIconContentDescriptionRes,
-                    tintColor = deliveryIndicatorTintColor
-                )
-            }
-        }
+    if (deliveryIconRes != null && deliveryIconContentDescriptionRes != null) {
+        DeliveryIndicator(
+            deliveryIconRes = deliveryIconRes,
+            deliveryIconContentDescriptionRes = deliveryIconContentDescriptionRes,
+            tintColor = deliveryIndicatorTintColor
+        )
     }
-
 }

+ 0 - 2
app/src/main/java/ch/threema/app/compose/theme/color/CustomColor.kt

@@ -27,8 +27,6 @@ import androidx.compose.ui.graphics.Color
 
 @Immutable
 data class CustomColor(
-    val ackTint: Color = Color(0xFF4caf50),
-    val decTint: Color = Color(0xFFff9800),
     val messageBubbleContainerReceive: Color = Color.Red,
 )
 

+ 1 - 3
app/src/main/java/ch/threema/app/debug/PatternLibraryActivity.kt

@@ -171,9 +171,7 @@ class PatternLibraryActivity : AppCompatActivity() {
                         ColorSection(
                             "Custom",
                             listOf(
-                                MaterialTheme.customColorScheme.messageBubbleContainerReceive to "messageBubbleContainerReceive",
-                                MaterialTheme.customColorScheme.ackTint to "ackTint",
-                                MaterialTheme.customColorScheme.decTint to "decTint"
+                                MaterialTheme.customColorScheme.messageBubbleContainerReceive to "messageBubbleContainerReceive"
                             )
                         ),
                     )

+ 2 - 1
app/src/main/java/ch/threema/app/dialogs/BallotVoteDialog.java

@@ -55,6 +55,7 @@ import ch.threema.app.utils.BallotUtil;
 import ch.threema.app.utils.LoadingUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.storage.models.ballot.BallotChoiceModel;
 import ch.threema.storage.models.ballot.BallotModel;
 import ch.threema.storage.models.ballot.BallotVoteModel;
@@ -302,7 +303,7 @@ public class BallotVoteDialog extends ThreemaDialogFragment {
 			return;
 		}
 		try {
-			final BallotVoteResult result = this.ballotService.vote(ballotModel.getId(), this.listAdapter.getSelectedChoices());
+			final BallotVoteResult result = this.ballotService.vote(ballotModel.getId(), this.listAdapter.getSelectedChoices(), TriggerSource.LOCAL);
 			if (result != null) {
 				RuntimeUtil.runOnUiThread(() -> {
 					if (activity != null) {

+ 0 - 357
app/src/main/java/ch/threema/app/dialogs/MessageDetailDialog.java

@@ -1,357 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2019-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.dialogs;
-
-import android.annotation.SuppressLint;
-import android.content.Intent;
-import android.graphics.Bitmap;
-import android.graphics.Color;
-import android.os.AsyncTask;
-import android.os.Build;
-import android.os.Bundle;
-import android.view.View;
-import android.widget.ImageView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
-import androidx.appcompat.app.AppCompatDialog;
-import androidx.compose.ui.platform.ComposeView;
-import androidx.core.app.ActivityCompat;
-
-import com.google.android.material.button.MaterialButton;
-import com.google.android.material.card.MaterialCardView;
-import com.google.android.material.chip.Chip;
-import com.google.android.material.chip.ChipDrawable;
-import com.google.android.material.chip.ChipGroup;
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-import com.google.android.material.divider.MaterialDivider;
-
-import java.util.List;
-import java.util.Map;
-
-import org.slf4j.Logger;
-
-import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.activities.ContactDetailActivity;
-import ch.threema.app.activities.MessageDetailsUiModel;
-import ch.threema.app.activities.MessageDetailsViewModelKt;
-import ch.threema.app.activities.MessageTimestampsUiModel;
-import ch.threema.app.activities.ThreemaActivity;
-import ch.threema.app.compose.common.interop.ComposeJavaBridge;
-import ch.threema.app.listeners.MessageListener;
-import ch.threema.app.managers.ListenerManager;
-import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.services.ContactService;
-import ch.threema.app.services.MessageService;
-import ch.threema.app.stores.IdentityStore;
-import ch.threema.app.utils.AvatarConverterUtil;
-import ch.threema.app.utils.BitmapUtil;
-import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.NameUtil;
-import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.base.ThreemaException;
-import ch.threema.base.utils.LoggingUtil;
-import ch.threema.storage.models.AbstractMessageModel;
-import ch.threema.storage.models.ContactModel;
-import ch.threema.storage.models.GroupMessageModel;
-import ch.threema.storage.models.MessageState;
-import ch.threema.storage.models.MessageType;
-
-public class MessageDetailDialog extends ThreemaDialogFragment implements View.OnClickListener {
-
-    private static final Logger logger = LoggingUtil.getThreemaLogger("MessageDetailDialog");
-
-    private static final String BUNDLE_KEY_TITLE_RES_ID = "title";
-    private static final String BUNDLE_KEY_MESSAGE_ID = "messageId";
-    private static final String BUNDLE_KEY_MESSAGE_TYPE = "messageType";
-
-    private View dialogView;
-
-    private @Nullable ContactService contactService = null;
-    private @Nullable IdentityStore identityStore = null;
-    private @Nullable AbstractMessageModel messageModel = null;
-
-    private final MessageListener messageListener = new MessageListener() {
-        @Override
-        public void onNew(AbstractMessageModel newMessage) {
-        }
-
-        @Override
-        public void onModified(List<AbstractMessageModel> modifiedMessageModels) {
-            if (messageModel != null) {
-                for (AbstractMessageModel modifiedMessageModel : modifiedMessageModels) {
-                    if (modifiedMessageModel.getId() == messageModel.getId()) {
-                        RuntimeUtil.runOnUiThread(() -> updateAckDisplay(modifiedMessageModel));
-                        break;
-                    }
-                }
-            }
-        }
-
-        @Override
-        public void onRemoved(AbstractMessageModel removedMessageModel) {
-        }
-
-        @Override
-        public void onRemoved(List<AbstractMessageModel> removedMessageModels) {
-        }
-
-        @Override
-        public void onProgressChanged(AbstractMessageModel messageModel, int newProgress) {
-        }
-
-        @Override
-        public void onResendDismissed(@NonNull AbstractMessageModel messageModel) {
-            // Ignore
-        }
-    };
-
-    @NonNull
-    public static MessageDetailDialog newInstance(
-        @StringRes int titleResId,
-        int messageId,
-        @Nullable String messageType
-    ) {
-        MessageDetailDialog dialog = new MessageDetailDialog();
-        Bundle args = new Bundle();
-        args.putInt(BUNDLE_KEY_TITLE_RES_ID, titleResId);
-        args.putInt(BUNDLE_KEY_MESSAGE_ID, messageId);
-        args.putString(BUNDLE_KEY_MESSAGE_TYPE, messageType);
-        dialog.setArguments(args);
-        return dialog;
-    }
-
-    @Override
-    public void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-
-        ListenerManager.messageListeners.add(this.messageListener);
-    }
-
-    @Override
-    public void onDestroy() {
-        ListenerManager.messageListeners.remove(this.messageListener);
-
-        super.onDestroy();
-    }
-
-    @NonNull
-    @Override
-    public AppCompatDialog onCreateDialog(Bundle savedInstanceState) {
-
-        final @Nullable ServiceManager serviceManager = ThreemaApplication.getServiceManager();
-        @Nullable MessageService messageService = null;
-        if (serviceManager != null) {
-            try {
-                messageService = serviceManager.getMessageService();
-                identityStore = serviceManager.getIdentityStore();
-                contactService = serviceManager.getContactService();
-            } catch (ThreemaException threemaException) {
-                logger.error("Required services are not available", threemaException);
-            }
-        }
-
-        final @StringRes int titleResId = requireArguments().getInt(BUNDLE_KEY_TITLE_RES_ID);
-        final int messageId = requireArguments().getInt(BUNDLE_KEY_MESSAGE_ID);
-        final @Nullable String messageType = requireArguments().getString(BUNDLE_KEY_MESSAGE_TYPE);
-
-        if (messageService != null) {
-            messageModel = messageService.getMessageModelFromId(messageId, messageType);
-        } else {
-            messageModel = null;
-        }
-
-        dialogView = requireActivity().getLayoutInflater().inflate(R.layout.dialog_message_detail, null);
-
-        if (messageModel != null) {
-            MessageTimestampsUiModel timestampsUiModel = MessageDetailsViewModelKt.toMessageTimestampsUiModel(messageModel);
-            MessageDetailsUiModel detailsUiModel = MessageDetailsViewModelKt.toMessageDetailsUiModel(messageModel);
-            final ComposeView messageDetailComposeView = dialogView.findViewById(R.id.message_detail_compose_view);
-            ComposeJavaBridge.INSTANCE.setContentMessageDetails(messageDetailComposeView, timestampsUiModel, detailsUiModel);
-        }
-
-        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity(), getTheme());
-        builder.setView(dialogView);
-
-        if (titleResId != -1) {
-            builder.setTitle(titleResId);
-        }
-
-        builder.setPositiveButton(getString(R.string.ok), null);
-
-        if (messageModel != null && !messageModel.isStatusMessage() && messageModel.getType() != MessageType.GROUP_CALL_STATUS) {
-            updateAckDisplay(messageModel);
-        }
-
-        return builder.create();
-    }
-
-    private synchronized void updateAckDisplay(AbstractMessageModel messageModel) {
-
-        if (!ConfigUtils.isGroupAckEnabled()) {
-            return;
-        }
-
-        if (dialogView == null) {
-            return;
-        }
-
-        if (!isAdded()) {
-            return;
-        }
-
-        if (messageModel == null) {
-            return;
-        }
-
-        if (contactService == null) {
-            return;
-        }
-
-        final MaterialDivider groupAckDivider = dialogView.findViewById(R.id.groupack_divider);
-        final MaterialCardView ackCard = dialogView.findViewById(R.id.ack_card);
-        final ImageView ackIcon = dialogView.findViewById(R.id.ack_icon);
-        final ChipGroup ackData = dialogView.findViewById(R.id.ack_data);
-        final MaterialButton ackCountView = dialogView.findViewById(R.id.ack_count);
-        final MaterialCardView decCard = dialogView.findViewById(R.id.dec_card);
-        final ImageView decIcon = dialogView.findViewById(R.id.dec_icon);
-        final ChipGroup decData = dialogView.findViewById(R.id.dec_data);
-        final MaterialButton decCountView = dialogView.findViewById(R.id.dec_count);
-
-        if (messageModel instanceof GroupMessageModel) {
-            Map<String, Object> messageStates = ((GroupMessageModel) messageModel).getGroupMessageStates();
-            if (messageStates != null && !messageStates.isEmpty()) {
-                int ackCount = 0, decCount = 0;
-                ackData.removeAllViews();
-                decData.removeAllViews();
-
-                for (Map.Entry<String, Object> entry : messageStates.entrySet()) {
-                    ContactModel contactModel = contactService.getByIdentity(entry.getKey());
-                    if (contactModel == null) {
-                        continue;
-                    }
-
-                    // an ack or dec state implies "read"
-                    if (MessageState.USERACK.toString().equals(entry.getValue())) {
-                        appendChip(ackData, contactModel);
-                        ackCount++;
-                    } else if (MessageState.USERDEC.toString().equals(entry.getValue())) {
-                        appendChip(decData, contactModel);
-                        decCount++;
-                    }
-
-                    if (ackCount > 0) {
-                        ackCard.setVisibility(View.VISIBLE);
-                        ackCountView.setText(String.valueOf(ackCount));
-                        if (messageModel.getState() == MessageState.USERACK) {
-                            ackIcon.setImageResource(R.drawable.ic_thumb_up_filled);
-                        }
-                    } else {
-                        ackCard.setVisibility(View.GONE);
-                    }
-
-                    if (decCount > 0) {
-                        decCard.setVisibility(View.VISIBLE);
-                        decCountView.setText(String.valueOf(decCount));
-                        if (messageModel.getState() == MessageState.USERDEC) {
-                            decIcon.setImageResource(R.drawable.ic_thumb_down_filled);
-                        }
-                    } else {
-                        decCard.setVisibility(View.GONE);
-                    }
-
-                    if (decCount > 0 || ackCount > 0) {
-                        groupAckDivider.setVisibility(View.VISIBLE);
-                    }
-                }
-            }
-        }
-    }
-
-    @SuppressLint("StaticFieldLeak")
-    private void appendChip(ChipGroup chipGroup, @NonNull ContactModel contactModel) {
-        Chip chip = new Chip(requireContext());
-        ChipDrawable chipDrawable = ChipDrawable.createFromAttributes(
-            requireContext(),
-            null,
-            0,
-            R.style.Threema_Chip_MessageDetails
-        );
-        chip.setChipDrawable(chipDrawable);
-        chip.setEnsureMinTouchTargetSize(false);
-        chip.setTag(contactModel.getIdentity());
-        chip.setOnClickListener(this);
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-            chip.setTextAppearance(R.style.Threema_TextAppearance_Chip_ChatNotice);
-        } else {
-            chip.setTextSize(14);
-        }
-
-        //noinspection deprecation
-        new AsyncTask<Void, Void, Bitmap>() {
-            @Override
-            protected Bitmap doInBackground(Void... params) {
-                if (contactService == null) {
-                    return null;
-                }
-                Bitmap bitmap = contactService.getAvatar(contactModel, false);
-                if (bitmap != null) {
-                    return BitmapUtil.replaceTransparency(bitmap, Color.WHITE);
-                }
-                return null;
-            }
-
-            @Override
-            protected void onPostExecute(Bitmap avatar) {
-                if (avatar != null) {
-                    chip.setChipIcon(AvatarConverterUtil.convertToRound(getResources(), avatar));
-                } else {
-                    chip.setChipIconResource(R.drawable.ic_contact);
-                }
-            }
-        }.execute();
-
-        chip.setText(NameUtil.getShortName(contactModel));
-        chipGroup.addView(chip);
-    }
-
-    @Override
-    public void onClick(View view) {
-        if (identityStore == null || !(view instanceof Chip)) {
-            return;
-        }
-        final @Nullable String identity = (String) view.getTag();
-        if (identity != null && !identity.equals(identityStore.getIdentity())) {
-            Intent intent = new Intent(getContext(), ContactDetailActivity.class);
-            intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, identity);
-            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
-            ActivityCompat.startActivityForResult(
-                requireActivity(),
-                intent,
-                ThreemaActivity.ACTIVITY_ID_CONTACT_DETAIL,
-                null
-            );
-        }
-    }
-}

+ 256 - 0
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionGroup.kt

@@ -0,0 +1,256 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.emojireactions
+
+import android.content.Context
+import android.graphics.Canvas
+import android.util.AttributeSet
+import android.view.ContextThemeWrapper
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.widget.LinearLayoutCompat
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.emojireactions.EmojiReactionsButton.OnEmojiReactionButtonClickListener
+import ch.threema.app.messagereceiver.MessageReceiver
+import ch.threema.data.models.EmojiReactionData
+import ch.threema.storage.models.AbstractMessageModel
+import kotlin.math.roundToInt
+
+data class ButtonInfo(
+    val emojiSequence: String,
+    val count: Int,
+    val isChecked: Boolean
+)
+
+class EmojiReactionGroup : LinearLayoutCompat, OnEmojiReactionButtonClickListener,
+    SelectEmojiButton.OnSelectEmojiButtonClickListener,
+    MoreReactionsButton.OnMoreReactionsButtonClickListener {
+    private var messageModel: AbstractMessageModel? = null
+    private var buttonInfoList: MutableList<ButtonInfo> = ArrayList()
+    private var bubbleView: View? = null
+    private var messageReceiver: MessageReceiver<*>? = null
+    var onEmojiReactionGroupClickListener: OnEmojiReactionGroupClickListener? = null
+    val userService = ThreemaApplication.requireServiceManager().userService
+    var reactions: List<EmojiReactionData> = ArrayList()
+    private var listViewWidth = -1
+    private var messageBubbleWidth = -1
+    private val buttonWidth = resources.getDimensionPixelSize(R.dimen.emojireactions_width)
+    private val buttonWidthWithCount = resources.getDimensionPixelSize(R.dimen.emojireactions_width_with_count)
+    private lateinit var contextThemeWrapper: ContextThemeWrapper
+
+    constructor(context: Context) : super(
+        context
+    ) {
+        init()
+    }
+
+    constructor(context: Context, attrs: AttributeSet?) : super(
+        context, attrs
+    ) {
+        init()
+    }
+
+    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
+        context,
+        attrs,
+        defStyleAttr
+    ) {
+        init()
+    }
+
+    private fun init() {
+        setWillNotDraw(false)
+
+        orientation = HORIZONTAL
+        gravity = Gravity.LEFT or Gravity.CENTER_VERTICAL
+
+        contextThemeWrapper = getContextThemeWrapper(context)
+    }
+
+    fun setMessageModel(messageReceiver: MessageReceiver<*>, messageModel: AbstractMessageModel, reactions: List<EmojiReactionData>) {
+        this.messageModel = messageModel
+        this.reactions = reactions
+        this.messageReceiver = messageReceiver
+
+        val newBubbleView: View = (parent as ViewGroup).findViewById(R.id.message_block)
+        if (newBubbleView != bubbleView) {
+            this.bubbleView = newBubbleView
+            this.messageBubbleWidth = getBubbleWidth()
+            if (this.listViewWidth == -1) {
+                this.listViewWidth = getListViewWidth()
+            }
+        }
+
+        drawButtons()
+    }
+
+    private fun drawButtons() {
+        synchronized(buttonInfoList) {
+            if (messageBubbleWidth == -1) {
+                return
+            }
+
+            if (listViewWidth == -1) {
+                return
+            }
+
+            val newButtonInfoList: MutableList<ButtonInfo> = ArrayList()
+
+            if (reactions.isNotEmpty()) {
+                val grouped = reactions.groupingBy { it.emojiSequence }.eachCount()
+
+                for ((key, count) in grouped) {
+                    newButtonInfoList.add(
+                        ButtonInfo(
+                            emojiSequence = key,
+                            count,
+                            isChecked = reactions.any { reaction -> reaction.emojiSequence == key && reaction.senderIdentity == userService.identity })
+                    )
+                }
+            }
+
+            if (newButtonInfoList != buttonInfoList) {
+                var allowedWidth: Int = if (messageBubbleWidth * 2 > listViewWidth) {
+                    // When the message occupies more than 50% of the screen, the emojis occupy 100% of the message and can overflow to cover 60% of the screen.
+                    (listViewWidth * 0.6).roundToInt()
+                } else {
+                    // When the message is very short (10% to 20%), the line of emojis can only occupy 50% of the whole screen. (The bubbles can go a little bit outside the message).
+                    (listViewWidth * 0.5).roundToInt()
+                }
+                // deduct width of mandatory select button
+                allowedWidth -= buttonWidth
+
+                removeAllViewsInLayout()
+
+                var usedWidth = 0
+                for ((index, buttonInfo) in newButtonInfoList.withIndex()) {
+                    val emojiReactionButton = createEmojiReactionButton(buttonInfo)
+                    usedWidth += if (buttonInfo.count > 1) {
+                        buttonWidthWithCount
+                    } else {
+                        buttonWidth
+                    }
+
+                    if (usedWidth >= allowedWidth && index < newButtonInfoList.size - 1) {
+                        createAndAddMoreReactionsButton(newButtonInfoList.size - index)
+                        break
+                    }
+
+                    emojiReactionButton.onEmojiReactionButtonClickListener = this
+                    addViewInLayout(emojiReactionButton, -1, emojiReactionButton.layoutParams)
+                }
+
+                messageReceiver?.
+                    takeIf { it.emojiReactionSupport != MessageReceiver.Reactions_NONE }?.
+                    takeIf { newButtonInfoList.isNotEmpty() }?.
+                    let {
+                        createAndAddSelectEmojiButton()
+                    }
+
+                buttonInfoList = newButtonInfoList
+
+                requestLayout()
+                parent.requestLayout()
+            }
+        }
+    }
+
+    private fun createEmojiReactionButton(buttonInfo: ButtonInfo): EmojiReactionsButton {
+        val emojiReactionsButton = EmojiReactionsButton(contextThemeWrapper)
+        emojiReactionsButton.setButtonData(buttonInfo.emojiSequence, buttonInfo.count)
+        emojiReactionsButton.isChecked = buttonInfo.isChecked
+        return emojiReactionsButton
+    }
+
+    private fun createAndAddMoreReactionsButton(remainingCount: Int) {
+        val moreReactionsButton = MoreReactionsButton(contextThemeWrapper)
+        moreReactionsButton.setButtonData("+$remainingCount")
+        moreReactionsButton.isChecked = false
+        moreReactionsButton.onMoreReactionsButtonClickListener = this
+        addViewInLayout(moreReactionsButton, -1, moreReactionsButton.layoutParams)
+    }
+
+    private fun createAndAddSelectEmojiButton() {
+        val selectEmojiButton = SelectEmojiButton(contextThemeWrapper)
+        selectEmojiButton.isChecked = false
+        selectEmojiButton.onSelectEmojiButtonClickListener = this
+        addViewInLayout(selectEmojiButton, -1, selectEmojiButton.layoutParams)
+    }
+
+    private fun getContextThemeWrapper(context: Context): ContextThemeWrapper {
+        return ContextThemeWrapper(
+            context,
+            R.style.Threema_EmojiReactions_Button
+        )
+    }
+
+    override fun onDraw(canvas: Canvas) {
+        super.onDraw(canvas)
+
+        if (parent == null) {
+            return
+        }
+
+        listViewWidth = getListViewWidth()
+        messageBubbleWidth = getBubbleWidth()
+
+        drawButtons()
+    }
+
+    private fun getListViewWidth(): Int {
+        val listView = parent?.parent as? View
+        return listView?.width ?: -1
+    }
+
+    private fun getBubbleWidth(): Int {
+        return bubbleView?.width ?: -1
+    }
+
+    interface OnEmojiReactionGroupClickListener {
+        fun onEmojiReactionClick(messageModel: AbstractMessageModel?, emojiSequence: String?)
+        fun onEmojiReactionLongClick(messageModel: AbstractMessageModel?, emojiSequence: String?)
+        fun onSelectButtonClick(messageModel: AbstractMessageModel?)
+        fun onMoreReactionsButtonClick(messageModel: AbstractMessageModel?)
+    }
+
+    override fun onClick(emojiSequence: String?) {
+        onEmojiReactionGroupClickListener?.onEmojiReactionClick(messageModel, emojiSequence)
+    }
+
+    override fun onLongClick(emojiSequence: String?) {
+        onEmojiReactionGroupClickListener?.onEmojiReactionLongClick(messageModel, emojiSequence)
+    }
+
+    override fun onSelectButtonClick() {
+        onEmojiReactionGroupClickListener?.onSelectButtonClick(messageModel)
+    }
+
+    override fun onMoreReactionsButtonClick() {
+        onEmojiReactionGroupClickListener?.onMoreReactionsButtonClick(messageModel)
+    }
+
+    override fun onMoreReactionsButtonLongClick() {
+        onEmojiReactionGroupClickListener?.onEmojiReactionLongClick(messageModel, null)
+    }
+}

+ 135 - 0
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsButton.kt

@@ -0,0 +1,135 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.emojireactions
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.util.AttributeSet
+import android.view.GestureDetector
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.widget.Space
+import android.widget.TextView
+import androidx.core.view.isVisible
+import ch.threema.app.R
+import ch.threema.app.emojis.EmojiTextView
+import com.google.android.material.card.MaterialCardView
+
+class EmojiReactionsButton : MaterialCardView {
+    private var labelText: String? = null
+    private var emojiSequence: String? = null
+
+    var onEmojiReactionButtonClickListener: OnEmojiReactionButtonClickListener? = null
+
+    private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
+        override fun onSingleTapUp(e: MotionEvent): Boolean {
+            onEmojiReactionButtonClickListener?.onClick(emojiSequence)
+            performClick()
+            return true
+        }
+
+        override fun onLongPress(e: MotionEvent) {
+            onEmojiReactionButtonClickListener?.onLongClick(emojiSequence)
+            performLongClick()
+        }
+    })
+
+    private lateinit var labelView: TextView
+    private lateinit var emojiView: EmojiTextView
+    private lateinit var spacerView: Space
+
+    constructor(context: Context) : super(
+        context
+    ) {
+        init()
+    }
+
+    constructor(context: Context, attrs: AttributeSet?) : super(
+        context,
+        attrs
+    ) {
+        init()
+    }
+
+    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
+        context,
+        attrs,
+        defStyleAttr
+    ) {
+        init()
+    }
+
+    private fun init() {
+        layoutParams = LayoutParams(WRAP_CONTENT, MATCH_PARENT)
+        isClickable = true
+        isFocusable = true
+        isCheckable = true
+        isDuplicateParentStateEnabled = false
+
+        LayoutInflater.from(context).inflate(R.layout.view_emoji_reaction, this as ViewGroup)
+
+        labelView = findViewById(R.id.reaction_label)
+        emojiView = findViewById(R.id.reaction_emoji)
+        spacerView = findViewById(R.id.reaction_spacer)
+    }
+
+    private fun renderContents() {
+        emojiView.isVisible = emojiSequence?.isNotEmpty() == true
+        if (emojiView.isVisible) {
+            emojiSequence = emojiView.setSingleEmojiSequence(emojiSequence).toString()
+        }
+
+        labelView.isVisible = labelText?.isNotEmpty() == true
+        if (labelView.isVisible) {
+            labelView.text = labelText
+        }
+
+        spacerView.isVisible = emojiView.isVisible && labelView.isVisible
+    }
+
+    @SuppressLint("ClickableViewAccessibility")
+    override fun onTouchEvent(event: MotionEvent): Boolean {
+        gestureDetector.onTouchEvent(event)
+
+        if (event.action == MotionEvent.ACTION_UP) {
+            isPressed = false
+        } else if (event.action == MotionEvent.ACTION_DOWN) {
+            isPressed = true
+        }
+
+        return true
+    }
+
+    fun setButtonData(emojiSequence: String, count: Int) {
+        this.emojiSequence = emojiSequence
+        labelText = if (count > 1) count.toString() else ""
+        renderContents()
+    }
+
+    interface OnEmojiReactionButtonClickListener {
+        fun onClick(emojiSequence: String?)
+        fun onLongClick(emojiSequence: String?)
+    }
+}

+ 143 - 0
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsGridAdapter.java

@@ -0,0 +1,143 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.emojireactions;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AbsListView;
+import android.widget.BaseAdapter;
+import android.widget.Toast;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import androidx.annotation.ColorInt;
+import androidx.core.content.res.ResourcesCompat;
+import ch.threema.app.R;
+import ch.threema.app.ThreemaApplication;
+import ch.threema.app.emojis.EmojiItemView;
+import ch.threema.app.emojis.EmojiManager;
+import ch.threema.app.services.UserService;
+import ch.threema.app.ui.LongToast;
+import ch.threema.data.models.EmojiReactionData;
+
+public class EmojiReactionsGridAdapter extends BaseAdapter {
+	private final int emojiItemSize;
+	private final int emojiItemPaddingSize;
+	@ColorInt private final int diverseHintColor;
+	private final List<ReactionEntry> emojis;
+	final Context context;
+
+	private final KeyClickListener keyClickListener;
+
+	public EmojiReactionsGridAdapter(Context context,
+	                                 List<EmojiReactionData> emojiReactions,
+	                                 KeyClickListener listener) {
+		this.context = context;
+		this.keyClickListener = listener;
+		this.diverseHintColor = context.getResources().getColor(R.color.emoji_picker_hint);
+		if (EmojiManager.getInstance(context).getSpritemapInSampleSize() == 1) {
+			this.emojiItemSize = context.getResources().getDimensionPixelSize(R.dimen.emoji_picker_item_size);
+			this.emojiItemPaddingSize = (emojiItemSize - context.getResources().getDimensionPixelSize(R.dimen.emoji_picker_emoji_size)) / 2;
+		} else {
+			this.emojiItemSize = 44;
+			this.emojiItemPaddingSize = (emojiItemSize - 32) / 2;
+		}
+		this.emojis = new ArrayList<>();
+		UserService userService = ThreemaApplication.requireServiceManager().getUserService();
+
+		for(EmojiReactionData reaction : emojiReactions) {
+			boolean isSender = reaction.senderIdentity.equals(userService.getIdentity());
+			Optional<ReactionEntry> existing = this.emojis.stream().filter(e -> e.emojiSequence.equals(reaction.emojiSequence)).findFirst();
+
+			if (existing.isPresent()) {
+				existing.get().isSender = existing.get().isSender || isSender;
+			} else {
+				this.emojis.add(new ReactionEntry(reaction.emojiSequence, isSender));
+			}
+		}
+	}
+
+	@Override
+	public View getView(final int position, View convertView, ViewGroup parent) {
+		final ReactionEntry item = getItem(position);
+
+		final EmojiItemView view;
+		if (convertView instanceof EmojiItemView) {
+			view = (EmojiItemView)convertView;
+		} else {
+			final EmojiItemView emojiItemView = new EmojiItemView(context);
+			Drawable background = ResourcesCompat.getDrawable(context.getResources(), R.drawable.selector_emoji_reactions_grid_item, null);
+			emojiItemView.setBackground(background);
+			emojiItemView.setPadding(emojiItemPaddingSize, emojiItemPaddingSize, emojiItemPaddingSize, emojiItemPaddingSize);
+			emojiItemView.setLayoutParams(new AbsListView.LayoutParams(emojiItemSize, emojiItemSize));
+			view = emojiItemView;
+		}
+
+		if (view.setEmoji(item.emojiSequence, false, diverseHintColor) != null) {
+			view.setContentDescription(item.emojiSequence);
+			view.setOnClickListener(v -> keyClickListener.onEmojiReactionClicked(item.emojiSequence));
+			view.post(() -> view.setSelected(item.isSender));
+		} else {
+			view.setOnClickListener(v -> LongToast.makeText(context, R.string.reaction_cannot_be_displayed, Toast.LENGTH_LONG).show());
+		}
+
+		return view;
+	}
+
+	@Override
+	public int getCount() {
+		return this.emojis.size();
+	}
+
+	@Override
+	public ReactionEntry getItem(int position) {
+		return this.emojis.get(position);
+	}
+
+	@Override
+	public boolean hasStableIds() {
+		return true;
+	}
+
+	@Override
+	public long getItemId(int position) {
+		return position;
+	}
+
+	public interface KeyClickListener {
+		void onEmojiReactionClicked(String emojiCodeString);
+	}
+
+	public static class ReactionEntry {
+		final String emojiSequence;
+		boolean isSender;
+
+		public ReactionEntry(String emojiSequence, boolean isSender) {
+			this.emojiSequence = emojiSequence;
+			this.isSender = isSender;
+		}
+	}
+}

+ 398 - 0
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewActivity.kt

@@ -0,0 +1,398 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.emojireactions
+
+import android.annotation.SuppressLint
+import android.content.res.Configuration.ORIENTATION_LANDSCAPE
+import android.graphics.Typeface
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import android.view.ViewTreeObserver
+import android.widget.TextView
+import android.graphics.Color
+import androidx.activity.viewModels
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.content.ContextCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.children
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.RecyclerView
+import androidx.viewpager2.widget.ViewPager2
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.activities.ThreemaToolbarActivity
+import ch.threema.app.emojis.EmojiTextView
+import ch.threema.app.emojis.EmojiUtil
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.IntentDataUtil
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.data.models.EmojiReactionData
+import ch.threema.storage.models.AbstractMessageModel
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
+import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
+import com.google.android.material.shape.MaterialShapeDrawable
+import com.google.android.material.tabs.TabLayout
+import com.google.android.material.tabs.TabLayoutMediator
+import kotlinx.coroutines.launch
+import org.slf4j.Logger
+import kotlin.math.roundToInt
+
+private val logger: Logger = LoggingUtil.getThreemaLogger("EmojiReactionOverviewActivity")
+
+class EmojiReactionsOverviewActivity : ThreemaToolbarActivity() {
+
+    private lateinit var viewPager: ViewPager2
+    private lateinit var parentLayout: CoordinatorLayout
+    private lateinit var infoBox: View
+    private var initialItem: String? = null
+    private var messageModel: AbstractMessageModel? = null
+    private val items = mutableListOf<EmojiReactionItems>()
+    private var tabLayoutMediator: TabLayoutMediator? = null
+    private var emojiReactionsOverviewAdapter: EmojiReactionsOverviewAdapter? = null
+    private val statusBarColorExpanded: Int by lazy {
+        ContextCompat.getColor(this, R.color.attach_status_bar_color_expanded)
+    }
+    private val statusBarColorCollapsed: Int by lazy {
+        ContextCompat.getColor(this, R.color.attach_status_bar_color_collapsed)
+    }
+
+    data class EmojiReactionItems(val emojiSequence: String, val count: Int, val isMyReaction: Boolean)
+
+    override fun getLayoutResource(): Int {
+        return R.layout.activity_emojireactions_overview
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+
+        super.onCreate(savedInstanceState)
+    }
+
+    @SuppressLint("ClickableViewAccessibility")
+    override fun initActivity(savedInstanceState: Bundle?): Boolean {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0)
+        }
+
+        val serviceManager = ThreemaApplication.requireServiceManager()
+        val messageService = serviceManager.messageService
+        val emojiReactionsRepository = serviceManager.modelRepositories.emojiReaction
+
+        messageModel = IntentDataUtil.getAbstractMessageModel(intent, messageService)
+        messageModel?.let { message ->
+            initialItem = intent.getStringExtra(EXTRA_INITIAL_EMOJI)
+
+            val emojiReactionsViewModel: EmojiReactionsViewModel by viewModels {
+                EmojiReactionsViewModel.provideFactory(
+                    emojiReactionsRepository,
+                    messageService,
+                    messageId = message.uid
+                )
+            }
+
+            lifecycleScope.launch {
+                repeatOnLifecycle(Lifecycle.State.STARTED) {
+                    emojiReactionsViewModel.emojiReactionsUiState
+                        .collect { uiState -> onUiStateChanged(uiState, emojiReactionsViewModel, message) }
+                }
+            }
+
+            if (!super.initActivity(savedInstanceState)) {
+                finish()
+                return false
+            }
+
+            infoBox = findViewById(R.id.infobox)
+            window.statusBarColor = statusBarColorCollapsed
+
+            setupParentLayout()
+            setupViewPager()
+        } ?: run {
+            logger.error("No message model found")
+            finish()
+            return false
+        }
+        return true
+    }
+
+    private fun onUiStateChanged(
+        uiState: EmojiReactionsViewModel.EmojiReactionsUiState,
+        emojiReactionsViewModel: EmojiReactionsViewModel,
+        message: AbstractMessageModel
+    ) {
+        val oldItems = items.toList()
+
+        items.clear()
+        items.addAll(processReactions(uiState.emojiReactions))
+
+        // transition to zero items means we have no reactions left to show, so quit
+        if (oldItems.isNotEmpty() && items.isEmpty()) {
+            finish()
+            return
+        }
+
+        if (requiresViewPagerRecreation(oldItems, items)) {
+            recreateViewPager(emojiReactionsViewModel, message)
+            setupDefaultViewPagerItem()
+        } else {
+            emojiReactionsOverviewAdapter?.notifyDataSetChanged()
+        }
+    }
+
+    /**
+     * Show an infobox if there are new-style emoji reactions but we can't send them (V1)
+     */
+    private fun setupInfoBox() {
+        if (!ConfigUtils.canSendEmojiReactions() && items.isNotEmpty()) {
+
+            val count = items.filter { item ->
+                item.emojiSequence != EmojiUtil.THUMBS_UP_SEQUENCE && item.emojiSequence != EmojiUtil.THUMBS_DOWN_SEQUENCE
+            }
+
+            infoBox.visibility = if (count.isEmpty()) View.GONE else View.VISIBLE
+        }
+    }
+
+    /**
+     * Group and sort reactions and return a list of EmojiReactionItems
+     */
+    private fun processReactions(reactions: List<EmojiReactionData>): List<EmojiReactionItems> {
+        return reactions
+            .groupBy { it.emojiSequence }
+            .map { (emojiSequence, reactionList) ->
+                val count = reactionList.size
+                val isMyReaction = reactionList.any { it.senderIdentity == myIdentity }
+                EmojiReactionItems(emojiSequence, count, isMyReaction)
+            }
+            .sortedByDescending { it.count }
+    }
+
+    /**
+     * Setup view pager item to show initially
+     */
+    private fun setupDefaultViewPagerItem() {
+        val initialItemIndex = if (initialItem != null && items.isNotEmpty()) {
+            items.indexOfFirst { it.emojiSequence == initialItem }.takeIf { it != -1 }
+        } else {
+            if (items.isNotEmpty()) 0 else null
+        }
+
+        initialItemIndex?.let {
+            viewPager.post { viewPager.setCurrentItem(it, false) }
+        }
+        initialItem = null
+    }
+
+    /**
+     * Setup view pager and adapter
+     */
+    private fun setupViewPagerAdapter(emojiReactionsViewModel: EmojiReactionsViewModel, messageModel: AbstractMessageModel) {
+        emojiReactionsOverviewAdapter = EmojiReactionsOverviewAdapter(
+            this,
+            emojiReactionsViewModel,
+            messageModel
+        )
+        viewPager.adapter = emojiReactionsOverviewAdapter
+    }
+
+    /**
+     * Setup parent layout and set click listener to dismiss bottom sheet
+     */
+    private fun setupParentLayout() {
+        val bottomSheetLayout = findViewById<ConstraintLayout>(R.id.bottom_sheet)
+        val bottomSheetBehavior = setupBottomSheet(bottomSheetLayout)
+
+        parentLayout = findViewById(R.id.parent_layout)
+        parentLayout.setOnClickListener { _: View? ->
+            bottomSheetBehavior.setState(
+                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
+            }
+        })
+    }
+
+    private fun setupViewPager() {
+        viewPager = findViewById(R.id.view_pager)
+        // workarounds for dragging issues with ViewPager2
+        // see also https://github.com/material-components/material-components-android/issues/2689
+        viewPager.children.find { it is RecyclerView }?.let {
+            (it as RecyclerView).isNestedScrollingEnabled = false
+        }
+        viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
+            override fun onPageScrollStateChanged(state: Int) {
+                super.onPageScrollStateChanged(state)
+
+                if (state == ViewPager2.SCROLL_STATE_IDLE) {
+                    parentLayout.requestLayout()
+                }
+            }
+        })
+    }
+
+    /**
+     * Setup tab layout and attach it to view pager via TabLayoutMediator
+     * Also sets accessibility texts for custom views
+     */
+    private fun setupTabLayout() {
+        val textColor = ConfigUtils.getColorFromAttribute(this, R.attr.textColorPrimary)
+        val tabLayout = findViewById<TabLayout>(R.id.tab_layout)
+
+        tabLayoutMediator = TabLayoutMediator(tabLayout, viewPager) { tab, position ->
+            if (position > items.lastIndex) {
+                return@TabLayoutMediator
+            }
+
+            val emojiSequence = items[position].emojiSequence
+            val emojiCount = items[position].count.toString()
+            val isMyReaction = items[position].isMyReaction
+
+            tab.customView = layoutInflater.inflate(R.layout.tab_emoji_reactions_overview, null)
+            tab.customView?.let {
+                it.findViewById<EmojiTextView>(R.id.emoji).setSingleEmojiSequence(emojiSequence)
+                val countTextView = it.findViewById<TextView>(R.id.count)
+
+                countTextView?.apply {
+                    text = emojiCount
+                    typeface = if (isMyReaction) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
+                }
+                // we use a shadow to achieve a "bolder than bold" effect
+                if (isMyReaction) {
+                    countTextView.setShadowLayer(1f, 2f, 0f, textColor)
+                } else {
+                    countTextView.setShadowLayer(0f, 0f, 0f, Color.TRANSPARENT)
+                }
+            }
+            tab.contentDescription =
+                getString(R.string.tab_emoji_reactions_overview_content_description, emojiCount, emojiSequence)
+        }
+        tabLayoutMediator?.attach()
+    }
+
+    /**
+     * Setup bottom sheet and set callback for state changes
+     */
+    private fun setupBottomSheet(bottomSheetLayout: ConstraintLayout) : BottomSheetBehavior<ConstraintLayout> {
+        val dragHandle = findViewById<View>(R.id.drag_handle)
+
+        val bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout)
+
+        bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
+        bottomSheetBehavior.isShouldRemoveExpandedCorners = true
+        bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetCallback() {
+            var previousSlideOffset = -1f
+
+            override fun onStateChanged(bottomSheet: View, newState: Int) {
+                if (newState == BottomSheetBehavior.STATE_HIDDEN) {
+                    finish()
+                }
+            }
+
+            override fun onSlide(bottomSheet: View, slideOffset: Float) {
+                if (slideOffset > previousSlideOffset && slideOffset == 1.0f) {
+                    // fully expanded
+                    dragHandle.visibility = View.INVISIBLE
+                    window.statusBarColor = getBottomSheetBackgroundColor(bottomSheetLayout)
+                }
+                if (previousSlideOffset > slideOffset && previousSlideOffset == 1.0f) {
+                    // dragging
+                    dragHandle.visibility = View.VISIBLE
+                    window.statusBarColor = statusBarColorCollapsed
+                }
+                previousSlideOffset = slideOffset
+            }
+        })
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            bottomSheetLayout.post {
+                window.navigationBarColor = getBottomSheetBackgroundColor(bottomSheetLayout)
+            }
+        }
+
+        return bottomSheetBehavior
+    }
+
+    private fun getBottomSheetBackgroundColor(bottomSheetLayout: ConstraintLayout): Int {
+        val background = bottomSheetLayout.background
+        if (background is MaterialShapeDrawable) {
+            return background.resolvedTintColor
+        }
+        return statusBarColorExpanded
+    }
+
+    private fun requiresViewPagerRecreation(oldData: List<EmojiReactionItems>, newData: List<EmojiReactionItems>): Boolean {
+        if (oldData.size != newData.size) {
+            return true
+        }
+
+        for (i in oldData.indices) {
+            if (oldData[i] != newData[i]) {
+                return true
+            }
+        }
+        return false
+    }
+
+    private fun recreateViewPager(emojiReactionsViewModel: EmojiReactionsViewModel, messageModel: AbstractMessageModel) {
+        // Detach existing TabLayoutMediator (if any)
+        tabLayoutMediator?.detach()
+
+        // Create new adapter
+        setupViewPagerAdapter(emojiReactionsViewModel, messageModel)
+
+        // Create and attach new TabLayoutMediator
+        setupTabLayout()
+        setupInfoBox()
+    }
+
+    override fun finish() {
+        try {
+            super.finish()
+            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+                @Suppress("DEPRECATION")
+                overridePendingTransition(0, 0)
+            }
+        } catch (ignored: Exception) {
+            // ignore
+        }
+    }
+
+    companion object {
+        const val EXTRA_INITIAL_EMOJI = "extra_initial_emoji" // emoji to show initially
+    }
+}
+

+ 74 - 0
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewAdapter.kt

@@ -0,0 +1,74 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.emojireactions
+
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import ch.threema.data.models.EmojiReactionData
+import ch.threema.storage.models.AbstractMessageModel
+import kotlinx.coroutines.launch
+import kotlin.collections.eachCount
+import kotlin.collections.groupingBy
+import kotlin.collections.sortedByDescending
+
+class EmojiReactionsOverviewAdapter(
+    fragmentActivity: FragmentActivity,
+    private val viewModel: EmojiReactionsViewModel,
+    private val messageModel: AbstractMessageModel
+) :
+    FragmentStateAdapter(fragmentActivity) {
+
+    private val items = mutableListOf<Map.Entry<String, Int>>()
+
+    init {
+        fragmentActivity.lifecycleScope.launch {
+            fragmentActivity.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                viewModel.emojiReactionsUiState.collect { uiState ->
+                    items.clear()
+                    val reactions: MutableList<EmojiReactionData> = uiState.emojiReactions.toMutableList()
+                    if (reactions.isNotEmpty()) {
+                        val sortedList = reactions.groupingBy { it.emojiSequence }
+                            .eachCount()
+                            .entries
+                            .sortedByDescending { it.value }
+
+                        items.addAll(sortedList)
+                    }
+                    notifyDataSetChanged()
+                }
+            }
+        }
+    }
+
+    override fun getItemCount(): Int {
+        return items.size
+    }
+
+    override fun createFragment(position: Int): Fragment {
+        return EmojiReactionsOverviewFragment(items[position].key, messageModel = messageModel)
+    }
+}
+

+ 119 - 0
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewFragment.kt

@@ -0,0 +1,119 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.emojireactions
+
+import android.os.Bundle
+import android.os.Parcelable
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+import ch.threema.data.models.EmojiReactionData
+import ch.threema.storage.models.AbstractMessageModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+private const val RECYCLER_VIEW_STATE = "recyclerViewState"
+
+class EmojiReactionsOverviewFragment(val emojiSequence: String? = null, val messageModel: AbstractMessageModel) : Fragment(), EmojiReactionsOverviewListAdapter.OnItemClickListener {
+    private val emojiReactionsViewModel: EmojiReactionsViewModel by activityViewModels()
+    private lateinit var emojiReactionsOverviewListAdapter: EmojiReactionsOverviewListAdapter
+    private lateinit var recyclerView: RecyclerView
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        @Suppress("DEPRECATION")
+        retainInstance = true
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View? {
+        val view = inflater.inflate(R.layout.fragment_emojireactions_overview, container, false)
+        recyclerView = view.findViewById(R.id.emoji_reactions_list)
+
+        return view
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        emojiReactionsOverviewListAdapter = EmojiReactionsOverviewListAdapter(
+            messageModel = messageModel,
+            onItemClickListener = this
+        )
+        recyclerView.layoutManager = LinearLayoutManager(context)
+        recyclerView.adapter = emojiReactionsOverviewListAdapter
+        recyclerView.setHasFixedSize(true)
+        recyclerView.isNestedScrollingEnabled = true
+
+        val targetEmojiSequence = emojiSequence
+        viewLifecycleOwner.lifecycleScope.launch {
+            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                emojiReactionsViewModel.emojiReactionsUiState.collect { uiState ->
+                    val emojiReactionsForSequence = uiState.emojiReactions.filter { it.emojiSequence == targetEmojiSequence }
+                    emojiReactionsOverviewListAdapter.submitList(emojiReactionsForSequence)
+                    emojiReactionsOverviewListAdapter.notifyDataSetChanged()
+                }
+            }
+        }
+    }
+
+    override fun onRemoveClick(data: EmojiReactionData, position: Int) {
+        val messageService = ThreemaApplication.requireServiceManager().messageService
+        val messageModel = messageService.getGroupMessageModel(data.messageId) ?: messageService.getContactMessageModel(data.messageId)
+        val messageReceiver = messageService.getMessageReceiver(messageModel)
+
+        CoroutineScope(Dispatchers.Default).launch {
+            messageService.sendEmojiReaction(
+                messageModel as AbstractMessageModel,
+                data.emojiSequence,
+                messageReceiver,
+                false
+            )
+        }
+    }
+
+    override fun onSaveInstanceState(outState: Bundle) {
+        super.onSaveInstanceState(outState)
+        outState.putParcelable(RECYCLER_VIEW_STATE, recyclerView.layoutManager?.onSaveInstanceState())
+    }
+
+    override fun onViewStateRestored(savedInstanceState: Bundle?) {
+        super.onViewStateRestored(savedInstanceState)
+        @Suppress("DEPRECATION")
+        savedInstanceState?.getParcelable<Parcelable>(RECYCLER_VIEW_STATE)?.let {
+            recyclerView.layoutManager?.onRestoreInstanceState(it)
+        }
+    }
+}

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

@@ -0,0 +1,120 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.emojireactions
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.messagereceiver.MessageReceiver
+import ch.threema.app.services.ContactService
+import ch.threema.app.ui.AvatarView
+import ch.threema.app.utils.AdapterUtil
+import ch.threema.app.utils.MessageUtil
+import ch.threema.app.utils.NameUtil
+import ch.threema.data.models.EmojiReactionData
+import ch.threema.storage.models.AbstractMessageModel
+
+class EmojiReactionsOverviewListAdapter(
+    val messageModel: AbstractMessageModel?,
+    val onItemClickListener: OnItemClickListener?
+) :
+    ListAdapter<EmojiReactionData, EmojiReactionsOverviewListAdapter.EmojiReactionViewHolder>(
+        EmojiReactionDiffCallback()
+    ) {
+    val contactService: ContactService = ThreemaApplication.requireServiceManager().contactService
+    val messageService = ThreemaApplication.requireServiceManager().messageService
+    val messageReceiver: MessageReceiver<*> = messageService.getMessageReceiver(messageModel)
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmojiReactionViewHolder {
+        val itemView = LayoutInflater.from(parent.context)
+            .inflate(R.layout.item_emoji_reaction_list, parent, false)
+        return EmojiReactionViewHolder(itemView, ::getItem)
+    }
+
+    override fun onBindViewHolder(holder: EmojiReactionViewHolder, position: Int) {
+        val currentItem = getItem(position)
+        holder.onBind(currentItem)
+    }
+
+    class EmojiReactionDiffCallback : DiffUtil.ItemCallback<EmojiReactionData>() {
+        override fun areItemsTheSame(
+            oldItem: EmojiReactionData,
+            newItem: EmojiReactionData
+        ): Boolean {
+            return oldItem.emojiSequence == newItem.emojiSequence &&
+                oldItem.senderIdentity == newItem.senderIdentity &&
+                oldItem.messageId == newItem.messageId
+        }
+
+        override fun areContentsTheSame(
+            oldItem: EmojiReactionData,
+            newItem: EmojiReactionData
+        ): Boolean {
+            return oldItem == newItem
+        }
+    }
+
+    inner class EmojiReactionViewHolder(
+        itemView: View,
+        private val getItem: (Int) -> EmojiReactionData?
+    ) : RecyclerView.ViewHolder(itemView) {
+
+        private val contactAvatarView: AvatarView = itemView.findViewById(R.id.contact_avatar)
+        private val contactNameTextView: TextView = itemView.findViewById(R.id.contact_name)
+        private val removeIconView: ImageView = itemView.findViewById(R.id.remove_icon)
+
+        fun onBind(data: EmojiReactionData) {
+            val contactModel = contactService.getByIdentity(data.senderIdentity)
+            val avatar = contactService.getAvatar(contactModel, false)
+
+            contactNameTextView.text = NameUtil.getDisplayNameOrNickname(contactModel, true)
+            AdapterUtil.styleContact(contactNameTextView, contactModel)
+
+            contactAvatarView.setImageBitmap(avatar)
+            contactAvatarView.setBadgeVisible(contactService.showBadge(contactModel))
+
+            removeIconView.setOnClickListener {
+                val position = absoluteAdapterPosition
+                if (position != RecyclerView.NO_POSITION) {
+                    getItem(position)?.let {
+                        onItemClickListener?.onRemoveClick(it, position)
+                    }
+                }
+            }
+            removeIconView.isVisible = data.senderIdentity == contactService.me.identity
+                && messageReceiver.emojiReactionSupport != MessageReceiver.Reactions_NONE
+                && MessageUtil.canEmojiReact(messageModel)
+        }
+    }
+
+    fun interface OnItemClickListener {
+        fun onRemoveClick(data: EmojiReactionData, position: Int)
+    }
+}

+ 154 - 0
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsPickerActivity.kt

@@ -0,0 +1,154 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.emojireactions
+
+import android.annotation.SuppressLint
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import android.view.ViewStub
+import android.widget.LinearLayout
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.activities.ThreemaToolbarActivity
+import ch.threema.app.emojis.EmojiPicker
+import ch.threema.app.emojis.EmojiService
+import ch.threema.app.utils.IntentDataUtil
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.data.models.EmojiReactionData
+import ch.threema.data.models.EmojiReactionsModel
+import ch.threema.data.repositories.EmojiReactionsRepository
+import ch.threema.storage.models.AbstractMessageModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private val logger = LoggingUtil.getThreemaLogger("EmojiReactionPickerActivity")
+
+class EmojiReactionsPickerActivity : ThreemaToolbarActivity(), EmojiPicker.EmojiKeyListener {
+    private var emojiService: EmojiService = ThreemaApplication.requireServiceManager().emojiService
+    private var messageService = ThreemaApplication.requireServiceManager().messageService
+    private var messageModel: AbstractMessageModel? = null
+    private lateinit var parentLayout: LinearLayout
+    private var emojiPicker: EmojiPicker? = null
+
+    override fun getLayoutResource(): Int {
+        return R.layout.activity_emojireactions_picker
+    }
+
+    @SuppressLint("ClickableViewAccessibility")
+    override fun initActivity(savedInstanceState: Bundle?): Boolean {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0)
+        }
+
+        messageModel = IntentDataUtil.getAbstractMessageModel(intent, messageService)
+        if (messageModel == null) {
+            finish()
+            return false
+        }
+
+        if (!super.initActivity(savedInstanceState)) {
+            finish()
+            return false
+        }
+
+        parentLayout = findViewById(R.id.parent_layout)
+        parentLayout.setOnClickListener { _: View? ->
+            finish()
+        }
+
+        @Suppress("DEPRECATION")
+        window.statusBarColor = resources.getColor(R.color.attach_status_bar_color_collapsed)
+
+        emojiPicker = (findViewById<View>(R.id.emoji_stub) as ViewStub).inflate() as EmojiPicker
+        emojiPicker?.setEmojiKeyListener(this)
+
+        lifecycleScope.launch {
+            val emojiReactionsRepository: EmojiReactionsRepository = ThreemaApplication.requireServiceManager().modelRepositories.emojiReaction
+            val emojiReactionsModel: EmojiReactionsModel? = emojiReactionsRepository.getReactionsByMessage(messageModel!!)
+
+            emojiReactionsModel?.let { reactionModel ->
+                repeatOnLifecycle(Lifecycle.State.STARTED) {
+                    reactionModel.data.firstOrNull().let { emojiReactions: List<EmojiReactionData>? ->
+                        emojiPicker?.let { picker ->
+                            // inflate emoji picker
+                            withContext(Dispatchers.Main) {
+                                picker.init(emojiService, false, emojiReactions)
+                                showEmojiPicker()
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        return true
+    }
+
+    private fun showEmojiPicker() {
+        emojiPicker?.show(loadStoredSoftKeyboardHeight())
+    }
+
+    override fun finish() {
+        try {
+            super.finish()
+            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+                @Suppress("DEPRECATION")
+                overridePendingTransition(0, 0)
+            }
+        } catch (ignored: Exception) {
+            // ignored
+        }
+    }
+
+    override fun onBackspaceClick() {
+        // backspace key is disabled
+    }
+
+    override fun onEmojiClick(emojiCodeString: String?) {
+        if (emojiCodeString != null) {
+            // add selected reaction
+            val messageReceiver = messageService.getMessageReceiver(messageModel)
+
+            CoroutineScope(Dispatchers.Default).launch {
+                messageService.sendEmojiReaction(
+                    messageModel as AbstractMessageModel,
+                    emojiCodeString,
+                    messageReceiver,
+                    false
+                )
+            }
+        } else {
+            logger.debug("No emoji selected")
+        }
+        this.finish()
+    }
+
+    override fun onShowPicker() {
+        // not used
+    }
+}

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

@@ -0,0 +1,308 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.emojireactions
+
+import android.content.Context
+import android.graphics.drawable.BitmapDrawable
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewTreeObserver.OnGlobalLayoutListener
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.PopupWindow
+import androidx.core.content.res.ResourcesCompat
+import androidx.fragment.app.FragmentManager
+import ch.threema.app.R
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.dialogs.SimpleStringAlertDialog
+import ch.threema.app.emojis.EmojiItemView
+import ch.threema.app.emojis.EmojiUtil
+import ch.threema.app.services.ContactService
+import ch.threema.app.services.UserService
+import ch.threema.app.utils.AnimationUtil
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.NameUtil
+import ch.threema.app.utils.ViewUtil
+import ch.threema.data.models.EmojiReactionsModel
+import ch.threema.data.repositories.EmojiReactionsRepository
+import ch.threema.storage.models.AbstractMessageModel
+import ch.threema.storage.models.GroupMessageModel
+
+private const val FAKE_DISABLE_ALPHA = 0.2f
+
+class EmojiReactionsPopup(
+    val context: Context,
+    private val parentView: View,
+    val fragmentManager: FragmentManager,
+    private val isSendingReactionsAllowed: Boolean
+) :
+    PopupWindow(context), View.OnClickListener {
+
+    private val addReactionButton: ImageView
+    private var emojiReactionsPopupListener: EmojiReactionsPopupListener? = null
+    private val popupHeight =
+        2 * context.resources.getDimensionPixelSize(R.dimen.reaction_popup_content_margin) +
+            context.resources.getDimensionPixelSize(R.dimen.emoji_popup_cardview_margin_bottom) +
+            context.resources.getDimensionPixelSize(R.dimen.reaction_popup_emoji_size)
+    private val popupHorizontalOffset = context.resources.getDimensionPixelSize(R.dimen.reaction_popup_content_margin_horizontal)
+    private var messageModel: AbstractMessageModel? = null
+    private val emojiReactionsRepository: EmojiReactionsRepository = ThreemaApplication.requireServiceManager().modelRepositories.emojiReaction
+    private val userService: UserService = ThreemaApplication.requireServiceManager().userService
+    private val emojiService = ThreemaApplication.requireServiceManager().emojiService
+    private val selectedBackgroundColor = ResourcesCompat.getDrawable(context.resources, R.drawable.shape_emoji_popup_selected_background, null)
+    private val backgroundColor = ResourcesCompat.getColor(context.resources, android.R.color.transparent, null)
+    private val topReactions = arrayOf(
+        ReactionEntry(R.id.top_0, EmojiUtil.THUMBS_UP_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")
+    )
+
+    private val contactService: ContactService by lazy { ThreemaApplication.requireServiceManager().contactService }
+
+    init {
+        val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
+        contentView = layoutInflater.inflate(R.layout.popup_emojireactions, null, true) as FrameLayout
+
+        setupTopReactions()
+
+        addReactionButton = contentView.findViewById(R.id.add_reaction)
+        addReactionButton.tag = topReactions.size
+        addReactionButton.setOnClickListener(this)
+        if (!isSendingReactionsAllowed) {
+            if (ConfigUtils.canSendEmojiReactions()) {
+                // V2 clients: display implausible buttons as disabled but still clickable
+                addReactionButton.alpha = FAKE_DISABLE_ALPHA
+            } else {
+                // V1 clients: do not display implausible buttons
+                addReactionButton.visibility = View.GONE
+            }
+        }
+
+        inputMethodMode = INPUT_METHOD_NOT_NEEDED
+        width = FrameLayout.LayoutParams.WRAP_CONTENT
+        height = FrameLayout.LayoutParams.WRAP_CONTENT
+
+        setBackgroundDrawable(BitmapDrawable())
+        animationStyle = 0
+        isOutsideTouchable = true
+        isTouchable = true
+        isFocusable = true
+        ViewUtil.setTouchModal(this, false)
+    }
+
+    private fun setupTopReactions() {
+        for ((index, topReaction) in topReactions.withIndex()) {
+            val emojiItemView: EmojiItemView = contentView.findViewById(topReaction.resourceId)
+
+            applyEmojiDiversity(topReaction)
+            emojiItemView.tag = index
+            emojiItemView.setOnClickListener(this)
+            emojiItemView.setEmoji(topReaction.emojiSequence, false, 0)
+
+            if (isDisabledButton(index)) {
+                if (ConfigUtils.canSendEmojiReactions()) {
+                    // V2 clients: display implausible buttons as disabled but still clickable
+                    emojiItemView.alpha = FAKE_DISABLE_ALPHA
+                } else {
+                    // V1 clients: do not display implausible buttons
+                    emojiItemView.visibility = View.GONE
+                }
+            }
+
+            topReaction.emojiItemView = emojiItemView
+        }
+    }
+
+    private fun applyEmojiDiversity(topReaction: ReactionEntry) {
+        if (isSendingReactionsAllowed) {
+            val value = emojiService.getPreferredDiversity(topReaction.emojiSequence)
+            if (value != topReaction.emojiSequence) {
+                topReaction.emojiSequence = value
+            }
+        }
+    }
+
+    fun show(
+        originView: View,
+        messageModel: AbstractMessageModel?
+    ) {
+        if (messageModel == null) {
+            return
+        }
+
+        this.messageModel = messageModel
+        isDismissing = false
+
+        val horizontalOffset = if (messageModel.isOutbox) 0 else this.popupHorizontalOffset
+
+        val originLocation = intArrayOf(0, 0)
+        originView.getLocationInWindow(originLocation)
+        showAtLocation(
+            parentView,
+            Gravity.LEFT or Gravity.TOP,
+            originLocation[0] + horizontalOffset,
+            originLocation[1] - this.popupHeight
+        )
+
+        val emojiReactionsModel: EmojiReactionsModel? = emojiReactionsRepository.getReactionsByMessage(messageModel)
+
+        contentView.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
+            override fun onGlobalLayout() {
+                contentView.viewTreeObserver.removeOnGlobalLayoutListener(this)
+
+                AnimationUtil.popupAnimateIn(contentView)
+
+                var animationDelay = 10
+                val animationDelayStep = 60
+
+                for (topReaction in topReactions) {
+                    if (topReaction.emojiItemView != null) {
+                        AnimationUtil.bubbleAnimate(
+                            topReaction.emojiItemView,
+                            animationDelayStep.let { animationDelay += it; animationDelay })
+
+                        if (hasUserReacted(topReaction, emojiReactionsModel)) {
+                            topReaction.emojiItemView?.background = selectedBackgroundColor
+                        } else {
+                            topReaction.emojiItemView?.setBackgroundColor(backgroundColor)
+                        }
+                        AnimationUtil.bubbleAnimate(addReactionButton, animationDelay)
+                    }
+                }
+            }
+        })
+    }
+
+    private fun hasUserReacted(topReaction: ReactionEntry, emojiReactionsModel: EmojiReactionsModel?): Boolean {
+        val reactionList = emojiReactionsModel?.data?.value
+        return reactionList?.any { reaction ->
+            reaction.emojiSequence == topReaction.emojiSequence && reaction.senderIdentity == userService.identity
+        } ?: false
+    }
+
+    /**
+     * Check if the button with the supplied index should be fake-disabled
+     */
+    private fun isDisabledButton(index: Int): Boolean {
+        return !isSendingReactionsAllowed && index >= 2
+    }
+
+    override fun dismiss() {
+        if (isDismissing) {
+            return
+        }
+        isDismissing = true
+        AnimationUtil.popupAnimateOut(contentView) {
+            super.dismiss()
+        }
+    }
+
+    fun setListener(listener: EmojiReactionsPopupListener?) {
+        this.emojiReactionsPopupListener = listener
+    }
+
+    override fun onClick(v: View) {
+        emojiReactionsPopupListener?.let { listener ->
+            this.messageModel?.let { message ->
+                if (isDisabledButton(v.tag as Int)) {
+                    onDisabledButtonClicked()
+                    return
+                } else {
+                    if (v.id == addReactionButton.id) {
+                        listener.onAddReactionClicked(message)
+                    } else {
+                        if (v is EmojiItemView) {
+                            if (isSendingReactionsAllowed || v.background != selectedBackgroundColor) {
+                                listener.onTopReactionClicked(message, v.emoji)
+                            } else {
+                                // A reaction that cannot be sent was clicked (e.g. Thumbs up was clicked,
+                                // while a thumbs up is already sent, but only ack/dec are supported)
+                                onImpossibleReactionClicked()
+                                return
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        dismiss()
+    }
+
+    private fun onDisabledButtonClicked() {
+        val body = if (messageModel is GroupMessageModel) {
+            context.getString(R.string.emoji_reactions_unavailable_group_body)
+        } else {
+            messageModel.getDisplayNameOrNickname()
+                ?.let { name -> context.getString(R.string.emoji_reactions_unavailable_body, name) }
+        }
+
+        createAlertDialogIfBodySet(
+            R.string.emoji_reactions_unavailable_title,
+            body
+        )?.show(fragmentManager, "dis")
+    }
+
+    private fun onImpossibleReactionClicked() {
+        val body = if (ConfigUtils.canSendEmojiReactions()) {
+            if (messageModel is GroupMessageModel) {
+                context.getString(R.string.emoji_reactions_cannot_remove_group_body)
+            } else {
+                messageModel.getDisplayNameOrNickname()
+                    ?.let { name -> context.getString(R.string.emoji_reactions_cannot_remove_body, name) }
+            }
+        } else {
+            context.getString(R.string.emoji_reactions_cannot_remove_v1_body)
+        }
+
+        createAlertDialogIfBodySet(
+            R.string.emoji_reactions_cannot_remove_title,
+            body
+        )?.show(fragmentManager, "imp")
+    }
+
+    private fun createAlertDialogIfBodySet(titleResId: Int, body: String?): SimpleStringAlertDialog? {
+        return body?.let {
+            SimpleStringAlertDialog.newInstance(titleResId, it)
+        }
+    }
+
+    private fun AbstractMessageModel?.getDisplayNameOrNickname(): String? {
+        return this?.let { NameUtil.getDisplayNameOrNickname(context, it, contactService) }
+    }
+
+    interface EmojiReactionsPopupListener {
+        fun onTopReactionClicked(messageModel: AbstractMessageModel, emojiSequence: String)
+        fun onAddReactionClicked(messageModel: AbstractMessageModel)
+    }
+
+    companion object {
+        private var isDismissing = false
+    }
+
+    private class ReactionEntry(val resourceId: Int, var emojiSequence: String) {
+        var emojiItemView: EmojiItemView? = null
+    }
+}

+ 83 - 0
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsViewModel.kt

@@ -0,0 +1,83 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.emojireactions
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.createSavedStateHandle
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import ch.threema.app.activities.StateFlowViewModel
+import ch.threema.app.services.MessageService
+import ch.threema.data.models.EmojiReactionData
+import ch.threema.data.models.EmojiReactionsModel
+import ch.threema.data.repositories.EmojiReactionsRepository
+import ch.threema.storage.models.AbstractMessageModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+
+class EmojiReactionsViewModel(
+    savedStateHandle: SavedStateHandle,
+    emojiReactionsRepository: EmojiReactionsRepository,
+    private val messageService: MessageService
+) : StateFlowViewModel() {
+    private var emojiReactionsModel: EmojiReactionsModel? = null
+
+    companion object {
+        private const val MESSAGE_UID = "messageUid"
+
+        fun provideFactory(
+            emojiReactionsRepository: EmojiReactionsRepository,
+            messageService: MessageService,
+            messageId: String
+        ) = viewModelFactory {
+            initializer {
+                EmojiReactionsViewModel(
+                    this.createSavedStateHandle().apply {
+                        set(MESSAGE_UID, messageId)
+                    },
+                    emojiReactionsRepository,
+                    messageService
+                )
+            }
+        }
+    }
+
+    private val messageUid = checkNotNull(savedStateHandle[MESSAGE_UID]) as String
+
+    val emojiReactionsUiState: StateFlow<EmojiReactionsUiState> =
+        getMessageModel()?.let {
+            emojiReactionsModel = emojiReactionsRepository.getReactionsByMessage(it)
+            (emojiReactionsModel?.data ?: MutableStateFlow(emptyList()))
+                .map { reactions -> EmojiReactionsUiState(reactions ?: emptyList()) }
+                .stateInViewModel(initialValue = EmojiReactionsUiState(emptyList()))
+        } ?: MutableStateFlow(EmojiReactionsUiState(emptyList()))
+
+
+    private fun getMessageModel(): AbstractMessageModel? {
+        return messageService.getGroupMessageModel(messageUid) ?: messageService.getContactMessageModel(messageUid)
+    }
+
+    data class EmojiReactionsUiState(
+        val emojiReactions: List<EmojiReactionData>
+    )
+}

+ 116 - 0
app/src/main/java/ch/threema/app/emojireactions/MoreReactionsButton.kt

@@ -0,0 +1,116 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 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.emojireactions
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.util.AttributeSet
+import android.view.GestureDetector
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.ViewGroup
+import android.widget.TextView
+import ch.threema.app.R
+import com.google.android.material.card.MaterialCardView
+
+class MoreReactionsButton : MaterialCardView {
+    private var labelText: String? = null
+    var onMoreReactionsButtonClickListener: OnMoreReactionsButtonClickListener? = null
+
+    private val gestureDetector =
+        GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
+            override fun onSingleTapUp(e: MotionEvent): Boolean {
+                onMoreReactionsButtonClickListener?.onMoreReactionsButtonClick()
+                performClick()
+                return true
+            }
+
+            override fun onLongPress(e: MotionEvent) {
+                onMoreReactionsButtonClickListener?.onMoreReactionsButtonLongClick()
+                performLongClick()
+            }
+        })
+
+    private lateinit var labelView: TextView
+
+    constructor(context: Context) : super(
+        context
+    ) {
+        init()
+    }
+
+    constructor(context: Context, attrs: AttributeSet?) : super(
+        context,
+        attrs
+    ) {
+        init()
+    }
+
+    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
+        context,
+        attrs,
+        defStyleAttr
+    ) {
+        init()
+    }
+
+    private fun init() {
+        layoutParams = LayoutParams(
+            ViewGroup.LayoutParams.WRAP_CONTENT,
+            ViewGroup.LayoutParams.MATCH_PARENT
+        )
+        isClickable = true
+        isFocusable = true
+        isDuplicateParentStateEnabled = false
+
+        LayoutInflater.from(context).inflate(R.layout.view_more_reactions_button, this as ViewGroup)
+
+        labelView = findViewById(R.id.reaction_label)
+    }
+
+    private fun renderContents() {
+        labelView.text = labelText
+    }
+
+    @SuppressLint("ClickableViewAccessibility")
+    override fun onTouchEvent(event: MotionEvent): Boolean {
+        gestureDetector.onTouchEvent(event)
+
+        if (event.action == MotionEvent.ACTION_UP) {
+            isPressed = false
+        } else if (event.action == MotionEvent.ACTION_DOWN) {
+            isPressed = true
+        }
+
+        return true
+    }
+
+    fun setButtonData(text: String) {
+        labelText = text
+        renderContents()
+    }
+
+    interface OnMoreReactionsButtonClickListener {
+        fun onMoreReactionsButtonClick()
+        fun onMoreReactionsButtonLongClick()
+    }
+}

+ 96 - 0
app/src/main/java/ch/threema/app/emojireactions/SelectEmojiButton.kt

@@ -0,0 +1,96 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2013-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.emojireactions
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.util.AttributeSet
+import android.view.GestureDetector
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.ViewGroup
+import ch.threema.app.R
+import com.google.android.material.card.MaterialCardView
+
+class SelectEmojiButton : MaterialCardView {
+    var onSelectEmojiButtonClickListener: OnSelectEmojiButtonClickListener? = null
+
+    private val gestureDetector =
+        GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
+            override fun onSingleTapUp(e: MotionEvent): Boolean {
+                onSelectEmojiButtonClickListener?.onSelectButtonClick()
+                performClick()
+                return true
+            }
+        })
+
+
+    constructor(context: Context) : super(
+        context
+    ) {
+        init()
+    }
+
+    constructor(context: Context, attrs: AttributeSet?) : super(
+        context,
+        attrs
+    ) {
+        init()
+    }
+
+    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
+        context,
+        attrs,
+        defStyleAttr
+    ) {
+        init()
+    }
+
+    private fun init() {
+        layoutParams = LayoutParams(
+            ViewGroup.LayoutParams.WRAP_CONTENT,
+            ViewGroup.LayoutParams.MATCH_PARENT
+        )
+        isClickable = true
+        isFocusable = true
+        isDuplicateParentStateEnabled = false
+
+        LayoutInflater.from(context).inflate(R.layout.view_emoji_select_button, this as ViewGroup)
+    }
+
+    @SuppressLint("ClickableViewAccessibility")
+    override fun onTouchEvent(event: MotionEvent): Boolean {
+        gestureDetector.onTouchEvent(event)
+
+        if (event.action == MotionEvent.ACTION_UP) {
+            isPressed = false
+        } else if (event.action == MotionEvent.ACTION_DOWN) {
+            isPressed = true
+        }
+
+        return true
+    }
+
+    fun interface OnSelectEmojiButtonClickListener {
+        fun onSelectButtonClick()
+    }
+}

+ 1 - 1
app/src/main/java/ch/threema/app/emojis/EmojiConversationTextView.java

@@ -59,7 +59,7 @@ public class EmojiConversationTextView extends MaterialTextView {
 	@Override
 	public void setText(@Nullable CharSequence text, BufferType type) {
 		if (emojiMarkupUtil != null) {
-			super.setText(emojiMarkupUtil.addTextSpans(getContext(), text, this, this.ignoreMarkup, false, true), type);
+			super.setText(emojiMarkupUtil.addTextSpans(getContext(), text, this, this.ignoreMarkup, false, true, false), type);
 		} else {
 			super.setText(text, type);
 		}

+ 1 - 1
app/src/main/java/ch/threema/app/emojis/EmojiDrawable.java

@@ -34,7 +34,7 @@ import androidx.annotation.Nullable;
 
 public class EmojiDrawable extends Drawable {
 	private final SpriteCoordinates coords;
-	private int spritemapInSampleSize;
+	private final int spritemapInSampleSize;
 	private Bitmap bitmap;
 
 	private static final Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG);

+ 1 - 1
app/src/main/java/ch/threema/app/emojis/EmojiGridAdapter.java

@@ -110,7 +110,7 @@ public class EmojiGridAdapter extends BaseAdapter {
 			view = (EmojiItemView)convertView;
 		} else {
 			final EmojiItemView emojiItemView = new EmojiItemView(context);
-			Drawable background = ResourcesCompat.getDrawable(context.getResources(), R.drawable.listitem_background_selector_noripple, null);
+			Drawable background = ResourcesCompat.getDrawable(context.getResources(), R.drawable.selector_emoji_reactions_grid_item, null);
 			emojiItemView.setBackground(background);
 			emojiItemView.setPadding(emojiItemPaddingSize, emojiItemPaddingSize, emojiItemPaddingSize, emojiItemPaddingSize);
 			emojiItemView.setLayoutParams(new AbsListView.LayoutParams(emojiItemSize, emojiItemSize));

+ 37 - 2
app/src/main/java/ch/threema/app/emojis/EmojiItemView.java

@@ -24,6 +24,7 @@ package ch.threema.app.emojis;
 import android.content.Context;
 import android.graphics.Canvas;
 import android.graphics.Paint;
+import android.graphics.Rect;
 import android.graphics.drawable.Drawable;
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
@@ -50,21 +51,53 @@ public class EmojiItemView extends View implements Drawable.Callback {
 		super(context, attrs, defStyleAttr);
 	}
 
-	public void setEmoji(String emoji, boolean hasDiverse, @ColorInt int diverseColor) {
+	public Drawable setEmoji(String emoji, boolean hasDiverse, @ColorInt int diverseColor) {
 		this.emoji = emoji;
 		this.drawable = EmojiManager.getInstance(getContext()).getEmojiDrawable(emoji);
 		this.hasDiverse = hasDiverse;
 		this.diverseColor = diverseColor;
 
 		postInvalidate();
+
+		return this.drawable;
 	}
 
 	public String getEmoji() {
 		return emoji;
 	}
 
+	private void drawCenteredCharacter(Canvas canvas, String character) {
+		Paint centeredCharacterPaint = new Paint();
+		centeredCharacterPaint.setTextAlign(Paint.Align.CENTER);
+
+		// Measure text bounds to get width and height
+		Rect bounds = new Rect();
+		centeredCharacterPaint.getTextBounds(character, 0, 1, bounds);
+		int textWidth = bounds.width();
+		int textHeight = bounds.height();
+
+		// Get canvas dimensions
+		int canvasWidth = canvas.getWidth();
+		int canvasHeight = canvas.getHeight();
+
+		// Calculate scaling factor to fit text within canvas
+		float scaleX = (float) canvasWidth / textWidth;
+		float scaleY = (float) canvasHeight / textHeight;
+		float scaleFactor = Math.min(scaleX, scaleY);
+
+		// Apply scaling to text size
+		centeredCharacterPaint.setTextSize(centeredCharacterPaint.getTextSize() * scaleFactor);
+
+		// Calculate centered position
+		float x = canvasWidth / 2f; // Horizontal center
+		float y = canvasHeight / 2f - (centeredCharacterPaint.descent() + centeredCharacterPaint.ascent()) / 2f; // Vertical center
+
+		// Draw the character
+		canvas.drawText(character, x, y, centeredCharacterPaint);
+	}
+
 	@Override
-	protected void onDraw(Canvas canvas) {
+	protected void onDraw(@NonNull Canvas canvas) {
 		if (this.drawable != null) {
 			this.drawable.setBounds(getPaddingLeft(),
 				getPaddingTop(),
@@ -81,6 +114,8 @@ public class EmojiItemView extends View implements Drawable.Callback {
 				this.paint.setColor(this.diverseColor);
 				canvas.drawText("◢", xPos, yPos, this.paint);
 			}
+		} else {
+			drawCenteredCharacter(canvas, EmojiUtil.REPLACEMENT_CHARACTER);
 		}
 	}
 

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

@@ -31,7 +31,6 @@ import android.view.ViewGroup
 import androidx.core.content.res.ResourcesCompat
 import android.widget.AbsListView
 import ch.threema.app.R
-import ch.threema.app.utils.ConfigUtils
 
 class EmojiListAdapter(
 	context: Context,

+ 3 - 20
app/src/main/java/ch/threema/app/emojis/EmojiManager.java

@@ -22,7 +22,6 @@
 package ch.threema.app.emojis;
 
 import android.content.Context;
-import android.graphics.Bitmap;
 import android.graphics.drawable.Drawable;
 
 import org.slf4j.Logger;
@@ -36,8 +35,6 @@ import ch.threema.app.R;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.utils.LoggingUtil;
 import java8.util.concurrent.CompletableFuture;
-import java8.util.function.Consumer;
-import java8.util.function.Supplier;
 
 public class EmojiManager {
     private static final Logger logger = LoggingUtil.getThreemaLogger("EmojiManager");
@@ -90,6 +87,7 @@ public class EmojiManager {
      * @param emojiSequence - sequence of UTF-8 characters representing the emoji
      * @return Drawable for emoji or null if there is no matching emoji
      */
+	@Nullable
     public Drawable getEmojiDrawable(String emojiSequence) {
         EmojiParser.ParseResult result = EmojiParser.parseAt(emojiSequence, 0);
 
@@ -117,23 +115,8 @@ public class EmojiManager {
                 } else {
                     try {
                         CompletableFuture
-                            .supplyAsync(new Supplier<Bitmap>() {
-                                @Override
-                                public Bitmap get() {
-                                    return emojiGroup.getSpritemapBitmap(coordinates.spritemapId).loadSpritemapAsset();
-                                }
-                            })
-                            .thenAccept(new Consumer<Bitmap>() {
-                                @Override
-                                public void accept(Bitmap bitmap) {
-                                    RuntimeUtil.runOnUiThread(new Runnable() {
-                                        @Override
-                                        public void run() {
-                                            drawable.setBitmap(bitmap);
-                                        }
-                                    });
-                                }
-                            })
+                            .supplyAsync(() -> emojiGroup.getSpritemapBitmap(coordinates.spritemapId).loadSpritemapAsset())
+                            .thenAccept(bitmap -> RuntimeUtil.runOnUiThread(() -> drawable.setBitmap(bitmap)))
                             .get();
                     } catch (InterruptedException | ExecutionException e) {
                         logger.error("Exception", e);

+ 19 - 7
app/src/main/java/ch/threema/app/emojis/EmojiMarkupUtil.java

@@ -38,6 +38,7 @@ import java.util.ArrayList;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import androidx.annotation.NonNull;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.services.ContactService;
@@ -103,10 +104,22 @@ public class EmojiMarkupUtil {
 	}
 
 	public CharSequence addTextSpans(Context context, CharSequence text, TextView textView, boolean ignoreMarkup, boolean singleScale) {
-		return addTextSpans(context, text, textView, ignoreMarkup, ignoreMarkup, singleScale);
+		return addTextSpans(context, text, textView, ignoreMarkup, ignoreMarkup, singleScale, false);
 	}
 
-	public CharSequence addTextSpans(Context context, CharSequence text, TextView textView, boolean ignoreMarkup, boolean ignoreMentions, boolean singleScale) {
+	/**
+	 * Add text spans to given CharSequence such as markup, mentions and emojis
+	 * @param context A context
+	 * @param text CharSequence to add text spans to
+	 * @param textView The TextView where the text is going to be located (used for size calculations)
+	 * @param ignoreMarkup Do not substitute markups with corresponding Spans
+	 * @param ignoreMentions Do not substitute mentions with corresponding Spans
+	 * @param singleScale Scale up single emojis in text containing emojis only
+	 * @param overrideEmojiStyleSetting Override the user's desired emoji style and always use the app supplied emoji set
+	 * @return CharSequence with spans applied, if any
+	 */
+    @NonNull
+	public CharSequence addTextSpans(Context context, CharSequence text, TextView textView, boolean ignoreMarkup, boolean ignoreMentions, boolean singleScale, boolean overrideEmojiStyleSetting) {
 		if (text == null) {
 			return "";
 		}
@@ -119,7 +132,7 @@ public class EmojiMarkupUtil {
 			return builder;
 		}
 
-		if (context != null && textView != null && (ConfigUtils.isDefaultEmojiStyle() || length <= 5)) {
+		if (context != null && textView != null && (ConfigUtils.isDefaultEmojiStyle() || overrideEmojiStyleSetting || length <= 5)) {
 			ArrayList<Pair<EmojiParser.ParseResult, Integer>> results = new ArrayList<>();
 			boolean containsRegularText = false;
 
@@ -143,7 +156,7 @@ public class EmojiMarkupUtil {
 			if (!results.isEmpty()) {
 				int scaleFactor = singleScale && ConfigUtils.isBiggerSingleEmojis(context) && !containsRegularText && results.size() <= LARGE_EMOJI_THRESHOLD ? LARGE_EMOJI_SCALE_FACTOR : 1;
 
-				if (ConfigUtils.isDefaultEmojiStyle()) {
+				if (ConfigUtils.isDefaultEmojiStyle() || overrideEmojiStyleSetting) {
 					for (Pair<EmojiParser.ParseResult, Integer> result : results) {
 						Drawable drawable = EmojiManager.getInstance(context).getEmojiDrawable(result.first.coords);
 
@@ -192,7 +205,7 @@ public class EmojiMarkupUtil {
 			matches.add(new Pair<>(matcher.start(), matcher.end()));
 		}
 
-		if (matches.size() < 1) {
+		if (matches.isEmpty()) {
 			return inputText;
 		}
 
@@ -245,7 +258,6 @@ public class EmojiMarkupUtil {
 
 	/**
 	 * Replace mentions by text instead of spans (used where spans cannot be displayed, i.e. in notifications)
-	 * @param inputText
 	 * @return ChatSequence where all mentions have been replaced by contact names or ids
 	 */
 	private CharSequence applyTextMentionMarkup(CharSequence inputText) {
@@ -299,7 +311,7 @@ public class EmojiMarkupUtil {
 	}
 
 	public CharSequence formatBodyTextString(Context context, String string, int maxLen) {
-		if (string != null && string.length() > 0)
+		if (string != null && !string.isEmpty())
 			return addMarkup(context, string.substring(0, Math.min(maxLen, string.length())));
 		else
 			return "";

+ 69 - 12
app/src/main/java/ch/threema/app/emojis/EmojiPagerAdapter.java

@@ -28,27 +28,41 @@ import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.GridView;
+import android.widget.TextView;
+
+import java.util.List;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.viewpager.widget.PagerAdapter;
 import ch.threema.app.R;
+import ch.threema.app.emojireactions.EmojiReactionsGridAdapter;
+import ch.threema.data.models.EmojiReactionData;
 
 public class EmojiPagerAdapter extends PagerAdapter {
 
 	private final Context context;
 	private final EmojiGridAdapter.KeyClickListener listener;
+	private final EmojiReactionsGridAdapter.KeyClickListener reactionsListener;
 	private final EmojiPicker emojiPicker;
 	private final EmojiService emojiService;
 	private final LayoutInflater layoutInflater;
-
-	EmojiPagerAdapter(Context context,
-	                  EmojiPicker emojiPicker,
-	                  EmojiService emojiService,
-	                  EmojiGridAdapter.KeyClickListener listener) {
+	private final List<EmojiReactionData> emojiReactions;
+
+	EmojiPagerAdapter(
+			Context context,
+			EmojiPicker emojiPicker,
+			EmojiService emojiService,
+			EmojiGridAdapter.KeyClickListener listener,
+			EmojiReactionsGridAdapter.KeyClickListener reactionsListener,
+			List<EmojiReactionData> emojiReactions
+	) {
 		this.context = context;
 		this.listener = listener;
+		this.reactionsListener = reactionsListener;
 		this.emojiPicker = emojiPicker;
 		this.emojiService = emojiService;
+		this.emojiReactions = emojiReactions;
 		this.layoutInflater = LayoutInflater.from(context);
 	}
 
@@ -61,9 +75,16 @@ public class EmojiPagerAdapter extends PagerAdapter {
 	@SuppressLint("StaticFieldLeak")
 	@Override
 	public Object instantiateItem(@NonNull final ViewGroup container, final int position) {
+		final View layout;
+		final GridView recentsGridView;
 
-		final View layout = layoutInflater.inflate(R.layout.emoji_picker_gridview, null);
-		final GridView gridView = (GridView) layout;
+		if (emojiReactions != null && position == 0) {
+			layout = layoutInflater.inflate(R.layout.emoji_reactions_picker_gridview, null);
+			recentsGridView = layout.findViewById(R.id.emoji_gridview);
+		} else {
+			layout = layoutInflater.inflate(R.layout.emoji_picker_gridview, null);
+			recentsGridView = (GridView) layout;
+		}
 
 		new AsyncTask<Void, Void, EmojiGridAdapter>() {
 			@Override
@@ -78,22 +99,59 @@ public class EmojiPagerAdapter extends PagerAdapter {
 			@Override
 			protected void onPostExecute(EmojiGridAdapter adapter) {
 				container.addView(layout);
-				gridView.setAdapter(adapter);
+				recentsGridView.setAdapter(adapter);
 			}
 		}.execute();
 
 		// tag this view for efficient refreshing
-		gridView.setTag(Integer.toString(position));
-
-		((GridView) layout).setOnItemClickListener((adapterView, view, i, l) -> {
+		recentsGridView.setTag(Integer.toString(position));
+		recentsGridView.setOnItemClickListener((adapterView, view, i, l) -> {
 			// this listener is used for hardware keyboards only.
 			EmojiInfo item = (EmojiInfo) adapterView.getAdapter().getItem(i);
 			listener.onEmojiKeyClicked(item.emojiSequence);
 		});
 
+		if (position == 0) {
+			setupEmojiReactions(layout, emojiReactions);
+		}
+
 		return layout;
 	}
 
+	@SuppressLint("StaticFieldLeak")
+	private void setupEmojiReactions(@NonNull View layout, @Nullable List<EmojiReactionData> emojiReactions) {
+		if (emojiReactions == null) {
+			return;
+		}
+
+		GridView reactionsGridView = layout.findViewById(R.id.reactions_gridview);
+		TextView reactionsTitle = layout.findViewById(R.id.reactions_title);
+		TextView recentsTitle = layout.findViewById(R.id.recents_title);
+
+		if (emojiReactions.isEmpty()) {
+			reactionsTitle.setVisibility(View.GONE);
+			recentsTitle.setVisibility(View.GONE);
+			reactionsGridView.setVisibility(View.GONE);
+		} else {
+			emojiService.syncRecentEmojis();
+			recentsTitle.setVisibility(emojiService.hasNoRecentEmojis() ? View.GONE : View.VISIBLE);
+			new AsyncTask<Void, Void, EmojiReactionsGridAdapter>() {
+				@Override
+				protected EmojiReactionsGridAdapter doInBackground(Void... params) {
+					return new EmojiReactionsGridAdapter(
+						context,
+						emojiReactions,
+						reactionsListener);
+				}
+
+				@Override
+				protected void onPostExecute(EmojiReactionsGridAdapter adapter) {
+					reactionsGridView.setAdapter(adapter);
+				}
+			}.execute();
+		}
+	}
+
 	@Override
 	public void destroyItem(ViewGroup container, int position, @NonNull Object view) {
 		container.removeView((View) view);
@@ -108,5 +166,4 @@ public class EmojiPagerAdapter extends PagerAdapter {
 	public CharSequence getPageTitle(int position) {
 		return emojiPicker.getGroupTitle(position);
 	}
-
 }

+ 69 - 17
app/src/main/java/ch/threema/app/emojis/EmojiPicker.java

@@ -35,6 +35,7 @@ import android.widget.LinearLayout;
 import android.widget.RelativeLayout;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.viewpager.widget.ViewPager;
 
 import com.google.android.material.tabs.TabLayout;
@@ -42,11 +43,17 @@ import com.google.android.material.tabs.TabLayout;
 import org.slf4j.Logger;
 
 import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
 
 import ch.threema.app.R;
+import ch.threema.app.emojireactions.EmojiReactionsGridAdapter;
 import ch.threema.app.ui.LockableViewPager;
+import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.EditTextUtil;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.models.EmojiReactionData;
 
 public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.EmojiSearchListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("EmojiPicker");
@@ -62,6 +69,7 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 	private RecentEmojiRemovePopup recentRemovePopup;
 	private RelativeLayout pickerHeader;
 	private EmojiSearchWidget emojiSearchWidget;
+	private List<EmojiReactionData> emojiReactions;
 	private boolean isKeyboardAnimated = false; // whether keyboard animation is enabled for this activity
 
 	private final LinearLayout.LayoutParams searchLayoutParams = new LinearLayout.LayoutParams(
@@ -100,13 +108,25 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 	}
 
 	public void init(EmojiService emojiService, boolean isKeyboardAnimated) {
+		init(emojiService, isKeyboardAnimated, null);
+	}
+
+	public void init(EmojiService emojiService, boolean isKeyboardAnimated, @Nullable List<EmojiReactionData> emojiReactions) {
 		this.emojiService = emojiService;
 		this.isKeyboardAnimated = isKeyboardAnimated;
-
+		this.emojiReactions = Optional.ofNullable(emojiReactions)
+            .map(reactions -> reactions.stream()
+                .filter(reaction -> EmojiUtil.isFullyQualifiedEmoji(reaction.emojiSequence))
+                .collect(Collectors.toList()))
+            .orElse(null);
 		this.emojiPickerView = LayoutInflater.from(getContext()).inflate(R.layout.emoji_picker, this, true);
 
 		initEmojiSearchWidget();
 
+		if (isInReactionsMode()) {
+			findViewById(R.id.backspace_button).setVisibility(View.GONE);
+		}
+
 		this.recentRemovePopup = new RecentEmojiRemovePopup(getContext(),  this.emojiPickerView);
 		this.recentRemovePopup.setListener(this::removeEmojiFromRecent);
 
@@ -145,7 +165,8 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 		initPagerAdapter();
 	}
 
-	public boolean isShown() {
+	@Override
+    public boolean isShown() {
 		return getVisibility() == VISIBLE;
 	}
 
@@ -197,7 +218,16 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 
 	@Override
 	public void onHideEmojiSearch() {
-		hide();
+		if (isInReactionsMode()) {
+			EditTextUtil.hideSoftKeyboard(emojiSearchWidget.searchInput);
+			showEmojiPicker();
+		} else {
+			hide();
+		}
+	}
+
+	private boolean isInReactionsMode() {
+		return emojiReactions != null;
 	}
 
 	private void showEmojiSearch() {
@@ -219,7 +249,11 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 		viewPager.setVisibility(VISIBLE);
 		emojiSearchWidget.setVisibility(GONE);
 
-		setVisibility(VISIBLE);
+		if (isInReactionsMode()) {
+			AnimationUtil.slideInAnimation(this, true, getResources().getInteger(android.R.integer.config_shortAnimTime));
+		} else {
+			setVisibility(VISIBLE);
+		}
 
 		for (EmojiPickerListener listener : this.emojiPickerListeners) {
 			listener.onEmojiPickerOpen();
@@ -246,10 +280,21 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 
 			@Override
 			public void onRecentLongClicked(View view, String emojiCodeString) {
-				onRecentListLongClicked(view, emojiCodeString);
+				if (!isInReactionsMode()) {
+					onRecentListLongClicked(view, emojiCodeString);
+				}
 			}
 		};
 
+		EmojiReactionsGridAdapter.KeyClickListener reactionsListener = null;
+		if (isInReactionsMode()) {
+			reactionsListener = emojiCodeString -> {
+				emojiKeyListener.onEmojiClick(emojiCodeString);
+				emojiService.addToRecentEmojis(emojiCodeString);
+				emojiService.saveRecentEmojis();
+			};
+		}
+
 		pickerHeader = findViewById(R.id.emoji_picker_header);
 		viewPager = findViewById(R.id.emoji_pager);
 		int currentItem = this.viewPager.getCurrentItem();
@@ -258,7 +303,9 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 			getContext(),
 			this,
 			emojiService,
-			keyClickListener);
+			keyClickListener,
+			reactionsListener,
+			emojiReactions);
 
 		this.viewPager.setAdapter(emojiPagerAdapter);
 		this.viewPager.setOffscreenPageLimit(1);
@@ -279,8 +326,8 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 		this.viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
 			@Override
 			public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
-
-			}
+                // nothing to do
+            }
 
 			@Override
 			public void onPageSelected(int position) {
@@ -293,19 +340,24 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 
 			@Override
 			public void onPageScrollStateChanged(int state) {
-
-			}
+                // nothing to do
+            }
 		});
 
-		// show first regular tab if there are no recent emojis
-		if (currentItem == 0 && emojiService.hasNoRecentEmojis()) {
-			this.viewPager.setCurrentItem(1);
-		}
-
+		setInitialTab(currentItem);
 		initEmojiSearchButton();
 		initEmojiBackspaceButton();
 	}
 
+	private void setInitialTab(int currentItem) {
+		// show first regular tab if there are no recent emojis (and no reactions)
+		if (currentItem == 0
+				&& emojiService.hasNoRecentEmojis()
+				&& (emojiReactions == null || emojiReactions.isEmpty())) {
+			this.viewPager.setCurrentItem(1);
+		}
+	}
+
 	@SuppressLint("ClickableViewAccessibility")
 	private void initEmojiBackspaceButton() {
 		LinearLayout backspaceButton = emojiPickerView.findViewById(R.id.backspace_button);
@@ -396,8 +448,8 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 	}
 
 	public interface EmojiPickerListener {
-		default void onEmojiPickerOpen() {};
-		default void onEmojiPickerClose() {};
+		default void onEmojiPickerOpen() {}
+		default void onEmojiPickerClose() {}
 	}
 
 	public interface EmojiKeyListener {

+ 137 - 135
app/src/main/java/ch/threema/app/emojis/EmojiSearchWidget.kt

@@ -40,82 +40,84 @@ import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.launch
 
 class EmojiSearchWidget : ConstraintLayout {
-	lateinit var searchInput: EditText
-	lateinit var clearSearchButton: AppCompatImageButton
-
-	private lateinit var listener: EmojiSearchListener
-	private lateinit var resultsList: RecyclerView
-	private lateinit var noResultText: TextView
-	private lateinit var resultAdapter: EmojiListAdapter
-	private lateinit var emojiService: EmojiService
-	private lateinit var diverseEmojiPopup: DiverseEmojiPopup
-
-	constructor(context: Context) : super(context)
-	constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
-	constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
-		context,
-		attrs,
-		defStyleAttr
-	)
-
-	fun init(listener: EmojiSearchListener, emojiService: EmojiService) {
-		this.listener = listener
-		this.emojiService = emojiService
-
-		inflate(context, R.layout.emoji_search_widget, this)
-
-		searchInput = findViewById(R.id.search_term)!!
-		clearSearchButton = findViewById(R.id.button_clear_search)!!
-		clearSearchButton.setOnClickListener { searchInput.setText("") }
-
-		searchInput.setOnFocusChangeListener { v, hasFocus -> if (v == searchInput && !hasFocus) {
-			listener.onHideEmojiSearch()
-			hide()
-		}  }
-
-		findViewById<AppCompatImageButton>(R.id.button_show_picker).setOnClickListener { listener.onShowPicker() }
+    lateinit var searchInput: EditText
+    lateinit var clearSearchButton: AppCompatImageButton
+
+    private lateinit var listener: EmojiSearchListener
+    private lateinit var resultsList: RecyclerView
+    private lateinit var noResultText: TextView
+    private lateinit var resultAdapter: EmojiListAdapter
+    private lateinit var emojiService: EmojiService
+    private lateinit var diverseEmojiPopup: DiverseEmojiPopup
+
+    constructor(context: Context) : super(context)
+    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
+    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
+        context,
+        attrs,
+        defStyleAttr
+    )
+
+    fun init(listener: EmojiSearchListener, emojiService: EmojiService) {
+        this.listener = listener
+        this.emojiService = emojiService
+
+        inflate(context, R.layout.emoji_search_widget, this)
+
+        searchInput = findViewById(R.id.search_term)!!
+        clearSearchButton = findViewById(R.id.button_clear_search)!!
+        clearSearchButton.setOnClickListener { searchInput.setText("") }
+
+        searchInput.setOnFocusChangeListener { v, hasFocus ->
+            if (v == searchInput && !hasFocus) {
+                listener.onHideEmojiSearch()
+                hide()
+            }
+        }
+
+        findViewById<AppCompatImageButton>(R.id.button_show_picker).setOnClickListener { listener.onShowPicker() }
         configureEmojiSearch()
-		configureSearchResults()
-
-		diverseEmojiPopup = DiverseEmojiPopup(context, this)
-		diverseEmojiPopup.setListener(object : DiverseEmojiPopup.DiverseEmojiPopupListener {
-			override fun onDiverseEmojiClick(
-				parentEmojiSequence: String?,
-				emojiSequence: String?
-			) {
-				onEmojiClick(emojiSequence)
-				if (parentEmojiSequence != null && emojiSequence != null) {
-					emojiService.setDiverseEmojiPreference(parentEmojiSequence, emojiSequence)
-				}
-			}
-
-			override fun onOpen() {
-				// noop
-			}
-
-			override fun onClose() {
-				// noop
-			}
-		})
-	}
-
-	override fun isShown(): Boolean {
-		return visibility == VISIBLE
-	}
-
-	fun show() {
-		visibility = VISIBLE
-	}
-
-	fun hide() {
-		emojiService.saveRecentEmojis()
-		searchInput.setText("")
-		visibility = GONE
-	}
+        configureSearchResults()
+
+        diverseEmojiPopup = DiverseEmojiPopup(context, this)
+        diverseEmojiPopup.setListener(object : DiverseEmojiPopup.DiverseEmojiPopupListener {
+            override fun onDiverseEmojiClick(
+                parentEmojiSequence: String?,
+                emojiSequence: String?
+            ) {
+                onEmojiClick(emojiSequence)
+                if (parentEmojiSequence != null && emojiSequence != null) {
+                    emojiService.setDiverseEmojiPreference(parentEmojiSequence, emojiSequence)
+                }
+            }
+
+            override fun onOpen() {
+                // noop
+            }
+
+            override fun onClose() {
+                // noop
+            }
+        })
+    }
+
+    override fun isShown(): Boolean {
+        return visibility == VISIBLE
+    }
+
+    fun show() {
+        visibility = VISIBLE
+    }
+
+    fun hide() {
+        emojiService.saveRecentEmojis()
+        searchInput.setText("")
+        visibility = GONE
+    }
 
     private fun configureEmojiSearch() {
         searchInput.addTextChangedListener(object : TextWatcher {
-	        private var ongoingJob: Job? = null
+            private var ongoingJob: Job? = null
             override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
                 // noop
             }
@@ -125,71 +127,71 @@ class EmojiSearchWidget : ConstraintLayout {
             }
 
             override fun afterTextChanged(s: Editable?) {
-	            ongoingJob?.cancel()
-	            ongoingJob = MainScope().launch {
-		            setSearchResults(emojiService.search(s.toString()))
-	            }
-	            clearSearchButton.visibility = if (s.toString().isEmpty()) {
-	            	View.GONE
-	            } else {
-	            	View.VISIBLE
-	            }
+                ongoingJob?.cancel()
+                ongoingJob = MainScope().launch {
+                    setSearchResults(emojiService.search(s.toString()))
+                }
+                clearSearchButton.visibility = if (s.toString().isEmpty()) {
+                    View.GONE
+                } else {
+                    View.VISIBLE
+                }
             }
         })
-	    searchInput.setOnEditorActionListener { v, i, _ ->
-		    if (i == EditorInfo.IME_ACTION_SEARCH) {
-		    	EditTextUtil.hideSoftKeyboard(v)
-		    	true
-	        } else {
-	        	false
-		    }
-	    }
+        searchInput.setOnEditorActionListener { v, i, _ ->
+            if (i == EditorInfo.IME_ACTION_SEARCH) {
+                EditTextUtil.hideSoftKeyboard(v)
+                true
+            } else {
+                false
+            }
+        }
+    }
+
+    private fun configureSearchResults() {
+        resultAdapter = EmojiListAdapter(context, object : EmojiListAdapter.KeyClickListener {
+            override fun onEmojiKeyClicked(emojiCodeString: String?) = onEmojiClick(emojiCodeString)
+
+            override fun onEmojiKeyLongClicked(view: View?, emojiCodeString: String?) {
+                val emojiInfo = EmojiUtil.getEmojiInfo(emojiCodeString)
+                if (emojiInfo != null && emojiInfo.diversityFlag == EmojiSpritemap.DIVERSITY_PARENT) {
+                    diverseEmojiPopup.show(view, emojiCodeString)
+                }
+            }
+        }, emojiService)
+
+        noResultText = findViewById(R.id.no_search_results)!!
+        noResultText.height = resultAdapter.getItemHeight()
+        resultsList = findViewById(R.id.search_results)
+        resultsList.minimumHeight = resultAdapter.getItemHeight()
+        resultsList.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
+        resultsList.adapter = resultAdapter
+
+        setSearchResults(emojiService.getRecentEmojis())
+    }
+
+    private fun onEmojiClick(emojiSequence: String?) {
+        emojiSequence?.let {
+            listener.onEmojiClick(it)
+            emojiService.addToRecentEmojis(it)
+        }
     }
 
-	private fun configureSearchResults() {
-		resultAdapter = EmojiListAdapter(context, object : EmojiListAdapter.KeyClickListener {
-			override fun onEmojiKeyClicked(emojiCodeString: String?) = onEmojiClick(emojiCodeString)
-
-			override fun onEmojiKeyLongClicked(view: View?, emojiCodeString: String?) {
-				val emojiInfo = EmojiUtil.getEmojiInfo(emojiCodeString)
-				if (emojiInfo != null && emojiInfo.diversityFlag == EmojiSpritemap.DIVERSITY_PARENT) {
-					diverseEmojiPopup.show(view, emojiCodeString)
-				}
-			}
-		}, emojiService)
-
-		noResultText = findViewById(R.id.no_search_results)!!
-		noResultText.height = resultAdapter.getItemHeight()
-		resultsList = findViewById(R.id.search_results)
-		resultsList.minimumHeight = resultAdapter.getItemHeight()
-		resultsList.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
-		resultsList.adapter = resultAdapter
-
-		setSearchResults(emojiService.getRecentEmojis())
-	}
-
-	private fun onEmojiClick(emojiSequence: String?) {
-		emojiSequence?.let {
-			listener.onEmojiClick(it)
-			emojiService.addToRecentEmojis(it)
-		}
-	}
-
-	private fun setSearchResults(results: List<EmojiInfo>) {
-		resultsList.scrollToPosition(0)
-		resultAdapter.setEmojis(results)
-		if (results.isEmpty()) {
-			resultsList.visibility = View.GONE
-			noResultText.visibility = View.VISIBLE
-		} else {
-			resultsList.visibility = View.VISIBLE
-			noResultText.visibility = View.GONE
-		}
-	}
-
-	interface EmojiSearchListener {
-		fun onHideEmojiSearch()
-		fun onShowPicker()
-		fun onEmojiClick(emojiSequence: String)
-	}
+    private fun setSearchResults(results: List<EmojiInfo>) {
+        resultsList.scrollToPosition(0)
+        resultAdapter.setEmojis(results)
+        if (results.isEmpty()) {
+            resultsList.visibility = View.GONE
+            noResultText.visibility = View.VISIBLE
+        } else {
+            resultsList.visibility = View.VISIBLE
+            noResultText.visibility = View.GONE
+        }
+    }
+
+    interface EmojiSearchListener {
+        fun onHideEmojiSearch()
+        fun onShowPicker()
+        fun onEmojiClick(emojiSequence: String)
+    }
 }

+ 19 - 1
app/src/main/java/ch/threema/app/emojis/EmojiTextView.java

@@ -30,6 +30,7 @@ import androidx.annotation.Nullable;
 import androidx.appcompat.widget.AppCompatTextView;
 
 public class EmojiTextView extends AppCompatTextView {
+
 	protected final EmojiMarkupUtil emojiMarkupUtil;
 
 	public EmojiTextView(Context context) {
@@ -49,12 +50,29 @@ public class EmojiTextView extends AppCompatTextView {
 	@Override
 	public void setText(@Nullable CharSequence text, BufferType type) {
 		if (emojiMarkupUtil != null) {
-			super.setText(emojiMarkupUtil.addTextSpans(getContext(), text, this, true), type);
+			super.setText(emojiMarkupUtil.addTextSpans(getContext(), text, this, true, true, false, true), type);
 		} else {
 			super.setText(text, type);
 		}
 	}
 
+	/**
+	 * Set text of the EmojiTextView to one single emoji.
+	 * If the text contains none or more than one emoji, or the provided emojiSequence is not known to EmojiMarkupUtil,
+	 * it will be substituted by the Unicode replacement character
+	 * @param emojiSequence Text to set
+	 * @return the text that was actually set
+	 */
+	public CharSequence setSingleEmojiSequence(@Nullable CharSequence emojiSequence) {
+		if (emojiMarkupUtil != null && EmojiUtil.isFullyQualifiedEmoji(emojiSequence)) {
+			CharSequence emojifiedSequence = emojiMarkupUtil.addTextSpans(getContext(), emojiSequence, this, true, true, false, true);
+            super.setText(emojifiedSequence, BufferType.SPANNABLE);
+            return getText();
+		}
+		super.setText(EmojiUtil.REPLACEMENT_CHARACTER, BufferType.NORMAL);
+		return getText();
+	}
+
 	@Override
 	public void invalidateDrawable(@NonNull Drawable drawable) {
 		if (drawable instanceof EmojiDrawable) {

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

@@ -21,11 +21,45 @@
 
 package ch.threema.app.emojis;
 
+import org.slf4j.Logger;
+
+import java.lang.ref.WeakReference;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import ch.threema.base.utils.LoggingUtil;
 
 import static ch.threema.app.emojis.EmojiSpritemap.emojiCategories;
 
 public class EmojiUtil {
+    private static final Logger logger = LoggingUtil.getThreemaLogger("EmojiUtil");
+
+	public static final String REPLACEMENT_CHARACTER = "\uFFFD";
+	public static final String THUMBS_UP_SEQUENCE = "\uD83D\uDC4D";
+	public static final String THUMBS_DOWN_SEQUENCE = "\uD83D\uDC4E";
+
+	private static final Set<String> THUMBS_UP_VARIANTS = Set.of(
+		"\ud83d\udc4d",
+		"\ud83d\udc4d\ud83c\udffb",
+		"\ud83d\udc4d\ud83c\udffc",
+		"\ud83d\udc4d\ud83c\udffd",
+		"\ud83d\udc4d\ud83c\udffe",
+		"\ud83d\udc4d\ud83c\udfff"
+	);
+
+	private static final Set<String> THUMBS_DOWN_VARIANTS = Set.of(
+		"\ud83d\udc4e",
+		"\ud83d\udc4e\ud83c\udffb",
+		"\ud83d\udc4e\ud83c\udffc",
+		"\ud83d\udc4e\ud83c\udffd",
+		"\ud83d\udc4e\ud83c\udffe",
+		"\ud83d\udc4e\ud83c\udfff"
+	);
+
+    private static WeakReference<Set<String>> FULLY_QUALIFIED_EMOJI = new WeakReference<>(null);
+
 	@Nullable
 	public static EmojiInfo getEmojiInfo(String emojiSequence) {
 		for (EmojiCategory emojiCategory : emojiCategories) {
@@ -37,4 +71,47 @@ public class EmojiUtil {
 		}
 		return null;
 	}
+
+    public static boolean isFullyQualifiedEmoji(@Nullable CharSequence emojiSequence) {
+        if (emojiSequence == null) {
+            return false;
+        }
+        return getFullyQualifiedEmoji().contains(emojiSequence.toString());
+    }
+
+    @NonNull
+    private static synchronized Set<String> getFullyQualifiedEmoji() {
+        Set<String> fullyQualifiedEmoji = FULLY_QUALIFIED_EMOJI.get();
+        if (fullyQualifiedEmoji == null) {
+            logger.debug("Prepare set of fully qualified emoji");
+            fullyQualifiedEmoji = emojiCategories.stream()
+                .flatMap(category -> category.emojiInfos.stream())
+                .map(info -> info.emojiSequence)
+                .collect(Collectors.toSet());
+            FULLY_QUALIFIED_EMOJI = new WeakReference<>(fullyQualifiedEmoji);
+        }
+        return fullyQualifiedEmoji;
+    }
+
+	/**
+	 * Checks if the given emoji sequence is a thumbs up emoji regardless of color
+	 * @param emojiSequence The unicode sequence to check
+	 * @return true if the emoji sequence is a thumbs up emoji, false otherwise
+	 */
+	public static boolean isThumbsUpEmoji(String emojiSequence) {
+		return EmojiUtil.THUMBS_UP_VARIANTS.contains(emojiSequence);
+	}
+
+	/**
+	 * Checks if the given emoji sequence is a thumbs down emoji regardless of color
+	 * @param emojiSequence The unicode sequence to check
+	 * @return true if the emoji sequence is a thumbs down emoji, false otherwise
+	 */
+	public static boolean isThumbsDownEmoji(String emojiSequence) {
+		return EmojiUtil.THUMBS_DOWN_VARIANTS.contains(emojiSequence);
+	}
+
+    public static boolean isThumbsUpOrDownEmoji(@NonNull String emojiSequence) {
+        return isThumbsUpEmoji(emojiSequence) || isThumbsDownEmoji(emojiSequence);
+    }
 }

+ 352 - 149
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -33,7 +33,6 @@ import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.content.res.Configuration;
-import android.graphics.Color;
 import android.graphics.Paint;
 import android.graphics.Typeface;
 import android.media.AudioManager;
@@ -86,6 +85,7 @@ import com.google.android.material.progressindicator.CircularProgressIndicator;
 import com.google.android.material.textfield.TextInputLayout;
 import com.google.common.util.concurrent.ListenableFuture;
 
+import org.jetbrains.annotations.Contract;
 import org.slf4j.Logger;
 
 import java.io.File;
@@ -101,6 +101,7 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ExecutionException;
@@ -109,7 +110,6 @@ import androidx.activity.result.ActivityResultLauncher;
 import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.AnyThread;
 import androidx.annotation.ColorInt;
-import androidx.annotation.MainThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
@@ -137,7 +137,6 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
 import androidx.transition.Slide;
 import androidx.transition.Transition;
 import androidx.transition.TransitionManager;
-
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.actions.SendAction;
@@ -150,9 +149,9 @@ import ch.threema.app.activities.GroupNotificationsActivity;
 import ch.threema.app.activities.HomeActivity;
 import ch.threema.app.activities.ImagePaintActivity;
 import ch.threema.app.activities.MediaGalleryActivity;
+import ch.threema.app.activities.MessageDetailsActivity;
 import ch.threema.app.activities.RecipientListBaseActivity;
 import ch.threema.app.activities.SendMediaActivity;
-import ch.threema.app.activities.MessageDetailsActivity;
 import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.activities.ThreemaToolbarActivity;
 import ch.threema.app.activities.WorkExplainActivity;
@@ -165,13 +164,17 @@ import ch.threema.app.compose.common.interop.ComposeJavaBridge;
 import ch.threema.app.dialogs.ExpandableTextEntryDialog;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
-import ch.threema.app.dialogs.MessageDetailDialog;
 import ch.threema.app.dialogs.ResendGroupMessageDialog;
 import ch.threema.app.dialogs.SelectorDialog;
+import ch.threema.app.dialogs.SimpleStringAlertDialog;
+import ch.threema.app.emojireactions.EmojiReactionsOverviewActivity;
+import ch.threema.app.emojireactions.EmojiReactionsPickerActivity;
+import ch.threema.app.emojireactions.EmojiReactionsPopup;
 import ch.threema.app.emojis.EmojiButton;
 import ch.threema.app.emojis.EmojiMarkupUtil;
 import ch.threema.app.emojis.EmojiPicker;
 import ch.threema.app.emojis.EmojiTextView;
+import ch.threema.app.emojis.EmojiUtil;
 import ch.threema.app.glide.AvatarOptions;
 import ch.threema.app.grouplinks.IncomingGroupRequestActivity;
 import ch.threema.app.grouplinks.OpenGroupRequestNoticeView;
@@ -189,9 +192,12 @@ import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.mediaattacher.MediaAttachActivity;
 import ch.threema.app.mediaattacher.MediaFilterQuery;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
+import ch.threema.app.messagereceiver.DistributionListMessageReceiver;
 import ch.threema.app.messagereceiver.GroupMessageReceiver;
 import ch.threema.app.messagereceiver.MessageReceiver;
+import ch.threema.app.messagereceiver.SendingPermissionValidationResult;
 import ch.threema.app.routines.ReadMessagesRoutine;
+import ch.threema.app.services.BlockedIdentitiesService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.DeadlineListService;
@@ -200,9 +206,7 @@ import ch.threema.app.services.DistributionListService;
 import ch.threema.app.services.DownloadService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.GroupService;
-import ch.threema.app.services.IdListService;
 import ch.threema.app.services.MessageService;
-import ch.threema.app.services.notification.NotificationService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.QRCodeServiceImpl;
 import ch.threema.app.services.RingtoneService;
@@ -212,7 +216,7 @@ import ch.threema.app.services.WallpaperService;
 import ch.threema.app.services.ballot.BallotService;
 import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.services.messageplayer.MessagePlayerService;
-import ch.threema.app.ui.AckjiPopup;
+import ch.threema.app.services.notification.NotificationService;
 import ch.threema.app.ui.AvatarView;
 import ch.threema.app.ui.ContentCommitComposeEditText;
 import ch.threema.app.ui.ConversationListView;
@@ -248,10 +252,14 @@ import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.EditTextUtil;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.GroupCallUtilKt;
+import ch.threema.app.utils.GroupFeatureAdoptionRate;
+import ch.threema.app.utils.GroupFeatureSupport;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.LinkifyUtil;
 import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.LogUtil;
+import ch.threema.app.utils.MessageUtil;
+import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.NavigationUtil;
 import ch.threema.app.utils.QuoteUtil;
 import ch.threema.app.utils.RuntimeUtil;
@@ -271,13 +279,13 @@ import ch.threema.app.voip.services.VoipStateService;
 import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
-import ch.threema.app.messagereceiver.SendingPermissionValidationResult;
+import ch.threema.data.repositories.EmojiReactionsRepository;
 import ch.threema.domain.models.IdentityType;
 import ch.threema.domain.models.MessageId;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
-import ch.threema.domain.protocol.csp.messages.file.FileData;
+import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.factories.RejectedGroupMessageFactory;
 import ch.threema.storage.models.AbstractMessageModel;
@@ -287,13 +295,12 @@ import ch.threema.storage.models.DateSeparatorMessageModel;
 import ch.threema.storage.models.DistributionListMessageModel;
 import ch.threema.storage.models.DistributionListModel;
 import ch.threema.storage.models.FirstUnreadMessageModel;
-import ch.threema.app.utils.GroupFeatureSupport;
-import ch.threema.app.utils.GroupFeatureAdoptionRate;
 import ch.threema.storage.models.GroupMessageModel;
 import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.ballot.BallotModel;
+import ch.threema.storage.models.data.DisplayTag;
 import ch.threema.storage.models.data.MessageContentsType;
 
 import static android.view.WindowManager.LayoutParams.FLAG_SECURE;
@@ -305,12 +312,6 @@ import static ch.threema.app.preference.SettingsAdvancedOptionsFragment.THREEMA_
 import static ch.threema.app.services.messageplayer.MessagePlayer.SOURCE_AUDIORECORDER;
 import static ch.threema.app.services.messageplayer.MessagePlayer.SOURCE_LIFECYCLE;
 import static ch.threema.app.services.messageplayer.MessagePlayer.SOURCE_VOIP;
-import static ch.threema.app.ui.AckjiPopup.ITEM_ACK;
-import static ch.threema.app.ui.AckjiPopup.ITEM_DEC;
-import static ch.threema.app.ui.AckjiPopup.ITEM_EDIT;
-import static ch.threema.app.ui.AckjiPopup.ITEM_IMAGE_REPLY;
-import static ch.threema.app.ui.AckjiPopup.ITEM_INFO;
-import static ch.threema.app.ui.AckjiPopup.ITEM_STAR;
 import static ch.threema.app.ui.ScrollButtonManager.SCROLLBUTTON_VIEW_TIMEOUT;
 import static ch.threema.app.ui.ScrollButtonManager.TYPE_DOWN;
 import static ch.threema.app.utils.LinkifyUtil.DIALOG_TAG_CONFIRM_LINK;
@@ -416,7 +417,7 @@ public class ComposeMessageFragment extends Fragment implements
 	private ContactService contactService;
 	private MessageService messageService;
 	private NotificationService notificationService;
-	private IdListService blockedContactsService;
+	private BlockedIdentitiesService blockedIdentitiesService;
 	private ConversationService conversationService;
 	private DeviceService deviceService;
 	private WallpaperService wallpaperService;
@@ -427,8 +428,14 @@ public class ComposeMessageFragment extends Fragment implements
 	private VoipStateService voipStateService;
 	private DownloadService downloadService;
 	private LicenseService licenseService;
+    private EmojiReactionsRepository emojiReactionsRepository;
 
 	private ActivityResultLauncher<Intent> wallpaperLauncher;
+	private final ActivityResultLauncher<Intent> emojiReactionsLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
+		if (actionMode != null) {
+			actionMode.finish();
+		}
+	});
 	private final ActivityResultLauncher<Intent> imageReplyLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
 		if (result.getResultCode() == Activity.RESULT_CANCELED) {
 			logger.info("Canceled image reply");
@@ -476,7 +483,7 @@ public class ComposeMessageFragment extends Fragment implements
 	private ImageView wallpaperView;
 	private ActionBar actionBar;
 	private TooltipPopup workTooltipPopup;
-	private AckjiPopup ackjiPopup;
+	private EmojiReactionsPopup emojiReactionsPopup;
 	private QuotePopup quotePopup;
 	private OpenBallotNoticeView openBallotNoticeView;
 	private OpenGroupRequestNoticeView openGroupRequestNoticeView;
@@ -518,7 +525,6 @@ public class ComposeMessageFragment extends Fragment implements
 	private final SimpleDateFormat dayFormatter = new SimpleDateFormat("yyyyMMdd");
 	private ThumbnailCache<?> thumbnailCache = null;
 
-
 	@Override
 	public boolean getActionModeEnabled() {
 		return actionMode != null;
@@ -689,6 +695,13 @@ public class ComposeMessageFragment extends Fragment implements
 				if (composeMessageAdapter != null) {
 					composeMessageAdapter.notifyItemsChanged(modifiedMessageModels);
 				}
+                if (modifiedMessageModels.size() == 1) {
+                    final @Nullable AbstractMessageModel modifiedMessageModel = modifiedMessageModels.get(0);
+                    if (modifiedMessageModel != null && modifiedMessageModel.isDeleted()) {
+                        updateActionModeIfNecessary(modifiedMessageModel);
+                        dismissEmojiReactionPopupIfNecessary(modifiedMessageModel);
+                    }
+                }
 			});
 		}
 
@@ -725,6 +738,47 @@ public class ComposeMessageFragment extends Fragment implements
 		public void onResendDismissed(@NonNull AbstractMessageModel messageModel) {
 			// Ignore
 		}
+
+        /**
+         *  If we currently selected one message we have to consider dismissing the emoji-reactions-popup
+         *  if it was remote-deleted.
+         */
+        private void dismissEmojiReactionPopupIfNecessary(final @NonNull AbstractMessageModel deletedMessageModel) {
+
+            if (emojiReactionsPopup == null || !emojiReactionsPopup.isShowing() || selectedMessages.isEmpty()) {
+                return;
+            }
+
+            // Determine if the newly remote-deleted message is currently selected
+            final boolean deletedMessageIsCurrentlySelected = selectedMessages.stream().anyMatch(
+                (selectedMessageModel -> selectedMessageModel.getId() == deletedMessageModel.getId())
+            );
+
+            if (deletedMessageIsCurrentlySelected) {
+                emojiReactionsPopup.dismiss();
+            }
+        }
+
+        /**
+         *  If we currently have some messages selected (actionMode is visible) we have to consider updating the
+         *  menu items it displays when a message was remote-deleted.
+         */
+        private void updateActionModeIfNecessary(final @NonNull AbstractMessageModel deletedMessageModel){
+
+            // It is only possible to remote-delete a single message at a time
+            if (actionMode == null || selectedMessages.isEmpty()) {
+                return;
+            }
+
+            // Determine if the newly remote-deleted message is currently selected
+            final boolean deletedMessageIsCurrentlySelected = selectedMessages.stream().anyMatch(
+                (selectedMessageModel -> selectedMessageModel.getId() == deletedMessageModel.getId())
+            );
+
+            if (deletedMessageIsCurrentlySelected) {
+                actionMode.invalidate();
+            }
+        }
 	};
 
 	private final MessageDeletedForAllListener messageDeletedForAllListener = new MessageDeletedForAllListener() {
@@ -2623,7 +2677,7 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 
 		// Exclude blocked contacts
-		if (blockedContactsService.has(contactModel.getIdentity())) {
+		if (blockedIdentitiesService.isBlocked(contactModel.getIdentity())) {
 			return false;
 		}
 
@@ -3051,6 +3105,7 @@ public class ComposeMessageFragment extends Fragment implements
 				preferenceService,
 				downloadService,
 				licenseService,
+				emojiReactionsRepository,
 				messageReceiver,
 				convListView,
 				thumbnailCache,
@@ -3076,7 +3131,7 @@ public class ComposeMessageFragment extends Fragment implements
 
 						Runnable resendMessage = () -> ThreemaApplication.sendMessageExecutorService.execute(() -> {
 							try {
-								messageService.resendMessage(messageModel, messageReceiver, null, finalRecipientIdentities);
+								messageService.resendMessage(messageModel, messageReceiver, null, finalRecipientIdentities, new MessageId(), TriggerSource.LOCAL);
 							} catch (Exception e) {
 								RuntimeUtil.runOnUiThread(() -> {
 									if (isAdded()) {
@@ -3207,6 +3262,145 @@ public class ComposeMessageFragment extends Fragment implements
 						}
 					});
 				}
+
+				@Override
+				public void onEmojiReactionClick(@Nullable String emojiSequence, @Nullable AbstractMessageModel messageModel) {
+					if (emojiSequence != null && messageModel != null) {
+						if (EmojiUtil.REPLACEMENT_CHARACTER.equals(emojiSequence)) {
+                            logger.info("Unknown emoji reaction sequence clicked");
+                            RuntimeUtil.runOnUiThread(() -> {
+                                Optional.ofNullable(getContext())
+                                    .ifPresent(ctx -> LongToast.makeText(
+                                        ctx, R.string.reaction_cannot_be_displayed, Toast.LENGTH_LONG
+                                    ).show());
+                            });
+                        } else {
+							if (!MessageUtil.canEmojiReact(messageModel)) {
+								return;
+							}
+
+							RuntimeUtil.runOnWorkerThread(() -> {
+								try {
+                                    MessageReceiver<?> receiver = messageReceiver;
+                                    if (receiver == null) {
+                                        return;
+                                    }
+                                    boolean isReactionsSupportNone = receiver.getEmojiReactionSupport() == MessageReceiver.Reactions_NONE;
+                                    if (isReactionsSupportNone && isWithdraw(messageModel, emojiSequence)) {
+                                        showImpossibleWithdrawErrorDialog(messageModel);
+                                    } else if (!messageService.sendEmojiReaction(messageModel, emojiSequence, receiver, false)) {
+                                        showErrorDialogOnSendEmojiReactionFailed(messageModel);
+									}
+								} catch (Exception e) {
+									logger.error("failed to send emoji reaction", e);
+								}
+							});
+						}
+					} else {
+						logger.debug("messageModel or emojiSequence is null");
+					}
+				}
+
+                private boolean isWithdraw(@NonNull AbstractMessageModel messageModel, @NonNull String emojiSequence) {
+                    String userIdentity = userService.getIdentity();
+                    return emojiReactionsRepository.safeGetReactionsByMessage(messageModel).stream()
+                        .anyMatch(reaction -> reaction.senderIdentity.equals(userIdentity) && reaction.emojiSequence.equals(emojiSequence));
+                }
+
+                private void showImpossibleWithdrawErrorDialog(@NonNull AbstractMessageModel messageModel) {
+                    Optional.ofNullable(getImpossibleWithdrawErrorText(messageModel)).ifPresent(dialogBody ->
+                        RuntimeUtil.runOnUiThread(() -> SimpleStringAlertDialog.newInstance(
+                                R.string.emoji_reactions_cannot_remove_title, dialogBody
+                            ).show(getParentFragmentManager(), "er")
+                        )
+                    );
+                }
+
+                @Nullable
+                private String getImpossibleWithdrawErrorText(@NonNull AbstractMessageModel messageModel) {
+                    final Context context = getContext();
+                    if (context == null) {
+                        logger.warn("Could not get reaction withdraw error text. Context is null.");
+                        return null;
+                    }
+
+                    if (ConfigUtils.canSendEmojiReactions()) {
+                        // Phase 2 reaction support: Withdraw only possible if receiver supports reactions.
+                        if (messageModel instanceof GroupMessageModel) {
+                            logger.info("Cannot withdraw reaction in group without reaction support.");
+                            return context.getString(R.string.emoji_reactions_cannot_remove_group_body);
+                        } else {
+                            logger.info("Cannot withdraw reaction, because chat partner does not support reactions yet.");
+                            String name = NameUtil.getDisplayNameOrNickname(context, messageModel, contactService);
+                            return name == null
+                                ? null
+                                : context.getString(R.string.emoji_reactions_cannot_remove_body, name);
+                        }
+                    } else {
+                        // Phase 1 reaction support: Withdraw not supported by client
+                        return context.getString(R.string.emoji_reactions_cannot_remove_v1_body);
+                    }
+                }
+
+                @AnyThread
+                private void showErrorDialogOnSendEmojiReactionFailed(@NonNull AbstractMessageModel messageModel) {
+                    Optional.ofNullable(getReactionSendFailedErrorText(messageModel)).ifPresent(dialogBody ->
+                        RuntimeUtil.runOnUiThread(() -> SimpleStringAlertDialog.newInstance(
+                                R.string.emoji_reactions_unavailable_title, dialogBody
+                            ).show(getParentFragmentManager(), "er")
+                        )
+                    );
+                }
+
+                @Nullable
+                private String getReactionSendFailedErrorText(@NonNull AbstractMessageModel messageModel) {
+                    final Context context = getContext();
+                    if (context == null) {
+                        logger.warn("Could not get reaction error text. Context is null.");
+                        return null;
+                    }
+                    if (ConfigUtils.canSendEmojiReactions()) {
+                        // Phase 2 reaction supported by this client. Therefore an error means the receiver
+                        // does not support reactions.
+                        if (isGroupChat) {
+                            // The group members can change so that no other group members support
+                            // reactions anymore. In this group it is not possible to send reactions anymore
+                            // but it is possible to still attempt sending a reaction by tapping a reaction
+                            // that is already present in the chat.
+                            return context.getString(R.string.emoji_reactions_unavailable_group_body);
+                        } else {
+                            // If the contact does not support emoji reactions, the only way to send a reaction
+                            // is by tapping a reaction already present in the chat. This means, the chat partner
+                            // has previously supported reactions.
+                            // Thus, we conclude this error happened due to a client downgrade of the chat partner.
+                            logger.info("Emoji reactions seems to be unavailable due to a client downgrade of the chat partner.");
+                            String name = NameUtil.getDisplayNameOrNickname(context, messageModel, contactService);
+                            return name == null
+                                ? null
+                                : context.getString(R.string.emoji_reactions_unavailable_body, name);
+                        }
+                    } else {
+                        // Phase 1 reaction support: reactions can only be received but not sent.
+                        return context.getString(R.string.emoji_reactions_sending_not_supported_body);
+                    }
+                }
+
+				@Override
+				public void onEmojiReactionLongClick(@Nullable String emojiSequence, @Nullable AbstractMessageModel messageModel) {
+					showEmojiReactionsOverview(messageModel, emojiSequence);
+				}
+
+				@Override
+				public void onSelectButtonClick(@Nullable AbstractMessageModel messageModel) {
+					if (MessageUtil.canEmojiReact(messageModel)) {
+						showEmojiReactionsPicker(messageModel);
+					}
+				}
+
+				@Override
+				public void onMoreReactionsButtonClick(@Nullable AbstractMessageModel messageModel) {
+					showEmojiReactionsOverview(messageModel, null);
+				}
 			});
 
 			insertToList(values, false, !hiddenChatsListService.has(messageReceiver.getUniqueIdString()), false);
@@ -3220,6 +3414,20 @@ public class ComposeMessageFragment extends Fragment implements
 		removeIsTypingFooter();
 	}
 
+	private void showEmojiReactionsOverview(@Nullable AbstractMessageModel messageModel, @Nullable String emojiSequence) {
+		if (messageModel == null) {
+			logger.error("MessageModel is null");
+			return;
+		}
+
+		Intent intent = new Intent(activity, EmojiReactionsOverviewActivity.class);
+		IntentDataUtil.append(messageModel, intent);
+		if (emojiSequence != null) {
+			intent.putExtra(EmojiReactionsOverviewActivity.EXTRA_INITIAL_EMOJI, emojiSequence);
+		}
+		emojiReactionsLauncher.launch(intent);
+	}
+
 	/**
 	 * Jump to first unread message keeping in account shift caused by date separators and other decorations
 	 * Currently depends on various globals...
@@ -3235,7 +3443,6 @@ public class ComposeMessageFragment extends Fragment implements
 						break;
 					}
 					position--;
-
 				}
 				final int finalUnreadCount = unreadCount;
 				if (!isHidden()) {
@@ -3475,53 +3682,69 @@ public class ComposeMessageFragment extends Fragment implements
 			return;
 		}
 
-		showAckjiPopup(view);
+		showEmojiReactionsPopup(view, selectedMessage);
 	}
 
-	private void showAckjiPopup(View originView) {
-		if (ackjiPopup == null) {
-			ackjiPopup = new AckjiPopup(getContext(), convListView);
-			ackjiPopup.setListener(new AckjiPopup.AckDecPopupListener() {
-				@Override
-				public void onAckjiClicked(final int clickedItem) {
-					if (actionMode == null) {
-						return;
-					}
+	private void showEmojiReactionsPopup(@NonNull View originView, @NonNull AbstractMessageModel messageModel) {
+		if (messageReceiver == null) {
+			logger.error("No MessageReceiver to show emoji reactions popup for");
+			return;
+		}
 
-					actionMode.finish();
+        // Don't even show popup in case of a DistributionList
+        if (messageReceiver instanceof DistributionListMessageReceiver) {
+            logger.debug("Cannot react on distribution list messages");
+            return;
+        }
 
-					switch (clickedItem) {
-						case ITEM_ACK:
-							sendUserAck();
-							break;
-						case ITEM_DEC:
-							sendUserDec();
-							break;
-						case ITEM_IMAGE_REPLY:
-							sendImageReply();
-							break;
-						case ITEM_INFO:
-							showMessageLog(selectedMessages.get(0));
-							break;
-						case ITEM_EDIT:
-							tryEditingSelectedMessage();
-							break;
-						case ITEM_STAR:
-							toggleStar(selectedMessages.get(0));
-							break;
+        // check if we can react on this kind of message
+        if (!MessageUtil.canEmojiReact(messageModel)) {
+            return;
+        }
+
+        boolean isReactionsSupportNone = messageReceiver.getEmojiReactionSupport() == MessageReceiver.Reactions_NONE;
+
+		if (messageReceiver instanceof ContactMessageReceiver
+			&& isReactionsSupportNone
+			&& messageModel.isOutbox()) {
+			logger.debug("Cannot react on my own messages if reactions are not yet supported by recipient");
+			return;
+		}
+
+		emojiReactionsPopup = new EmojiReactionsPopup(requireContext(), convListView, getParentFragmentManager(), !isReactionsSupportNone);
+		emojiReactionsPopup.setListener(new EmojiReactionsPopup.EmojiReactionsPopupListener() {
+			@Override
+			public void onTopReactionClicked(@NonNull final AbstractMessageModel messageModel, @NonNull final String emojiSequence) {
+				RuntimeUtil.runOnWorkerThread(() -> {
+					try {
+                        messageService.sendEmojiReaction(messageModel, emojiSequence, Objects.requireNonNull(messageReceiver), false);
+					} catch (Exception e) {
+						logger.error("Failed to send emoji reaction", e);
 					}
-				}
+				});
 
-				@Override
-				public void onOpen() {
+				if (actionMode == null) {
+					return;
 				}
+				actionMode.finish();
+			}
 
-				@Override
-				public void onClose() {
-				}
-			});
+			@Override
+			public void onAddReactionClicked(@NonNull final AbstractMessageModel messageModel) {
+				showEmojiReactionsPicker(messageModel);
+			}
+		});
+		emojiReactionsPopup.show(originView.findViewById(R.id.message_block), selectedMessages.get(0));
+	}
+
+	private void showEmojiReactionsPicker(@Nullable AbstractMessageModel messageModel) {
+		if (messageModel != null && messageReceiver != null) {
+			Intent intent = new Intent(activity, EmojiReactionsPickerActivity.class);
+			IntentDataUtil.append(messageModel, intent);
+			emojiReactionsLauncher.launch(intent);
+		} else {
+			logger.debug("MessageModel or Receiver is null");
 		}
-		ackjiPopup.show(originView.findViewById(R.id.message_block), selectedMessages.get(0));
 	}
 
 	/**
@@ -3530,6 +3753,7 @@ public class ComposeMessageFragment extends Fragment implements
 	 * @param selectedMessage Message Model of the item
 	 * @return true if item is selectable, false otherwise
 	 */
+	@Contract("_, null -> false")
 	private boolean isItemSelectable(int viewType, @Nullable AbstractMessageModel selectedMessage) {
 		if (viewType == ComposeMessageAdapter.TYPE_FIRST_UNREAD  ||
 			viewType == ComposeMessageAdapter.TYPE_DATE_SEPARATOR) {
@@ -4007,17 +4231,6 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 	}
 
-	private void showMessageLog(@Nullable AbstractMessageModel messageModel) {
-		if (messageModel == null) {
-			return;
-		}
-		MessageDetailDialog.newInstance(
-            R.string.message_log_title,
-            messageModel.getId(),
-            messageModel.getClass().toString()
-        ).show(getParentFragmentManager(), DIALOG_TAG_MESSAGE_DETAIL);
-	}
-
 	/**
 	 * Toggles the "starred" flag for the provided message and saves it to the database
 	 * @param messageModel AbstractMessageModel of the message
@@ -4291,8 +4504,8 @@ public class ComposeMessageFragment extends Fragment implements
 			// do not update if no longer attached to activity
 			return;
 		}
-		if (TestUtil.required(this.blockMenuItem, this.blockedContactsService, this.contactModel)) {
-			boolean state = this.blockedContactsService.has(this.contactModel.getIdentity());
+		if (TestUtil.required(this.blockMenuItem, this.blockedIdentitiesService, this.contactModel)) {
+			boolean state = this.blockedIdentitiesService.isBlocked(this.contactModel.getIdentity());
 			this.blockMenuItem.setTitle(state ? getString(R.string.unblock_contact) : getString(R.string.block_contact));
 			this.blockMenuItem.setShowAsAction(state ? MenuItem.SHOW_AS_ACTION_ALWAYS : MenuItem.SHOW_AS_ACTION_NEVER);
 			this.mutedMenuItem.setShowAsAction(state ? MenuItem.SHOW_AS_ACTION_NEVER : MenuItem.SHOW_AS_ACTION_IF_ROOM);
@@ -4310,7 +4523,7 @@ public class ComposeMessageFragment extends Fragment implements
 			if (isGroupChat) {
 				updateGroupCallMenuItem();
 			} else if (callItem != null) {
-				if (ContactUtil.canReceiveVoipMessages(contactModel, blockedContactsService) && ConfigUtils.isCallsEnabled()) {
+				if (ContactUtil.canReceiveVoipMessages(contactModel, blockedIdentitiesService) && ConfigUtils.isCallsEnabled()) {
 					logger.debug("updateVoipMenu newState " + newState);
 					callItem.setIcon(R.drawable.ic_phone_locked_outline);
 					callItem.setTitle(R.string.threema_call);
@@ -4415,8 +4628,8 @@ public class ComposeMessageFragment extends Fragment implements
 				activity.startActivity(intent);
 			}
 		} else if (id == R.id.menu_block_contact) {
-			if (this.blockedContactsService.has(contactModel.getIdentity())) {
-				this.blockedContactsService.toggle(activity, contactModel);
+			if (this.blockedIdentitiesService.isBlocked(contactModel.getIdentity())) {
+				this.blockedIdentitiesService.unblockIdentity(contactModel.getIdentity(), getContext());
 				updateBlockMenu();
 			} else {
 				GenericAlertDialog.newInstance(R.string.block_contact, R.string.really_block_contact, R.string.yes, R.string.no).setTargetFragment(this).show(getFragmentManager(), DIALOG_TAG_CONFIRM_BLOCK);
@@ -4519,7 +4732,7 @@ public class ComposeMessageFragment extends Fragment implements
 	private void createShortcut() {
 		if (!this.isGroupChat &&
 			!this.isDistributionListChat &&
-			ContactUtil.canReceiveVoipMessages(contactModel, blockedContactsService) &&
+			ContactUtil.canReceiveVoipMessages(contactModel, blockedIdentitiesService) &&
 			ConfigUtils.isCallsEnabled()) {
 							ArrayList<SelectorDialogItem> items = new ArrayList<>();
 				items.add(new SelectorDialogItem(getString(R.string.prefs_header_chat), R.drawable.ic_outline_chat_bubble_outline));
@@ -4587,7 +4800,7 @@ public class ComposeMessageFragment extends Fragment implements
 
 	public class ComposeMessageAction implements ActionMode.Callback {
 		private final int position;
-		private MenuItem quoteItem, forwardItem, saveItem, copyItem, qrItem, shareItem, showText;
+		private MenuItem quoteItem, forwardItem, saveItem, copyItem, qrItem, shareItem, infoItem, editItem, starItem, unStarItem, imageReplyItem, deleteItem;
 
 		ComposeMessageAction(int position) {
 			this.position = position;
@@ -4595,14 +4808,17 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 
 		private void updateActionMenu(Menu menu) {
-			boolean isQuotable = selectedMessages.size() == 1;
-			boolean showAsQRCode = selectedMessages.size() == 1;
-			boolean showAsText = selectedMessages.size() == 1;
+			boolean isSingleMessage = selectedMessages.size() == 1;
+			boolean isQuotable = isSingleMessage;
+			boolean showAsQRCode = isSingleMessage;
+			boolean canShowInfo = isSingleMessage;
 			boolean isForwardable = selectedMessages.size() <= MAX_FORWARDABLE_ITEMS;
 			boolean isSaveable = !AppRestrictionUtil.isShareMediaDisabled(getContext());
 			boolean isCopyable = true;
 			boolean isShareable = !AppRestrictionUtil.isShareMediaDisabled(getContext());
-			boolean hasDefaultRendering = false;
+			boolean isEditable = isSingleMessage && MessageUtil.canEdit(selectedMessages.get(0));
+			boolean canSendImageReply = isSingleMessage && MessageUtil.canSendImageReply(selectedMessages.get(0));
+			boolean canStarMessage = isSingleMessage && MessageUtil.canStarMessage(selectedMessages.get(0));
 
 			if (selectedMessages.stream().anyMatch(AbstractMessageModel::isDeleted)) {
 				onlyShowItems(menu, R.id.menu_message_discard);
@@ -4613,32 +4829,30 @@ public class ComposeMessageFragment extends Fragment implements
 				if (message == null) continue;
 				isQuotable = isQuotable && isQuotable(message);
 				showAsQRCode = showAsQRCode && canShowAsQRCode(message);
-				showAsText = showAsText && canShowAsText(message);
 				isForwardable = isForwardable && isForwardable(message);
 				isSaveable = isSaveable && isSaveable(message);
 				isCopyable = isCopyable && isCopyable(message);
 				isShareable = isShareable && isShareable(message);
-				hasDefaultRendering = hasDefaultRendering || isDefaultRendering(message);
 			}
 
 			// Sharing text message is only possible when there is exactly one selected message
-			isShareable = isShareable && (selectedMessages.size() == 1 || !containsTextMessage(selectedMessages));
+			isShareable = isShareable && (isSingleMessage || !containsTextMessage(selectedMessages));
 
 			quoteItem.setVisible(isQuotable);
-			qrItem.setVisible(showAsQRCode);
-			showText.setVisible(showAsText);
+			qrItem.setVisible(false /*showAsQRCode*/); // TODO(ANDR-3498): Reenable or remove completely
+			infoItem.setVisible(canShowInfo);
 			forwardItem.setVisible(isForwardable);
 			saveItem.setVisible(isSaveable);
 			copyItem.setVisible(isCopyable);
 			shareItem.setVisible(isShareable);
+			editItem.setVisible(isEditable);
+			imageReplyItem.setVisible(canSendImageReply);
 
-			if (hasDefaultRendering) {
-				saveItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
-				forwardItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
-			} else {
-				saveItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
-				forwardItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
-			}
+            boolean isMessageCurrentlyStarred = (selectedMessages.get(0).getDisplayTags() & DisplayTag.DISPLAY_TAG_STARRED) == DisplayTag.DISPLAY_TAG_STARRED;
+            starItem.setVisible(canStarMessage && !isMessageCurrentlyStarred);
+            unStarItem.setVisible(canStarMessage && isMessageCurrentlyStarred);
+
+			deleteItem.setShowAsAction(isSingleMessage ? MenuItem.SHOW_AS_ACTION_IF_ROOM : MenuItem.SHOW_AS_ACTION_ALWAYS);
 		}
 
 		private void onlyShowItems(Menu menu, int... ids) {
@@ -4698,11 +4912,6 @@ public class ComposeMessageFragment extends Fragment implements
 			return isText || isFileWithCaption; // is text (not status) or a file with non-empty caption
 		}
 
-		private boolean isDefaultRendering(@NonNull AbstractMessageModel message) {
-			return message.getType() == MessageType.FILE                                    // if it is a file
-				&& message.getFileData().getRenderingType() == FileData.RENDERING_DEFAULT;  // and default rendering is set
-		}
-
 		private boolean containsTextMessage(@NonNull List<AbstractMessageModel> messages) {
 			for (AbstractMessageModel message : messages) {
 				if (message.getType() == MessageType.TEXT) {
@@ -4761,7 +4970,12 @@ public class ComposeMessageFragment extends Fragment implements
 			qrItem = menu.findItem(R.id.menu_message_qrcode);
 			shareItem = menu.findItem(R.id.menu_share);
 			quoteItem = menu.findItem(R.id.menu_message_quote);
-			showText = menu.findItem(R.id.menu_show_text);
+			infoItem = menu.findItem(R.id.menu_info);
+			editItem = menu.findItem(R.id.menu_message_edit);
+			starItem = menu.findItem(R.id.menu_message_star);
+			unStarItem = menu.findItem(R.id.menu_message_unstar);
+			imageReplyItem = menu.findItem(R.id.menu_message_image_reply);
+			deleteItem = menu.findItem(R.id.menu_message_discard);
 
 			updateActionMenu(menu);
 
@@ -4812,13 +5026,21 @@ public class ComposeMessageFragment extends Fragment implements
 			} else if (id == R.id.menu_message_quote) {
 				showQuotePopup(null);
 				mode.finish();
-			} else if (id == R.id.menu_show_text) {
+			} else if (id == R.id.menu_info) {
 				showTextChatBubble(selectedMessages.get(0));
 				mode.finish();
+			} else if (id == R.id.menu_message_star || id == R.id.menu_message_unstar) {
+				toggleStar(selectedMessages.get(0));
+				mode.finish();
+			} else if (id == R.id.menu_message_edit) {
+				tryEditingSelectedMessage();
+				mode.finish();
+			} else  if (id == R.id.menu_message_image_reply) {
+				sendImageReply();
+				mode.finish();
 			} else {
 				return false;
 			}
-
 			return true;
 		}
 
@@ -4832,8 +5054,8 @@ public class ComposeMessageFragment extends Fragment implements
 			convListView.requestLayout();
 			convListView.post(() -> convListView.setChoiceMode(AbsListView.CHOICE_MODE_SINGLE));
 
-			if (ackjiPopup != null) {
-				ackjiPopup.dismiss();
+			if (emojiReactionsPopup != null) {
+				emojiReactionsPopup.dismiss();
 			}
 
 			// If the action mode has been left without clearing up the selected messages, we need
@@ -5018,6 +5240,24 @@ public class ComposeMessageFragment extends Fragment implements
 
 			editMessageActionMode = null;
 		}
+
+        /**
+         * Get the currently stored text for the given message model. For a text message, this is just
+         * the message's text. In case of a file message, the caption is returned. For other message
+         * types, null is returned.
+         */
+        @Nullable
+        private String getEditableText(@Nullable AbstractMessageModel messageModel) {
+            if (messageModel == null) {
+                return null;
+            }
+            if (messageModel.getType() == MessageType.TEXT) {
+                return messageModel.getBody();
+            } else if (messageModel.getType() == MessageType.FILE) {
+                return messageModel.getCaption();
+            }
+            return null;
+        }
 	}
 
 	private void showTextChatBubble(AbstractMessageModel messageModel) {
@@ -5034,26 +5274,6 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 	}
 
-	@MainThread
-	private void sendUserAck() {
-		AbstractMessageModel messageModel = selectedMessages.get(0);
-
-		if (messageModel != null) {
-			new Thread(() -> messageService.sendUserAcknowledgement(messageModel, false)).start();
-			Toast.makeText(getActivity(), R.string.message_acknowledged, Toast.LENGTH_SHORT).show();
-		}
-	}
-
-	@MainThread
-	private void sendUserDec() {
-		AbstractMessageModel messageModel = selectedMessages.get(0);
-
-		if (messageModel != null) {
-			new Thread(() -> messageService.sendUserDecline(messageModel, false)).start();
-			Toast.makeText(getActivity(), R.string.message_declined, Toast.LENGTH_SHORT).show();
-		}
-	}
-
 	/**
 	 * Start the {@code ImagePaintActivity} to edit the (first) currently selected message.
 	 */
@@ -5342,7 +5562,7 @@ public class ComposeMessageFragment extends Fragment implements
 				this.notificationService,
 				this.distributionListService,
 				this.messagePlayerService,
-				this.blockedContactsService,
+				this.blockedIdentitiesService,
 				this.ballotService,
 				this.conversationService,
 				this.deviceService,
@@ -5367,7 +5587,7 @@ public class ComposeMessageFragment extends Fragment implements
 				this.notificationService = serviceManager.getNotificationService();
 				this.distributionListService = serviceManager.getDistributionListService();
 				this.messagePlayerService = serviceManager.getMessagePlayerService();
-				this.blockedContactsService = serviceManager.getBlockedContactsService();
+				this.blockedIdentitiesService = serviceManager.getBlockedIdentitiesService();
 				this.ballotService = serviceManager.getBallotService();
 				this.databaseServiceNew = serviceManager.getDatabaseServiceNew();
 				this.conversationService = serviceManager.getConversationService();
@@ -5381,6 +5601,7 @@ public class ComposeMessageFragment extends Fragment implements
 				this.voipStateService = serviceManager.getVoipStateService();
 				this.downloadService = serviceManager.getDownloadService();
 				this.licenseService = serviceManager.getLicenseService();
+                this.emojiReactionsRepository = serviceManager.getModelRepositories().getEmojiReaction();
 			} catch (Exception e) {
 				LogUtil.exception(e, activity);
 			}
@@ -5401,7 +5622,7 @@ public class ComposeMessageFragment extends Fragment implements
 				}
 				break;
 			case ThreemaApplication.CONFIRM_TAG_CLOSE_BALLOT:
-				BallotUtil.closeBallot((AppCompatActivity) requireActivity(), (BallotModel) data, ballotService);
+				BallotUtil.closeBallot((AppCompatActivity) requireActivity(), (BallotModel) data, ballotService, new MessageId(), TriggerSource.LOCAL);
 				break;
 			case DIALOG_TAG_CONFIRM_CALL:
 				VoipUtil.initiateCall((AppCompatActivity) requireActivity(), contactModel, false, null);
@@ -5410,7 +5631,7 @@ public class ComposeMessageFragment extends Fragment implements
 				emptyChat();
 				break;
 			case DIALOG_TAG_CONFIRM_BLOCK:
-				blockedContactsService.toggle(activity, contactModel);
+				blockedIdentitiesService.toggleBlocked(contactModel.getIdentity(), getContext());
 				updateBlockMenu();
 				break;
 			case DIALOG_TAG_CONFIRM_LINK:
@@ -5605,7 +5826,7 @@ public class ComposeMessageFragment extends Fragment implements
 
 				final String spammerIdentity = spammerContactModel.getIdentity();
 				if (block) {
-					blockedContactsService.add(spammerIdentity);
+					blockedIdentitiesService.blockIdentity(spammerIdentity, null);
 					ThreemaApplication.requireServiceManager().getExcludedSyncIdentitiesService().add(spammerIdentity);
 
 					if (messageReceiver != null) {
@@ -5688,23 +5909,5 @@ public class ComposeMessageFragment extends Fragment implements
 			logger.error("Unable to stop VoiceMessagePlayer", e);
 		}
 	}
-
-    /**
-     * Get the currently stored text for the given message model. For a text message, this is just
-     * the message's text. In case of a file message, the caption is returned. For other message
-     * types, null is returned.
-     */
-    @Nullable
-    private String getEditableText(@Nullable AbstractMessageModel messageModel) {
-        if (messageModel == null) {
-            return null;
-        }
-        if (messageModel.getType() == MessageType.TEXT) {
-            return messageModel.getBody();
-        } else if (messageModel.getType() == MessageType.FILE) {
-            return messageModel.getCaption();
-        }
-        return null;
-    }
 }
 

+ 5 - 4
app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java

@@ -108,6 +108,7 @@ import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
 import ch.threema.app.services.AvatarCacheService;
+import ch.threema.app.services.BlockedIdentitiesService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.PreferenceService;
@@ -652,7 +653,7 @@ public class ContactsSectionFragment
 							contactModels,
 							contactService,
 							serviceManager.getPreferenceService(),
-							serviceManager.getBlockedContactsService(),
+							serviceManager.getBlockedIdentitiesService(),
 							ContactsSectionFragment.this,
 							Glide.with(getContext())
 						);
@@ -1180,7 +1181,7 @@ public class ContactsSectionFragment
 				}
 			}
 
-			if (serviceManager.getBlockedContactsService().has(contactModel.getIdentity())) {
+			if (serviceManager.getBlockedIdentitiesService().isBlocked(contactModel.getIdentity())) {
 				items.add(new SelectorDialogItem(getString(R.string.unblock_contact), R.drawable.ic_block));
 			} else {
 				items.add(new SelectorDialogItem(getString(R.string.block_contact), R.drawable.ic_block));
@@ -1427,7 +1428,7 @@ public class ContactsSectionFragment
 				sdialog.show(getParentFragmentManager(), DIALOG_TAG_REPORT_SPAM);
 				break;
 			case SELECTOR_TAG_BLOCK:
-				serviceManager.getBlockedContactsService().toggle(getActivity(), contactModel);
+				serviceManager.getBlockedIdentitiesService().toggleBlocked(contactModel.getIdentity(), getContext());
 				break;
 			case SELECTOR_TAG_DELETE:
 				deleteContacts(Set.of(contactModel));
@@ -1463,7 +1464,7 @@ public class ContactsSectionFragment
 
 						final String spammerIdentity = contactModel.getIdentity();
 						if (checked) {
-							ThreemaApplication.requireServiceManager().getBlockedContactsService().add(spammerIdentity);
+							ThreemaApplication.requireServiceManager().getBlockedIdentitiesService().blockIdentity(spammerIdentity, null);
 							ThreemaApplication.requireServiceManager().getExcludedSyncIdentitiesService().add(spammerIdentity);
 
 							try {

+ 3 - 3
app/src/main/java/ch/threema/app/fragments/MemberListFragment.java

@@ -48,12 +48,12 @@ import ch.threema.app.activities.ProfilePicRecipientsActivity;
 import ch.threema.app.adapters.FilterResultsListener;
 import ch.threema.app.adapters.FilterableListAdapter;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.services.BlockedIdentitiesService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.DeadlineListService;
 import ch.threema.app.services.DistributionListService;
 import ch.threema.app.services.GroupService;
-import ch.threema.app.services.IdListService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.ui.EmptyView;
 import ch.threema.app.utils.LogUtil;
@@ -69,7 +69,7 @@ public abstract class MemberListFragment extends ListFragment implements FilterR
 	protected DistributionListService distributionListService;
 	protected ConversationService conversationService;
 	protected PreferenceService preferenceService;
-	protected IdListService blockedContactsService;
+	protected BlockedIdentitiesService blockedIdentitiesService;
 	protected DeadlineListService hiddenChatsListService;
 	protected Activity activity;
 	protected Parcelable listInstanceState;
@@ -92,7 +92,7 @@ public abstract class MemberListFragment extends ListFragment implements FilterR
 			contactService = serviceManager.getContactService();
 			groupService = serviceManager.getGroupService();
 			distributionListService = serviceManager.getDistributionListService();
-			blockedContactsService = serviceManager.getBlockedContactsService();
+			blockedIdentitiesService = serviceManager.getBlockedIdentitiesService();
 			conversationService = serviceManager.getConversationService();
 			preferenceService = serviceManager.getPreferenceService();
 			hiddenChatsListService = serviceManager.getHiddenChatsListService();

+ 3 - 2
app/src/main/java/ch/threema/app/fragments/RecipientListFragment.java

@@ -63,6 +63,7 @@ import ch.threema.app.activities.RecipientListBaseActivity;
 import ch.threema.app.adapters.FilterResultsListener;
 import ch.threema.app.adapters.FilterableListAdapter;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.services.BlockedIdentitiesService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.DeadlineListService;
@@ -95,7 +96,7 @@ public abstract class RecipientListFragment extends ListFragment implements List
 	protected DistributionListService distributionListService;
 	protected ConversationService conversationService;
 	protected PreferenceService preferenceService;
-	protected IdListService blockedContactsService;
+	protected BlockedIdentitiesService blockedIdentitiesService;
 	protected DeadlineListService hiddenChatsListService;
 	protected FragmentActivity activity;
 	protected Parcelable listInstanceState;
@@ -119,7 +120,7 @@ public abstract class RecipientListFragment extends ListFragment implements List
 			contactService = serviceManager.getContactService();
 			groupService = serviceManager.getGroupService();
 			distributionListService = serviceManager.getDistributionListService();
-			blockedContactsService = serviceManager.getBlockedContactsService();
+			blockedIdentitiesService = serviceManager.getBlockedIdentitiesService();
 			conversationService = serviceManager.getConversationService();
 			preferenceService = serviceManager.getPreferenceService();
 			hiddenChatsListService = serviceManager.getHiddenChatsListService();

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

@@ -102,7 +102,7 @@ public class UserListFragment extends RecipientListFragment {
 					null,
 					checkedItemPositions,
 					contactService,
-					blockedContactsService,
+                    blockedIdentitiesService,
 					hiddenChatsListService,
 					preferenceService,
 					UserListFragment.this,

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

@@ -142,7 +142,7 @@ public class UserMemberListFragment extends MemberListFragment {
 					preselectedIdentities,
 					checkedItemPositions,
 					contactService,
-					blockedContactsService,
+                    blockedIdentitiesService,
 					hiddenChatsListService,
 					preferenceService,
 					UserMemberListFragment.this,

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

@@ -174,7 +174,7 @@ public class WorkUserListFragment extends RecipientListFragment {
 					null,
 					checkedItemPositions,
 					contactService,
-					blockedContactsService,
+                    blockedIdentitiesService,
 					hiddenChatsListService,
 					preferenceService,
 					WorkUserListFragment.this,

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

@@ -131,7 +131,7 @@ public class WorkUserMemberListFragment extends MemberListFragment {
 						preselectedIdentities,
 						checkedItemPositions,
 						contactService,
-						blockedContactsService,
+                        blockedIdentitiesService,
 						hiddenChatsListService,
 						preferenceService,
 						WorkUserMemberListFragment.this,

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

@@ -35,7 +35,6 @@ import androidx.annotation.IdRes
 import androidx.appcompat.widget.SearchView
 import androidx.core.view.WindowCompat
 import androidx.core.view.WindowInsetsCompat
-import androidx.lifecycle.Observer
 import androidx.lifecycle.ViewModelProvider
 import androidx.recyclerview.widget.DefaultItemAnimator
 import androidx.recyclerview.widget.LinearLayoutManager

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

@@ -77,6 +77,7 @@ import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TextUtil;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.domain.protocol.csp.messages.location.Poi;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupMessageModel;
@@ -398,7 +399,7 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
                     }
                 }
 
-                SpannableStringBuilder emojified = (SpannableStringBuilder) EmojiMarkupUtil.getInstance().addTextSpans(context, fullText, viewHolder.snippetView, true, false, false);
+                SpannableStringBuilder emojified = (SpannableStringBuilder) EmojiMarkupUtil.getInstance().addTextSpans(context, fullText, viewHolder.snippetView, true, false, false, false);
 
                 int transitionStart = emojified.nextSpanTransition(firstMatch - snippetThreshold, firstMatch, EmojiImageSpan.class);
                 if (transitionStart == firstMatch) {
@@ -409,7 +410,7 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
                 }
             }
         }
-        return EmojiMarkupUtil.getInstance().addTextSpans(context, fullText, viewHolder.snippetView, false, false, false);
+        return EmojiMarkupUtil.getInstance().addTextSpans(context, fullText, viewHolder.snippetView, false, false, false, false);
     }
 
 
@@ -444,19 +445,13 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
                 }
                 break;
             case LOCATION:
-                final LocationDataModel location = messageModel.getLocationData();
-                StringBuilder locationStringBuilder = new StringBuilder();
-                if (!TestUtil.isEmptyOrNull(location.getPoi())) {
-                    locationStringBuilder.append(location.getPoi());
+                final @NonNull LocationDataModel locationDataModel = messageModel.getLocationData();
+                if (locationDataModel.poi == null) {
+                    break;
                 }
-                if (!TestUtil.isEmptyOrNull(location.getAddress())) {
-                    if (locationStringBuilder.length() > 0) {
-                        locationStringBuilder.append(" - ");
-                    }
-                    locationStringBuilder.append(location.getAddress());
-                }
-                if (locationStringBuilder.length() > 0) {
-                    snippetText = getSnippet(locationStringBuilder.toString(), this.queryString, viewHolder);
+                final @Nullable String poiSnippetForSearch = locationDataModel.poi.getSnippetForSearchOrNull();
+                if (poiSnippetForSearch != null) {
+                    snippetText = getSnippet(poiSnippetForSearch, this.queryString, viewHolder);
                 }
                 break;
             default:

+ 45 - 16
app/src/main/java/ch/threema/app/managers/ServiceManager.java

@@ -54,6 +54,8 @@ import ch.threema.app.services.ApiService;
 import ch.threema.app.services.ApiServiceImpl;
 import ch.threema.app.services.AvatarCacheService;
 import ch.threema.app.services.AvatarCacheServiceImpl;
+import ch.threema.app.services.BlockedIdentitiesService;
+import ch.threema.app.services.BlockedIdentitiesServiceImpl;
 import ch.threema.app.services.BrowserDetectionService;
 import ch.threema.app.services.BrowserDetectionServiceImpl;
 import ch.threema.app.services.CacheService;
@@ -84,6 +86,8 @@ import ch.threema.app.services.LocaleServiceImpl;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.MessageServiceImpl;
+import ch.threema.app.services.NotificationPreferenceService;
+import ch.threema.app.services.NotificationPreferenceServiceImpl;
 import ch.threema.app.services.notification.NotificationService;
 import ch.threema.app.services.notification.NotificationServiceImpl;
 import ch.threema.app.services.PinLockService;
@@ -190,6 +194,8 @@ public class ServiceManager {
     @Nullable
     private PreferenceService preferencesService;
     @Nullable
+    private NotificationPreferenceService notificationPreferenceService;
+    @Nullable
     private LocaleService localeService;
     @Nullable
     private DeviceService deviceService;
@@ -227,7 +233,9 @@ public class ServiceManager {
     private SystemScreenLockService systemScreenLockService;
 
     @Nullable
-    private IdListService blockedContactsService, excludedSyncIdentitiesService, profilePicRecipientsService;
+    private BlockedIdentitiesService blockedIdentitiesService;
+    @Nullable
+    private IdListService excludedSyncIdentitiesService, profilePicRecipientsService;
     @Nullable
     private DeadlineListService mutedChatsListService, hiddenChatListService, mentionOnlyChatsListService;
     @Nullable
@@ -445,7 +453,7 @@ public class ServiceManager {
                 this.getUserService(),
                 this.getIdentityStore(),
                 this.getPreferenceService(),
-                this.getBlockedContactsService(),
+                this.getBlockedIdentitiesService(),
                 this.getProfilePicRecipientsService(),
                 this.getFileService(),
                 this.cacheService,
@@ -479,9 +487,10 @@ public class ServiceManager {
                 this.getApiService(),
                 this.getDownloadService(),
                 this.getHiddenChatsListService(),
-                this.getBlockedContactsService(),
+                this.getBlockedIdentitiesService(),
+                this.getMultiDeviceManager(),
                 this.getModelRepositories().getEditHistory(),
-                this.getMultiDeviceManager()
+                this.getModelRepositories().getEmojiReaction()
             );
         }
 
@@ -493,12 +502,25 @@ public class ServiceManager {
         if (this.preferencesService == null) {
             this.preferencesService = new PreferenceServiceImpl(
                 this.getContext(),
-                this.coreServiceManager.getPreferenceStore()
+                this.coreServiceManager.getPreferenceStore(),
+                this.getTaskManager(),
+                this.getMultiDeviceManager(),
+                this.getNonceFactory()
             );
         }
         return this.preferencesService;
     }
 
+    @NonNull
+    public NotificationPreferenceService getNotificationPreferenceService() {
+        if (notificationPreferenceService == null) {
+            notificationPreferenceService = new NotificationPreferenceServiceImpl(
+                getContext(), getPreferenceStore()
+            );
+        }
+        return notificationPreferenceService;
+    }
+
     @NonNull
     public QRCodeService getQRCodeService() {
         if (this.qrCodeService == null) {
@@ -769,7 +791,7 @@ public class ServiceManager {
                 this.getDistributionListService(),
                 this.getMessageService(),
                 this.getHiddenChatsListService(),
-                this.getBlockedContactsService(),
+                this.getBlockedIdentitiesService(),
                 this.getConversationTagService()
             );
         }
@@ -793,7 +815,7 @@ public class ServiceManager {
                 this.getContext(),
                 this.getLockAppService(),
                 this.getHiddenChatsListService(),
-                this.getPreferenceService(),
+                this.getNotificationPreferenceService(),
                 this.getRingtoneService()
             );
         }
@@ -815,7 +837,7 @@ public class ServiceManager {
                 this.getDeviceService(),
                 this.getFileService(),
                 this.getIdentityStore(),
-                this.getBlockedContactsService(),
+                this.getBlockedIdentitiesService(),
                 this.getApiService()
             );
         }
@@ -824,12 +846,15 @@ public class ServiceManager {
     }
 
     @NonNull
-    public IdListService getBlockedContactsService() {
-        if (this.blockedContactsService == null) {
-            // Keep the uniqueListName `identity_list_blacklist` to avoid a migration of the key in the preferences
-            this.blockedContactsService = new IdListServiceImpl("identity_list_blacklist", this.getPreferenceService());
+    public BlockedIdentitiesService getBlockedIdentitiesService() {
+        if (this.blockedIdentitiesService == null) {
+            this.blockedIdentitiesService = new BlockedIdentitiesServiceImpl(
+                getPreferenceService(),
+                getMultiDeviceManager(),
+                getTaskCreator()
+            );
         }
-        return this.blockedContactsService;
+        return this.blockedIdentitiesService;
     }
 
     @NonNull
@@ -877,6 +902,7 @@ public class ServiceManager {
                 this.getMessageService(),
                 this.getFileService(),
                 this.getPreferenceService(),
+                this.getNotificationPreferenceService(),
                 this.getHiddenChatsListService()
             );
         }
@@ -934,7 +960,7 @@ public class ServiceManager {
                 this.getDistributionListService(),
                 this.getLocaleService(),
                 this.getFileService(),
-                this.getBlockedContactsService(),
+                this.getBlockedIdentitiesService(),
                 this.getExcludedSyncIdentitiesService(),
                 this.getProfilePicRecipientsService(),
                 this.getDatabaseServiceNew(),
@@ -963,7 +989,10 @@ public class ServiceManager {
     @NonNull
     public RingtoneService getRingtoneService() {
         if (this.ringtoneService == null) {
-            this.ringtoneService = new RingtoneServiceImpl(this.getPreferenceService());
+            this.ringtoneService = new RingtoneServiceImpl(
+                this.getPreferenceService(),
+                this.getNotificationPreferenceService()
+            );
         }
 
         return this.ringtoneService;
@@ -1017,7 +1046,7 @@ public class ServiceManager {
                 this.getMessageService(),
                 this.getNotificationService(),
                 this.databaseServiceNew,
-                this.getBlockedContactsService(),
+                this.getBlockedIdentitiesService(),
                 this.getPreferenceService(),
                 this.getUserService(),
                 this.getHiddenChatsListService(),

+ 81 - 36
app/src/main/java/ch/threema/app/messagereceiver/ContactMessageReceiver.java

@@ -37,8 +37,8 @@ import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.multidevice.MultiDeviceManager;
+import ch.threema.app.services.BlockedIdentitiesService;
 import ch.threema.app.services.ContactService;
-import ch.threema.app.services.IdListService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.stores.IdentityStore;
 import ch.threema.app.tasks.OutboundIncomingContactMessageUpdateReadTask;
@@ -47,6 +47,7 @@ import ch.threema.app.tasks.OutgoingContactDeleteMessageTask;
 import ch.threema.app.tasks.OutgoingContactEditMessageTask;
 import ch.threema.app.tasks.OutgoingFileMessageTask;
 import ch.threema.app.tasks.OutgoingLocationMessageTask;
+import ch.threema.app.tasks.OutgoingContactReactionMessageTask;
 import ch.threema.app.tasks.OutgoingPollSetupMessageTask;
 import ch.threema.app.tasks.OutgoingPollVoteContactMessageTask;
 import ch.threema.app.tasks.OutgoingTextMessageTask;
@@ -56,6 +57,7 @@ import ch.threema.app.tasks.OutgoingVoipCallHangupMessageTask;
 import ch.threema.app.tasks.OutgoingVoipCallOfferMessageTask;
 import ch.threema.app.tasks.OutgoingVoipCallRingingMessageTask;
 import ch.threema.app.tasks.OutgoingVoipICECandidateMessageTask;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.TestUtil;
@@ -63,6 +65,7 @@ import ch.threema.base.ThreemaException;
 import ch.threema.base.crypto.SymmetricEncryptionResult;
 import ch.threema.base.utils.Utils;
 import ch.threema.domain.models.MessageId;
+import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotData;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotId;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotVote;
@@ -74,6 +77,8 @@ import ch.threema.domain.protocol.csp.messages.voip.VoipICECandidatesData;
 import ch.threema.domain.taskmanager.ActiveTaskCodec;
 import ch.threema.domain.taskmanager.Task;
 import ch.threema.domain.taskmanager.TaskManager;
+import ch.threema.domain.taskmanager.TriggerSource;
+import ch.threema.protobuf.csp.e2e.Reaction;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
@@ -90,7 +95,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 	private final ServiceManager serviceManager;
 	private final DatabaseServiceNew databaseServiceNew;
 	private final IdentityStore identityStore;
-	private final IdListService blockedContactsService;
+	private final BlockedIdentitiesService blockedIdentitiesService;
 	private final @NonNull TaskManager taskManager;
 	private final @NonNull MultiDeviceManager multiDeviceManager;
 
@@ -99,14 +104,14 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 	                              @NonNull ServiceManager serviceManager,
 	                              DatabaseServiceNew databaseServiceNew,
 	                              IdentityStore identityStore,
-	                              IdListService blockedContactsService
+	                              @NonNull BlockedIdentitiesService blockedIdentitiesService
 	) {
 		this.contactModel = contactModel;
 		this.contactService = contactService;
 		this.serviceManager = serviceManager;
 		this.databaseServiceNew = databaseServiceNew;
 		this.identityStore = identityStore;
-		this.blockedContactsService = blockedContactsService;
+		this.blockedIdentitiesService = blockedIdentitiesService;
 		this.taskManager = serviceManager.getTaskManager();
 		this.multiDeviceManager = serviceManager.getMultiDeviceManager();
 	}
@@ -118,7 +123,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 			contactMessageReceiver.serviceManager,
 			contactMessageReceiver.databaseServiceNew,
 			contactMessageReceiver.identityStore,
-			contactMessageReceiver.blockedContactsService
+			contactMessageReceiver.blockedIdentitiesService
 		);
 	}
 
@@ -270,14 +275,15 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 
 	@Override
 	public void createAndSendBallotSetupMessage(
-		BallotData ballotData,
-		BallotModel ballotModel,
-		MessageModel messageModel,
-		@Nullable MessageId messageId,
-		@Nullable Collection<String> recipientIdentities
-	) throws ThreemaException {
-		// Create a new message id if the given message id is null
-		messageModel.setApiMessageId(messageId != null ? messageId.toString() : new MessageId().toString());
+        @NonNull BallotData ballotData,
+        @NonNull BallotModel ballotModel,
+        @NonNull MessageModel messageModel,
+        @NonNull MessageId messageId,
+        @Nullable Collection<String> recipientIdentities,
+        @NonNull TriggerSource triggerSource
+    ) throws ThreemaException {
+		// Save the given message id to the model
+		messageModel.setApiMessageId(messageId.toString());
 		saveLocalModel(messageModel);
 
 		final BallotId ballotId = new BallotId(Utils.hexStringToByteArray(ballotModel.getApiBallotId()));
@@ -288,22 +294,25 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 
 		bumpLastUpdate();
 
-		// Schedule outgoing text message task
-		scheduleTask(new OutgoingPollSetupMessageTask(
-			messageModel.getId(),
-			Type_CONTACT,
-			Set.of(messageModel.getIdentity()),
-			ballotId,
-			ballotData,
-			serviceManager
-		));
+		// Schedule outgoing text message task if this has been triggered by local
+        if (triggerSource == TriggerSource.LOCAL) {
+            scheduleTask(new OutgoingPollSetupMessageTask(
+                messageModel.getId(),
+                Type_CONTACT,
+                Set.of(messageModel.getIdentity()),
+                ballotId,
+                ballotData,
+                serviceManager
+            ));
+        }
 	}
 
 	@Override
-	public void createAndSendBallotVoteMessage(BallotVote[] votes, BallotModel ballotModel) throws ThreemaException {
-		// Create message id
-		MessageId messageId = new MessageId();
-
+	public void createAndSendBallotVoteMessage(
+        BallotVote[] votes,
+        BallotModel ballotModel,
+        @NonNull TriggerSource triggerSource
+    ) throws ThreemaException {
 		final BallotId ballotId = new BallotId(Utils.hexStringToByteArray(ballotModel.getApiBallotId()));
 
 		if (ballotModel.getType() == BallotModel.Type.RESULT_ON_CLOSE) {
@@ -317,15 +326,21 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		contactService.setAcquaintanceLevel(contactModel.getIdentity(), ContactModel.AcquaintanceLevel.DIRECT);
 		contactService.setIsArchived(contactModel.getIdentity(), false);
 
-		// Schedule outgoing text message task
-		scheduleTask(new OutgoingPollVoteContactMessageTask(
-			messageId,
-			ballotId,
-			ballotModel.getCreatorIdentity(),
-			votes,
-			contactModel.getIdentity(),
-			serviceManager
-		));
+
+        if (triggerSource == TriggerSource.LOCAL) {
+            // Create message id
+            MessageId messageId = new MessageId();
+
+            // Schedule outgoing text message task
+            scheduleTask(new OutgoingPollVoteContactMessageTask(
+                messageId,
+                ballotId,
+                ballotModel.getCreatorIdentity(),
+                votes,
+                contactModel.getIdentity(),
+                serviceManager
+            ));
+        }
 	}
 
 	/**
@@ -461,6 +476,20 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		);
 	}
 
+	public void sendReactionMessage(AbstractMessageModel messageModel, Reaction.ActionCase actionCase, @NonNull String emojiSequence, @NonNull Date reactedAt) {
+		scheduleTask(
+			new OutgoingContactReactionMessageTask(
+				contactModel.getIdentity(),
+				messageModel.getId(),
+				new MessageId(),
+				actionCase,
+				emojiSequence,
+				reactedAt,
+				serviceManager
+			)
+		);
+	}
+
 	@Override
 	public List<MessageModel> loadMessages(MessageService.MessageFilter filter) {
 		return databaseServiceNew.getMessageModelFactory().find(
@@ -573,7 +602,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 	@Override
 	public SendingPermissionValidationResult validateSendingPermission() {
 		int cannotSendResId = 0;
-		if (blockedContactsService.has(contactModel.getIdentity())) {
+		if (blockedIdentitiesService.isBlocked(contactModel.getIdentity())) {
 			cannotSendResId = R.string.blocked_cannot_send;
 		} else {
 			if (contactModel.getState() != null) {
@@ -611,6 +640,22 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		contactService.bumpLastUpdate(contactModel.getIdentity());
 	}
 
+	/**
+	 * Check whether we should send emoji reactions to this particular MessageReceiver
+	 * @return true if we should send emoji reactions to this MessageReceiver, false otherwise
+	 */
+	@Override
+    @EmojiReactionsSupport
+	public int getEmojiReactionSupport() {
+		if (!ConfigUtils.canSendEmojiReactions()) {
+			return Reactions_NONE;
+		}
+
+        return ThreemaFeature.canEmojiReactions((this).getContact().getFeatureMask())
+            ? Reactions_FULL
+            : Reactions_NONE;
+	}
+
 	@Override
 	public @NonNull String toString() {
 		return "ContactMessageReceiver (identity = " + contactModel.getIdentity() + ")";

+ 19 - 7
app/src/main/java/ch/threema/app/messagereceiver/DistributionListMessageReceiver.java

@@ -42,6 +42,7 @@ import ch.threema.base.crypto.SymmetricEncryptionResult;
 import ch.threema.domain.models.MessageId;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotData;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotVote;
+import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
@@ -164,17 +165,22 @@ public class DistributionListMessageReceiver implements MessageReceiver<Distribu
 
 	@Override
 	public void createAndSendBallotSetupMessage(
-		BallotData ballotData,
-		BallotModel ballotModel,
-		DistributionListMessageModel abstractMessageModel,
-		@Nullable MessageId messageId,
-		@Nullable Collection<String> recipientIdentities
-	) {
+        @NonNull BallotData ballotData,
+        @NonNull BallotModel ballotModel,
+        @NonNull DistributionListMessageModel abstractMessageModel,
+        @Nullable MessageId messageId,
+        @Nullable Collection<String> recipientIdentities,
+        @NonNull TriggerSource triggerSource
+    ) {
 		// Not supported in distribution lists
 	}
 
 	@Override
-	public void createAndSendBallotVoteMessage(BallotVote[] votes, BallotModel ballotModel) {
+	public void createAndSendBallotVoteMessage(
+        BallotVote[] votes,
+        BallotModel ballotModel,
+        @NonNull TriggerSource triggerSource
+    ) {
 		// Not supported in distribution lists
 	}
 
@@ -286,6 +292,12 @@ public class DistributionListMessageReceiver implements MessageReceiver<Distribu
 		}
 	}
 
+	@Override
+    @EmojiReactionsSupport
+	public int getEmojiReactionSupport() {
+		return Reactions_NONE;
+	}
+
 	@Override
 	public boolean equals(Object o) {
 		if (this == o) return true;

+ 114 - 16
app/src/main/java/ch/threema/app/messagereceiver/GroupMessageReceiver.java

@@ -24,6 +24,8 @@ package ch.threema.app.messagereceiver;
 import android.content.Intent;
 import android.graphics.Bitmap;
 
+import org.slf4j.Logger;
+
 import java.util.Collection;
 import java.util.Date;
 import java.util.HashSet;
@@ -35,6 +37,7 @@ import java.util.UUID;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.emojis.EmojiUtil;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.multidevice.MultiDeviceManager;
 import ch.threema.app.services.GroupService;
@@ -43,14 +46,17 @@ import ch.threema.app.tasks.OutboundIncomingGroupMessageUpdateReadTask;
 import ch.threema.app.tasks.OutgoingFileMessageTask;
 import ch.threema.app.tasks.OutgoingGroupDeleteMessageTask;
 import ch.threema.app.tasks.OutgoingGroupEditMessageTask;
+import ch.threema.app.tasks.OutgoingGroupReactionMessageTask;
 import ch.threema.app.tasks.OutgoingLocationMessageTask;
 import ch.threema.app.tasks.OutgoingPollSetupMessageTask;
 import ch.threema.app.tasks.OutgoingPollVoteGroupMessageTask;
 import ch.threema.app.tasks.OutgoingTextMessageTask;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.GroupUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.crypto.SymmetricEncryptionResult;
+import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.Utils;
 import ch.threema.domain.models.MessageId;
 import ch.threema.domain.protocol.ThreemaFeature;
@@ -58,6 +64,8 @@ import ch.threema.domain.protocol.csp.messages.ballot.BallotData;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotId;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotVote;
 import ch.threema.domain.taskmanager.TaskManager;
+import ch.threema.domain.taskmanager.TriggerSource;
+import ch.threema.protobuf.csp.e2e.Reaction;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.GroupMessageModel;
@@ -69,7 +77,12 @@ import ch.threema.storage.models.ballot.BallotModel;
 import ch.threema.storage.models.data.MessageContentsType;
 import ch.threema.storage.models.data.media.FileDataModel;
 
+import static ch.threema.app.utils.MessageUtil.canSendUserAcknowledge;
+import static ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGUSERACK;
+import static ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGUSERDEC;
+
 public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel> {
+	private static final Logger logger = LoggingUtil.getThreemaLogger("GroupMessageReceiver");
 
 	private final GroupModel group;
 	private final GroupService groupService;
@@ -239,12 +252,13 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 
 	@Override
 	public void createAndSendBallotSetupMessage(
-		final BallotData ballotData,
-		final BallotModel ballotModel,
-		@NonNull GroupMessageModel messageModel,
-		@Nullable MessageId messageId,
-		@Nullable Collection<String> recipientIdentities
-	) throws ThreemaException {
+        @NonNull final BallotData ballotData,
+        @NonNull final BallotModel ballotModel,
+        @NonNull GroupMessageModel messageModel,
+        @Nullable MessageId messageId,
+        @Nullable Collection<String> recipientIdentities,
+        @NonNull TriggerSource triggerSource
+        ) throws ThreemaException {
 		final BallotId ballotId = new BallotId(Utils.hexStringToByteArray(ballotModel.getApiBallotId()));
 
 		// Create a new message id if the given message id is null
@@ -253,19 +267,25 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 
 		bumpLastUpdate();
 
-		// Schedule outgoing text message task
-		taskManager.schedule(new OutgoingPollSetupMessageTask(
-			messageModel.getId(),
-			Type_GROUP,
-			getRecipientIdentities(recipientIdentities),
-			ballotId,
-			ballotData,
-			serviceManager
-		));
+		// Schedule outgoing text message task if this is triggered from local
+        if (triggerSource == TriggerSource.LOCAL) {
+            taskManager.schedule(new OutgoingPollSetupMessageTask(
+                messageModel.getId(),
+                Type_GROUP,
+                getRecipientIdentities(recipientIdentities),
+                ballotId,
+                ballotData,
+                serviceManager
+            ));
+        }
 	}
 
 	@Override
-	public void createAndSendBallotVoteMessage(final BallotVote[] votes, final BallotModel ballotModel) throws ThreemaException {
+	public void createAndSendBallotVoteMessage(
+        final BallotVote[] votes,
+        final BallotModel ballotModel,
+        @NonNull TriggerSource triggerSource
+     ) throws ThreemaException {
 		// Create message id
 		MessageId messageId = new MessageId();
 
@@ -332,6 +352,57 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 		);
 	}
 
+	/**
+	 * Send a reaction message to the group. Members who do not support reactions will receive an ack/dec instead
+	 * @param messageModel MessageModel the reaction reacts to
+	 * @param actionCase The action case of the reaction (WITHDRAW is not backwards compatible and wil not cause an ack/dec to be sent)
+	 * @param emojiSequence The emoji sequence of the reaction
+	 * @param reactedAt The timestamp of the reaction
+	 */
+	public void sendReactionMessage(AbstractMessageModel messageModel, Reaction.ActionCase actionCase, @NonNull String emojiSequence, @NonNull Date reactedAt) {
+		// identities that support receiving emoji reactions
+		Set<String> emojiReactionsIdentities = GroupUtil.getRecipientIdentitiesByFeatureSupport(groupService.getFeatureSupport(group, ThreemaFeature.EMOJI_REACTIONS));
+		// all group identities except sender
+		Set<String> groupIdentities = groupService.getMembersWithoutUser(group);
+
+		if (!emojiReactionsIdentities.isEmpty()) {
+			taskManager.schedule(
+				new OutgoingGroupReactionMessageTask(
+					messageModel.getId(),
+					new MessageId(),
+					actionCase,
+					emojiSequence,
+					reactedAt,
+					emojiReactionsIdentities,
+					serviceManager
+				)
+			);
+			groupIdentities.removeAll(emojiReactionsIdentities);
+		}
+
+		// Fall back to acks for users who do not yet support receiving emoji reactions
+		if (actionCase == Reaction.ActionCase.APPLY && !groupIdentities.isEmpty() && canSendUserAcknowledge(messageModel)) {
+			MessageService messageService;
+			try {
+				messageService = ThreemaApplication.getServiceManager().getMessageService();
+			} catch (Exception e) {
+				throw new RuntimeException(e);
+			}
+
+			if (EmojiUtil.isThumbsUpEmoji(emojiSequence)) {
+				// send ack to these receivers
+				if (!messageService.sendGroupDeliveryReceipt(groupIdentities, (GroupMessageModel) messageModel, DELIVERYRECEIPT_MSGUSERACK)) {
+					logger.error("Unable to send ack message.");
+				}
+			} else if (EmojiUtil.isThumbsDownEmoji(emojiSequence)) {
+				// send dec to these receivers
+				if (!messageService.sendGroupDeliveryReceipt(groupIdentities, (GroupMessageModel) messageModel, DELIVERYRECEIPT_MSGUSERDEC)) {
+					logger.error("Unable to send dec message.");
+				}
+			}
+		}
+	}
+
 	@Override
 	public List<GroupMessageModel> loadMessages(MessageService.MessageFilter filter) {
 		return databaseServiceNew.getGroupMessageModelFactory().find(
@@ -469,6 +540,33 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 		}
 	}
 
+	@Override
+    @EmojiReactionsSupport
+	public int getEmojiReactionSupport() {
+		if (!ConfigUtils.canSendEmojiReactions()) {
+			return Reactions_NONE;
+		}
+
+		GroupModel currentGroup = getGroup();
+		if (!groupService.isGroupMember(currentGroup)) {
+			return Reactions_NONE;
+		}
+		if (groupService.isNotesGroup(currentGroup)) {
+			return Reactions_FULL;
+		}
+
+		switch (groupService.getFeatureSupport(currentGroup, ThreemaFeature.EMOJI_REACTIONS).getAdoptionRate()) {
+			case PARTIAL:
+				return Reactions_PARTIAL;
+			case ALL:
+				return Reactions_FULL;
+            case NONE:
+                // Fallthrough
+			default:
+				return Reactions_NONE; // Handle unknown adoption rates
+		}
+	}
+
 	@Override
 	public @NonNull String toString() {
 		return "GroupMessageReceiver (GroupId = " + group.getId() + ")";

+ 37 - 12
app/src/main/java/ch/threema/app/messagereceiver/MessageReceiver.java

@@ -40,6 +40,7 @@ import ch.threema.base.crypto.SymmetricEncryptionResult;
 import ch.threema.domain.models.MessageId;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotData;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotVote;
+import ch.threema.domain.taskmanager.TriggerSource;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.ballot.BallotModel;
@@ -55,9 +56,13 @@ public interface MessageReceiver<M extends AbstractMessageModel> {
 	@IntDef({ Type_CONTACT, Type_GROUP, Type_DISTRIBUTION_LIST })
 	@interface MessageReceiverType {}
 
-	interface OnSendingPermissionDenied {
-		void denied(int errorResId);
-	}
+    int Reactions_NONE = 0;
+    int Reactions_FULL = 1;
+    int Reactions_PARTIAL = 2;
+
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({ Reactions_NONE, Reactions_FULL, Reactions_PARTIAL })
+    @interface EmojiReactionsSupport {}
 
 	/**
 	 * Return all affected contact message receivers.
@@ -110,20 +115,33 @@ public interface MessageReceiver<M extends AbstractMessageModel> {
 	) throws ThreemaException;
 
 	/**
-	 * send a ballot (create) message
+	 * Send a ballot (create) message. Note that the message is only sent if the trigger source is
+     * local. The message id is added to the message model in any case.
+     * TODO(ANDR-3518): The trigger source should not be passed until here. This is only a security
+     *  measure as the ballot service has many side effects. Ideally, this method would only be
+     *  called if a csp message should really be sent out.
 	 */
 	void createAndSendBallotSetupMessage(
-			final BallotData ballotData,
-			final BallotModel ballotModel,
-			M abstractMessageModel,
-			@Nullable MessageId messageId,
-			@Nullable Collection<String> recipientIdentities
+			@NonNull final BallotData ballotData,
+			@NonNull final BallotModel ballotModel,
+			@NonNull M abstractMessageModel,
+			@NonNull MessageId messageId,
+			@Nullable Collection<String> recipientIdentities,
+            @NonNull TriggerSource triggerSource
 	) throws ThreemaException;
 
 	/**
-	 * send a ballot vote message
+	 * Send a ballot vote message. Note that the message is only sent if the trigger source is
+     * local.
+     * TODO(ANDR-3518): The trigger source should not be passed until here. This is only a security
+     *  measure as the ballot service has many side effects. Ideally, this method would only be
+     *  called if a csp message should really be sent out.
 	 */
-	void createAndSendBallotVoteMessage(BallotVote[] votes, BallotModel ballotModel) throws ThreemaException;
+	void createAndSendBallotVoteMessage(
+        BallotVote[] votes,
+        BallotModel ballotModel,
+        @NonNull TriggerSource triggerSource
+    ) throws ThreemaException;
 
 	/**
 	 * select and filter (if filter is set) all message models
@@ -213,7 +231,7 @@ public interface MessageReceiver<M extends AbstractMessageModel> {
 
 	/**
 	 * all receiving identities
-	 * @return list of identities
+	 * @return array of identities
 	 */
 	String[] getIdentities();
 
@@ -224,4 +242,11 @@ public interface MessageReceiver<M extends AbstractMessageModel> {
 	 * Not that this method only has an effect if it is supported by the implementing receiver.
 	 */
 	void bumpLastUpdate();
+
+    /**
+     * Check how this particular MessageReceiver supports emoji reactions
+     * @return @EmojiReactionsSupport
+     */
+    @EmojiReactionsSupport
+	int getEmojiReactionSupport();
 }

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

@@ -124,7 +124,7 @@ class DeviceLinkingDataCollector(
     private val distributionListService by lazy { serviceManager.distributionListService }
     private val deviceCookieManager by lazy { serviceManager.deviceCookieManager }
     private val preferenceService by lazy { serviceManager.preferenceService }
-    private val blockedIdentitiesService by lazy { serviceManager.blockedContactsService }
+    private val blockedIdentitiesService by lazy { serviceManager.blockedIdentitiesService }
     private val excludeFromSyncService by lazy { serviceManager.excludedSyncIdentitiesService }
     private val fileService by lazy { serviceManager.fileService }
     private val hiddenChatsService by lazy { serviceManager.hiddenChatsListService }
@@ -340,7 +340,7 @@ class DeviceLinkingDataCollector(
 
     private fun collectBlockedIdentities(): Identities {
         return identities {
-            identities += blockedIdentitiesService.all.toSet()
+            identities += blockedIdentitiesService.getAllBlockedIdentities()
         }
     }
 
@@ -396,7 +396,7 @@ class DeviceLinkingDataCollector(
                 else -> Contact.IdentityType.UNRECOGNIZED
             }
             acquaintanceLevel = when (contactModel.acquaintanceLevel) {
-                ContactModel.AcquaintanceLevel.GROUP -> Contact.AcquaintanceLevel.GROUP
+                ContactModel.AcquaintanceLevel.GROUP -> Contact.AcquaintanceLevel.GROUP_OR_DELETED
                 ContactModel.AcquaintanceLevel.DIRECT -> Contact.AcquaintanceLevel.DIRECT
             }
             activityState = when (contactModel.state) {

+ 2 - 1
app/src/main/java/ch/threema/app/notifications/NotificationChannels.kt

@@ -40,6 +40,7 @@ import androidx.core.app.NotificationManagerCompat
 import androidx.preference.PreferenceManager
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
+import ch.threema.app.services.ServicesConstants
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.SoundUtil
 import ch.threema.base.utils.LoggingUtil
@@ -450,7 +451,7 @@ object NotificationChannels {
 
     private fun getRingtoneUriFromPrefsKey(context: Context, sharedPreferences: SharedPreferences, key: Int) : Uri {
         val ringtone = sharedPreferences.getString(context.getString(key), null)
-        return if (!ringtone.isNullOrEmpty() && "null" != ringtone) Uri.parse(ringtone) else Settings.System.DEFAULT_NOTIFICATION_URI
+        return if (!ringtone.isNullOrEmpty() && ringtone != ServicesConstants.PREFERENCES_NULL) Uri.parse(ringtone) else Settings.System.DEFAULT_NOTIFICATION_URI
     }
 
     /**

+ 40 - 7
app/src/main/java/ch/threema/app/preference/SettingsPrivacyFragment.kt

@@ -37,7 +37,7 @@ import androidx.preference.TwoStatePreference
 import androidx.work.WorkManager
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
-import ch.threema.app.activities.BlockedContactsActivity
+import ch.threema.app.activities.BlockedIdentitiesActivity
 import ch.threema.app.activities.ExcludedSyncIdentitiesActivity
 import ch.threema.app.dialogs.GenericAlertDialog
 import ch.threema.app.dialogs.GenericProgressDialog
@@ -48,6 +48,7 @@ import ch.threema.app.routines.SynchronizeContactsRoutine
 import ch.threema.app.services.SynchronizeContactsService
 import ch.threema.app.utils.*
 import ch.threema.base.utils.LoggingUtil
+import ch.threema.domain.taskmanager.TriggerSource
 import ch.threema.localcrypto.MasterKeyLockedException
 import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback
 import com.google.android.material.snackbar.Snackbar
@@ -55,6 +56,9 @@ import com.google.android.material.snackbar.Snackbar
 private val logger = LoggingUtil.getThreemaLogger("SettingsPrivacyFragment")
 
 class SettingsPrivacyFragment : ThreemaPreferenceFragment(), GenericAlertDialog.DialogClickListener {
+    private val serviceManager by lazy { ThreemaApplication.requireServiceManager() }
+    private val preferenceService by lazy { serviceManager.preferenceService }
+    private val multiDeviceManager by lazy { serviceManager.multiDeviceManager }
 
     private lateinit var disableScreenshot: CheckBoxPreference
     private var disableScreenshotChecked = false
@@ -98,8 +102,7 @@ class SettingsPrivacyFragment : ThreemaPreferenceFragment(), GenericAlertDialog.
         disableScreenshot = getPref(R.string.preferences__hide_screenshots)
         disableScreenshotChecked = this.disableScreenshot.isChecked
 
-        if (ConfigUtils.getScreenshotsDisabled(ThreemaApplication.getServiceManager()?.preferenceService,
-                        ThreemaApplication.getServiceManager()?.lockAppService)) {
+        if (ConfigUtils.getScreenshotsDisabled(preferenceService, serviceManager.lockAppService)) {
             disableScreenshot.isEnabled = false
             disableScreenshot.isSelectable = false
         }
@@ -117,6 +120,10 @@ class SettingsPrivacyFragment : ThreemaPreferenceFragment(), GenericAlertDialog.
         initDirectSharePref()
 
         updateView()
+
+        if (multiDeviceManager.isMultiDeviceActive) {
+            subscribeToMdRelevantSettings()
+        }
     }
 
     override fun getPreferenceTitleResource(): Int = R.string.prefs_privacy
@@ -172,9 +179,9 @@ class SettingsPrivacyFragment : ThreemaPreferenceFragment(), GenericAlertDialog.
             contactSyncPreference.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { preference: Preference, newValue: Any ->
                 val newCheckedValue = newValue == true
                 if ((preference as TwoStatePreference).isChecked != newCheckedValue) {
-                    requirePreferenceService().emailSyncHashCode = 0
-                    requirePreferenceService().phoneNumberSyncHashCode = 0
-                    requirePreferenceService().timeOfLastContactSync = 0L
+                    preferenceService.emailSyncHashCode = 0
+                    preferenceService.phoneNumberSyncHashCode = 0
+                    preferenceService.timeOfLastContactSync = 0L
 
                     if (newCheckedValue) {
                         enableSync()
@@ -218,7 +225,7 @@ class SettingsPrivacyFragment : ThreemaPreferenceFragment(), GenericAlertDialog.
 
     private fun initBlockedContactsPref() {
         getPref<Preference>("pref_blocked_contacts").setOnPreferenceClickListener {
-            startActivity(Intent(activity, BlockedContactsActivity::class.java))
+            startActivity(Intent(activity, BlockedIdentitiesActivity::class.java))
             false
         }
     }
@@ -313,6 +320,32 @@ class SettingsPrivacyFragment : ThreemaPreferenceFragment(), GenericAlertDialog.
         }
     }
 
+    private fun subscribeToMdRelevantSettings() {
+        getPref<CheckBoxPreference>(R.string.preferences__block_unknown)
+            .setOnPreferenceChangeListener { _, newValue ->
+                // Note that it is necessary that the preference is already persisted before it is
+                // reflected.
+                preferenceService.setBlockUnknown(newValue as Boolean, TriggerSource.LOCAL)
+                true
+            }
+
+        getPref<CheckBoxPreference>(R.string.preferences__read_receipts)
+            .setOnPreferenceChangeListener { _, newValue ->
+                // Note that it is necessary that the preference is already persisted before it is
+                // reflected.
+                preferenceService.setReadReceipts(newValue as Boolean, TriggerSource.LOCAL)
+                true
+            }
+
+        getPref<CheckBoxPreference>(R.string.preferences__typing_indicator)
+            .setOnPreferenceChangeListener { _, newValue ->
+                // Note that it is necessary that the preference is already persisted before it is
+                // reflected.
+                preferenceService.setTypingIndicator(newValue as Boolean, TriggerSource.LOCAL)
+                true
+            }
+    }
+
     companion object {
         private const val DIALOG_TAG_VALIDATE = "vali"
         private const val DIALOG_TAG_SYNC_CONTACTS = "syncC"

部分文件因为文件数量过多而无法显示