diff --git a/protobuf/Database.proto b/protobuf/Database.proto new file mode 100644 index 0000000000..c50a258216 --- /dev/null +++ b/protobuf/Database.proto @@ -0,0 +1,24 @@ +/** + * Copyright (C) 2014-2016 Open Whisper Systems + * + * Licensed according to the LICENSE file in this repository. + */ + +syntax = "proto3"; + +package signal; + +option java_package = "org.thoughtcrime.securesms.database.model"; +option java_outer_classname = "DatabaseProtos"; + + +message ReactionList { + message Reaction { + string emoji = 1; + uint64 author = 2; + uint64 sentTime = 3; + uint64 receivedTime = 4; + } + + repeated Reaction reactions = 1; +} diff --git a/res/animator/reactions_scrubber_hide.xml b/res/animator/reactions_scrubber_hide.xml new file mode 100644 index 0000000000..bfaf01ea4f --- /dev/null +++ b/res/animator/reactions_scrubber_hide.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/res/animator/reactions_scrubber_reveal.xml b/res/animator/reactions_scrubber_reveal.xml new file mode 100644 index 0000000000..1b753fdaea --- /dev/null +++ b/res/animator/reactions_scrubber_reveal.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable-hdpi/ic_delete_white_24dp.webp b/res/drawable-hdpi/ic_delete_white_24dp.webp deleted file mode 100644 index a8d54c978d..0000000000 Binary files a/res/drawable-hdpi/ic_delete_white_24dp.webp and /dev/null differ diff --git a/res/drawable-hdpi/ic_forward_white_24dp.webp b/res/drawable-hdpi/ic_forward_white_24dp.webp deleted file mode 100644 index c01726477a..0000000000 Binary files a/res/drawable-hdpi/ic_forward_white_24dp.webp and /dev/null differ diff --git a/res/drawable-hdpi/ic_reply_white_24dp.webp b/res/drawable-hdpi/ic_reply_white_24dp.webp deleted file mode 100644 index 7ee6e6aae6..0000000000 Binary files a/res/drawable-hdpi/ic_reply_white_24dp.webp and /dev/null differ diff --git a/res/drawable-mdpi/ic_delete_white_24dp.webp b/res/drawable-mdpi/ic_delete_white_24dp.webp deleted file mode 100644 index 3328877c46..0000000000 Binary files a/res/drawable-mdpi/ic_delete_white_24dp.webp and /dev/null differ diff --git a/res/drawable-mdpi/ic_forward_white_24dp.webp b/res/drawable-mdpi/ic_forward_white_24dp.webp deleted file mode 100644 index 9bb2ce0e14..0000000000 Binary files a/res/drawable-mdpi/ic_forward_white_24dp.webp and /dev/null differ diff --git a/res/drawable-mdpi/ic_reply_white_24dp.webp b/res/drawable-mdpi/ic_reply_white_24dp.webp deleted file mode 100644 index ae81f91fd7..0000000000 Binary files a/res/drawable-mdpi/ic_reply_white_24dp.webp and /dev/null differ diff --git a/res/drawable-xhdpi/ic_delete_white_24dp.webp b/res/drawable-xhdpi/ic_delete_white_24dp.webp deleted file mode 100644 index 2ce7d2a685..0000000000 Binary files a/res/drawable-xhdpi/ic_delete_white_24dp.webp and /dev/null differ diff --git a/res/drawable-xhdpi/ic_forward_white_24dp.webp b/res/drawable-xhdpi/ic_forward_white_24dp.webp deleted file mode 100644 index 19adbb4cbb..0000000000 Binary files a/res/drawable-xhdpi/ic_forward_white_24dp.webp and /dev/null differ diff --git a/res/drawable-xhdpi/ic_reply_white_24dp.webp b/res/drawable-xhdpi/ic_reply_white_24dp.webp deleted file mode 100644 index 01e4f14168..0000000000 Binary files a/res/drawable-xhdpi/ic_reply_white_24dp.webp and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_delete_white_24dp.webp b/res/drawable-xxhdpi/ic_delete_white_24dp.webp deleted file mode 100644 index d167093dd5..0000000000 Binary files a/res/drawable-xxhdpi/ic_delete_white_24dp.webp and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_forward_white_24dp.webp b/res/drawable-xxhdpi/ic_forward_white_24dp.webp deleted file mode 100644 index 1fa198ef85..0000000000 Binary files a/res/drawable-xxhdpi/ic_forward_white_24dp.webp and /dev/null differ diff --git a/res/drawable-xxhdpi/ic_reply_white_24dp.webp b/res/drawable-xxhdpi/ic_reply_white_24dp.webp deleted file mode 100644 index 8b2db850f1..0000000000 Binary files a/res/drawable-xxhdpi/ic_reply_white_24dp.webp and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_delete_white_24dp.webp b/res/drawable-xxxhdpi/ic_delete_white_24dp.webp deleted file mode 100644 index f61ec1eb13..0000000000 Binary files a/res/drawable-xxxhdpi/ic_delete_white_24dp.webp and /dev/null differ diff --git a/res/drawable-xxxhdpi/ic_reply_white_24dp.webp b/res/drawable-xxxhdpi/ic_reply_white_24dp.webp deleted file mode 100644 index 4c92b9a7a1..0000000000 Binary files a/res/drawable-xxxhdpi/ic_reply_white_24dp.webp and /dev/null differ diff --git a/res/drawable/conversation_reaction_overlay_background_dark.xml b/res/drawable/conversation_reaction_overlay_background_dark.xml new file mode 100644 index 0000000000..0ff9bdd274 --- /dev/null +++ b/res/drawable/conversation_reaction_overlay_background_dark.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/res/drawable/ic_copy_outline_24.xml b/res/drawable/ic_copy_outline_24.xml new file mode 100644 index 0000000000..70a745ef6d --- /dev/null +++ b/res/drawable/ic_copy_outline_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/res/drawable/ic_copy_solid_24.xml b/res/drawable/ic_copy_solid_24.xml new file mode 100644 index 0000000000..e6edfc206e --- /dev/null +++ b/res/drawable/ic_copy_solid_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/res/drawable/ic_forward_outline.xml b/res/drawable/ic_forward_outline_24.xml similarity index 95% rename from res/drawable/ic_forward_outline.xml rename to res/drawable/ic_forward_outline_24.xml index d1463dda12..f77ad2becb 100644 --- a/res/drawable/ic_forward_outline.xml +++ b/res/drawable/ic_forward_outline_24.xml @@ -4,6 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/res/drawable/ic_forward_solid_24.xml b/res/drawable/ic_forward_solid_24.xml new file mode 100644 index 0000000000..10b92dd8e4 --- /dev/null +++ b/res/drawable/ic_forward_solid_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/res/drawable/ic_info_solid_24.xml b/res/drawable/ic_info_solid_24.xml new file mode 100644 index 0000000000..0f6ba70ab5 --- /dev/null +++ b/res/drawable/ic_info_solid_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/res/drawable/ic_reply_outline_24.xml b/res/drawable/ic_reply_outline_24.xml new file mode 100644 index 0000000000..535336fdd3 --- /dev/null +++ b/res/drawable/ic_reply_outline_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/res/drawable/ic_reply_solid_24.xml b/res/drawable/ic_reply_solid_24.xml new file mode 100644 index 0000000000..5fd2caa43b --- /dev/null +++ b/res/drawable/ic_reply_solid_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/res/drawable/ic_save_24.xml b/res/drawable/ic_save_24.xml new file mode 100644 index 0000000000..32a936e3ce --- /dev/null +++ b/res/drawable/ic_save_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/res/drawable/ic_select_24.xml b/res/drawable/ic_select_24.xml new file mode 100644 index 0000000000..8520b0ac8c --- /dev/null +++ b/res/drawable/ic_select_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/res/drawable/ic_trash_outline_24.xml b/res/drawable/ic_trash_outline_24.xml new file mode 100644 index 0000000000..f297fee28a --- /dev/null +++ b/res/drawable/ic_trash_outline_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/res/drawable/ic_trash_solid_24.xml b/res/drawable/ic_trash_solid_24.xml new file mode 100644 index 0000000000..d531058056 --- /dev/null +++ b/res/drawable/ic_trash_solid_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/res/drawable/ic_x_conversation.xml b/res/drawable/ic_x_conversation.xml new file mode 100644 index 0000000000..2b4f81a4f0 --- /dev/null +++ b/res/drawable/ic_x_conversation.xml @@ -0,0 +1,9 @@ + + + diff --git a/res/drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_dark.xml b/res/drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_dark.xml new file mode 100644 index 0000000000..5c42bb319e --- /dev/null +++ b/res/drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_dark.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/res/drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_light.xml b/res/drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_light.xml new file mode 100644 index 0000000000..e9f36f9be2 --- /dev/null +++ b/res/drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_light.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/res/drawable/reactions_old_background_dark.xml b/res/drawable/reactions_old_background_dark.xml new file mode 100644 index 0000000000..fe3c5b6ca4 --- /dev/null +++ b/res/drawable/reactions_old_background_dark.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/res/drawable/reactions_recv_background_dark.xml b/res/drawable/reactions_recv_background_dark.xml new file mode 100644 index 0000000000..2b6aa5b590 --- /dev/null +++ b/res/drawable/reactions_recv_background_dark.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/res/drawable/reactions_recv_background_light.xml b/res/drawable/reactions_recv_background_light.xml new file mode 100644 index 0000000000..bdab4b535c --- /dev/null +++ b/res/drawable/reactions_recv_background_light.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/res/drawable/reactions_send_background_dark.xml b/res/drawable/reactions_send_background_dark.xml new file mode 100644 index 0000000000..fac02c4dca --- /dev/null +++ b/res/drawable/reactions_send_background_dark.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/res/drawable/reactions_send_background_light.xml b/res/drawable/reactions_send_background_light.xml new file mode 100644 index 0000000000..8a6891ecb0 --- /dev/null +++ b/res/drawable/reactions_send_background_light.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/res/layout/conversation_activity.xml b/res/layout/conversation_activity.xml index a9064a4be6..db3c719e76 100644 --- a/res/layout/conversation_activity.xml +++ b/res/layout/conversation_activity.xml @@ -142,4 +142,5 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + diff --git a/res/layout/conversation_fragment.xml b/res/layout/conversation_fragment.xml index ee229b09f7..25fcbb6178 100644 --- a/res/layout/conversation_fragment.xml +++ b/res/layout/conversation_fragment.xml @@ -55,5 +55,4 @@ android:alpha="0.9" android:contentDescription="@string/conversation_fragment__scroll_to_the_bottom_content_description" android:src="@drawable/ic_scroll_down"/> - diff --git a/res/layout/conversation_item_received.xml b/res/layout/conversation_item_received.xml index 1d5cb37021..25c207992b 100644 --- a/res/layout/conversation_item_received.xml +++ b/res/layout/conversation_item_received.xml @@ -27,7 +27,7 @@ android:id="@+id/reply_icon" android:layout_width="@dimen/conversation_item_reply_size" android:layout_height="@dimen/conversation_item_reply_size" - android:src="@drawable/ic_reply_white_24dp" + app:srcCompat="?menu_reply_icon" android:tint="?compose_icon_tint" android:alpha="0" android:layout_alignTop="@id/body_bubble" @@ -231,5 +231,41 @@ android:orientation="vertical" android:gravity="center_vertical"/> + + + + + + + + diff --git a/res/layout/conversation_item_sent.xml b/res/layout/conversation_item_sent.xml index 3a7d161349..0467136a6e 100644 --- a/res/layout/conversation_item_sent.xml +++ b/res/layout/conversation_item_sent.xml @@ -1,71 +1,70 @@ - + android:orientation="horizontal"> + android:clipChildren="false" + android:clipToPadding="false"> + android:alpha="0" + app:srcCompat="?menu_reply_icon" + android:tint="?compose_icon_tint" /> + tools:visibility="visible" /> + android:layout="@layout/conversation_item_sent_shared_contact" + android:visibility="gone" /> + android:layout_marginTop="@dimen/message_bubble_top_padding" + android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" + android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding" + android:layout="@layout/conversation_item_sent_audio" /> + android:layout_marginTop="@dimen/message_bubble_top_padding" + android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" + android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding" + android:layout="@layout/conversation_item_sent_document" /> + android:layout_marginTop="@dimen/message_bubble_top_padding" + android:layout_marginEnd="@dimen/message_bubble_horizontal_padding" + android:layout_marginBottom="@dimen/message_bubble_collapsed_footer_padding" + android:layout="@layout/conversation_item_sent_revealable" /> + app:scaleEmojis="true" + tools:text="Mango pickle lorem ipsum" /> + android:visibility="gone" /> + android:gravity="end" + app:footer_icon_color="?attr/conversation_item_sent_icon_color" + app:footer_text_color="?attr/conversation_item_sent_text_secondary_color" /> + app:footer_icon_color="?conversation_sticker_footer_icon_color" + app:footer_text_color="?conversation_sticker_footer_text_color" /> @@ -183,11 +182,47 @@ android:id="@+id/indicators_parent" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:orientation="vertical" - android:paddingBottom="4dp" - android:layout_marginStart="8dp" android:layout_alignParentEnd="true" - android:layout_alignParentBottom="true" /> + android:layout_alignParentBottom="true" + android:layout_marginStart="8dp" + android:orientation="vertical" + android:paddingBottom="4dp" /> + + + + + + + + diff --git a/res/layout/conversation_reaction_long_press_toolbar.xml b/res/layout/conversation_reaction_long_press_toolbar.xml new file mode 100644 index 0000000000..fdcfa492cc --- /dev/null +++ b/res/layout/conversation_reaction_long_press_toolbar.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/res/layout/conversation_reaction_scrubber.xml b/res/layout/conversation_reaction_scrubber.xml new file mode 100644 index 0000000000..c51790b39c --- /dev/null +++ b/res/layout/conversation_reaction_scrubber.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/reactions_bottom_sheet_dialog_fragment.xml b/res/layout/reactions_bottom_sheet_dialog_fragment.xml new file mode 100644 index 0000000000..b7d3343d8f --- /dev/null +++ b/res/layout/reactions_bottom_sheet_dialog_fragment.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/res/layout/reactions_bottom_sheet_dialog_fragment_emoji_item.xml b/res/layout/reactions_bottom_sheet_dialog_fragment_emoji_item.xml new file mode 100644 index 0000000000..31f83dea5b --- /dev/null +++ b/res/layout/reactions_bottom_sheet_dialog_fragment_emoji_item.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml b/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml new file mode 100644 index 0000000000..dbe24e59f1 --- /dev/null +++ b/res/layout/reactions_bottom_sheet_dialog_fragment_recipient_item.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/res/layout/sticker_management_sticker_item.xml b/res/layout/sticker_management_sticker_item.xml index 0770de7783..53b4657688 100644 --- a/res/layout/sticker_management_sticker_item.xml +++ b/res/layout/sticker_management_sticker_item.xml @@ -88,7 +88,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:tint="?sticker_management_action_button_color" - app:srcCompat="@drawable/ic_forward_outline" + app:srcCompat="@drawable/ic_forward_outline_24" app:layout_constraintTop_toTopOf="@id/sticker_management_share_button" app:layout_constraintBottom_toBottomOf="@id/sticker_management_share_button" app:layout_constraintStart_toStartOf="@id/sticker_management_share_button" diff --git a/res/layout/sticker_preview_activity.xml b/res/layout/sticker_preview_activity.xml index ea6768e960..fab1b5f403 100644 --- a/res/layout/sticker_preview_activity.xml +++ b/res/layout/sticker_preview_activity.xml @@ -71,7 +71,7 @@ android:layout_height="wrap_content" android:tint="@color/core_grey_90" android:visibility="gone" - app:srcCompat="@drawable/ic_forward_outline" + app:srcCompat="@drawable/ic_forward_outline_24" app:layout_constraintTop_toTopOf="@id/sticker_install_share_button" app:layout_constraintBottom_toBottomOf="@id/sticker_install_share_button" app:layout_constraintStart_toStartOf="@id/sticker_install_share_button" diff --git a/res/menu/conversation_context.xml b/res/menu/conversation_context.xml index f0d23d6ee8..8803997884 100644 --- a/res/menu/conversation_context.xml +++ b/res/menu/conversation_context.xml @@ -15,10 +15,11 @@ android:icon="?menu_copy_icon" app:showAsAction="always" /> - + - + diff --git a/res/menu/conversation_reactions_long_press_menu.xml b/res/menu/conversation_reactions_long_press_menu.xml new file mode 100644 index 0000000000..5da3518829 --- /dev/null +++ b/res/menu/conversation_reactions_long_press_menu.xml @@ -0,0 +1,40 @@ + + + + + + + + + + \ No newline at end of file diff --git a/res/menu/media_overview_context.xml b/res/menu/media_overview_context.xml index 264af23279..464499861f 100644 --- a/res/menu/media_overview_context.xml +++ b/res/menu/media_overview_context.xml @@ -7,7 +7,7 @@ diff --git a/res/values/attrs.xml b/res/values/attrs.xml index e3eaee6aa6..ab012af1ea 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -92,6 +92,12 @@ + + + + + + @@ -172,6 +178,7 @@ + diff --git a/res/values/colors.xml b/res/values/colors.xml index 2dc4a227da..85651e8e2a 100644 --- a/res/values/colors.xml +++ b/res/values/colors.xml @@ -16,8 +16,10 @@ #33000000 #66000000 #99000000 + #CC000000 #33ffffff + #4Dffffff #99ffffff #ccffffff #e6ffffff diff --git a/res/values/dimens.xml b/res/values/dimens.xml index 4b9bc7ae11..ab7013380d 100644 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -46,6 +46,12 @@ 320dp 128dp + -37dp + 32dp + 16dp + 28dp + 60dp + 175dp 85dp 130dp @@ -64,6 +70,11 @@ 8dp 1dp + 28dp + + 25dp + 0dp + 20dp 28dp @@ -129,4 +140,12 @@ @dimen/selection_item_header_height + 136dp + 40dp + 60dp + 136dp + 40dp + 20dp + 16dp + diff --git a/res/values/emoji.xml b/res/values/emoji.xml index 045e125f3d..ac17853d0b 100644 --- a/res/values/emoji.xml +++ b/res/values/emoji.xml @@ -1,3 +1,10 @@ + \u2764\ufe0f + \ud83d\udc4d + \ud83d\udc4e + \ud83d\ude02 + \ud83d\ude2e + \ud83d\ude22 + \ud83d\ude21 diff --git a/res/values/integers.xml b/res/values/integers.xml index bb4c483c99..adb103844f 100644 --- a/res/values/integers.xml +++ b/res/values/integers.xml @@ -1,4 +1,7 @@ 300 + 400 + 380 + 10 \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index 306e474964..a049335284 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -894,6 +894,7 @@ Open Signal to check for recent notifications. %1$s %2$s Contact + Reacted to your message: %1$s Default @@ -1467,6 +1468,9 @@ Resend message Reply to message + + Select multiple + Save attachment diff --git a/res/values/styles.xml b/res/values/styles.xml index 689464f33f..608d74ff3b 100644 --- a/res/values/styles.xml +++ b/res/values/styles.xml @@ -397,4 +397,8 @@ + + + + diff --git a/res/values/themes.xml b/res/values/themes.xml index 4e0e00af87..7764035897 100644 --- a/res/values/themes.xml +++ b/res/values/themes.xml @@ -162,6 +162,8 @@ @color/core_grey_90 @color/core_grey_60 + @style/Theme.Design.Light.BottomSheetDialog + @style/TextSecure.LightActionBar @style/TextSecure.LightActionBar.TabBar @color/core_grey_50 @@ -257,6 +259,12 @@ @drawable/ic_emoji_emoticon_light_20 @drawable/emoji_variation_selector_background_light + @drawable/reactions_recv_background_light + @drawable/reactions_send_background_light + @drawable/reactions_old_background_dark + @drawable/conversation_reaction_overlay_background_dark + @drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_light + @color/core_grey_05 @color/core_grey_90 @color/core_grey_60 @@ -307,16 +315,17 @@ @drawable/ic_unlocked_white_24dp @drawable/ic_lock_white_24dp @drawable/ic_lock_white_18dp - @drawable/ic_delete_white_24dp + @drawable/ic_trash_outline_24 @drawable/ic_select_all_white_24dp @drawable/ic_call_split_white_24dp @drawable/ic_check_24 @drawable/ic_refresh_white_24dp - @drawable/ic_content_copy_white_24dp + @drawable/ic_copy_outline_24 @drawable/ic_info_outline_white_24dp - @drawable/ic_forward_white_24dp - @drawable/ic_download_filled_white_24 - @drawable/ic_reply_white_24dp + @drawable/ic_forward_outline_24 + @drawable/ic_save_24 + @drawable/ic_reply_outline_24 + @drawable/ic_select_24 @drawable/ic_message_outline_tinted_24 @drawable/ic_bell_outline_24 @@ -392,6 +401,8 @@ @color/core_grey_05 @color/core_grey_25 + @style/Theme.Design.BottomSheetDialog + @style/TextSecure.DarkActionBar @style/TextSecure.DarkActionBar.TabBar @style/ThemeOverlay.AppCompat.Dark @@ -500,6 +511,13 @@ @color/transparent_white_90 @color/transparent_white_80 + @drawable/reactions_recv_background_dark + @drawable/reactions_send_background_dark + @drawable/reactions_old_background_dark + @drawable/conversation_reaction_overlay_background_dark + @drawable/reactions_bottom_sheet_dialog_fragment_emoji_item_selected_dark + + @color/core_grey_85 @color/core_grey_65 @color/core_grey_75 @@ -537,16 +555,17 @@ @drawable/ic_unlocked_white_24dp @drawable/ic_lock_white_24dp @drawable/ic_lock_white_18dp - @drawable/ic_delete_white_24dp + @drawable/ic_trash_solid_24 @drawable/ic_select_all_white_24dp @drawable/ic_call_split_white_24dp @drawable/ic_check_24 @drawable/ic_refresh_white_24dp - @drawable/ic_content_copy_white_24dp - @drawable/ic_info_outline_white_24dp - @drawable/ic_forward_white_24dp - @drawable/ic_download_filled_white_24 - @drawable/ic_reply_white_24dp + @drawable/ic_copy_solid_24 + @drawable/ic_info_solid_24 + @drawable/ic_forward_solid_24 + @drawable/ic_save_24 + @drawable/ic_reply_solid_24 + @drawable/ic_select_24 @drawable/ic_message_solid_tinted_24 @drawable/ic_bell_solid_24 diff --git a/src/org/thoughtcrime/securesms/BindableConversationItem.java b/src/org/thoughtcrime/securesms/BindableConversationItem.java index c602e5427a..f820c652e8 100644 --- a/src/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/src/org/thoughtcrime/securesms/BindableConversationItem.java @@ -7,6 +7,7 @@ import android.view.View; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; @@ -43,5 +44,6 @@ public interface BindableConversationItem extends Unbindable { void onAddToContactsClicked(@NonNull Contact contact); void onMessageSharedContactClicked(@NonNull List choices); void onInviteSharedContactClicked(@NonNull List choices); + void onReactionClicked(long messageId, boolean isMms); } } diff --git a/src/org/thoughtcrime/securesms/components/MaskView.java b/src/org/thoughtcrime/securesms/components/MaskView.java new file mode 100644 index 0000000000..a2b38040b9 --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/MaskView.java @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewTreeObserver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.util.ViewUtil; + +public class MaskView extends View { + + private View target; + private int[] targetLocation = new int[2]; + private int statusBarHeight; + private Paint maskPaint; + + private final ViewTreeObserver.OnDrawListener onDrawListener = this::invalidate; + + public MaskView(@NonNull Context context) { + super(context); + } + + public MaskView(@NonNull Context context, @Nullable AttributeSet attributeSet) { + super(context, attributeSet); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + maskPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + setLayerType(LAYER_TYPE_HARDWARE, maskPaint); + + maskPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); + + statusBarHeight = ViewUtil.getStatusBarHeight(this); + } + + public void setTarget(@Nullable View target) { + if (this.target != null) { + this.target.getViewTreeObserver().removeOnDrawListener(onDrawListener); + } + + this.target = target; + + if (this.target != null) { + this.target.getViewTreeObserver().addOnDrawListener(onDrawListener); + } + + invalidate(); + } + + @Override + protected void onDraw(@NonNull Canvas canvas) { + super.onDraw(canvas); + + if (target == null) { + return; + } + + target.getLocationInWindow(targetLocation); + + Bitmap mask = Bitmap.createBitmap(target.getWidth(), target.getHeight(), Bitmap.Config.ARGB_8888); + Canvas maskCanvas = new Canvas(mask); + + target.draw(maskCanvas); + + canvas.drawBitmap(mask, 0, targetLocation[1] - statusBarHeight, maskPaint); + + mask.recycle(); + } +} diff --git a/src/org/thoughtcrime/securesms/components/MaxHeightFrameLayout.java b/src/org/thoughtcrime/securesms/components/MaxHeightFrameLayout.java new file mode 100644 index 0000000000..7928ad6667 --- /dev/null +++ b/src/org/thoughtcrime/securesms/components/MaxHeightFrameLayout.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; + +import org.thoughtcrime.securesms.R; + +public class MaxHeightFrameLayout extends FrameLayout { + + private final int maxHeight; + + public MaxHeightFrameLayout(@NonNull Context context) { + this(context, null); + } + + public MaxHeightFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public MaxHeightFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + if (attrs != null) { + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MaxHeightFrameLayout); + + maxHeight = a.getDimensionPixelSize(R.styleable.MaxHeightFrameLayout_mhfl_maxHeight, 0); + + a.recycle(); + } else { + maxHeight = 0; + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, Math.min(bottom, top + maxHeight)); + } +} diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java index be0d6de73f..1a86c134ab 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -49,6 +49,7 @@ import android.view.KeyEvent; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; +import android.view.MotionEvent; import android.view.View; import android.view.View.OnClickListener; import android.view.View.OnFocusChangeListener; @@ -145,6 +146,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.identity.IdentityRecordList; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.StickerRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.ReminderUpdateEvent; @@ -311,6 +313,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private ConversationSearchBottomBar searchNav; private MenuItem searchViewItem; private FrameLayout messageRequestOverlay; + private ConversationReactionOverlay reactionOverlay; private AttachmentTypeSelector attachmentTypeSelector; private AttachmentManager attachmentManager; @@ -524,6 +527,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity super.onDestroy(); } + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + return reactionOverlay.applyTouchEvent(ev) || super.dispatchTouchEvent(ev); + } + @Override public void onActivityResult(final int reqCode, int resultCode, Intent data) { Log.i(TAG, "onActivityResult called: " + reqCode + ", " + resultCode + " , " + data); @@ -1581,6 +1589,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity inputPanel = ViewUtil.findById(this, R.id.bottom_panel); searchNav = ViewUtil.findById(this, R.id.conversation_search_nav); messageRequestOverlay = ViewUtil.findById(this, R.id.fragment_overlay_container); + reactionOverlay = ViewUtil.findById(this, R.id.conversation_reaction_scrubber); ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle); ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button); @@ -1636,6 +1645,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity searchNav.setEventListener(this); inlineAttachmentButton.setOnClickListener(v -> handleAddAttachment()); + + reactionOverlay.setOnReactionSelectedListener(this::onReactionSelected); } protected void initializeActionBar() { @@ -1748,6 +1759,24 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity .show(TooltipPopup.POSITION_ABOVE); } + + private void onReactionSelected(MessageRecord messageRecord, String emoji) { + final Context context = getApplicationContext(); + + SignalExecutors.BOUNDED.execute(() -> { + ReactionRecord oldRecord = Stream.of(messageRecord.getReactions()) + .filter(record -> record.getAuthor().equals(Recipient.self().getId())) + .findFirst() + .orElse(null); + + if (oldRecord != null && oldRecord.getEmoji().equals(emoji)) { + MessageSender.sendReactionRemoval(context, messageRecord.getId(), messageRecord.isMms(), oldRecord); + } else { + MessageSender.sendNewReaction(context, messageRecord.getId(), messageRecord.isMms(), emoji); + } + }); + } + @Override public void onSearchMoveUpPressed() { searchViewModel.onMoveUp(); @@ -2753,6 +2782,33 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity }); } + public void handleReaction(@NonNull View maskTarget, + @NonNull MessageRecord messageRecord, + @NonNull Toolbar.OnMenuItemClickListener toolbarListener, + @NonNull ConversationReactionOverlay.OnHideListener onHideListener) + { + reactionOverlay.setOnToolbarItemClickedListener(toolbarListener); + reactionOverlay.setOnHideListener(onHideListener); + reactionOverlay.show(this, maskTarget, messageRecord); + } + + @Override + public void onCursorChanged() { + if (!reactionOverlay.isShowing()) { + return; + } + + SimpleTask.run(() -> { + //noinspection CodeBlock2Expr + return DatabaseFactory.getMmsSmsDatabase(this) + .checkMessageExists(reactionOverlay.getMessageRecord()); + }, messageExists -> { + if (!messageExists) { + reactionOverlay.hide(); + } + }); + } + @Override public void setThreadId(long threadId) { this.threadId = threadId; diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/src/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index b3594b14ec..ddd095883e 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -139,7 +139,7 @@ public class ConversationAdapter interface ItemClickListener extends BindableConversationItem.EventListener { void onItemClick(MessageRecord item); - void onItemLongClick(MessageRecord item); + void onItemLongClick(View maskTarget, MessageRecord item); } @SuppressWarnings("ConstantConditions") @@ -224,7 +224,7 @@ public class ConversationAdapter }); itemView.setOnLongClickListener(view -> { if (clickListener != null) { - clickListener.onItemLongClick(itemView.getMessageRecord()); + clickListener.onItemLongClick(itemView, itemView.getMessageRecord()); } return true; }); diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 8ddf6cec1f..d7746202ef 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -26,19 +26,6 @@ import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.ActivityCompat; -import androidx.core.app.ActivityOptionsCompat; -import androidx.fragment.app.Fragment; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.Loader; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.view.ActionMode; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.OnScrollListener; import android.text.ClipboardManager; import android.text.TextUtils; import android.view.LayoutInflater; @@ -54,7 +41,23 @@ import android.widget.TextView; import android.widget.Toast; import android.widget.ViewSwitcher; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.ActivityCompat; +import androidx.core.app.ActivityOptionsCompat; +import androidx.fragment.app.Fragment; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.OnScrollListener; + import com.annimon.stream.Stream; +import com.google.android.collect.Sets; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.MessageDetailsActivity; @@ -91,10 +94,10 @@ import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.profiles.UnknownSenderView; import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment; import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity; import org.thoughtcrime.securesms.revealable.ViewOnceUtil; import org.thoughtcrime.securesms.sms.MessageSender; @@ -146,6 +149,7 @@ public class ConversationFragment extends Fragment private int previousOffset; private int activeOffset; private boolean firstLoad; + private boolean isReacting; private ActionMode actionMode; private Locale locale; private RecyclerView list; @@ -326,7 +330,9 @@ public class ConversationFragment extends Fragment if (!isTypingIndicatorShowing() && isAtBottom()) { Context context = requireContext(); list.setVerticalScrollBarEnabled(false); - list.post(() -> getListLayoutManager().smoothScrollToPosition(context, 0, 250)); + list.post(() -> { if (!isReacting) { + getListLayoutManager().smoothScrollToPosition(context, 0, 250); + }}); list.postDelayed(() -> list.setVerticalScrollBarEnabled(true), 300); adapter.setHeaderView(typingView); adapter.notifyItemInserted(0); @@ -341,7 +347,7 @@ public class ConversationFragment extends Fragment } } else { if (getListLayoutManager().findFirstCompletelyVisibleItemPosition() == 0 && getListLayoutManager().getItemCount() > 1 && !replacedByIncomingMessage) { - getListLayoutManager().smoothScrollToPosition(requireContext(), 1, 250); + if (!isReacting) getListLayoutManager().smoothScrollToPosition(requireContext(), 1, 250); list.setVerticalScrollBarEnabled(false); list.postDelayed(() -> { adapter.setHeaderView(null); @@ -707,8 +713,8 @@ public class ConversationFragment extends Fragment } activeOffset = loader.getOffset(); - adapter.changeCursor(cursor); + listener.onCursorChanged(); int lastSeenPosition = adapter.findLastSeenPosition(lastSeen); @@ -749,6 +755,7 @@ public class ConversationFragment extends Fragment public void onLoaderReset(@NonNull Loader arg0) { if (list.getAdapter() != null) { getListAdapter().changeCursor(null); + listener.onCursorChanged(); } } @@ -866,6 +873,11 @@ public class ConversationFragment extends Fragment void onMessageActionToolbarOpened(); void onForwardClicked(); void onMessageRequest(); + void handleReaction(@NonNull View maskTarget, + @NonNull MessageRecord messageRecord, + @NonNull Toolbar.OnMenuItemClickListener toolbarListener, + @NonNull ConversationReactionOverlay.OnHideListener onHideListener); + void onCursorChanged(); } private class ConversationScrollListener extends OnScrollListener { @@ -955,8 +967,21 @@ public class ConversationFragment extends Fragment } @Override - public void onItemLongClick(MessageRecord messageRecord) { - if (actionMode == null) { + public void onItemLongClick(View maskTarget, MessageRecord messageRecord) { + + if (actionMode != null) return; + + if (FeatureFlags.REACTION_SENDING && + messageRecord.isSecure() && + ((ConversationAdapter) list.getAdapter()).getSelectedItems().isEmpty()) + { + isReacting = true; + list.setLayoutFrozen(true); + listener.handleReaction(maskTarget, messageRecord, new ReactionsToolbarListener(messageRecord), () -> { + isReacting = false; + list.setLayoutFrozen(false); + }); + } else { ((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord); list.getAdapter().notifyDataSetChanged(); @@ -1094,6 +1119,13 @@ public class ConversationFragment extends Fragment CommunicationActions.composeSmsThroughDefaultApp(getContext(), recipient, getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url))); }); } + + @Override + public void onReactionClicked(long messageId, boolean isMms) { + if (getContext() == null) return; + + ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(requireFragmentManager(), null); + } } @Override @@ -1105,6 +1137,36 @@ public class ConversationFragment extends Fragment } } + private void handleEnterMultiSelect(@NonNull MessageRecord messageRecord) { + ((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord); + list.getAdapter().notifyDataSetChanged(); + + actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback); + } + + private class ReactionsToolbarListener implements Toolbar.OnMenuItemClickListener { + + private final MessageRecord messageRecord; + + private ReactionsToolbarListener(@NonNull MessageRecord messageRecord) { + this.messageRecord = messageRecord; + } + + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case R.id.action_info: handleDisplayDetails(messageRecord); return true; + case R.id.action_delete: handleDeleteMessages(Sets.newHashSet(messageRecord)); return true; + case R.id.action_copy: handleCopyMessage(Sets.newHashSet(messageRecord)); return true; + case R.id.action_reply: handleReplyMessage(messageRecord); return true; + case R.id.action_multiselect: handleEnterMultiSelect(messageRecord); return true; + case R.id.action_forward: handleForwardMessage(messageRecord); return true; + case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) messageRecord); return true; + default: return false; + } + } + } + private class ActionModeCallback implements ActionMode.Callback { private int statusBarColor; diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java index 6a73382506..7af24e4461 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -26,11 +26,6 @@ import android.graphics.PorterDuff; import android.graphics.Rect; import android.graphics.Typeface; import android.net.Uri; -import androidx.annotation.DimenRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; - import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; @@ -45,15 +40,21 @@ import android.text.style.URLSpan; import android.text.util.Linkify; import android.util.AttributeSet; import android.util.TypedValue; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.DimenRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.view.ViewCompat; + import com.annimon.stream.Stream; -import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.BindableConversationItem; import org.thoughtcrime.securesms.ConfirmIdentityDialog; import org.thoughtcrime.securesms.MediaPreviewActivity; @@ -159,6 +160,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati private AvatarImageView contactPhoto; private AlertView alertView; private ViewGroup container; + protected ViewGroup reactionsContainer; private @NonNull Set batchSelected = new HashSet<>(); private @NonNull Outliner outliner = new Outliner(); @@ -169,8 +171,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati private Stub sharedContactStub; private Stub linkPreviewStub; private Stub stickerStub; - private Stub revealableStub; + private Stub revealableStub; private @Nullable EventListener eventListener; + private ConversationItemReactionBubbles conversationItemReactionBubbles; private int defaultBubbleColor; private int measureCalls; @@ -225,6 +228,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati this.quoteView = findViewById(R.id.quote_view); this.container = findViewById(R.id.container); this.reply = findViewById(R.id.reply_icon); + this.reactionsContainer = findViewById(R.id.reactions_bubbles_container); + + this.conversationItemReactionBubbles = new ConversationItemReactionBubbles(this.reactionsContainer); setOnClickListener(new ClickListener(null)); @@ -274,6 +280,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati setAuthor(messageRecord, previousMessageRecord, nextMessageRecord, groupThread); setQuote(messageRecord, previousMessageRecord, nextMessageRecord, groupThread); setMessageSpacing(context, messageRecord, previousMessageRecord, nextMessageRecord, groupThread); + setReactions(messageRecord); setFooter(messageRecord, nextMessageRecord, locale, groupThread); } @@ -369,7 +376,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati @Override public void unbind() { if (recipient != null) { - recipient.removeForeverObserver(this);; + recipient.removeForeverObserver(this); } } @@ -900,6 +907,15 @@ public class ConversationItem extends LinearLayout implements BindableConversati } } + private void setReactions(@NonNull MessageRecord current) { + conversationItemReactionBubbles.setReactions(current.getReactions()); + reactionsContainer.setOnClickListener(v -> { + if (eventListener == null) return; + + eventListener.onReactionClicked(current.getId(), current.isMms()); + }); + } + private void setFooter(@NonNull MessageRecord current, @NonNull Optional next, @NonNull Locale locale, boolean isGroupThread) { ViewUtil.updateLayoutParams(footer, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItemReactionBubbles.java b/src/org/thoughtcrime/securesms/conversation/ConversationItemReactionBubbles.java new file mode 100644 index 0000000000..f5d7041144 --- /dev/null +++ b/src/org/thoughtcrime/securesms/conversation/ConversationItemReactionBubbles.java @@ -0,0 +1,178 @@ +package org.thoughtcrime.securesms.conversation; + +import android.graphics.drawable.Drawable; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Pair; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiTextView; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.ThemeUtil; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +final class ConversationItemReactionBubbles { + + private final ViewGroup reactionsContainer; + private final EmojiTextView primaryEmojiReaction; + private final EmojiTextView secondaryEmojiReaction; + + ConversationItemReactionBubbles(@NonNull ViewGroup reactionsContainer) { + this.reactionsContainer = reactionsContainer; + this.primaryEmojiReaction = reactionsContainer.findViewById(R.id.reactions_bubbles_primary); + this.secondaryEmojiReaction = reactionsContainer.findViewById(R.id.reactions_bubbles_secondary); + } + + void setReactions(@NonNull List reactions) { + if (reactions.size() == 0) { + hideAllReactions(); + return; + } + + final Collection reactionInfos = getReactionInfos(reactions); + + if (reactionInfos.size() == 1) { + displaySingleReaction(reactionInfos.iterator().next()); + } else { + displayMultipleReactions(reactionInfos); + } + } + + private static @NonNull Collection getReactionInfos(@NonNull List reactions) { + final Map counters = new HashMap<>(); + + for (ReactionRecord reaction : reactions) { + + ReactionInfo info = counters.get(reaction.getEmoji()); + if (info == null) { + info = new ReactionInfo(reaction.getEmoji(), + 1, + reaction.getDateReceived(), + Recipient.self().getId().equals(reaction.getAuthor())); + } else { + info = new ReactionInfo(reaction.getEmoji(), + info.count + 1, + Math.max(info.lastSeen, reaction.getDateReceived()), + info.userWasSender || Recipient.self().getId().equals(reaction.getAuthor())); + } + + counters.put(reaction.getEmoji(), info); + } + + return counters.values(); + } + + private void hideAllReactions() { + reactionsContainer.setVisibility(View.GONE); + } + + private void displaySingleReaction(@NonNull ReactionInfo reactionInfo) { + reactionsContainer.setVisibility(View.VISIBLE); + primaryEmojiReaction.setVisibility(View.VISIBLE); + secondaryEmojiReaction.setVisibility(View.GONE); + + primaryEmojiReaction.setText(reactionInfo.emoji); + primaryEmojiReaction.setBackground(getBackgroundDrawableForReactionBubble(reactionInfo)); + } + + private void displayMultipleReactions(@NonNull Collection reactionInfos) { + reactionsContainer.setVisibility(View.VISIBLE); + primaryEmojiReaction.setVisibility(View.VISIBLE); + secondaryEmojiReaction.setVisibility(View.VISIBLE); + + Pair primaryAndSecondaryReactions = getPrimaryAndSecondaryReactions(reactionInfos); + primaryEmojiReaction.setText(primaryAndSecondaryReactions.first.emoji); + primaryEmojiReaction.setBackground(getBackgroundDrawableForReactionBubble(primaryAndSecondaryReactions.first)); + secondaryEmojiReaction.setText(primaryAndSecondaryReactions.second.emoji); + secondaryEmojiReaction.setBackground(getBackgroundDrawableForReactionBubble(primaryAndSecondaryReactions.second)); + } + + private Drawable getBackgroundDrawableForReactionBubble(@NonNull ReactionInfo reactionInfo) { + return ThemeUtil.getThemedDrawable(reactionsContainer.getContext(), + reactionInfo.userWasSender ? R.attr.reactions_sent_background : R.attr.reactions_recv_background); + } + + private Pair getPrimaryAndSecondaryReactions(@NonNull Collection reactionInfos) { + ReactionInfo mostPopular = null; + ReactionInfo latestReaction = null; + ReactionInfo secondLatestReaction = null; + ReactionInfo ourReaction = null; + + for (ReactionInfo current : reactionInfos) { + if (current.userWasSender) { + ourReaction = current; + } + + if (mostPopular == null) { + mostPopular = current; + } else if (mostPopular.count < current.count) { + mostPopular = current; + } + + if (latestReaction == null) { + latestReaction = current; + } else if (latestReaction.lastSeen < current.lastSeen) { + + if (current.count == mostPopular.count) { + mostPopular = current; + } + + secondLatestReaction = latestReaction; + latestReaction = current; + } else if (secondLatestReaction == null) { + secondLatestReaction = current; + } + } + + if (mostPopular == null) { + throw new AssertionError("getPrimaryAndSecondaryReactions was called with an empty list."); + } + + if (ourReaction != null && !mostPopular.equals(ourReaction)) { + return Pair.create(mostPopular, ourReaction); + } else { + return Pair.create(mostPopular, mostPopular.equals(latestReaction) ? secondLatestReaction : latestReaction); + } + } + + private static class ReactionInfo { + private final String emoji; + private final int count; + private final long lastSeen; + private final boolean userWasSender; + + private ReactionInfo(@NonNull String emoji, int count, long lastSeen, boolean userWasSender) { + this.emoji = emoji; + this.count = count; + this.lastSeen = lastSeen; + this.userWasSender = userWasSender; + } + + @Override + public int hashCode() { + return Objects.hash(emoji, count, lastSeen, userWasSender); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof ReactionInfo)) return false; + + ReactionInfo other = (ReactionInfo) obj; + + return other.emoji.equals(emoji) && + other.count == count && + other.lastSeen == lastSeen && + other.userWasSender == userWasSender; + } + } +} diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/src/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java new file mode 100644 index 0000000000..504e7ceb6b --- /dev/null +++ b/src/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -0,0 +1,551 @@ +package org.thoughtcrime.securesms.conversation; + +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.app.Activity; +import android.content.Context; +import android.graphics.PointF; +import android.graphics.Rect; +import android.os.Build; +import android.util.AttributeSet; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; +import android.widget.RelativeLayout; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.ConstraintSet; +import androidx.core.content.ContextCompat; +import androidx.core.util.Preconditions; +import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; +import org.thoughtcrime.securesms.components.MaskView; +import org.thoughtcrime.securesms.components.emoji.EmojiTextView; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.MessageRecordUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.ViewUtil; + +import java.util.List; + +public final class ConversationReactionOverlay extends RelativeLayout { + + private static final Interpolator INTERPOLATOR = new DecelerateInterpolator(); + + private final Rect emojiViewGlobalRect = new Rect(); + private final Rect emojiStripViewBounds = new Rect(); + private float segmentSize; + + private final Boundary horizontalEmojiBoundary = new Boundary(); + private final Boundary verticalScrubBoundary = new Boundary(); + private final PointF deadzoneTouchPoint = new PointF(); + private final PointF lastSeenDownPoint = new PointF(); + + private Activity activity; + private MessageRecord messageRecord; + private OverlayState overlayState = OverlayState.HIDDEN; + + private boolean downIsOurs; + private boolean isToolbarTouch; + private int selected = -1; + private int originalStatusBarColor; + + private View backgroundView; + private ConstraintLayout foregroundView; + private View selectedView; + private View[] emojiViews; + private MaskView maskView; + private Toolbar toolbar; + + private int statusBarHeight; + private float touchDownDeadZoneSize; + private float distanceFromTouchDownPointToTopOfScrubberDeadZone; + private float distanceFromTouchDownPointToBottomOfScrubberDeadZone; + private int scrubberDistanceFromTouchDown; + private int scrubberHeight; + private int halfActionBarHeight; + private int selectedVerticalTranslation; + private int scrubberHorizontalMargin; + private int animationEmojiStartDelayFactor; + + private OnReactionSelectedListener onReactionSelectedListener; + private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener; + private OnHideListener onHideListener; + + private AnimatorSet revealAnimatorSet = new AnimatorSet(); + private AnimatorSet hideAnimatorSet = new AnimatorSet(); + + public ConversationReactionOverlay(@NonNull Context context) { + super(context); + } + + public ConversationReactionOverlay(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + backgroundView = findViewById(R.id.conversation_reaction_scrubber_background); + foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground); + selectedView = findViewById(R.id.conversation_reaction_current_selection_indicator); + maskView = findViewById(R.id.conversation_reaction_mask); + toolbar = findViewById(R.id.conversation_reaction_toolbar); + + toolbar.setOnMenuItemClickListener(this::handleToolbarItemClicked); + toolbar.setNavigationOnClickListener(view -> hide()); + + emojiViews = Stream.of(ReactionEmoji.values()) + .map(e -> { + EmojiTextView view = findViewById(e.viewId); + view.setText(e.emoji); + return view; + }).toArray(View[]::new); + + distanceFromTouchDownPointToTopOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_top); + distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom); + + touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size); + statusBarHeight = ViewUtil.getStatusBarHeight(this); + scrubberDistanceFromTouchDown = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_distance); + scrubberHeight = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_height); + halfActionBarHeight = (int) ThemeUtil.getThemedDimen(getContext(), R.attr.actionBarSize) / 2; + selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation); + scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin); + + animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor); + + initAnimators(); + } + + public void show(@NonNull Activity activity, @NonNull View maskTarget, @NonNull MessageRecord messageRecord) { + + if (overlayState != OverlayState.HIDDEN) { + return; + } + + this.messageRecord = messageRecord; + overlayState = OverlayState.UNINITAILIZED; + selected = -1; + + setupToolbarMenuItems(); + setupSelectedEmojiBackground(); + + final float scrubberTranslationY = Math.max(-scrubberDistanceFromTouchDown + halfActionBarHeight, + lastSeenDownPoint.y - scrubberHeight - scrubberDistanceFromTouchDown); + + final float halfWidth = foregroundView.getWidth() / 2f + scrubberHorizontalMargin; + final float screenWidth = getResources().getDisplayMetrics().widthPixels; + final float scrubberTranslationX = Util.clamp(lastSeenDownPoint.x - halfWidth, + scrubberHorizontalMargin, + screenWidth + scrubberHorizontalMargin - halfWidth * 2); + + backgroundView.setTranslationX(scrubberTranslationX); + backgroundView.setTranslationY(scrubberTranslationY); + + foregroundView.setTranslationX(scrubberTranslationX); + foregroundView.setTranslationY(scrubberTranslationY); + + verticalScrubBoundary.update(lastSeenDownPoint.y - distanceFromTouchDownPointToTopOfScrubberDeadZone, + lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone); + + maskView.setTarget(maskTarget); + + hideAnimatorSet.end(); + setVisibility(View.VISIBLE); + revealAnimatorSet.start(); + + if (Build.VERSION.SDK_INT >= 21) { + this.activity = activity; + originalStatusBarColor = activity.getWindow().getStatusBarColor(); + activity.getWindow().setStatusBarColor(ContextCompat.getColor(activity, R.color.core_grey_45)); + } + } + + public void hide() { + maskView.setTarget(null); + overlayState = OverlayState.HIDDEN; + + revealAnimatorSet.end(); + hideAnimatorSet.start(); + + if (Build.VERSION.SDK_INT >= 21 && activity != null) { + activity.getWindow().setStatusBarColor(originalStatusBarColor); + activity = null; + } + + if (onHideListener != null) { + onHideListener.onHide(); + } + } + + public boolean isShowing() { + return overlayState != OverlayState.HIDDEN; + } + + public @NonNull MessageRecord getMessageRecord() { + return messageRecord; + } + + public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) { + if (!isShowing()) { + if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) { + lastSeenDownPoint.set(motionEvent.getRawX(), motionEvent.getRawY()); + } + return false; + } + + if ((motionEvent.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) != 0) { + return true; + } + + if (overlayState == OverlayState.UNINITAILIZED) { + backgroundView.getGlobalVisibleRect(emojiStripViewBounds); + emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect); + emojiStripViewBounds.left = emojiViewGlobalRect.left; + emojiViews[emojiViews.length - 1].getGlobalVisibleRect(emojiViewGlobalRect); + emojiStripViewBounds.right = emojiViewGlobalRect.right; + + segmentSize = emojiStripViewBounds.width() / (float) emojiViews.length; + + downIsOurs = false; + + deadzoneTouchPoint.set(motionEvent.getRawX(), motionEvent.getRawY()); + + overlayState = OverlayState.DEADZONE; + } + + if (overlayState == OverlayState.DEADZONE) { + float deltaX = Math.abs(deadzoneTouchPoint.x - motionEvent.getRawX()); + float deltaY = Math.abs(deadzoneTouchPoint.y - motionEvent.getRawY()); + + if (deltaX > touchDownDeadZoneSize || deltaY > touchDownDeadZoneSize) { + overlayState = OverlayState.SCRUB; + } else { + if (motionEvent.getAction() == MotionEvent.ACTION_UP) { + overlayState = OverlayState.TAP; + + if (downIsOurs) { + handleUpEvent(); + return true; + } + } + + return MotionEvent.ACTION_MOVE == motionEvent.getAction(); + } + } + + if (isToolbarTouch) { + if (motionEvent.getAction() == MotionEvent.ACTION_CANCEL || motionEvent.getAction() == MotionEvent.ACTION_UP) { + isToolbarTouch = false; + } + return false; + } + + switch (motionEvent.getAction()) { + case MotionEvent.ACTION_DOWN: + selected = getSelectedIndexViaDownEvent(motionEvent); + + if (selected == -1) { + if (motionEvent.getRawY() < toolbar.getHeight() + statusBarHeight) { + isToolbarTouch = true; + return false; + } + } + + deadzoneTouchPoint.set(motionEvent.getRawX(), motionEvent.getRawY()); + overlayState = OverlayState.DEADZONE; + downIsOurs = true; + return true; + case MotionEvent.ACTION_MOVE: + selected = getSelectedIndexViaMoveEvent(motionEvent); + return true; + case MotionEvent.ACTION_UP: + handleUpEvent(); + return downIsOurs; + case MotionEvent.ACTION_CANCEL: + hide(); + return downIsOurs; + default: + return false; + } + } + + private void setupSelectedEmojiBackground() { + final String oldEmoji = getOldEmoji(messageRecord); + + if (oldEmoji == null) { + selectedView.setVisibility(View.GONE); + } + + for (int i = 0; i < emojiViews.length; i++) { + final View view = emojiViews[i]; + view.setScaleX(1.0f); + view.setScaleY(1.0f); + view.setTranslationY(0); + + if (ReactionEmoji.values()[i].emoji.equals(oldEmoji)) { + selectedView.setVisibility(View.VISIBLE); + + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(foregroundView); + constraintSet.clear(selectedView.getId(), ConstraintSet.LEFT); + constraintSet.clear(selectedView.getId(), ConstraintSet.RIGHT); + constraintSet.connect(selectedView.getId(), ConstraintSet.LEFT, view.getId(), ConstraintSet.LEFT); + constraintSet.connect(selectedView.getId(), ConstraintSet.RIGHT, view.getId(), ConstraintSet.RIGHT); + constraintSet.applyTo(foregroundView); + } + } + } + + private int getSelectedIndexViaDownEvent(@NonNull MotionEvent motionEvent) { + return getSelectedIndexViaMotionEvent(motionEvent, new Boundary(emojiStripViewBounds.top, emojiStripViewBounds.bottom)); + } + + private int getSelectedIndexViaMoveEvent(@NonNull MotionEvent motionEvent) { + return getSelectedIndexViaMotionEvent(motionEvent, verticalScrubBoundary); + } + + private int getSelectedIndexViaMotionEvent(@NonNull MotionEvent motionEvent, @NonNull Boundary boundary) { + int selected = -1; + + for (int i = 0; i < emojiViews.length; i++) { + final float emojiLeft = (segmentSize * i) + emojiStripViewBounds.left; + horizontalEmojiBoundary.update(emojiLeft, emojiLeft + segmentSize); + + if (horizontalEmojiBoundary.contains(motionEvent.getRawX()) && boundary.contains(motionEvent.getRawY())) { + selected = i; + } + } + + if (this.selected != -1 && this.selected != selected) { + shrinkView(emojiViews[this.selected]); + } + + if (this.selected != selected && selected != -1) { + growView(emojiViews[selected]); + } + + return selected; + } + + private void growView(@NonNull View view) { + view.animate() + .scaleY(1.5f) + .scaleX(1.5f) + .translationY(-selectedVerticalTranslation) + .setDuration(400) + .setInterpolator(INTERPOLATOR) + .start(); + } + + private void shrinkView(@NonNull View view) { + view.animate() + .scaleX(1.0f) + .scaleY(1.0f) + .translationY(0) + .setDuration(400) + .setInterpolator(INTERPOLATOR) + .start(); + } + + private void handleUpEvent() { + hide(); + if (selected != -1 && onReactionSelectedListener != null) { + onReactionSelectedListener.onReactionSelected(messageRecord, ReactionEmoji.values()[selected].emoji); + } + } + + public void setOnReactionSelectedListener(@Nullable OnReactionSelectedListener onReactionSelectedListener) { + this.onReactionSelectedListener = onReactionSelectedListener; + } + + public void setOnToolbarItemClickedListener(@Nullable Toolbar.OnMenuItemClickListener onToolbarItemClickedListener) { + this.onToolbarItemClickedListener = onToolbarItemClickedListener; + } + + public void setOnHideListener(@Nullable OnHideListener onHideListener) { + this.onHideListener = onHideListener; + } + + private static @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) { + return Stream.of(messageRecord.getReactions()) + .filter(record -> record.getAuthor() + .serialize() + .equals(Recipient.self() + .getId() + .serialize())) + .findFirst() + .map(ReactionRecord::getEmoji) + .orElse(null); + } + + private void setupToolbarMenuItems() { + toolbar.getMenu().findItem(R.id.action_copy).setVisible(shouldShowCopy()); + toolbar.getMenu().findItem(R.id.action_download).setVisible(shouldShowDownload()); + toolbar.getMenu().findItem(R.id.action_forward).setVisible(shouldShowForward()); + } + + private boolean shouldShowCopy() { + return !MessageRecordUtil.hasSticker(messageRecord) && + !MessageRecordUtil.isMediaMessage(messageRecord) && + !MessageRecordUtil.hasSharedContact(messageRecord); + } + + private boolean shouldShowDownload() { + return MessageRecordUtil.isMediaMessage(messageRecord) || + MessageRecordUtil.hasLocation(messageRecord); + } + + + private boolean shouldShowForward() { + return !MessageRecordUtil.hasSharedContact(messageRecord); + } + + private boolean handleToolbarItemClicked(@NonNull MenuItem menuItem) { + + hide(); + + if (onToolbarItemClickedListener == null) { + return false; + } + + return onToolbarItemClickedListener.onMenuItemClick(menuItem); + } + + private void initAnimators() { + + int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration); + + List reveals = Stream.of(emojiViews) + .mapIndexed((idx, v) -> { + Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal); + anim.setTarget(v); + anim.setStartDelay(idx * animationEmojiStartDelayFactor); + return anim; + }) + .toList(); + + Animator overlayRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); + overlayRevealAnim.setTarget(maskView); + overlayRevealAnim.setDuration(duration); + reveals.add(overlayRevealAnim); + + Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); + backgroundRevealAnim.setTarget(backgroundView); + backgroundRevealAnim.setDuration(duration); + reveals.add(backgroundRevealAnim); + + Animator selectedRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in); + selectedRevealAnim.setTarget(selectedView); + selectedRevealAnim.setDuration(duration); + reveals.add(selectedRevealAnim); + + revealAnimatorSet.setInterpolator(INTERPOLATOR); + revealAnimatorSet.playTogether(reveals); + + List hides = Stream.of(emojiViews) + .mapIndexed((idx, v) -> { + Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide); + anim.setTarget(v); + anim.setStartDelay(idx * animationEmojiStartDelayFactor); + return anim; + }) + .toList(); + + Animator overlayHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); + overlayHideAnim.setTarget(maskView); + overlayHideAnim.setDuration(duration); + hides.add(overlayHideAnim); + + Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); + backgroundHideAnim.setTarget(backgroundView); + backgroundHideAnim.setDuration(duration); + hides.add(backgroundHideAnim); + + Animator selectedHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out); + selectedHideAnim.setTarget(selectedView); + selectedHideAnim.setDuration(duration); + hides.add(selectedHideAnim); + + hideAnimatorSet.addListener(new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + setVisibility(View.INVISIBLE); + } + }); + + hideAnimatorSet.setInterpolator(INTERPOLATOR); + hideAnimatorSet.playTogether(hides); + } + + public interface OnHideListener { + void onHide(); + } + + public interface OnReactionSelectedListener { + void onReactionSelected(@NonNull MessageRecord messageRecord, String emoji); + } + + private static class Boundary { + private float min; + private float max; + + Boundary() {} + + Boundary(float min, float max) { + update(min, max); + } + + private void update(float min, float max) { + Preconditions.checkArgument(min < max, "Min must be less than max"); + this.min = min; + this.max = max; + } + + public boolean contains(float value) { + return this.min < value && this.max > value; + } + } + + private enum ReactionEmoji { + HEART(R.id.reaction_1, "\u2764\ufe0f"), + THUMBS_UP(R.id.reaction_2, "\ud83d\udc4d"), + THUMBS_DOWN(R.id.reaction_3, "\ud83d\udc4e"), + LAUGH(R.id.reaction_4, "\ud83d\ude02"), + SURPRISE(R.id.reaction_5, "\ud83d\ude2e"), + SAD(R.id.reaction_6, "\ud83d\ude22"), + ANGRY(R.id.reaction_7, "\ud83d\ude21"); + + final @IdRes int viewId; + final String emoji; + + ReactionEmoji(int viewId, String emoji) { + this.viewId = viewId; + this.emoji = emoji; + } + } + + private enum OverlayState { + HIDDEN, + UNINITAILIZED, + DEADZONE, + SCRUB, + TAP + } +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java b/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java index 793331ba00..1ae897475b 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java @@ -33,6 +33,7 @@ final class ConversationSwipeAnimationHelper { float progress = dx / TRIGGER_DX; updateBodyBubbleTransition(conversationItem.bodyBubble, dx, sign); + updateReactionsTransition(conversationItem.reactionsContainer, dx, sign); updateReplyIconTransition(conversationItem.reply, dx, progress, sign); updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, progress, sign); } @@ -45,6 +46,10 @@ final class ConversationSwipeAnimationHelper { bodyBubble.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign); } + private static void updateReactionsTransition(@NonNull View reactionsContainer, float dx, float sign) { + reactionsContainer.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(dx) * sign); + } + private static void updateReplyIconTransition(@NonNull View replyIcon, float dx, float progress, float sign) { if (progress > 0.05f) { replyIcon.setAlpha(REPLY_ALPHA_INTERPOLATOR.getInterpolation(progress)); diff --git a/src/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/src/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index b80e030f30..3ef66d23ca 100644 --- a/src/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/src/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -116,6 +116,7 @@ import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.util.AvatarUtil; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -339,23 +340,9 @@ public class ConversationListFragment extends MainFragment implements LoaderMana } private void initializeProfileIcon(@NonNull Recipient recipient) { - ImageView icon = requireView().findViewById(R.id.toolbar_icon); - String name = Optional.fromNullable(recipient.getDisplayName(requireContext())).or(Optional.fromNullable(TextSecurePreferences.getProfileName(requireContext()))).or(""); - MaterialColor fallbackColor = recipient.getColor(); - - if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) { - fallbackColor = ContactColors.generateFor(name); - } - - Drawable fallback = new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40).asDrawable(requireContext(), fallbackColor.toAvatarColor(requireContext())); - - GlideApp.with(this) - .load(new ProfileContactPhoto(recipient.getId(), String.valueOf(TextSecurePreferences.getProfileAvatarId(requireContext())))) - .error(fallback) - .circleCrop() - .diskCacheStrategy(DiskCacheStrategy.ALL) - .into(icon); + ImageView icon = requireView().findViewById(R.id.toolbar_icon); + AvatarUtil.loadIconIntoImageView(recipient, icon); icon.setOnClickListener(v -> getNavigator().goToAppSettings()); } diff --git a/src/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java b/src/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java index c8adb01166..178b31eae6 100644 --- a/src/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java +++ b/src/org/thoughtcrime/securesms/database/CursorRecyclerViewAdapter.java @@ -22,6 +22,7 @@ import android.database.DataSetObserver; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.ViewHolder; import android.view.View; diff --git a/src/org/thoughtcrime/securesms/database/MessagingDatabase.java b/src/org/thoughtcrime/securesms/database/MessagingDatabase.java index 82dfd3317a..18c5f293e1 100644 --- a/src/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/src/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -7,23 +7,29 @@ import android.text.TextUtils; import androidx.annotation.NonNull; +import com.annimon.stream.Stream; +import com.google.protobuf.InvalidProtocolBufferException; + import net.sqlcipher.database.SQLiteDatabase; import org.thoughtcrime.securesms.database.documents.Document; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.DatabaseProtos.ReactionList; +import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.insights.InsightsConstants; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.JsonUtils; import org.whispersystems.libsignal.IdentityKey; +import org.whispersystems.libsignal.util.guava.Optional; import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.Iterator; import java.util.List; -import java.util.concurrent.TimeUnit; public abstract class MessagingDatabase extends Database implements MmsSmsColumns { @@ -90,6 +96,91 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn return "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")"; } + public void setReactionsSeen(long threadId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues values = new ContentValues(); + String whereClause = THREAD_ID + " = ?"; + String[] whereArgs = new String[]{String.valueOf(threadId)}; + + values.put(REACTIONS_UNREAD, 0); + values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); + + db.update(getTableName(), values, whereClause, whereArgs); + } + + public void setAllReactionsSeen() { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues values = new ContentValues(); + + values.put(REACTIONS_UNREAD, 0); + values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); + + db.update(getTableName(), values, null, null); + } + + public void addReaction(long messageId, @NonNull ReactionRecord reaction) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + + try { + ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance()); + ReactionList.Reaction newReaction = ReactionList.Reaction.newBuilder() + .setEmoji(reaction.getEmoji()) + .setAuthor(reaction.getAuthor().toLong()) + .setSentTime(reaction.getDateSent()) + .setReceivedTime(reaction.getDateReceived()) + .build(); + + ReactionList updatedList = pruneByAuthor(reactions, reaction.getAuthor()).toBuilder() + .addReactions(newReaction) + .build(); + + setReactions(db, messageId, updatedList); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + notifyConversationListeners(getThreadId(db, messageId)); + } + + public void deleteReaction(long messageId, @NonNull RecipientId author) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + + try { + ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance()); + ReactionList updatedList = pruneByAuthor(reactions, author); + + setReactions(db, messageId, updatedList); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + notifyConversationListeners(getThreadId(db, messageId)); + } + + public boolean hasReaction(long messageId, @NonNull ReactionRecord reactionRecord) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance()); + + for (ReactionList.Reaction reaction : reactions.getReactionsList()) { + if (reactionRecord.getAuthor().toLong() == reaction.getAuthor() && + reactionRecord.getEmoji().equals(reaction.getEmoji())) + { + return true; + } + } + + return false; + } + public void addMismatchedIdentity(long messageId, @NonNull RecipientId recipientId, IdentityKey identityKey) { try { addToDocument(messageId, MISMATCHED_IDENTITIES, @@ -110,6 +201,28 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn } } + protected List parseReactions(@NonNull Cursor cursor) { + byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(REACTIONS)); + + if (raw != null) { + try { + return Stream.of(ReactionList.parseFrom(raw).getReactionsList()) + .map(r -> { + return new ReactionRecord(r.getEmoji(), + RecipientId.from(r.getAuthor()), + r.getSentTime(), + r.getReceivedTime()); + }) + .toList(); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "[parseReactions] Failed to parse reaction list!", e); + return Collections.emptyList(); + } + } else { + return Collections.emptyList(); + } + } + protected , I> void removeFromDocument(long messageId, String column, I object, Class clazz) throws IOException { SQLiteDatabase database = databaseHelper.getWritableDatabase(); database.beginTransaction(); @@ -205,6 +318,62 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn } } + private static @NonNull ReactionList pruneByAuthor(@NonNull ReactionList reactionList, @NonNull RecipientId recipientId) { + List pruned = Stream.of(reactionList.getReactionsList()) + .filterNot(r -> r.getAuthor() == recipientId.toLong()) + .toList(); + + return reactionList.toBuilder() + .clearReactions() + .addAllReactions(pruned) + .build(); + } + + private @NonNull Optional getReactions(SQLiteDatabase db, long messageId) { + String[] projection = new String[]{ REACTIONS }; + String query = ID + " = ?"; + String[] args = new String[]{String.valueOf(messageId)}; + + try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(REACTIONS)); + + if (raw != null) { + return Optional.of(ReactionList.parseFrom(raw)); + } + } + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "[getRecipients] Failed to parse reaction list!", e); + } + + return Optional.absent(); + } + + private void setReactions(@NonNull SQLiteDatabase db, long messageId, @NonNull ReactionList reactionList) { + ContentValues values = new ContentValues(1); + values.put(REACTIONS, reactionList.getReactionsList().isEmpty() ? null : reactionList.toByteArray()); + values.put(REACTIONS_UNREAD, reactionList.getReactionsCount() != 0 ? 1 : 0); + + String query = ID + " = ?"; + String[] args = new String[] { String.valueOf(messageId) }; + + db.update(getTableName(), values, query, args); + } + + private long getThreadId(@NonNull SQLiteDatabase db, long messageId) { + String[] projection = new String[]{ THREAD_ID }; + String query = ID + " = ?"; + String[] args = new String[]{ String.valueOf(messageId) }; + + try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); + } + } + + return -1; + } + public static class SyncMessageId { private final RecipientId recipientId; diff --git a/src/org/thoughtcrime/securesms/database/MmsDatabase.java b/src/org/thoughtcrime/securesms/database/MmsDatabase.java index df29a57d69..f5fe7c0575 100644 --- a/src/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/src/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord; import org.thoughtcrime.securesms.database.model.Quote; +import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.TrimThreadJob; import org.thoughtcrime.securesms.linkpreview.LinkPreview; @@ -112,26 +113,60 @@ public class MmsDatabase extends MessagingDatabase { public static final String VIEW_ONCE = "reveal_duration"; - public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + - THREAD_ID + " INTEGER, " + DATE_SENT + " INTEGER, " + DATE_RECEIVED + " INTEGER, " + MESSAGE_BOX + " INTEGER, " + - READ + " INTEGER DEFAULT 0, " + "m_id" + " TEXT, " + "sub" + " TEXT, " + - "sub_cs" + " INTEGER, " + BODY + " TEXT, " + PART_COUNT + " INTEGER, " + - "ct_t" + " TEXT, " + CONTENT_LOCATION + " TEXT, " + RECIPIENT_ID + " INTEGER, " + - ADDRESS_DEVICE_ID + " INTEGER, " + - EXPIRY + " INTEGER, " + "m_cls" + " TEXT, " + MESSAGE_TYPE + " INTEGER, " + - "v" + " INTEGER, " + MESSAGE_SIZE + " INTEGER, " + "pri" + " INTEGER, " + - "rr" + " INTEGER, " + "rpt_a" + " INTEGER, " + "resp_st" + " INTEGER, " + - STATUS + " INTEGER, " + TRANSACTION_ID + " TEXT, " + "retr_st" + " INTEGER, " + - "retr_txt" + " TEXT, " + "retr_txt_cs" + " INTEGER, " + "read_status" + " INTEGER, " + - "ct_cls" + " INTEGER, " + "resp_txt" + " TEXT, " + "d_tm" + " INTEGER, " + - DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " + - NETWORK_FAILURE + " TEXT DEFAULT NULL," + "d_rpt" + " INTEGER, " + - SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + EXPIRES_IN + " INTEGER DEFAULT 0, " + - EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " INTEGER DEFAULT 0, " + - READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + QUOTE_ID + " INTEGER DEFAULT 0, " + - QUOTE_AUTHOR + " TEXT, " + QUOTE_BODY + " TEXT, " + QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " + - QUOTE_MISSING + " INTEGER DEFAULT 0, " + SHARED_CONTACTS + " TEXT, " + UNIDENTIFIED + " INTEGER DEFAULT 0, " + - LINK_PREVIEWS + " TEXT, " + VIEW_ONCE + " INTEGER DEFAULT 0);"; + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + THREAD_ID + " INTEGER, " + + DATE_SENT + " INTEGER, " + + DATE_RECEIVED + " INTEGER, " + + MESSAGE_BOX + " INTEGER, " + + READ + " INTEGER DEFAULT 0, " + + "m_id" + " TEXT, " + + "sub" + " TEXT, " + + "sub_cs" + " INTEGER, " + + BODY + " TEXT, " + + PART_COUNT + " INTEGER, " + + "ct_t" + " TEXT, " + + CONTENT_LOCATION + " TEXT, " + + RECIPIENT_ID + " INTEGER, " + + ADDRESS_DEVICE_ID + " INTEGER, " + + EXPIRY + " INTEGER, " + + "m_cls" + " TEXT, " + + MESSAGE_TYPE + " INTEGER, " + + "v" + " INTEGER, " + + MESSAGE_SIZE + " INTEGER, " + + "pri" + " INTEGER, " + + "rr" + " INTEGER, " + + "rpt_a" + " INTEGER, " + + "resp_st" + " INTEGER, " + + STATUS + " INTEGER, " + + TRANSACTION_ID + " TEXT, " + + "retr_st" + " INTEGER, " + + "retr_txt" + " TEXT, " + + "retr_txt_cs" + " INTEGER, " + + "read_status" + " INTEGER, " + + "ct_cls" + " INTEGER, " + + "resp_txt" + " TEXT, " + + "d_tm" + " INTEGER, " + + DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + + MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " + + NETWORK_FAILURE + " TEXT DEFAULT NULL," + + "d_rpt" + " INTEGER, " + + SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + + EXPIRES_IN + " INTEGER DEFAULT 0, " + + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + + NOTIFIED + " INTEGER DEFAULT 0, " + + READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + + QUOTE_ID + " INTEGER DEFAULT 0, " + + QUOTE_AUTHOR + " TEXT, " + + QUOTE_BODY + " TEXT, " + + QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " + + QUOTE_MISSING + " INTEGER DEFAULT 0, " + + SHARED_CONTACTS + " TEXT, " + + UNIDENTIFIED + " INTEGER DEFAULT 0, " + + LINK_PREVIEWS + " TEXT, " + + VIEW_ONCE + " INTEGER DEFAULT 0, " + + REACTIONS + " BLOB DEFAULT NULL, " + + REACTIONS_UNREAD + " INTEGER DEFAULT 0, " + + REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1);"; public static final String[] CREATE_INDEXS = { "CREATE INDEX IF NOT EXISTS mms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", @@ -152,7 +187,7 @@ public class MmsDatabase extends MessagingDatabase { BODY, PART_COUNT, RECIPIENT_ID, ADDRESS_DEVICE_ID, DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT, MISMATCHED_IDENTITIES, NETWORK_FAILURE, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING, - SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, VIEW_ONCE, + SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, VIEW_ONCE, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN, "json_group_array(json_object(" + "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + @@ -334,6 +369,18 @@ public class MmsDatabase extends MessagingDatabase { return cursor; } + public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException { + try (Cursor cursor = rawQuery(RAW_ID_WHERE, new String[] {messageId + ""})) { + MessageRecord record = new Reader(cursor).getNext(); + + if (record == null) { + throw new NoSuchMessageException("No message for ID: " + messageId); + } + + return record; + } + } + public Reader getExpireStartedMessages() { String where = EXPIRE_STARTED + " > 0"; return readerFor(rawQuery(where, null)); @@ -1414,7 +1461,7 @@ public class MmsDatabase extends MessagingDatabase { message.getOutgoingQuote().isOriginalMissing(), new SlideDeck(context, message.getOutgoingQuote().getAttachments())) : null, - message.getSharedContacts(), message.getLinkPreviews(), false); + message.getSharedContacts(), message.getLinkPreviews(), false, Collections.emptyList()); } } @@ -1486,24 +1533,25 @@ public class MmsDatabase extends MessagingDatabase { } private MediaMmsMessageRecord getMediaMmsMessageRecord(Cursor cursor) { - long id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.ID)); - long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_SENT)); - long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_RECEIVED)); - long box = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX)); - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.THREAD_ID)); - long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.RECIPIENT_ID)); - int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.ADDRESS_DEVICE_ID)); - int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.DELIVERY_RECEIPT_COUNT)); - int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.READ_RECEIPT_COUNT)); - String body = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.BODY)); - int partCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.PART_COUNT)); - String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.MISMATCHED_IDENTITIES)); - String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.NETWORK_FAILURE)); - int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.SUBSCRIPTION_ID)); - long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN)); - long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRE_STARTED)); - boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.UNIDENTIFIED)) == 1; - boolean isViewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 1; + long id = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.ID )); + long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_SENT )); + long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.NORMALIZED_DATE_RECEIVED)); + long box = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.MESSAGE_BOX )); + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.THREAD_ID )); + long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.RECIPIENT_ID )); + int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.ADDRESS_DEVICE_ID )); + int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.DELIVERY_RECEIPT_COUNT)); + int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.READ_RECEIPT_COUNT )); + String body = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.BODY )); + int partCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.PART_COUNT )); + String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.MISMATCHED_IDENTITIES)); + String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.NETWORK_FAILURE )); + int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.SUBSCRIPTION_ID )); + long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN )); + long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.EXPIRE_STARTED )); + boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.UNIDENTIFIED)) == 1; + boolean isViewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 1; + List reactions = parseReactions(cursor); if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { readReceiptCount = 0; @@ -1524,7 +1572,7 @@ public class MmsDatabase extends MessagingDatabase { addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, threadId, body, slideDeck, partCount, box, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, - isViewOnce, readReceiptCount, quote, contacts, previews, unidentified); + isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, reactions); } private List getMismatchedIdentities(String document) { diff --git a/src/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/src/org/thoughtcrime/securesms/database/MmsSmsColumns.java index 74b01c5040..ca01455fa7 100644 --- a/src/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/src/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -20,6 +20,9 @@ public interface MmsSmsColumns { public static final String EXPIRE_STARTED = "expire_started"; public static final String NOTIFIED = "notified"; public static final String UNIDENTIFIED = "unidentified"; + public static final String REACTIONS = "reactions"; + public static final String REACTIONS_UNREAD = "reactions_unread"; + public static final String REACTIONS_LAST_SEEN = "reactions_last_seen"; public static class Types { protected static final long TOTAL_MASK = 0xFFFFFFFF; diff --git a/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 898e17c963..27cdbbdc2f 100644 --- a/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/src/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -52,6 +52,7 @@ public class MmsSmsDatabase extends Database { MmsDatabase.MESSAGE_TYPE, MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsSmsColumns.UNIDENTIFIED, + MmsSmsColumns.REACTIONS, MmsDatabase.PART_COUNT, MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID, MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, @@ -73,7 +74,11 @@ public class MmsSmsDatabase extends Database { MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, MmsDatabase.LINK_PREVIEWS, - MmsDatabase.VIEW_ONCE}; + MmsDatabase.VIEW_ONCE, + MmsSmsColumns.READ, + MmsSmsColumns.REACTIONS, + MmsSmsColumns.REACTIONS_UNREAD, + MmsSmsColumns.REACTIONS_LAST_SEEN}; public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { super(context, databaseHelper); @@ -133,7 +138,7 @@ public class MmsSmsDatabase extends Database { public Cursor getUnread() { String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " ASC"; - String selection = MmsSmsColumns.READ + " = 0 AND " + MmsSmsColumns.NOTIFIED + " = 0"; + String selection = MmsSmsColumns.NOTIFIED + " = 0 AND (" + MmsSmsColumns.READ + " = 0 OR " + MmsSmsColumns.REACTIONS_UNREAD + ")"; return queryTables(PROJECTION, selection, order, null); } @@ -149,6 +154,18 @@ public class MmsSmsDatabase extends Database { } } + public boolean checkMessageExists(@NonNull MessageRecord messageRecord) { + if (messageRecord.isMms()) { + try (Cursor mms = DatabaseFactory.getMmsDatabase(context).getMessage(messageRecord.getId())) { + return mms != null && mms.getCount() > 0; + } + } else { + try (Cursor sms = DatabaseFactory.getSmsDatabase(context).getMessageCursor(messageRecord.getId())) { + return sms != null && sms.getCount() > 0; + } + } + } + public int getConversationCount(long threadId) { int count = DatabaseFactory.getSmsDatabase(context).getMessageCountForThread(threadId); count += DatabaseFactory.getMmsDatabase(context).getMessageCountForThread(threadId); @@ -299,7 +316,10 @@ public class MmsSmsDatabase extends Database { MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, MmsDatabase.LINK_PREVIEWS, - MmsDatabase.VIEW_ONCE}; + MmsDatabase.VIEW_ONCE, + MmsDatabase.REACTIONS, + MmsSmsColumns.REACTIONS_UNREAD, + MmsSmsColumns.REACTIONS_LAST_SEEN}; String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, @@ -326,7 +346,10 @@ public class MmsSmsDatabase extends Database { MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.SHARED_CONTACTS, MmsDatabase.LINK_PREVIEWS, - MmsDatabase.VIEW_ONCE}; + MmsDatabase.VIEW_ONCE, + MmsDatabase.REACTIONS, + MmsSmsColumns.REACTIONS_UNREAD, + MmsSmsColumns.REACTIONS_LAST_SEEN}; SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder(); SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder(); @@ -398,6 +421,9 @@ public class MmsSmsDatabase extends Database { mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS); mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS); mmsColumnsPresent.add(MmsDatabase.VIEW_ONCE); + mmsColumnsPresent.add(MmsDatabase.REACTIONS); + mmsColumnsPresent.add(MmsDatabase.REACTIONS_UNREAD); + mmsColumnsPresent.add(MmsDatabase.REACTIONS_LAST_SEEN); Set smsColumnsPresent = new HashSet<>(); smsColumnsPresent.add(MmsSmsColumns.ID); @@ -419,6 +445,9 @@ public class MmsSmsDatabase extends Database { smsColumnsPresent.add(SmsDatabase.DATE_RECEIVED); smsColumnsPresent.add(SmsDatabase.STATUS); smsColumnsPresent.add(SmsDatabase.UNIDENTIFIED); + smsColumnsPresent.add(SmsDatabase.REACTIONS); + smsColumnsPresent.add(SmsDatabase.REACTIONS_UNREAD); + smsColumnsPresent.add(SmsDatabase.REACTIONS_LAST_SEEN); @SuppressWarnings("deprecation") String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 4, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null); diff --git a/src/org/thoughtcrime/securesms/database/SmsDatabase.java b/src/org/thoughtcrime/securesms/database/SmsDatabase.java index 6412b5d21d..25927ce3fe 100644 --- a/src/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/src/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.TrimThreadJob; @@ -49,6 +50,7 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.io.IOException; import java.security.SecureRandom; +import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -75,14 +77,32 @@ public class SmsDatabase extends MessagingDatabase { public static final String SUBJECT = "subject"; public static final String SERVICE_CENTER = "service_center"; - public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " integer PRIMARY KEY, " + - THREAD_ID + " INTEGER, " + RECIPIENT_ID + " INTEGER, " + ADDRESS_DEVICE_ID + " INTEGER DEFAULT 1, " + PERSON + " INTEGER, " + - DATE_RECEIVED + " INTEGER, " + DATE_SENT + " INTEGER, " + PROTOCOL + " INTEGER, " + READ + " INTEGER DEFAULT 0, " + - STATUS + " INTEGER DEFAULT -1," + TYPE + " INTEGER, " + REPLY_PATH_PRESENT + " INTEGER, " + - DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0," + SUBJECT + " TEXT, " + BODY + " TEXT, " + - MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " + SERVICE_CENTER + " TEXT, " + SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + - EXPIRES_IN + " INTEGER DEFAULT 0, " + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + NOTIFIED + " DEFAULT 0, " + - READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNIDENTIFIED + " INTEGER DEFAULT 0);"; + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + THREAD_ID + " INTEGER, " + + RECIPIENT_ID + " INTEGER, " + + ADDRESS_DEVICE_ID + " INTEGER DEFAULT 1, " + + PERSON + " INTEGER, " + + DATE_RECEIVED + " INTEGER, " + + DATE_SENT + " INTEGER, " + + PROTOCOL + " INTEGER, " + + READ + " INTEGER DEFAULT 0, " + + STATUS + " INTEGER DEFAULT -1," + + TYPE + " INTEGER, " + + REPLY_PATH_PRESENT + " INTEGER, " + + DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0," + + SUBJECT + " TEXT, " + + BODY + " TEXT, " + + MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " + + SERVICE_CENTER + " TEXT, " + + SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + + EXPIRES_IN + " INTEGER DEFAULT 0, " + + EXPIRE_STARTED + " INTEGER DEFAULT 0, " + + NOTIFIED + " DEFAULT 0, " + + READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + + UNIDENTIFIED + " INTEGER DEFAULT 0, " + + REACTIONS + " BLOB DEFAULT NULL, " + + REACTIONS_UNREAD + " INTEGER DEFAULT 0, " + + REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1);"; public static final String[] CREATE_INDEXS = { "CREATE INDEX IF NOT EXISTS sms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", @@ -100,7 +120,7 @@ public class SmsDatabase extends MessagingDatabase { PROTOCOL, READ, STATUS, TYPE, REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT, MISMATCHED_IDENTITIES, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED, - NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED + NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN }; private final String OUTGOING_INSECURE_MESSAGE_CLAUSE = "(" + TYPE + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + TYPE + " & " + Types.SECURE_MESSAGE_BIT + ")"; @@ -868,7 +888,7 @@ public class SmsDatabase extends MessagingDatabase { 0, message.isSecureMessage() ? MmsSmsColumns.Types.getOutgoingEncryptedMessageType() : MmsSmsColumns.Types.getOutgoingSmsMessageType(), threadId, 0, new LinkedList(), message.getSubscriptionId(), message.getExpiresIn(), - System.currentTimeMillis(), 0, false); + System.currentTimeMillis(), 0, false, Collections.emptyList()); } } @@ -893,22 +913,23 @@ public class SmsDatabase extends MessagingDatabase { } public SmsMessageRecord getCurrent() { - long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.ID)); - long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.RECIPIENT_ID)); - int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.ADDRESS_DEVICE_ID)); - long type = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.TYPE)); - long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_RECEIVED)); - long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_SENT)); - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.THREAD_ID)); - int status = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.STATUS)); - int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.DELIVERY_RECEIPT_COUNT)); - int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.READ_RECEIPT_COUNT)); - String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.MISMATCHED_IDENTITIES)); - int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.SUBSCRIPTION_ID)); - long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRES_IN)); - long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRE_STARTED)); - String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY)); - boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.UNIDENTIFIED)) == 1; + long messageId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.ID )); + long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.RECIPIENT_ID )); + int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.ADDRESS_DEVICE_ID )); + long type = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.TYPE )); + long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_RECEIVED)); + long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.NORMALIZED_DATE_SENT )); + long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.THREAD_ID )); + int status = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.STATUS )); + int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.DELIVERY_RECEIPT_COUNT)); + int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.READ_RECEIPT_COUNT )); + String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.MISMATCHED_IDENTITIES)); + int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.SUBSCRIPTION_ID )); + long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRES_IN )); + long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(SmsDatabase.EXPIRE_STARTED )); + String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY )); + boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.UNIDENTIFIED)) == 1; + List reactions = parseReactions(cursor); if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { readReceiptCount = 0; @@ -923,7 +944,7 @@ public class SmsDatabase extends MessagingDatabase { dateSent, dateReceived, deliveryReceiptCount, type, threadId, status, mismatches, subscriptionId, expiresIn, expireStarted, - readReceiptCount, unidentified); + readReceiptCount, unidentified, reactions); } private List getMismatches(String document) { diff --git a/src/org/thoughtcrime/securesms/database/ThreadDatabase.java b/src/org/thoughtcrime/securesms/database/ThreadDatabase.java index 4fa925e41b..5b10037a61 100644 --- a/src/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/src/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -278,6 +278,9 @@ public class ThreadDatabase extends Database { final List smsRecords = DatabaseFactory.getSmsDatabase(context).setAllMessagesRead(); final List mmsRecords = DatabaseFactory.getMmsDatabase(context).setAllMessagesRead(); + DatabaseFactory.getSmsDatabase(context).setAllReactionsSeen(); + DatabaseFactory.getMmsDatabase(context).setAllReactionsSeen(); + notifyConversationListListeners(); return Util.concatenatedList(smsRecords, mmsRecords); @@ -312,6 +315,9 @@ public class ThreadDatabase extends Database { final List smsRecords = DatabaseFactory.getSmsDatabase(context).setMessagesRead(threadId); final List mmsRecords = DatabaseFactory.getMmsDatabase(context).setMessagesRead(threadId); + DatabaseFactory.getSmsDatabase(context).setReactionsSeen(threadId); + DatabaseFactory.getMmsDatabase(context).setReactionsSeen(threadId); + notifyConversationListListeners(); return Util.concatenatedList(smsRecords, mmsRecords); diff --git a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index fbb8c10c46..94b3a75d13 100644 --- a/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/src/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -51,7 +51,6 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences; import java.io.File; import java.util.List; -import java.util.UUID; public class SQLCipherOpenHelper extends SQLiteOpenHelper { @@ -93,8 +92,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int ATTACHMENT_CLEAR_HASHES_2 = 34; private static final int UUIDS = 35; private static final int USERNAMES = 36; + private static final int REACTIONS = 37; + + private static final int DATABASE_VERSION = 37; - private static final int DATABASE_VERSION = 36; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -626,6 +627,17 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS recipient_username_index ON recipient (username)"); } + if (oldVersion < REACTIONS) { + db.execSQL("ALTER TABLE sms ADD COLUMN reactions BLOB DEFAULT NULL"); + db.execSQL("ALTER TABLE mms ADD COLUMN reactions BLOB DEFAULT NULL"); + + db.execSQL("ALTER TABLE sms ADD COLUMN reactions_unread INTEGER DEFAULT 0"); + db.execSQL("ALTER TABLE mms ADD COLUMN reactions_unread INTEGER DEFAULT 0"); + + db.execSQL("ALTER TABLE sms ADD COLUMN reactions_last_seen INTEGER DEFAULT -1"); + db.execSQL("ALTER TABLE mms ADD COLUMN reactions_last_seen INTEGER DEFAULT -1"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java index 6b3108eca9..81342846ff 100644 --- a/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -57,12 +57,13 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { long expiresIn, long expireStarted, boolean viewOnce, int readReceiptCount, @Nullable Quote quote, @NonNull List contacts, - @NonNull List linkPreviews, boolean unidentified) + @NonNull List linkPreviews, boolean unidentified, + @NonNull List reactions) { super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck, - readReceiptCount, quote, contacts, linkPreviews, unidentified); + readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions); this.partCount = partCount; } diff --git a/src/org/thoughtcrime/securesms/database/model/MessageRecord.java b/src/org/thoughtcrime/securesms/database/model/MessageRecord.java index 025c792af9..c0b423f311 100644 --- a/src/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -53,6 +53,7 @@ public abstract class MessageRecord extends DisplayRecord { private final long expiresIn; private final long expireStarted; private final boolean unidentified; + private final List reactions; MessageRecord(long id, String body, Recipient conversationRecipient, Recipient individualRecipient, int recipientDeviceId, @@ -61,7 +62,8 @@ public abstract class MessageRecord extends DisplayRecord { List mismatches, List networkFailures, int subscriptionId, long expiresIn, long expireStarted, - int readReceiptCount, boolean unidentified) + int readReceiptCount, boolean unidentified, + @NonNull List reactions) { super(body, conversationRecipient, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, readReceiptCount); @@ -74,6 +76,7 @@ public abstract class MessageRecord extends DisplayRecord { this.expiresIn = expiresIn; this.expireStarted = expireStarted; this.unidentified = unidentified; + this.reactions = reactions; } public abstract boolean isMms(); @@ -249,4 +252,8 @@ public abstract class MessageRecord extends DisplayRecord { public boolean isViewOnce() { return false; } + + public @NonNull List getReactions() { + return reactions; + } } diff --git a/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index 88950880af..f595753a85 100644 --- a/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -32,9 +32,10 @@ public abstract class MmsMessageRecord extends MessageRecord { long expireStarted, boolean viewOnce, @NonNull SlideDeck slideDeck, int readReceiptCount, @Nullable Quote quote, @NonNull List contacts, - @NonNull List linkPreviews, boolean unidentified) + @NonNull List linkPreviews, boolean unidentified, + @NonNull List reactions) { - super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount, unidentified); + super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, deliveryStatus, deliveryReceiptCount, type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount, unidentified, reactions); this.slideDeck = slideDeck; this.quote = quote; diff --git a/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java index 7ce9a14803..d15e379569 100644 --- a/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java @@ -57,7 +57,8 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord { super(id, "", conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, new LinkedList(), new LinkedList(), subscriptionId, - 0, 0, false, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false); + 0, 0, false, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false, + Collections.emptyList()); this.contentLocation = contentLocation; this.messageSize = messageSize; diff --git a/src/org/thoughtcrime/securesms/database/model/ReactionRecord.java b/src/org/thoughtcrime/securesms/database/model/ReactionRecord.java new file mode 100644 index 0000000000..c597190c17 --- /dev/null +++ b/src/org/thoughtcrime/securesms/database/model/ReactionRecord.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.database.model; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.RecipientId; + +public class ReactionRecord { + private final String emoji; + private final RecipientId author; + private final long dateSent; + private final long dateReceived; + + public ReactionRecord(@NonNull String emoji, + @NonNull RecipientId author, + long dateSent, + long dateReceived) + { + this.emoji = emoji; + this.author = author; + this.dateSent = dateSent; + this.dateReceived = dateReceived; + } + + public @NonNull String getEmoji() { + return emoji; + } + + public @NonNull RecipientId getAuthor() { + return author; + } + + public long getDateSent() { + return dateSent; + } + + public long getDateReceived() { + return dateReceived; + } +} diff --git a/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java b/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java index 2a79ff7f86..a194be387e 100644 --- a/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java +++ b/src/org/thoughtcrime/securesms/database/model/SmsMessageRecord.java @@ -48,12 +48,13 @@ public class SmsMessageRecord extends MessageRecord { long type, long threadId, int status, List mismatches, int subscriptionId, long expiresIn, long expireStarted, - int readReceiptCount, boolean unidentified) + int readReceiptCount, boolean unidentified, + @NonNull List reactions) { super(id, body, recipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, threadId, status, deliveryReceiptCount, type, mismatches, new LinkedList<>(), subscriptionId, - expiresIn, expireStarted, readReceiptCount, unidentified); + expiresIn, expireStarted, readReceiptCount, unidentified, reactions); } public long getType() { diff --git a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 11b0508ec1..f28203300d 100644 --- a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -66,6 +66,7 @@ public final class JobManagerFactories { put(PushMediaSendJob.KEY, new PushMediaSendJob.Factory()); put(PushNotificationReceiveJob.KEY, new PushNotificationReceiveJob.Factory()); put(PushTextSendJob.KEY, new PushTextSendJob.Factory()); + put(ReactionSendJob.KEY, new ReactionSendJob.Factory()); put(RefreshAttributesJob.KEY, new RefreshAttributesJob.Factory()); put(RefreshOwnProfileJob.KEY, new RefreshOwnProfileJob.Factory()); put(RefreshPreKeysJob.KEY, new RefreshPreKeysJob.Factory()); diff --git a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java index 2f076ef1e4..34153eef06 100644 --- a/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java +++ b/src/org/thoughtcrime/securesms/jobs/PushDecryptJob.java @@ -59,6 +59,7 @@ import org.thoughtcrime.securesms.database.StickerDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.StickerRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupMessageProcessor; @@ -241,12 +242,13 @@ public class PushDecryptJob extends BaseJob { SignalServiceDataMessage message = content.getDataMessage().get(); boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent() || message.getPreviews().isPresent() || message.getSticker().isPresent(); - if (isInvalidMessage(message)) handleInvalidMessage(content.getSender(), content.getSenderDevice(), message.getGroupInfo(), content.getTimestamp(), smsMessageId); - else if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId); - else if (message.isGroupUpdate()) handleGroupMessage(content, message, smsMessageId); - else if (message.isExpirationUpdate()) handleExpirationUpdate(content, message, smsMessageId); - else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId); - else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId); + if (isInvalidMessage(message)) handleInvalidMessage(content.getSender(), content.getSenderDevice(), message.getGroupInfo(), content.getTimestamp(), smsMessageId); + else if (message.isEndSession()) handleEndSessionMessage(content, smsMessageId); + else if (message.isGroupUpdate()) handleGroupMessage(content, message, smsMessageId); + else if (message.isExpirationUpdate()) handleExpirationUpdate(content, message, smsMessageId); + else if (message.getReaction().isPresent()) handleReaction(content, message); + else if (isMediaMessage) handleMediaMessage(content, message, smsMessageId); + else if (message.getBody().isPresent()) handleTextMessage(content, message, smsMessageId); if (message.getGroupInfo().isPresent() && groupDatabase.isUnknownGroup(GroupUtil.getEncodedId(message.getGroupInfo().get().getGroupId(), false))) { handleUnknownGroupMessage(content, message.getGroupInfo().get()); @@ -525,6 +527,30 @@ public class PushDecryptJob extends BaseJob { } } + private void handleReaction(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message) { + SignalServiceDataMessage.Reaction reaction = message.getReaction().get(); + + Recipient targetAuthor = Recipient.externalPush(context, reaction.getTargetAuthor()); + MessageRecord targetMessage = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(reaction.getTargetSentTimestamp(), targetAuthor.getId()); + + if (targetMessage != null) { + Recipient reactionAuthor = Recipient.externalPush(context, content.getSender()); + MessagingDatabase db = targetMessage.isMms() ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + + if (reaction.isRemove()) { + db.deleteReaction(targetMessage.getId(), reactionAuthor.getId()); + MessageNotifier.updateNotification(context); + } else { + ReactionRecord reactionRecord = new ReactionRecord(reaction.getEmoji(), reactionAuthor.getId(), message.getTimestamp(), System.currentTimeMillis()); + db.addReaction(targetMessage.getId(), reactionRecord); + MessageNotifier.updateNotification(context, targetMessage.getThreadId(), false); + } + + } else { + Log.w(TAG, "[handleReaction] Could not find matching message! timestamp: " + reaction.getTargetSentTimestamp() + " author: " + targetAuthor.getId()); + } + } + private void handleSynchronizeVerifiedMessage(@NonNull VerifiedMessage verifiedMessage) { IdentityUtil.processVerifiedMessage(context, verifiedMessage); } @@ -599,6 +625,10 @@ public class PushDecryptJob extends BaseJob { threadId = GroupMessageProcessor.process(context, content, message.getMessage(), true); } else if (message.getMessage().isExpirationUpdate()) { threadId = handleSynchronizeSentExpirationUpdate(message); + } else if (message.getMessage().getReaction().isPresent()) { + handleReaction(content, message.getMessage()); + threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(getSyncMessageDestination(message)); + threadId = threadId != -1 ? threadId : null; } else if (message.getMessage().getAttachments().isPresent() || message.getMessage().getQuote().isPresent() || message.getMessage().getPreviews().isPresent() || message.getMessage().getSticker().isPresent()) { threadId = handleSynchronizeSentMediaMessage(message); } else { diff --git a/src/org/thoughtcrime/securesms/jobs/ReactionSendJob.java b/src/org/thoughtcrime/securesms/jobs/ReactionSendJob.java new file mode 100644 index 0000000000..a2baa39492 --- /dev/null +++ b/src/org/thoughtcrime/securesms/jobs/ReactionSendJob.java @@ -0,0 +1,283 @@ +package org.thoughtcrime.securesms.jobs; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessagingDatabase; +import org.thoughtcrime.securesms.database.NoSuchMessageException; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.database.model.SmsMessageRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.api.SignalServiceMessageSender; +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; +import org.whispersystems.signalservice.api.messages.SignalServiceGroup; +import org.whispersystems.signalservice.api.push.SignalServiceAddress; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class ReactionSendJob extends BaseJob { + + public static final String KEY = "ReactionSendJob"; + + private static final String TAG = Log.tag(ReactionSendJob.class); + + private static final String KEY_MESSAGE_ID = "message_id"; + private static final String KEY_IS_MMS = "is_mms"; + private static final String KEY_REACTION_EMOJI = "reaction_emoji"; + private static final String KEY_REACTION_AUTHOR = "reaction_author"; + private static final String KEY_REACTION_DATE_SENT = "reaction_date_sent"; + private static final String KEY_REACTION_DATE_RECEIVED = "reaction_date_received"; + private static final String KEY_REMOVE = "remove"; + private static final String KEY_RECIPIENTS = "recipients"; + private static final String KEY_INITIAL_RECIPIENT_COUNT = "initial_recipient_count"; + + private final long messageId; + private final boolean isMms; + private final List recipients; + private final int initialRecipientCount; + private final ReactionRecord reaction; + private final boolean remove; + + + @WorkerThread + public static @NonNull ReactionSendJob create(@NonNull Context context, + long messageId, + boolean isMms, + @NonNull ReactionRecord reaction, + boolean remove) + throws NoSuchMessageException + { + MessageRecord message = isMms ? DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId) + : DatabaseFactory.getSmsDatabase(context).getMessage(messageId); + + Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId()); + + if (conversationRecipient == null) { + throw new AssertionError("We have a message, but couldn't find the thread!"); + } + + List recipients = conversationRecipient.isGroup() ? Stream.of(conversationRecipient.getParticipants()).map(Recipient::getId).toList() + : Arrays.asList(conversationRecipient.getId()); + + recipients.remove(Recipient.self().getId()); + + return new ReactionSendJob(messageId, + isMms, + recipients, + recipients.size(), + reaction, + remove, + new Parameters.Builder() + .setQueue(conversationRecipient.getId().toQueueKey()) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .build()); + } + + private ReactionSendJob(long messageId, + boolean isMms, + @NonNull List recipients, + int initialRecipientCount, + @NonNull ReactionRecord reaction, + boolean remove, + @NonNull Parameters parameters) + { + super(parameters); + + this.messageId = messageId; + this.isMms = isMms; + this.recipients = recipients; + this.initialRecipientCount = initialRecipientCount; + this.reaction = reaction; + this.remove = remove; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId) + .putBoolean(KEY_IS_MMS, isMms) + .putString(KEY_REACTION_EMOJI, reaction.getEmoji()) + .putString(KEY_REACTION_AUTHOR, reaction.getAuthor().serialize()) + .putLong(KEY_REACTION_DATE_SENT, reaction.getDateSent()) + .putLong(KEY_REACTION_DATE_RECEIVED, reaction.getDateReceived()) + .putBoolean(KEY_REMOVE, remove) + .putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) + .putInt(KEY_INITIAL_RECIPIENT_COUNT, initialRecipientCount) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws Exception { + MessagingDatabase db; + MessageRecord message; + + if (isMms) { + db = DatabaseFactory.getMmsDatabase(context); + message = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId); + } else { + db = DatabaseFactory.getSmsDatabase(context); + message = DatabaseFactory.getSmsDatabase(context).getMessage(messageId); + } + + Recipient targetAuthor = message.isOutgoing() ? Recipient.self() : message.getIndividualRecipient(); + long targetSentTimestamp = message.getDateSent(); + + if (!remove && !db.hasReaction(messageId, reaction)) { + Log.w(TAG, "Went to add a reaction, but it's no longer present on the message!"); + return; + } + + if (remove && db.hasReaction(messageId, reaction)) { + Log.w(TAG, "Went to remove a reaction, but it's still there!"); + return; + } + + Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId()); + + if (conversationRecipient == null) { + throw new AssertionError("We have a message, but couldn't find the thread!"); + } + + List destinations = Stream.of(recipients).map(Recipient::resolved).toList(); + List completions = deliver(conversationRecipient, destinations, targetAuthor, targetSentTimestamp); + + for (Recipient completion : completions) { + recipients.remove(completion.getId()); + } + + Log.i(TAG, "Completed now: " + completions.size() + ", Remaining: " + recipients.size()); + + if (!recipients.isEmpty()) { + Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying."); + throw new RetryLaterException(); + } + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof IOException || + e instanceof RetryLaterException; + } + + @Override + public void onCanceled() { + if (recipients.size() < initialRecipientCount) { + Log.w(TAG, "Only sent a reaction to " + recipients.size() + "/" + initialRecipientCount + " recipients. Still, it sent to someone, so it stays."); + return; + } + + Log.w(TAG, "Failed to send the reaction to all recipients!"); + + MessagingDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + + if (remove && !db.hasReaction(messageId, reaction)) { + Log.w(TAG, "Reaction removal failed, so adding the reaction back."); + db.addReaction(messageId, reaction); + } else if (!remove && db.hasReaction(messageId, reaction)){ + Log.w(TAG, "Reaction addition failed, so removing the reaction."); + db.deleteReaction(messageId, reaction.getAuthor()); + } else { + Log.w(TAG, "Reaction state didn't match what we'd expect to revert it, so we're just leaving it alone."); + } + } + + private @NonNull List deliver(@NonNull Recipient conversationRecipient, @NonNull List destinations, @NonNull Recipient targetAuthor, long targetSentTimestamp) + throws IOException, UntrustedIdentityException + { + SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender(); + List addresses = Stream.of(destinations).map(t -> RecipientUtil.toSignalServiceAddress(context, t)).toList(); + List> unidentifiedAccess = Stream.of(destinations).map(recipient -> UnidentifiedAccessUtil.getAccessFor(context, recipient)).toList(); + SignalServiceDataMessage.Builder dataMessage = SignalServiceDataMessage.newBuilder() + .withTimestamp(System.currentTimeMillis()) + .withReaction(buildReaction(context, reaction, remove, targetAuthor, targetSentTimestamp)); + + if (conversationRecipient.isGroup()) { + dataMessage.asGroupMessage(new SignalServiceGroup(GroupUtil.getDecodedId(conversationRecipient.requireGroupId()))); + } + + + List results = messageSender.sendMessage(addresses, unidentifiedAccess, false, dataMessage.build()); + + Stream.of(results) + .filter(r -> r.getIdentityFailure() != null) + .map(SendMessageResult::getAddress) + .map(a -> Recipient.externalPush(context, a)) + .forEach(r -> Log.w(TAG, "Identity failure for " + r.getId())); + + Stream.of(results) + .filter(SendMessageResult::isUnregisteredFailure) + .map(SendMessageResult::getAddress) + .map(a -> Recipient.externalPush(context, a)) + .forEach(r -> Log.w(TAG, "Unregistered failure for " + r.getId())); + + + return Stream.of(results) + .filter(r -> r.getSuccess() != null || r.getIdentityFailure() != null || r.isUnregisteredFailure()) + .map(SendMessageResult::getAddress) + .map(a -> Recipient.externalPush(context, a)) + .toList(); + } + + private static SignalServiceDataMessage.Reaction buildReaction(@NonNull Context context, + @NonNull ReactionRecord reaction, + boolean remove, + @NonNull Recipient targetAuthor, + long targetSentTimestamp) + { + return new SignalServiceDataMessage.Reaction(reaction.getEmoji(), + remove, + RecipientUtil.toSignalServiceAddress(context, targetAuthor), + targetSentTimestamp); + } + + public static class Factory implements Job.Factory { + + @Override + public @NonNull + ReactionSendJob create(@NonNull Parameters parameters, @NonNull Data data) { + long messageId = data.getLong(KEY_MESSAGE_ID); + boolean isMms = data.getBoolean(KEY_IS_MMS); + List recipients = RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)); + int initialRecipientCount = data.getInt(KEY_INITIAL_RECIPIENT_COUNT); + ReactionRecord reaction = new ReactionRecord(data.getString(KEY_REACTION_EMOJI), + RecipientId.from(data.getString(KEY_REACTION_AUTHOR)), + data.getLong(KEY_REACTION_DATE_SENT), + data.getLong(KEY_REACTION_DATE_RECEIVED)); + boolean remove = data.getBoolean(KEY_REMOVE); + + return new ReactionSendJob(messageId, isMms, recipients, initialRecipientCount, reaction, remove, parameters); + } + } +} diff --git a/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java b/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java index 9c7dc3105b..f3c419d46a 100644 --- a/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java +++ b/src/org/thoughtcrime/securesms/notifications/MessageNotifier.java @@ -37,6 +37,9 @@ import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; +import androidx.core.text.HtmlCompat; + +import android.text.SpannableStringBuilder; import android.text.TextUtils; import org.thoughtcrime.securesms.ApplicationContext; @@ -46,11 +49,13 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.conversation.ConversationActivity; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; @@ -66,9 +71,11 @@ import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder; import org.whispersystems.signalservice.internal.util.Util; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.ListIterator; import java.util.Set; +import java.util.TreeSet; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -90,6 +97,7 @@ public class MessageNotifier { public static final String EXTRA_REMOTE_REPLY = "extra_remote_reply"; + private static final String EMOJI_REPLACEMENT_STRING = "__EMOJI__"; private static final int SUMMARY_NOTIFICATION_ID = 1338; private static final int PENDING_MESSAGES_ID = 1111; private static final String NOTIFICATION_GROUP = "messages"; @@ -455,34 +463,73 @@ public class MessageNotifier { Recipient threadRecipients = null; SlideDeck slideDeck = null; long timestamp = record.getTimestamp(); - + boolean isUnreadMessage = cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.READ)) == 0; + boolean hasUnreadReactions = cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.REACTIONS_UNREAD)) == 1; + long lastReactionRead = cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.REACTIONS_LAST_SEEN)); if (threadId != -1) { threadRecipients = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); } - if (KeyCachingService.isLocked(context)) { - body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message)); - } else if (record.isMms() && !((MmsMessageRecord) record).getSharedContacts().isEmpty()) { - Contact contact = ((MmsMessageRecord) record).getSharedContacts().get(0); - body = ContactUtil.getStringSummary(context, contact); - } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) { - body = SpanUtil.italic(context.getString(R.string.MessageNotifier_sticker)); - slideDeck = ((MmsMessageRecord) record).getSlideDeck(); - } else if (record.isMms() && ((MmsMessageRecord) record).isViewOnce()) { - body = SpanUtil.italic(context.getString(getViewOnceDescription((MmsMessageRecord) record))); - } else if (record.isMms() && TextUtils.isEmpty(body) && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { - body = SpanUtil.italic(context.getString(R.string.MessageNotifier_media_message)); - slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); - } else if (record.isMms() && !record.isMmsNotification() && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { - String message = context.getString(R.string.MessageNotifier_media_message_with_text, body); - int italicLength = message.length() - body.length(); - body = SpanUtil.italic(message, italicLength); - slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); + if (isUnreadMessage) { + if (KeyCachingService.isLocked(context)) { + body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message)); + } else if (record.isMms() && !((MmsMessageRecord) record).getSharedContacts().isEmpty()) { + Contact contact = ((MmsMessageRecord) record).getSharedContacts().get(0); + body = ContactUtil.getStringSummary(context, contact); + } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) { + body = SpanUtil.italic(context.getString(R.string.MessageNotifier_sticker)); + slideDeck = ((MmsMessageRecord) record).getSlideDeck(); + } else if (record.isMms() && ((MmsMessageRecord) record).isViewOnce()) { + body = SpanUtil.italic(context.getString(getViewOnceDescription((MmsMessageRecord) record))); + } else if (record.isMms() && TextUtils.isEmpty(body) && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { + body = SpanUtil.italic(context.getString(R.string.MessageNotifier_media_message)); + slideDeck = ((MediaMmsMessageRecord) record).getSlideDeck(); + } else if (record.isMms() && !record.isMmsNotification() && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) { + String message = context.getString(R.string.MessageNotifier_media_message_with_text, body); + int italicLength = message.length() - body.length(); + body = SpanUtil.italic(message, italicLength); + slideDeck = ((MediaMmsMessageRecord) record).getSlideDeck(); + } + + if (threadRecipients == null || !threadRecipients.isMuted()) { + notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)); + } } - if (threadRecipients == null || !threadRecipients.isMuted()) { - notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, slideDeck)); + if (hasUnreadReactions) { + for (ReactionRecord reaction : record.getReactions()) { + Recipient reactionSender = Recipient.resolved(reaction.getAuthor()); + if (reactionSender.equals(Recipient.self()) || !record.isOutgoing() || reaction.getDateReceived() <= lastReactionRead) { + continue; + } + + if (KeyCachingService.isLocked(context)) { + body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message)); + } else { + String text = SpanUtil.italic(context.getString(R.string.MessageNotifier_reacted_to_your_message, EMOJI_REPLACEMENT_STRING)).toString(); + String[] parts = text.split(EMOJI_REPLACEMENT_STRING); + + SpannableStringBuilder builder = new SpannableStringBuilder(); + for (int i = 0; i < parts.length; i++) { + builder.append(SpanUtil.italic(parts[i])); + + if (i != parts.length -1) { + builder.append(reaction.getEmoji()); + } + } + + if (text.endsWith(EMOJI_REPLACEMENT_STRING)) { + builder.append(reaction.getEmoji()); + } + + body = builder; + } + + if (threadRecipients == null || !threadRecipients.isMuted()) { + notificationState.addNotification(new NotificationItem(id, mms, reactionSender, conversationRecipient, threadRecipients, threadId, body, reaction.getDateReceived(), null)); + } + } } } diff --git a/src/org/thoughtcrime/securesms/notifications/NotificationState.java b/src/org/thoughtcrime/securesms/notifications/NotificationState.java index 48b3e3d584..a060db9f9d 100644 --- a/src/org/thoughtcrime/securesms/notifications/NotificationState.java +++ b/src/org/thoughtcrime/securesms/notifications/NotificationState.java @@ -4,6 +4,7 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.net.Uri; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -13,6 +14,9 @@ import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; @@ -21,10 +25,9 @@ public class NotificationState { private static final String TAG = NotificationState.class.getSimpleName(); - private final LinkedList notifications = new LinkedList<>(); - private final LinkedHashSet threads = new LinkedHashSet<>(); - - private int notificationCount = 0; + private final Comparator notificationItemComparator = (a, b) -> -Long.compare(a.getTimestamp(), b.getTimestamp()); + private final List notifications = new LinkedList<>(); + private final LinkedHashSet threads = new LinkedHashSet<>(); public NotificationState() {} @@ -35,19 +38,16 @@ public class NotificationState { } public void addNotification(NotificationItem item) { - notifications.addFirst(item); - - if (threads.contains(item.getThreadId())) { - threads.remove(item.getThreadId()); - } + notifications.add(item); + Collections.sort(notifications, notificationItemComparator); + threads.remove(item.getThreadId()); threads.add(item.getThreadId()); - notificationCount++; } public @Nullable Uri getRingtone(@NonNull Context context) { if (!notifications.isEmpty()) { - Recipient recipient = notifications.getFirst().getRecipient(); + Recipient recipient = notifications.get(0).getRecipient(); if (recipient != null) { return NotificationChannels.supported() ? NotificationChannels.getMessageRingtone(context, recipient) @@ -60,7 +60,7 @@ public class NotificationState { public VibrateState getVibrate() { if (!notifications.isEmpty()) { - Recipient recipient = notifications.getFirst().getRecipient(); + Recipient recipient = notifications.get(0).getRecipient(); if (recipient != null) { return recipient.resolve().getMessageVibrate(); @@ -74,7 +74,7 @@ public class NotificationState { return threads.size() > 1; } - public LinkedHashSet getThreads() { + public Collection getThreads() { return threads; } @@ -83,7 +83,7 @@ public class NotificationState { } public int getMessageCount() { - return notificationCount; + return notifications.size(); } public List getNotifications() { @@ -91,12 +91,13 @@ public class NotificationState { } public List getNotificationsForThread(long threadId) { - LinkedList list = new LinkedList<>(); + List list = new LinkedList<>(); for (NotificationItem item : notifications) { - if (item.getThreadId() == threadId) list.addFirst(item); + if (item.getThreadId() == threadId) list.add(item); } + Collections.sort(list, notificationItemComparator); return list; } diff --git a/src/org/thoughtcrime/securesms/reactions/EmojiCount.java b/src/org/thoughtcrime/securesms/reactions/EmojiCount.java new file mode 100644 index 0000000000..c5dcbc6396 --- /dev/null +++ b/src/org/thoughtcrime/securesms/reactions/EmojiCount.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.reactions; + +import androidx.annotation.NonNull; + +final class EmojiCount { + private final String emoji; + private final int count; + + EmojiCount(@NonNull String emoji, int count) { + this.emoji = emoji; + this.count = count; + } + + public @NonNull String getEmoji() { + return emoji; + } + + public int getCount() { + return count; + } +} diff --git a/src/org/thoughtcrime/securesms/reactions/ReactionEmojiCountAdapter.java b/src/org/thoughtcrime/securesms/reactions/ReactionEmojiCountAdapter.java new file mode 100644 index 0000000000..b8e5389abc --- /dev/null +++ b/src/org/thoughtcrime/securesms/reactions/ReactionEmojiCountAdapter.java @@ -0,0 +1,113 @@ +package org.thoughtcrime.securesms.reactions; + +import android.graphics.drawable.Drawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.emoji.EmojiTextView; +import org.thoughtcrime.securesms.util.ThemeUtil; + +import java.util.Collections; +import java.util.List; + +final class ReactionEmojiCountAdapter extends RecyclerView.Adapter { + + private List emojiCountList = Collections.emptyList(); + private int selectedPosition = -1; + + private final OnEmojiCountSelectedListener onEmojiCountSelectedListener; + + ReactionEmojiCountAdapter(@NonNull OnEmojiCountSelectedListener onEmojiCountSelectedListener) { + this.onEmojiCountSelectedListener = onEmojiCountSelectedListener; + } + + void updateData(@NonNull List newEmojiCount) { + if (selectedPosition != -1) { + EmojiCount oldSelection = emojiCountList.get(selectedPosition); + int newPosition = -1; + + for (int i = 0; i < newEmojiCount.size(); i++) { + if (newEmojiCount.get(i).getEmoji().equals(oldSelection.getEmoji())) { + newPosition = i; + break; + } + } + + if (newPosition == -1 && !newEmojiCount.isEmpty()) { + selectedPosition = 0; + onEmojiCountSelectedListener.onSelected(newEmojiCount.get(0)); + } else { + selectedPosition = newPosition; + } + } else if (!newEmojiCount.isEmpty()) { + selectedPosition = 0; + onEmojiCountSelectedListener.onSelected(newEmojiCount.get(0)); + } + + this.emojiCountList = newEmojiCount; + + notifyDataSetChanged(); + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.reactions_bottom_sheet_dialog_fragment_emoji_item, parent, false), position -> { + if (position != -1 && position != selectedPosition) { + onEmojiCountSelectedListener.onSelected(emojiCountList.get(position)); + + int oldPosition = selectedPosition; + selectedPosition = position; + + notifyItemChanged(oldPosition); + notifyItemChanged(position); + } + }); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(emojiCountList.get(position), selectedPosition); + } + + @Override + public int getItemCount() { + return emojiCountList.size(); + } + + static final class ViewHolder extends RecyclerView.ViewHolder { + + private final Drawable selected; + private final EmojiTextView emojiView; + private final TextView countView; + + public ViewHolder(@NonNull View itemView, @NonNull OnViewHolderClickListener onClickListener) { + super(itemView); + emojiView = itemView.findViewById(R.id.reactions_bottom_view_emoji_item_emoji); + countView = itemView.findViewById(R.id.reactions_bottom_view_emoji_item_text); + selected = ThemeUtil.getThemedDrawable(itemView.getContext(), R.attr.reactions_bottom_dialog_fragment_emoji_selected); + + itemView.setOnClickListener(v -> onClickListener.onClick(getAdapterPosition())); + } + + void bind(@NonNull EmojiCount emojiCount, int selectedPosition) { + emojiView.setText(emojiCount.getEmoji()); + countView.setText(String.valueOf(emojiCount.getCount())); + itemView.setBackground(getAdapterPosition() == selectedPosition ? selected : null); + } + } + + interface OnViewHolderClickListener { + void onClick(int position); + } + + interface OnEmojiCountSelectedListener { + void onSelected(@NonNull EmojiCount emojiCount); + } + +} diff --git a/src/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java b/src/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java new file mode 100644 index 0000000000..0011c41b03 --- /dev/null +++ b/src/org/thoughtcrime/securesms/reactions/ReactionRecipientsAdapter.java @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.reactions; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.AvatarUtil; + +import java.util.Collections; +import java.util.List; + +final class ReactionRecipientsAdapter extends RecyclerView.Adapter { + + private List data = Collections.emptyList(); + + public void updateData(List newData) { + data = newData; + notifyDataSetChanged(); + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.reactions_bottom_sheet_dialog_fragment_recipient_item, + parent, + false)); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(data.get(position)); + } + + @Override + public int getItemCount() { + return data.size(); + } + + final class ViewHolder extends RecyclerView.ViewHolder { + + private final AvatarImageView avatar; + private final TextView recipient; + + public ViewHolder(@NonNull View itemView) { + super(itemView); + + avatar = itemView.findViewById(R.id.reactions_bottom_view_recipient_avatar); + recipient = itemView.findViewById(R.id.reactions_bottom_view_recipient_name); + } + + void bind(Recipient recipient) { + this.recipient.setText(recipient.getDisplayName(itemView.getContext())); + + if (recipient.equals(Recipient.self())) { + AvatarUtil.loadIconIntoImageView(recipient, avatar); + } else { + this.avatar.setAvatar(GlideApp.with(avatar), recipient, false); + } + } + } + +} diff --git a/src/org/thoughtcrime/securesms/reactions/ReactionsBottomSheetDialogFragment.java b/src/org/thoughtcrime/securesms/reactions/ReactionsBottomSheetDialogFragment.java new file mode 100644 index 0000000000..61d8b1bdb9 --- /dev/null +++ b/src/org/thoughtcrime/securesms/reactions/ReactionsBottomSheetDialogFragment.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.reactions; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProviders; +import androidx.loader.app.LoaderManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.R; + +public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogFragment { + + private static final String ARGS_MESSAGE_ID = "reactions.args.message.id"; + private static final String ARGS_IS_MMS = "reactions.args.is.mms"; + + private long messageId; + private RecyclerView recipientRecyclerView; + private RecyclerView emojiRecyclerView; + private ReactionsLoader reactionsLoader; + private ReactionRecipientsAdapter recipientsAdapter; + private ReactionEmojiCountAdapter emojiCountAdapter; + private ReactionsViewModel viewModel; + + public static DialogFragment create(long messageId, boolean isMms) { + Bundle args = new Bundle(); + DialogFragment fragment = new ReactionsBottomSheetDialogFragment(); + + args.putLong(ARGS_MESSAGE_ID, messageId); + args.putBoolean(ARGS_IS_MMS, isMms); + + fragment.setArguments(args); + + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) + { + return inflater.inflate(R.layout.reactions_bottom_sheet_dialog_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + recipientRecyclerView = view.findViewById(R.id.reactions_bottom_view_recipient_recycler); + emojiRecyclerView = view.findViewById(R.id.reactions_bottom_view_emoji_recycler); + + messageId = getArguments().getLong(ARGS_MESSAGE_ID); + + setUpRecipientsRecyclerView(); + setUpEmojiRecyclerView(); + setUpViewModel(); + + LoaderManager.getInstance(requireActivity()).initLoader((int) messageId, null, reactionsLoader); + } + + @Override + public void onDestroyView() { + LoaderManager.getInstance(requireActivity()).destroyLoader((int) messageId); + super.onDestroyView(); + } + + private void setUpRecipientsRecyclerView() { + recipientsAdapter = new ReactionRecipientsAdapter(); + recipientRecyclerView.setAdapter(recipientsAdapter); + } + + private void setUpEmojiRecyclerView() { + emojiCountAdapter = new ReactionEmojiCountAdapter((emojiCount -> viewModel.setFilterEmoji(emojiCount.getEmoji()))); + emojiRecyclerView.setAdapter(emojiCountAdapter); + } + + private void setUpViewModel() { + reactionsLoader = new ReactionsLoader(requireContext(), + getArguments().getLong(ARGS_MESSAGE_ID), + getArguments().getBoolean(ARGS_IS_MMS)); + + ReactionsViewModel.Factory factory = new ReactionsViewModel.Factory(reactionsLoader); + viewModel = ViewModelProviders.of(this, factory).get(ReactionsViewModel.class); + + viewModel.getRecipients().observe(getViewLifecycleOwner(), reactions -> { + if (reactions.size() == 0) dismiss(); + + recipientsAdapter.updateData(reactions); + }); + + viewModel.getEmojiCounts().observe(getViewLifecycleOwner(), emojiCounts -> { + if (emojiCounts.size() == 0) dismiss(); + + emojiCountAdapter.updateData(emojiCounts); + }); + } +} diff --git a/src/org/thoughtcrime/securesms/reactions/ReactionsLoader.java b/src/org/thoughtcrime/securesms/reactions/ReactionsLoader.java new file mode 100644 index 0000000000..148cbcc97c --- /dev/null +++ b/src/org/thoughtcrime/securesms/reactions/ReactionsLoader.java @@ -0,0 +1,130 @@ +package org.thoughtcrime.securesms.reactions; + +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Pair; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.AbstractCursorLoader; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; + +import java.util.Collections; +import java.util.List; + +public class ReactionsLoader implements ReactionsViewModel.Repository, LoaderManager.LoaderCallbacks { + + private final long messageId; + private final boolean isMms; + private final Context appContext; + + private MutableLiveData> internalLiveData = new MutableLiveData<>(); + + public ReactionsLoader(@NonNull Context context, long messageId, boolean isMms) + { + this.messageId = messageId; + this.isMms = isMms; + this.appContext = context.getApplicationContext(); + } + + @Override + public @NonNull Loader onCreateLoader(int id, @Nullable Bundle args) { + return isMms ? new MmsMessageRecordCursorLoader(appContext, messageId) + : new SmsMessageRecordCursorLoader(appContext, messageId); + } + + @Override + public void onLoadFinished(@NonNull Loader loader, Cursor data) { + SignalExecutors.BOUNDED.execute(() -> { + MessageRecord record = isMms ? DatabaseFactory.getMmsDatabase(appContext).readerFor(data).getNext() + : DatabaseFactory.getSmsDatabase(appContext).readerFor(data).getNext(); + + if (record == null) { + internalLiveData.postValue(Collections.emptyList()); + } else { + internalLiveData.postValue(Stream.of(record.getReactions()) + .map(reactionRecord -> new Reaction(Recipient.resolved(reactionRecord.getAuthor()), + reactionRecord.getEmoji(), + reactionRecord.getDateReceived())) + .toList()); + } + }); + } + + @Override + public void onLoaderReset(@NonNull Loader loader) { + // Do nothing? + } + + @Override + public LiveData> getReactions() { + return internalLiveData; + } + + private static final class MmsMessageRecordCursorLoader extends AbstractCursorLoader { + + private final long messageId; + + public MmsMessageRecordCursorLoader(@NonNull Context context, long messageId) { + super(context); + this.messageId = messageId; + } + + @Override + public Cursor getCursor() { + return DatabaseFactory.getMmsDatabase(context).getMessage(messageId); + } + } + + private static final class SmsMessageRecordCursorLoader extends AbstractCursorLoader { + + private final long messageId; + + public SmsMessageRecordCursorLoader(@NonNull Context context, long messageId) { + super(context); + this.messageId = messageId; + } + + @Override + public Cursor getCursor() { + return DatabaseFactory.getSmsDatabase(context).getMessageCursor(messageId); + } + } + + static class Reaction { + private final Recipient sender; + private final String emoji; + private final long timestamp; + + private Reaction(@NonNull Recipient sender, @NonNull String emoji, long timestamp) { + this.sender = sender; + this.emoji = emoji; + this.timestamp = timestamp; + } + + public @NonNull Recipient getSender() { + return sender; + } + + public @NonNull String getEmoji() { + return emoji; + } + + public long getTimestamp() { + return timestamp; + } + } +} diff --git a/src/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java b/src/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java new file mode 100644 index 0000000000..710f3f697a --- /dev/null +++ b/src/org/thoughtcrime/securesms/reactions/ReactionsViewModel.java @@ -0,0 +1,84 @@ +package org.thoughtcrime.securesms.reactions; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +import static org.thoughtcrime.securesms.reactions.ReactionsLoader.*; + +public class ReactionsViewModel extends ViewModel { + + private final Repository repository; + private final MutableLiveData filterEmoji = new MutableLiveData<>(); + + public ReactionsViewModel(@NonNull Repository repository) { + this.repository = repository; + } + + public @NonNull LiveData> getRecipients() { + return Transformations.switchMap(filterEmoji, + emoji -> Transformations.map(repository.getReactions(), + reactions -> Stream.of(reactions) + .filter(reaction -> reaction.getEmoji().equals(emoji)) + .map(Reaction::getSender).toList())); + } + + public @NonNull LiveData> getEmojiCounts() { + return Transformations.map(repository.getReactions(), + reactionList -> Stream.of(reactionList) + .groupBy(Reaction::getEmoji) + .sorted(this::compareReactions) + .map(entry -> new EmojiCount(entry.getKey(), entry.getValue().size())) + .toList()); + } + + public void setFilterEmoji(String filterEmoji) { + this.filterEmoji.setValue(filterEmoji); + } + + private int compareReactions(@NonNull Map.Entry> lhs, @NonNull Map.Entry> rhs) { + int lengthComparison = -Integer.compare(lhs.getValue().size(), rhs.getValue().size()); + if (lengthComparison != 0) return lengthComparison; + + long latestTimestampLhs = getLatestTimestamp(lhs.getValue()); + long latestTimestampRhs = getLatestTimestamp(rhs.getValue()); + + return -Long.compare(latestTimestampLhs, latestTimestampRhs); + } + + private long getLatestTimestamp(List reactions) { + return Stream.of(reactions) + .max((a, b) -> Long.compare(a.getTimestamp(), b.getTimestamp())) + .map(Reaction::getTimestamp) + .orElse(-1L); + } + + interface Repository { + LiveData> getReactions(); + } + + static final class Factory implements ViewModelProvider.Factory { + + private final Repository repository; + + Factory(@NonNull Repository repository) { + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return (T) new ReactionsViewModel(repository); + } + } +} diff --git a/src/org/thoughtcrime/securesms/recipients/RecipientId.java b/src/org/thoughtcrime/securesms/recipients/RecipientId.java index ffa72b1e73..092ee8f022 100644 --- a/src/org/thoughtcrime/securesms/recipients/RecipientId.java +++ b/src/org/thoughtcrime/securesms/recipients/RecipientId.java @@ -70,6 +70,10 @@ public class RecipientId implements Parcelable, Comparable { return String.valueOf(id); } + public long toLong() { + return id; + } + public @NonNull String toQueueKey() { return "RecipientId::" + id; } diff --git a/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java b/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java index cf562a3b4c..5860741380 100644 --- a/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java +++ b/src/org/thoughtcrime/securesms/sms/IncomingTextMessage.java @@ -6,6 +6,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import android.telephony.SmsMessage; +import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.GroupUtil; import org.whispersystems.libsignal.util.guava.Optional; diff --git a/src/org/thoughtcrime/securesms/sms/MessageSender.java b/src/org/thoughtcrime/securesms/sms/MessageSender.java index e55c303c51..9ef692c70e 100644 --- a/src/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/src/org/thoughtcrime/securesms/sms/MessageSender.java @@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MessagingDatabase; import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase; @@ -37,6 +38,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.SmsMessageRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.Job; @@ -48,22 +50,21 @@ import org.thoughtcrime.securesms.jobs.MmsSendJob; import org.thoughtcrime.securesms.jobs.PushGroupSendJob; import org.thoughtcrime.securesms.jobs.PushMediaSendJob; import org.thoughtcrime.securesms.jobs.PushTextSendJob; +import org.thoughtcrime.securesms.jobs.ReactionSendJob; import org.thoughtcrime.securesms.jobs.SmsSendJob; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage; -import org.thoughtcrime.securesms.push.AccountManagerFactory; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.whispersystems.libsignal.util.guava.Optional; -import org.whispersystems.signalservice.api.SignalServiceAccountManager; -import org.whispersystems.signalservice.api.push.ContactTokenDetails; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Locale; @@ -229,6 +230,32 @@ public class MessageSender { } } + + public static void sendNewReaction(@NonNull Context context, long messageId, boolean isMms, @NonNull String emoji) { + MessagingDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + ReactionRecord reaction = new ReactionRecord(emoji, Recipient.self().getId(), System.currentTimeMillis(), System.currentTimeMillis()); + + db.addReaction(messageId, reaction); + + try { + ApplicationDependencies.getJobManager().add(ReactionSendJob.create(context, messageId, isMms, reaction, false)); + } catch (NoSuchMessageException e) { + Log.w(TAG, "[sendNewReaction] Could not find message! Ignoring."); + } + } + + public static void sendReactionRemoval(@NonNull Context context, long messageId, boolean isMms, @NonNull ReactionRecord reaction) { + MessagingDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context); + + db.deleteReaction(messageId, reaction.getAuthor()); + + try { + ApplicationDependencies.getJobManager().add(ReactionSendJob.create(context, messageId, isMms, reaction, true)); + } catch (NoSuchMessageException e) { + Log.w(TAG, "[sendReactionRemoval] Could not find message! Ignoring."); + } + } + public static void resendGroupMessage(Context context, MessageRecord messageRecord, RecipientId filterRecipientId) { if (!messageRecord.isMms()) throw new AssertionError("Not Group"); sendGroupPush(context, messageRecord.getRecipient(), messageRecord.getId(), filterRecipientId); diff --git a/src/org/thoughtcrime/securesms/util/AvatarUtil.java b/src/org/thoughtcrime/securesms/util/AvatarUtil.java new file mode 100644 index 0000000000..f25ec3ad16 --- /dev/null +++ b/src/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.widget.ImageView; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.color.MaterialColor; +import org.thoughtcrime.securesms.contacts.avatars.ContactColors; +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.libsignal.util.guava.Optional; + +public final class AvatarUtil { + + private AvatarUtil() { + } + + public static void loadIconIntoImageView(@NonNull Recipient recipient, @NonNull ImageView target) { + Context context = target.getContext(); + String name = Optional.fromNullable(recipient.getDisplayName(context)).or(Optional.fromNullable(TextSecurePreferences.getProfileName(context))).or(""); + MaterialColor fallbackColor = recipient.getColor(); + + if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) { + fallbackColor = ContactColors.generateFor(name); + } + + Drawable fallback = new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40).asDrawable(context, fallbackColor.toAvatarColor(context)); + + GlideApp.with(context) + .load(new ProfileContactPhoto(recipient.getId(), String.valueOf(TextSecurePreferences.getProfileAvatarId(context)))) + .error(fallback) + .circleCrop() + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(target); + } +} diff --git a/src/org/thoughtcrime/securesms/util/FeatureFlags.java b/src/org/thoughtcrime/securesms/util/FeatureFlags.java index 4f6823cb6e..89253dcd18 100644 --- a/src/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/src/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -22,4 +22,7 @@ public class FeatureFlags { /** Set or migrate PIN to KBS */ public static final boolean KBS = false; -} + + /** Send support for reactions. */ + public static final boolean REACTION_SENDING = false; +} \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/util/MessageRecordUtil.java b/src/org/thoughtcrime/securesms/util/MessageRecordUtil.java new file mode 100644 index 0000000000..e721de5fbd --- /dev/null +++ b/src/org/thoughtcrime/securesms/util/MessageRecordUtil.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.MmsMessageRecord; +import org.thoughtcrime.securesms.mms.Slide; + +public final class MessageRecordUtil { + + private MessageRecordUtil() { + } + + public static boolean isMediaMessage(@NonNull MessageRecord messageRecord) { + return messageRecord.isMms() && + !messageRecord.isMmsNotification() && + ((MediaMmsMessageRecord)messageRecord).containsMediaSlide() && + ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null; + } + + public static boolean hasSticker(@NonNull MessageRecord messageRecord) { + return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() != null; + } + + public static boolean hasSharedContact(@NonNull MessageRecord messageRecord) { + return messageRecord.isMms() && !((MmsMessageRecord)messageRecord).getSharedContacts().isEmpty(); + } + + public static boolean hasLocation(@NonNull MessageRecord messageRecord) { + return messageRecord.isMms() && !Stream.of(((MmsMessageRecord) messageRecord).getSlideDeck().getSlides()) + .anyMatch(Slide::hasLocation); + } +} diff --git a/src/org/thoughtcrime/securesms/util/SpanUtil.java b/src/org/thoughtcrime/securesms/util/SpanUtil.java index 15a0b0c441..8890b6efb5 100644 --- a/src/org/thoughtcrime/securesms/util/SpanUtil.java +++ b/src/org/thoughtcrime/securesms/util/SpanUtil.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.util; import android.graphics.Typeface; import android.text.Spannable; import android.text.SpannableString; +import android.text.style.AbsoluteSizeSpan; import android.text.style.ForegroundColorSpan; import android.text.style.RelativeSizeSpan; import android.text.style.StyleSpan; @@ -25,6 +26,12 @@ public class SpanUtil { return spannable; } + public static CharSequence ofSize(CharSequence sequence, int size) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new AbsoluteSizeSpan(size, true), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + public static CharSequence bold(CharSequence sequence) { SpannableString spannable = new SpannableString(sequence); spannable.setSpan(new StyleSpan(Typeface.BOLD), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); diff --git a/src/org/thoughtcrime/securesms/util/ThemeUtil.java b/src/org/thoughtcrime/securesms/util/ThemeUtil.java index 247b4c56ed..2f88eaa7ea 100644 --- a/src/org/thoughtcrime/securesms/util/ThemeUtil.java +++ b/src/org/thoughtcrime/securesms/util/ThemeUtil.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.res.Resources; import android.graphics.Color; import androidx.annotation.AttrRes; +import androidx.annotation.DimenRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StyleRes; @@ -59,6 +60,17 @@ public class ThemeUtil { return inflater.cloneInContext(contextThemeWrapper); } + public static float getThemedDimen(@NonNull Context context, @AttrRes int attr) { + TypedValue typedValue = new TypedValue(); + Resources.Theme theme = context.getTheme(); + + if (theme.resolveAttribute(attr, typedValue, true)) { + return typedValue.getDimension(context.getResources().getDisplayMetrics()); + } + + return 0; + } + private static String getAttribute(Context context, int attribute, String defaultValue) { TypedValue outValue = new TypedValue(); diff --git a/src/org/thoughtcrime/securesms/util/ViewUtil.java b/src/org/thoughtcrime/securesms/util/ViewUtil.java index d119b05ec0..81f49c5104 100644 --- a/src/org/thoughtcrime/securesms/util/ViewUtil.java +++ b/src/org/thoughtcrime/securesms/util/ViewUtil.java @@ -252,4 +252,13 @@ public class ViewUtil { return x > viewX && x < viewX + view.getWidth() && y > viewY && y < viewY + view.getHeight(); } + + public static int getStatusBarHeight(@NonNull View view) { + int result = 0; + int resourceId = view.getResources().getIdentifier("status_bar_height", "dimen", "android"); + if (resourceId > 0) { + result = view.getResources().getDimensionPixelSize(resourceId); + } + return result; + } }