From 80598d42cc169379829fb628a6ddd8ee57731b3a Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Mon, 24 Nov 2025 13:18:36 -0500 Subject: [PATCH] Add basic pinned message support. --- .../v2/items/V2ConversationItemShapeTest.kt | 2 + .../test/ConversationElementGenerator.kt | 1 + .../test/InternalConversationTestFragment.kt | 4 + app/src/main/AndroidManifest.xml | 4 + .../securesms/BindableConversationItem.java | 1 + .../components/ConversationItemFooter.java | 12 + .../components/emoji/EmojiTextView.java | 9 +- .../conversation/ConversationItem.java | 2 +- .../ConversationReactionOverlay.java | 12 +- .../conversation/ConversationUpdateItem.java | 11 + .../securesms/conversation/MenuState.java | 41 ++- .../conversation/PinnedMessagesBottomSheet.kt | 289 ++++++++++++++++++ .../conversation/PinnedMessagesRepository.kt | 51 ++++ .../conversation/PinnedMessagesViewModel.kt | 76 +++++ .../conversation/v2/ConversationBannerView.kt | 30 ++ .../conversation/v2/ConversationFragment.kt | 100 ++++++ .../conversation/v2/ConversationRepository.kt | 33 ++ .../conversation/v2/ConversationViewModel.kt | 33 ++ .../v2/PinDisappearingMessageBottomSheet.kt | 97 ++++++ .../v2/PinnedMessagesComponent.kt | 221 ++++++++++++++ .../V2ConversationItemMediaBindingBridge.kt | 6 +- ...V2ConversationItemTextOnlyBindingBridge.kt | 10 +- .../V2ConversationItemTextOnlyViewHolder.kt | 12 +- .../v2/items/V2FooterPositionDelegate.kt | 3 +- .../securesms/database/MessageTable.kt | 167 +++++++++- .../securesms/database/MessageType.kt | 5 +- .../securesms/database/MessageTypes.java | 5 + .../securesms/database/ThreadBodyUtil.java | 3 + .../helpers/SignalDatabaseMigrations.kt | 6 +- .../migration/V297_AddPinnedMessageColumns.kt | 19 ++ .../database/model/DisplayRecord.java | 4 + .../database/model/InMemoryMessageRecord.java | 1 + .../database/model/MessageRecord.java | 12 +- .../database/model/MmsMessageRecord.java | 15 +- .../securesms/dependencies/AppDependencies.kt | 7 + .../ApplicationDependencyProvider.java | 6 + .../securesms/fonts/SignalSymbols.kt | 2 + .../securesms/jobs/IndividualSendJob.java | 4 +- .../securesms/jobs/JobManagerFactories.java | 1 + .../securesms/jobs/PushGroupSendJob.java | 4 +- .../securesms/jobs/PushSendJob.java | 22 +- .../securesms/jobs/UnpinMessageJob.kt | 186 +++++++++++ .../securesms/keyvalue/UiHintValues.java | 14 + .../messagedetails/MessageDetailsFragment.kt | 4 + .../messages/DataMessageProcessor.kt | 148 +++++++++ .../securesms/mms/OutgoingMessage.kt | 11 + .../securesms/service/PinnedMessageManager.kt | 86 ++++++ .../securesms/util/MessageRecordUtil.kt | 61 ++-- .../securesms/util/RemoteConfig.kt | 24 ++ app/src/main/protowire/Database.proto | 8 + app/src/main/protowire/JobData.proto | 6 + .../res/drawable/symbol_pin_filled_12.xml | 10 + app/src/main/res/drawable/symbol_timer_80.xml | 13 + .../res/layout/conversation_item_footer.xml | 13 +- .../main/res/layout/pinned_message_stub.xml | 8 + .../layout/pinned_messages_bottom_sheet.xml | 56 ++++ .../res/layout/v2_conversation_fragment.xml | 7 + .../v2_conversation_item_media_incoming.xml | 14 +- .../v2_conversation_item_media_outgoing.xml | 14 +- ...2_conversation_item_text_only_incoming.xml | 14 +- ...2_conversation_item_text_only_outgoing.xml | 18 +- app/src/main/res/values/arrays.xml | 12 + app/src/main/res/values/strings.xml | 57 ++++ .../MockApplicationDependencyProvider.kt | 5 + .../securesms/database/FakeMessageRecords.kt | 1 + .../signal/core/ui/compose/DropdownMenus.kt | 41 +++ .../api/SignalServiceMessageSender.java | 25 ++ .../api/messages/EnvelopeContentValidator.kt | 8 + .../api/messages/SignalServiceDataMessage.kt | 26 +- .../src/main/protowire/SignalService.proto | 18 +- 70 files changed, 2162 insertions(+), 89 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesBottomSheet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinDisappearingMessageBottomSheet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinnedMessagesComponent.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V297_AddPinnedMessageColumns.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/UnpinMessageJob.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/service/PinnedMessageManager.kt create mode 100644 app/src/main/res/drawable/symbol_pin_filled_12.xml create mode 100644 app/src/main/res/drawable/symbol_timer_80.xml create mode 100644 app/src/main/res/layout/pinned_message_stub.xml create mode 100644 app/src/main/res/layout/pinned_messages_bottom_sheet.xml diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt index f677b42e06..a7b36cb1d6 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemShapeTest.kt @@ -356,5 +356,7 @@ class V2ConversationItemShapeTest { override fun onViewPollClicked(messageId: Long) = Unit override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) = Unit + + override fun onViewPinnedMessage(messageId: Long) = Unit } } diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt index 2f7724dd71..cdef9e883e 100644 --- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt +++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt @@ -121,6 +121,7 @@ class ConversationElementGenerator { null, 0, false, + 0, null ) diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt index 2ca0d4e4d8..b590f5fa25 100644 --- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt +++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/InternalConversationTestFragment.kt @@ -350,5 +350,9 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) { Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() } + + override fun onViewPinnedMessage(messageId: Long) { + Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() + } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 54fa1d187a..50baf0c91d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1268,6 +1268,10 @@ android:name=".service.ScheduledMessageManager$ScheduledMessagesAlarm" android:exported="false"/> + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index 0ebf960358..4ed42de69e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -148,5 +148,6 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, void onViewResultsClicked(long pollId); void onViewPollClicked(long messageId); void onToggleVote(@NonNull PollRecord poll, @NonNull PollOption pollOption, Boolean isChecked); + void onViewPinnedMessage(long messageId); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java index 046b2842b8..991f371de9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java @@ -55,6 +55,7 @@ public class ConversationItemFooter extends ConstraintLayout { private ExpirationTimerView timerView; private ImageView insecureIndicatorView; private DeliveryStatusView deliveryStatusView; + private ImageView pinnedView; private boolean onlyShowSendingStatus; private TextView audioDuration; private LottieAnimationView revealDot; @@ -98,6 +99,7 @@ public class ConversationItemFooter extends ConstraintLayout { timerView = findViewById(R.id.footer_expiration_timer); insecureIndicatorView = findViewById(R.id.footer_insecure_indicator); deliveryStatusView = findViewById(R.id.footer_delivery_status); + pinnedView = findViewById(R.id.footer_pinned); audioDuration = findViewById(R.id.footer_audio_duration); revealDot = findViewById(R.id.footer_revealed_dot); playbackSpeedToggleTextView = findViewById(R.id.footer_audio_playback_speed_toggle); @@ -143,6 +145,7 @@ public class ConversationItemFooter extends ConstraintLayout { presentInsecureIndicator(messageRecord); presentDeliveryStatus(messageRecord); presentAudioDuration(messageRecord); + presentPinnedIcon(messageRecord); } public void setAudioDuration(long totalDurationMillis, long currentPostionMillis) { @@ -174,6 +177,7 @@ public class ConversationItemFooter extends ConstraintLayout { timerView.setColorFilter(color, PorterDuff.Mode.SRC_IN); insecureIndicatorView.setColorFilter(color); deliveryStatusView.setTint(color); + pinnedView.setColorFilter(color, PorterDuff.Mode.SRC_IN); } public void setRevealDotColor(int color) { @@ -428,6 +432,14 @@ public class ConversationItemFooter extends ConstraintLayout { } } + private void presentPinnedIcon(@NonNull MessageRecord messageRecord) { + if (messageRecord.getPinnedUntil() > 0) { + pinnedView.setVisibility(View.VISIBLE); + } else { + pinnedView.setVisibility(View.GONE); + } + } + private void showAudioDurationViews() { audioDuration.setVisibility(View.VISIBLE); revealDot.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java index b6df3032d5..457d95d6e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java @@ -472,7 +472,7 @@ public class EmojiTextView extends AppCompatTextView { } } - private void ellipsizeEmojiTextForMaxLines() { + public void ellipsizeEmojiTextForMaxLines() { Runnable ellipsize = () -> { int maxLines = TextViewCompat.getMaxLines(EmojiTextView.this); if (maxLines <= 0 && maxLength < 0) { @@ -611,6 +611,13 @@ public class EmojiTextView extends AppCompatTextView { } } + public void enableRenderSpoilers() { + if (spoilerRendererDelegate == null) { + renderSpoilers = true; + spoilerRendererDelegate = new SpoilerRendererDelegate(this); + } + } + /** * Due to some peculiarities in how TextView deals with touch events, it's really easy to accidentally trigger * a click (say, when you try to scroll but you're at the bottom of a view.) Because of this, we handle these diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 7e1b4e43fc..ff41271ae4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -1921,7 +1921,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } private boolean forceFooter(@NonNull MessageRecord messageRecord) { - return hasAudio(messageRecord) || MessageRecordUtil.isEditMessage(messageRecord) || displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE; + return hasAudio(messageRecord) || MessageRecordUtil.isEditMessage(messageRecord) || displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE || messageRecord.getPinnedUntil() > 0; } private boolean forceGroupHeader(@NonNull MessageRecord messageRecord) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index 4fff0ec4bc..b9ee71bf9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -723,6 +723,14 @@ public final class ConversationReactionOverlay extends FrameLayout { items.add(new ActionItem(R.drawable.symbol_stop_24, getResources().getString(R.string.conversation_selection__menu_end_poll), () -> handleActionItemClicked(Action.END_POLL))); } + if (menuState.shouldShowPinMessage()) { + items.add(new ActionItem(R.drawable.symbol_pin_24, getResources().getString(R.string.conversation_selection__menu_pin_message), () -> handleActionItemClicked(Action.PIN_MESSAGE))); + } + + if (menuState.showShowUnpinMessage()) { + items.add(new ActionItem(R.drawable.symbol_pin_slash_24, getResources().getString(R.string.conversation_selection__menu_unpin_message), () -> handleActionItemClicked(Action.UNPIN_MESSAGE))); + } + backgroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE); foregroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE); @@ -908,6 +916,8 @@ public final class ConversationReactionOverlay extends FrameLayout { PAYMENT_DETAILS, VIEW_INFO, DELETE, - END_POLL + END_POLL, + PIN_MESSAGE, + UNPIN_MESSAGE } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 52b51e927a..83d1275912 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -685,6 +685,17 @@ public final class ConversationUpdateItem extends FrameLayout passthroughClickListener.onClick(v); } }); + } else if (MessageRecordUtil.hasPinnedMessageUpdate(conversationMessage.getMessageRecord())) { + actionButton.setText(R.string.PinnedMessage__go_to_message); + actionButton.setVisibility(VISIBLE); + actionButton.setOnClickListener(v -> { + // TODO(michelle): Handle when a message gets deleted + if (batchSelected.isEmpty() && eventListener != null && MessageRecordUtil.hasPinnedMessageUpdate(conversationMessage.getMessageRecord())) { + eventListener.onViewPinnedMessage(conversationMessage.getMessageRecord().getMessageExtras().pinnedMessage.pinnedMessageId); + } else { + passthroughClickListener.onClick(v); + } + }); } else { actionButton.setVisibility(GONE); actionButton.setOnClickListener(null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java index d34d2970f7..ef8954c02d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.MessageConstraintsUtil; +import org.thoughtcrime.securesms.util.RemoteConfig; import java.util.Set; import java.util.stream.Collectors; @@ -27,6 +28,8 @@ public final class MenuState { private final boolean paymentDetails; private final boolean edit; private final boolean pollTerminate; + private final boolean pinMessage; + private final boolean unpinMessage; private MenuState(@NonNull Builder builder) { forward = builder.forward; @@ -40,6 +43,8 @@ public final class MenuState { paymentDetails = builder.paymentDetails; edit = builder.edit; pollTerminate = builder.pollTerminate; + pinMessage = builder.pinMessage; + unpinMessage = builder.unpinMessage; } public boolean shouldShowForwardAction() { @@ -86,6 +91,14 @@ public final class MenuState { return pollTerminate; } + public boolean shouldShowPinMessage() { + return pinMessage; + } + + public boolean showShowUnpinMessage() { + return unpinMessage; + } + public static MenuState getMenuState(@NonNull Recipient conversationRecipient, @NonNull Set selectedParts, boolean shouldShowMessageRequest, @@ -105,6 +118,8 @@ public final class MenuState { boolean hasPayment = false; boolean hasPoll = false; boolean hasPollTerminate = false; + boolean canPinMessage = false; + boolean canUnpinMessage = false; for (MultiselectPart part : selectedParts) { MessageRecord messageRecord = part.getMessageRecord(); @@ -154,6 +169,14 @@ public final class MenuState { if (MessageRecordUtil.hasPoll(messageRecord) && !MessageRecordUtil.getPoll(messageRecord).getHasEnded() && messageRecord.isOutgoing()) { hasPollTerminate = true; } + + if (RemoteConfig.sendPinnedMessages() && !messageRecord.isPending() && messageRecord.getPinnedUntil() == 0 && !conversationRecipient.isReleaseNotes()) { // TODO(michelle): Also check against group permissions + canPinMessage = true; + } + + if (RemoteConfig.sendPinnedMessages() && messageRecord.getPinnedUntil() != 0 && !conversationRecipient.isReleaseNotes()) { // TODO(michelle): Also check against group permissions + canUnpinMessage = true; + } } boolean shouldShowForwardAction = !actionMessage && @@ -178,7 +201,9 @@ public final class MenuState { .shouldShowSaveAttachmentAction(false) .shouldShowResendAction(false) .shouldShowEdit(false) - .shouldShowPollTerminate(false); + .shouldShowPollTerminate(false) + .shouldShowPinMessage(false) + .shouldShowUnpinMessage(false); } else { MultiselectPart multiSelectRecord = selectedParts.iterator().next(); @@ -210,6 +235,8 @@ public final class MenuState { .shouldShowReactions(!conversationRecipient.isReleaseNotes()) .shouldShowPaymentDetails(hasPayment) .shouldShowPollTerminate(hasPollTerminate) + .shouldShowPinMessage(canPinMessage) + .shouldShowUnpinMessage(canUnpinMessage) .build(); } @@ -255,6 +282,8 @@ public final class MenuState { private boolean paymentDetails; private boolean edit; private boolean pollTerminate; + private boolean pinMessage; + private boolean unpinMessage; @NonNull Builder shouldShowForwardAction(boolean forward) { this.forward = forward; @@ -311,6 +340,16 @@ public final class MenuState { return this; } + @NonNull Builder shouldShowPinMessage(boolean pinMessage) { + this.pinMessage = pinMessage; + return this; + } + + @NonNull Builder shouldShowUnpinMessage(boolean unpinMessage) { + this.unpinMessage = unpinMessage; + return this; + } + @NonNull MenuState build() { return new MenuState(this); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesBottomSheet.kt new file mode 100644 index 0000000000..598aad72e2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesBottomSheet.kt @@ -0,0 +1,289 @@ +package org.thoughtcrime.securesms.conversation + +import android.annotation.SuppressLint +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.view.doOnNextLayout +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import org.signal.core.util.concurrent.LifecycleDisposable +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager +import org.thoughtcrime.securesms.conversation.colors.Colorizer +import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange +import org.thoughtcrime.securesms.linkpreview.LinkPreview +import org.thoughtcrime.securesms.polls.PollOption +import org.thoughtcrime.securesms.polls.PollRecord +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.StickyHeaderDecoration +import org.thoughtcrime.securesms.util.fragments.findListener +import java.util.Locale + +/** + * Bottom sheet to show all pinned messages + */ +class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() { + + override val peekHeightPercentage: Float = 1f + override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages + + private lateinit var messageAdapter: ConversationAdapter + private val viewModel: PinnedMessagesViewModel by viewModels( + factoryProducer = { + val threadId = requireArguments().getLong(KEY_THREAD_ID, -1) + val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException()) + + PinnedMessagesViewModel.Factory(AppDependencies.application, threadId, conversationRecipientId) + } + ) + + private val disposables: LifecycleDisposable = LifecycleDisposable() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = inflater.inflate(R.layout.pinned_messages_bottom_sheet, container, false) + disposables.bindTo(viewLifecycleOwner) + return view + } + + @SuppressLint("WrongThread") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException()) + val conversationRecipient = Recipient.resolved(conversationRecipientId) + + val colorizer = Colorizer() + + messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, Glide.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient.hasWallpaper, colorizer).apply { + setCondensedMode(ConversationItemDisplayMode.Condensed(scheduleMessageMode = false)) + } + + val list: RecyclerView = view.findViewById(R.id.pinned_list).apply { + layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true) + adapter = messageAdapter + itemAnimator = null + + doOnNextLayout { + // Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view + addItemDecoration(StickyHeaderDecoration(messageAdapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE)) + } + } + + val recyclerViewColorizer = RecyclerViewColorizer(list) + + disposables += viewModel.getMessages().subscribe { messages -> + if (messages.isEmpty()) { + dismiss() + } + + messageAdapter.submitList(messages) { + if (!list.canScrollVertically(1)) { + list.layoutManager?.scrollToPosition(0) + } + } + recyclerViewColorizer.setChatColors(conversationRecipient.chatColors) + } + + disposables += viewModel.getNameColorsMap().subscribe { map -> + colorizer.onNameColorsChanged(map) + messageAdapter.notifyItemRangeChanged(0, messageAdapter.itemCount, ConversationAdapterBridge.PAYLOAD_NAME_COLORS) + } + + initializeGiphyMp4(view.findViewById(R.id.video_container)!!, list) + + // TODO(michelle): Hide if not allowed to unpin / Check with design about a confirmation dialog here + val unpinAll = view.findViewById(R.id.unpin_all) + unpinAll.setOnClickListener { + viewModel.unpinMessage() + dismissAllowingStateLoss() + } + } + + private fun initializeGiphyMp4(videoContainer: ViewGroup, list: RecyclerView): GiphyMp4ProjectionRecycler { + val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation() + val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews( + requireContext(), + viewLifecycleOwner.lifecycle, + videoContainer, + maxPlayback + ) + val callback = GiphyMp4ProjectionRecycler(holders) + + GiphyMp4PlaybackController.attach(list, callback, maxPlayback) + list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0) + + return callback + } + + private fun getCallback(): ConversationBottomSheetCallback { + return findListener() ?: throw IllegalStateException("Parent must implement callback interface!") + } + + private fun getAdapterListener(): ConversationAdapter.ItemClickListener { + return getCallback().getConversationAdapterListener() + } + + // TODO(michelle): Allow for more interactions from the pinned messages sheet + private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by getAdapterListener() { + override fun onItemClick(item: MultiselectPart) { + dismiss() + getCallback().jumpToMessage(item.getMessageRecord()) + } + + override fun onItemLongClick(itemView: View, item: MultiselectPart) { + dismiss() + getCallback().jumpToMessage(item.getMessageRecord()) + } + + override fun onQuoteClicked(messageRecord: MmsMessageRecord) { + dismiss() + getCallback().jumpToMessage(messageRecord) + } + + override fun onLinkPreviewClicked(linkPreview: LinkPreview) { + dismiss() + getAdapterListener().onLinkPreviewClicked(linkPreview) + } + + override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) { + dismiss() + getAdapterListener().onQuotedIndicatorClicked(messageRecord) + } + + override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) { + dismiss() + getCallback().jumpToMessage(multiselectPart.conversationMessage.messageRecord) + } + + override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) { + dismiss() + getAdapterListener().onGroupMemberClicked(recipientId, groupId) + } + + override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) { + dismiss() + getAdapterListener().onMessageWithRecaptchaNeededClicked(messageRecord) + } + + override fun onSingleVoiceNotePlay(uri: Uri, messageId: Long, position: Double) { + super.onSingleVoiceNotePlay(uri, messageId, position) + } + + override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) { + dismiss() + getAdapterListener().onGroupMigrationLearnMoreClicked(membershipChange) + } + + override fun onChatSessionRefreshLearnMoreClicked() { + dismiss() + getAdapterListener().onChatSessionRefreshLearnMoreClicked() + } + + override fun onBadDecryptLearnMoreClicked(author: RecipientId) { + dismiss() + getAdapterListener().onBadDecryptLearnMoreClicked(author) + } + + override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) { + dismiss() + getAdapterListener().onSafetyNumberLearnMoreClicked(recipient) + } + + override fun onJoinGroupCallClicked() { + dismiss() + getAdapterListener().onJoinGroupCallClicked() + } + + override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) { + dismiss() + getAdapterListener().onInviteFriendsToGroupClicked(groupId) + } + + override fun onEnableCallNotificationsClicked() { + dismiss() + getAdapterListener().onEnableCallNotificationsClicked() + } + + override fun onCallToAction(action: String) { + dismiss() + getAdapterListener().onCallToAction(action) + } + + override fun onDonateClicked() { + dismiss() + getAdapterListener().onDonateClicked() + } + + override fun onRecipientNameClicked(target: RecipientId) { + dismiss() + getAdapterListener().onRecipientNameClicked(target) + } + + override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) { + dismiss() + getAdapterListener().onViewGiftBadgeClicked(messageRecord) + } + + override fun onActivatePaymentsClicked() { + dismiss() + getAdapterListener().onActivatePaymentsClicked() + } + + override fun onSendPaymentClicked(recipientId: RecipientId) { + dismiss() + getAdapterListener().onSendPaymentClicked(recipientId) + } + + override fun onEditedIndicatorClicked(conversationMessage: ConversationMessage) { + dismiss() + getAdapterListener().onEditedIndicatorClicked(conversationMessage) + } + + override fun onShowSafetyTips(forGroup: Boolean) = Unit + override fun onReportSpamLearnMoreClicked() = Unit + override fun onMessageRequestAcceptOptionsClicked() = Unit + override fun onItemDoubleClick(item: MultiselectPart) = Unit + override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean?) = Unit + override fun onViewResultsClicked(pollId: Long) = Unit + } + + companion object { + private val TAG = Log.tag(PinnedMessagesBottomSheet::class.java) + + private const val KEY_THREAD_ID = "thread_id" + private const val KEY_CONVERSATION_RECIPIENT_ID = "conversation_recipient_id" + + @JvmStatic + fun show(fragmentManager: FragmentManager, threadId: Long, conversationRecipientId: RecipientId) { + val args = Bundle().apply { + putLong(KEY_THREAD_ID, threadId) + putString(KEY_CONVERSATION_RECIPIENT_ID, conversationRecipientId.serialize()) + } + + val fragment = PinnedMessagesBottomSheet().apply { + arguments = args + } + + fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesRepository.kt new file mode 100644 index 0000000000..75e4f5307a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesRepository.kt @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.conversation + +import android.app.Application +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory +import org.thoughtcrime.securesms.conversation.v2.data.AttachmentHelper +import org.thoughtcrime.securesms.conversation.v2.data.ReactionHelper +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.AppDependencies + +/** + * Repository when getting the pinned messages shown in the pinned message bottom sheet + */ +class PinnedMessagesRepository { + + companion object { + private val TAG = Log.tag(PinnedMessagesRepository::class.java) + } + + fun getPinnedMessage(application: Application, threadId: Long): Observable> { + return Observable.create { emitter -> + emitter.onNext(getPinnedMessages(application, threadId)) + }.subscribeOn(Schedulers.io()) + } + + fun getPinnedMessageRecords(threadId: Long): List { + return SignalDatabase.messages.getPinnedMessages(threadId = threadId, orderByPinned = false) + } + + private fun getPinnedMessages(application: Application, threadId: Long): List { + var records: List = getPinnedMessageRecords(threadId) + + val reactionHelper = ReactionHelper() + val attachmentHelper = AttachmentHelper() + val threadRecipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(threadId)) + + reactionHelper.addAll(records) + attachmentHelper.addAll(records) + + reactionHelper.fetchReactions() + attachmentHelper.fetchAttachments() + + records = reactionHelper.buildUpdatedModels(records) + records = attachmentHelper.buildUpdatedModels(AppDependencies.application, records) + + return records.map { ConversationMessageFactory.createWithUnresolvedData(application, it, threadRecipient) } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesViewModel.kt new file mode 100644 index 0000000000..1805cc3bd1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesViewModel.kt @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.conversation + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper +import org.thoughtcrime.securesms.conversation.colors.NameColor +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.UnpinMessageJob +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * View model for the pinned messages bottom sheet + */ +class PinnedMessagesViewModel( + application: Application, + private val threadId: Long, + private val conversationRecipientId: RecipientId +) : AndroidViewModel(application) { + + companion object { + private val TAG = Log.tag(PinnedMessagesViewModel::class.java) + } + + private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper() + private val repository = PinnedMessagesRepository() + + fun getMessages(): Observable> { + return repository + .getPinnedMessage(getApplication(), threadId) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun getNameColorsMap(): Observable> { + return Observable.just(conversationRecipientId) + .map { conversationRecipientId -> + val conversationRecipient = Recipient.resolved(conversationRecipientId) + + if (conversationRecipient.groupId.isPresent) { + groupAuthorNameColorHelper.getColorMap(conversationRecipient.groupId.get()) + } else { + emptyMap() + } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun unpinMessage() { + viewModelScope.launch(Dispatchers.IO) { + repository.getPinnedMessageRecords(threadId).map { + val unpinJob = UnpinMessageJob.create(messageId = it.id) + if (unpinJob != null) { + AppDependencies.jobManager.add(unpinJob) + } else { + Log.w(TAG, "Unable to create unpin job for message ${it.id}, ignoring.") + } + } + } + } + + class Factory(private val application: Application, private val threadId: Long, private val conversationRecipientId: RecipientId) : ViewModelProvider.NewInstanceFactory() { + override fun create(modelClass: Class): T { + return modelClass.cast(PinnedMessagesViewModel(application, threadId, conversationRecipientId)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt index 85f291651c..ff0a61065d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt @@ -21,11 +21,14 @@ import org.thoughtcrime.securesms.banner.Banner import org.thoughtcrime.securesms.banner.BannerManager import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView +import org.thoughtcrime.securesms.compose.SignalTheme +import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.database.identity.IdentityRecordList import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.DynamicTheme import org.thoughtcrime.securesms.util.IdentityUtil import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.views.Stub @@ -50,6 +53,7 @@ class ConversationBannerView @JvmOverloads constructor( private val bannerStub: Stub by lazy { ViewUtil.findStubById(this, R.id.banner_stub) } private val reviewBannerStub: Stub by lazy { ViewUtil.findStubById(this, R.id.review_banner_stub) } private val voiceNotePlayerStub: Stub by lazy { ViewUtil.findStubById(this, R.id.voice_note_player_stub) } + private val pinnedMessageStub: Stub by lazy { ViewUtil.findStubById(this, R.id.pinned_message_stub) } var listener: Listener? = null @@ -129,6 +133,29 @@ class ConversationBannerView @JvmOverloads constructor( hide(voiceNotePlayerStub) } + fun showPinnedMessageStub(messages: List) { + show( + stub = pinnedMessageStub + ) { + this.apply { + setContent { + SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(context)) { + PinnedMessagesBanner( + messages = messages, + onUnpinMessage = { messageId -> listener?.onUnpinMessage(messageId) }, + onGoToMessage = { messageId -> listener?.onGoToMessage(messageId) }, + onViewAllMessages = { listener?.onViewAllMessages() } + ) + } + } + } + } + } + + fun hidePinnedMessageStub() { + hide(pinnedMessageStub) + } + private fun show(stub: Stub, bind: V.() -> Unit = {}) { TransitionManager.beginDelayedTransition(this, Slide(Gravity.TOP)) stub.get().bind() @@ -177,5 +204,8 @@ class ConversationBannerView @JvmOverloads constructor( fun onRequestReviewIndividual(recipientId: RecipientId) fun onReviewGroupMembers(groupId: GroupId.V2) fun onDismissReview() + fun onUnpinMessage(messageId: Long) + fun onGoToMessage(messageId: Long) + fun onViewAllMessages() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index fe40a0b384..5b31f15190 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -188,6 +188,7 @@ import org.thoughtcrime.securesms.conversation.MarkReadHelper import org.thoughtcrime.securesms.conversation.MenuState import org.thoughtcrime.securesms.conversation.MessageSendType import org.thoughtcrime.securesms.conversation.MessageStyler.getStyling +import org.thoughtcrime.securesms.conversation.PinnedMessagesBottomSheet import org.thoughtcrime.securesms.conversation.ReenableScheduledMessagesDialogFragment import org.thoughtcrime.securesms.conversation.ScheduleMessageContextMenu import org.thoughtcrime.securesms.conversation.ScheduleMessageDialogCallback @@ -377,6 +378,7 @@ import java.time.ZoneId import java.util.Locale import java.util.Optional import java.util.concurrent.ExecutionException +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds /** @@ -1179,6 +1181,16 @@ class ConversationFragment : } } + lifecycleScope.launch { + viewModel + .pinnedMessages + .flowWithLifecycle(viewLifecycleOwner.lifecycle) + .flowOn(Dispatchers.Main) + .collect { + presentPinnedMessage(it) + } + } + lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { val recipient = viewModel.recipientSnapshot @@ -1322,6 +1334,14 @@ class ConversationFragment : .addTo(disposables) } + private fun presentPinnedMessage(pinnedMessages: List) { + if (pinnedMessages.isNotEmpty()) { + binding.conversationBanner.showPinnedMessageStub(pinnedMessages) + } else { + binding.conversationBanner.hidePinnedMessageStub() + } + } + private fun presentTypingIndicator() { typingIndicatorAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { @@ -1656,6 +1676,55 @@ class ConversationFragment : } } + private fun handlePinMessage(conversationMessage: ConversationMessage) { + if (viewModel.pinnedMessages.value.size >= RemoteConfig.pinLimit) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(resources.getString(R.string.ConversationFragment__replace_title)) + .setMessage(resources.getString(R.string.ConversationFragment__replace_body)) + .setPositiveButton(R.string.ConversationFragment__replace) { _, _ -> + showPinForDialog(conversationMessage) + } + .setNegativeButton(R.string.ConversationFragment__cancel) { dialog, _ -> dialog.dismiss() } + .show() + } else { + showPinForDialog(conversationMessage) + } + } + + private fun showPinForDialog(conversationMessage: ConversationMessage) { + var selection = 1 + val labels = resources.getStringArray(R.array.ConversationFragment__pinned_for_labels) + val values = resources.getIntArray(R.array.ConversationFragment__pinned_for_values) + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(resources.getString(R.string.ConversationFragment__keep_pinned)) + .setSingleChoiceItems(labels, selection) { dialog, which -> + selection = which + } + .setPositiveButton(android.R.string.ok) { dialog, _ -> + if (conversationMessage.messageRecord.expiresIn > 0 && SignalStore.uiHints.shouldDisplayPinnedSheet()) { + PinDisappearingMessageBottomSheet.show(childFragmentManager) + SignalStore.uiHints.incrementSeenPinnedSheetCount() + } + disposables += viewModel + .pinMessage( + messageRecord = conversationMessage.messageRecord, + duration = if (values[selection] == -1) kotlin.time.Duration.INFINITE else values[selection].days, + threadRecipient = conversationMessage.threadRecipient + ) + .subscribe() + dialog.dismiss() + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() + } + .show() + } + + private fun handleUnpinMessage(messageId: Long) { + viewModel.unpinMessage(messageId) + } + private fun handleVideoCall() { val recipient = viewModel.recipientSnapshot ?: return if (!recipient.isGroup) { @@ -2712,6 +2781,15 @@ class ConversationFragment : progressDialog = null } + private fun viewPinnedMessage(messageId: Long) { + disposables += viewModel + .moveToMessage(messageId) + .subscribeBy( + onSuccess = { moveToPosition(it) }, + onError = { Toast.makeText(requireContext(), R.string.PinnedMessage__not_found, Toast.LENGTH_LONG).show() } + ) + } + private inner class SwipeAvailabilityProvider : ConversationItemSwipeCallback.SwipeAvailabilityProvider { override fun isSwipeAvailable(conversationMessage: ConversationMessage): Boolean { val recipient = viewModel.recipientSnapshot ?: return false @@ -3243,6 +3321,10 @@ class ConversationFragment : viewModel.toggleVote(poll, pollOption, isChecked) } + override fun onViewPinnedMessage(messageId: Long) { + viewPinnedMessage(messageId) + } + override fun onJoinGroupCallClicked() { val activity = activity ?: return val recipient = viewModel.recipientSnapshot ?: return @@ -3936,6 +4018,8 @@ class ConversationFragment : ConversationReactionOverlay.Action.VIEW_INFO -> handleDisplayDetails(conversationMessage) ConversationReactionOverlay.Action.DELETE -> handleDeleteMessages(conversationMessage.multiselectCollection.toSet()) ConversationReactionOverlay.Action.END_POLL -> handleEndPoll(conversationMessage.messageRecord.getPoll()?.id) + ConversationReactionOverlay.Action.PIN_MESSAGE -> handlePinMessage(conversationMessage) + ConversationReactionOverlay.Action.UNPIN_MESSAGE -> handleUnpinMessage(conversationMessage.messageRecord.id) } } } @@ -4136,6 +4220,22 @@ class ConversationFragment : override fun onDismissReview() { viewModel.onDismissReview() } + + override fun onUnpinMessage(messageId: Long) { + handleUnpinMessage(messageId) + } + + override fun onGoToMessage(messageId: Long) { + viewPinnedMessage(messageId) + } + + override fun onViewAllMessages() { + PinnedMessagesBottomSheet.show( + childFragmentManager, + args.threadId, + viewModel.recipientSnapshot?.id!! + ) + } } //endregion diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index eeefb9ff22..fb516bcc7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -63,6 +63,7 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.database.model.StickerRecord import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras +import org.thoughtcrime.securesms.database.model.databaseprotos.PinnedMessage import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies.databaseObserver @@ -300,6 +301,38 @@ class ConversationRepository( ) { System.currentTimeMillis() - sentTime > POLL_TERMINATE_TIMEOUT.inWholeMilliseconds } } + fun getPinnedMessages(threadId: Long): List { + return SignalDatabase.messages.getPinnedMessages(threadId = threadId, orderByPinned = true) + } + + fun pinMessage(messageRecord: MessageRecord, duration: Duration, threadRecipient: Recipient): Completable { + return Completable.create { emitter -> + val message = OutgoingMessage.pinMessage( + threadRecipient = threadRecipient, + sentTimeMillis = System.currentTimeMillis(), + expiresIn = threadRecipient.expiresInSeconds.seconds.inWholeMilliseconds, + messageExtras = MessageExtras( + pinnedMessage = PinnedMessage( + pinnedMessageId = messageRecord.id, + targetAuthorAci = messageRecord.fromRecipient.requireAci().toByteString(), + targetTimestamp = messageRecord.dateSent, + pinDurationInSeconds = if (duration.isInfinite()) MessageTable.PIN_FOREVER else duration.inWholeSeconds + ) + ) + ) + + Log.i(TAG, "Sending pin create to ${message.threadRecipient.id}, thread: ${messageRecord.threadId}") + + MessageSender.send( + AppDependencies.application, + message, + messageRecord.threadId, + MessageSender.SendType.SIGNAL, + null + ) { emitter.onComplete() } + }.subscribeOn(Schedulers.io()) + } + private fun applyUniversalExpireTimerIfNecessary(context: Context, recipient: Recipient, outgoingMessage: OutgoingMessage, threadId: Long): OutgoingMessage { if (!outgoingMessage.isExpirationUpdate && outgoingMessage.expiresIn == 0L) { val expireTimerVersion = RecipientUtil.setAndSendUniversalExpireTimerIfNecessary(context, recipient, threadId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 73c1999100..5c8f444e57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.PollVoteJob import org.thoughtcrime.securesms.jobs.RetrieveProfileJob +import org.thoughtcrime.securesms.jobs.UnpinMessageJob import org.thoughtcrime.securesms.keyboard.KeyboardUtil import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.linkpreview.LinkPreview @@ -207,6 +208,9 @@ class ConversationViewModel( private val internalBackPressedState = MutableStateFlow(BackPressedState()) val backPressedState: StateFlow = internalBackPressedState + private val internalPinnedMessages = MutableStateFlow>(emptyList()) + val pinnedMessages: StateFlow> = internalPinnedMessages + init { disposables += recipient .subscribeBy { @@ -237,6 +241,8 @@ class ConversationViewModel( _conversationThreadState.onNext(it) }) + getPinnedMessages() + disposables += conversationThreadState.flatMapObservable { threadState -> Observable.create { emitter -> val controller = threadState.items.controller @@ -248,6 +254,7 @@ class ConversationViewModel( } val conversationObserver = DatabaseObserver.Observer { controller.onDataInvalidated() + getPinnedMessages() } AppDependencies.databaseObserver.registerMessageUpdateObserver(messageUpdateObserver) @@ -339,6 +346,32 @@ class ConversationViewModel( } } + private fun getPinnedMessages() { + viewModelScope.launch(Dispatchers.IO) { + val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(threadId) + internalPinnedMessages.value = repository.getPinnedMessages(threadId).map { + ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(AppDependencies.application, it, threadRecipient!!) + } + } + } + + fun pinMessage(messageRecord: MessageRecord, duration: Duration, threadRecipient: Recipient): Completable { + return repository + .pinMessage(messageRecord, duration, threadRecipient) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun unpinMessage(messageId: Long) { + viewModelScope.launch(Dispatchers.IO) { + val unpinJob = UnpinMessageJob.create(messageId = messageId) + if (unpinJob != null) { + AppDependencies.jobManager.add(unpinJob) + } else { + Log.w(TAG, "Unable to create unpin job, ignoring.") + } + } + } + fun updateThreadHeader() { pagingController.onDataItemChanged(ConversationElementKey.threadHeader) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinDisappearingMessageBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinDisappearingMessageBottomSheet.kt new file mode 100644 index 0000000000..f707c06024 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinDisappearingMessageBottomSheet.kt @@ -0,0 +1,97 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.util.BottomSheetUtil + +/** + * Bottom sheet informing users about pinning disappearing messages + */ +class PinDisappearingMessageBottomSheet : ComposeBottomSheetDialogFragment() { + + companion object { + @JvmStatic + fun show(fragmentManager: FragmentManager) { + PinDisappearingMessageBottomSheet().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } + + @Composable + override fun SheetContent() { + PinDisappearingSheet( + onDismiss = { dismissAllowingStateLoss() } + ) + } +} + +@Composable +fun PinDisappearingSheet( + onDismiss: () -> Unit = {} +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BottomSheets.Handle() + + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_timer_80), + contentDescription = stringResource(R.string.PinnedMessage__disappearing_message_content_description), + modifier = Modifier.padding(vertical = 24.dp), + tint = Color.Unspecified + ) + + Text( + text = stringResource(R.string.PinnedMessage__disappearing_message_title), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 16.dp), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = stringResource(R.string.PinnedMessage__disappearing_message_body), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 4.dp), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Buttons.LargeTonal( + onClick = onDismiss, + modifier = Modifier.padding(top = 40.dp, bottom = 56.dp) + ) { + Text(stringResource(id = R.string.PinnedMessage__got_it)) + } + } +} + +@DayNightPreviews +@Composable +private fun PinnedDialogPreview() { + Previews.Preview { + PinDisappearingSheet() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinnedMessagesComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinnedMessagesComponent.kt new file mode 100644 index 0000000000..8ed88c5c41 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinnedMessagesComponent.kt @@ -0,0 +1,221 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.TextUtils +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.doOnPreDraw +import org.signal.core.ui.compose.DropdownMenus +import org.signal.core.ui.compose.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.emoji.EmojiTextView +import org.thoughtcrime.securesms.compose.GlideImage +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.fonts.SignalSymbols +import org.thoughtcrime.securesms.fonts.SignalSymbols.getSpannedString +import org.thoughtcrime.securesms.mms.AudioSlide +import org.thoughtcrime.securesms.mms.DecryptableUri +import org.thoughtcrime.securesms.mms.DocumentSlide +import org.thoughtcrime.securesms.mms.ImageSlide +import org.thoughtcrime.securesms.mms.StickerSlide +import org.thoughtcrime.securesms.mms.VideoSlide +import org.thoughtcrime.securesms.util.hasSharedContact +import org.thoughtcrime.securesms.util.isPoll +import org.thoughtcrime.securesms.util.isViewOnceMessage +import org.whispersystems.signalservice.api.payments.FormatterOptions +import kotlin.jvm.optionals.getOrDefault + +/** + * Displays pinned messages banner on conversation fragment + */ +@Composable +fun PinnedMessagesBanner( + messages: List = emptyList(), + onUnpinMessage: (Long) -> Unit = {}, + onGoToMessage: (Long) -> Unit = {}, + onViewAllMessages: () -> Unit = {} +) { + val menuController = remember { DropdownMenus.MenuController() } + var index by remember(messages) { mutableIntStateOf(messages.size - 1) } + val conversationMessage = messages[index % messages.size] + val message = conversationMessage.messageRecord as MmsMessageRecord + val (glyph, body, showThumbnail) = getMessageMetadata(conversationMessage) + + Column { + HorizontalDivider() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(SignalTheme.colors.colorSurface2) + .clickable { + index = (index + 1) % messages.size + onGoToMessage(message.id) + } + .padding(8.dp) + .height(IntrinsicSize.Min) + ) { + if (messages.size > 1) { + Heading(index, messages.size) + } + + if (showThumbnail && message.slideDeck.firstSlide?.uri != null) { + GlideImage( + model = DecryptableUri(message.slideDeck.firstSlide!!.uri!!), + modifier = Modifier + .padding(start = 8.dp) + .size(32.dp) + .clip(RoundedCornerShape(12.dp)) + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + ) { + Text( + text = if (message.fromRecipient.isSelf) { + stringResource(R.string.Recipient_you) + } else { + message.fromRecipient.getDisplayName(LocalContext.current) + }, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + val displayBody = if (glyph != null) { + SpannableStringBuilder() + .append(getSpannedString(LocalContext.current, SignalSymbols.Weight.REGULAR, glyph, -1)) + .append(" ") + .append(body) + } else { + body + } + + AndroidView( + factory = ::EmojiTextView + ) { view -> + view.enableRenderSpoilers() + view.text = displayBody + view.ellipsize = TextUtils.TruncateAt.END + view.maxLines = 1 + view.doOnPreDraw { + (it as EmojiTextView).ellipsizeEmojiTextForMaxLines() + } + } + } + + Box(modifier = Modifier.padding(horizontal = 8.dp)) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_pin_24), + contentDescription = stringResource(R.string.PinnedMessage__pinned), + modifier = Modifier + .clickable { menuController.show() } + .padding(vertical = 8.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + + DropdownMenus.Menu(controller = menuController, offsetX = 2.dp, offsetY = 16.dp) { menuController -> + Column { + DropdownMenus.ItemWithIcon(menuController, R.drawable.symbol_pin_slash_24, R.string.PinnedMessage__unpin_message) { onUnpinMessage(message.id) } + DropdownMenus.ItemWithIcon(menuController, R.drawable.symbol_chat_24, R.string.PinnedMessage__go_to_message) { onGoToMessage(message.id) } + DropdownMenus.ItemWithIcon(menuController, R.drawable.symbol_list_bullet_24, R.string.PinnedMessage__view_all_messages) { onViewAllMessages() } + } + } + } + } + } +} + +/** + * Heading to show how many pinned messages there are and which one (of three) is being displayed + */ +@Composable +fun Heading(selectedIndex: Int, size: Int) { + Column( + modifier = Modifier.fillMaxHeight() + ) { + for (i in 0 until size) { + Box( + modifier = Modifier + .padding(vertical = 2.dp) + .width(2.dp) + .weight(1f) + .background( + color = if (i == selectedIndex) { + MaterialTheme.colorScheme.onSurface + } else { + SignalTheme.colors.colorTransparentInverse2 + }, + shape = RoundedCornerShape(16.dp) + ) + ) + } + } +} + +/** + * Given the type of message, returns the associated glyph, body, and whether or not a thumbnail should be rendered with it + */ +@Composable +fun getMessageMetadata(conversationMessage: ConversationMessage): Triple { + val context = LocalContext.current + val message = conversationMessage.messageRecord as MmsMessageRecord + val slide = message.slideDeck.firstSlide + return if (slide is StickerSlide) { + Triple(SignalSymbols.Glyph.STICKER, SpannableString(stringResource(R.string.PinnedMessage__sticker)), false) + } else if (slide is AudioSlide) { + Triple(SignalSymbols.Glyph.AUDIO, SpannableString(stringResource(R.string.PinnedMessage__voice)), false) + } else if (slide is DocumentSlide) { + Triple(SignalSymbols.Glyph.FILE, SpannableString(slide.fileName.getOrDefault(stringResource(R.string.DocumentView_unnamed_file))), false) + } else if (message.isViewOnceMessage()) { + Triple(SignalSymbols.Glyph.VIEW_ONCE, SpannableString(stringResource(R.string.PinnedMessage__view_once)), false) + } else if (message.isPoll()) { + Triple(SignalSymbols.Glyph.POLL, SpannableString(stringResource(R.string.Poll__poll_question, message.body)), false) + } else if (message.hasSharedContact()) { + Triple(SignalSymbols.Glyph.PERSON_CIRCLE, SpannableString(message.sharedContacts.first().name.givenName), false) + } else if (message.isPaymentNotification && message.payment != null) { + Triple(SignalSymbols.Glyph.CREDIT_CARD, SpannableString(message.payment!!.amount.toString(FormatterOptions.defaults())), false) + } else if (slide?.isVideoGif == true) { + Triple(SignalSymbols.Glyph.GIF_RECTANGLE, SpannableString(stringResource(R.string.PinnedMessage__gif)), false) + } else if (slide is ImageSlide && message.body.isEmpty()) { + Triple(null, SpannableString(stringResource(R.string.PinnedMessage__photo)), true) + } else if (slide is VideoSlide && message.body.isEmpty()) { + Triple(null, SpannableString(stringResource(R.string.PinnedMessage__video)), true) + } else { + Triple(null, conversationMessage.getDisplayBody(context), true) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaBindingBridge.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaBindingBridge.kt index 562d2564f8..5a92696166 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaBindingBridge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemMediaBindingBridge.kt @@ -43,7 +43,8 @@ fun V2ConversationItemMediaIncomingBinding.bridge(): V2ConversationItemMediaBind footerBackground = conversationItemFooterBackground, alert = null, footerSpace = null, - isIncoming = true + isIncoming = true, + footerPinned = conversationItemFooterPinned ) return V2ConversationItemMediaBindingBridge( @@ -73,7 +74,8 @@ fun V2ConversationItemMediaOutgoingBinding.bridge(): V2ConversationItemMediaBind footerBackground = conversationItemFooterBackground, alert = conversationItemAlert, footerSpace = footerEndPad, - isIncoming = false + isIncoming = false, + footerPinned = conversationItemFooterPinned ) return V2ConversationItemMediaBindingBridge( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyBindingBridge.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyBindingBridge.kt index c21cfcd912..225413ca62 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyBindingBridge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyBindingBridge.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.conversation.v2.items import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.Space import android.widget.TextView import com.google.android.material.imageview.ShapeableImageView @@ -41,7 +42,8 @@ data class V2ConversationItemTextOnlyBindingBridge( val footerBackground: View, val footerSpace: Space?, val alert: AlertView?, - val isIncoming: Boolean + val isIncoming: Boolean, + val footerPinned: ImageView ) /** @@ -63,7 +65,8 @@ fun V2ConversationItemTextOnlyIncomingBinding.bridge(): V2ConversationItemTextOn footerBackground = conversationItemFooterBackground, alert = null, footerSpace = footerEndPad, - isIncoming = true + isIncoming = true, + footerPinned = conversationItemFooterPinned ) } @@ -86,6 +89,7 @@ fun V2ConversationItemTextOnlyOutgoingBinding.bridge(): V2ConversationItemTextOn footerBackground = conversationItemFooterBackground, alert = conversationItemAlert, footerSpace = footerEndPad, - isIncoming = false + isIncoming = false, + footerPinned = conversationItemFooterPinned ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt index 2df6fd5248..95d18bb8cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2ConversationItemTextOnlyViewHolder.kt @@ -95,7 +95,8 @@ open class V2ConversationItemTextOnlyViewHolder>( binding.footerDate, binding.footerExpiry, binding.deliveryStatus, - binding.footerBackground + binding.footerBackground, + binding.footerPinned ) override val reactionsView: View = binding.reactions @@ -257,6 +258,7 @@ open class V2ConversationItemTextOnlyViewHolder>( presentDate() presentDeliveryStatus() presentFooterBackground() + presentFooterPinned() presentFooterExpiry() presentFooterEndPadding() presentAlert() @@ -531,6 +533,12 @@ open class V2ConversationItemTextOnlyViewHolder>( } } + private fun presentFooterPinned() { + val pinned = binding.footerPinned + pinned.setColorFilter(themeDelegate.getFooterForegroundColor(conversationMessage), PorterDuff.Mode.SRC_IN) + pinned.visible = conversationMessage.messageRecord.pinnedUntil > 0 + } + private fun presentFooterEndPadding() { binding.footerSpace?.visibility = if (isForcedFooter() || shape.isEndingShape) { View.INVISIBLE @@ -802,7 +810,7 @@ open class V2ConversationItemTextOnlyViewHolder>( } private fun isForcedFooter(): Boolean { - return conversationMessage.messageRecord.isEditMessage || conversationMessage.messageRecord.expiresIn > 0L + return conversationMessage.messageRecord.isEditMessage || conversationMessage.messageRecord.expiresIn > 0L || conversationMessage.messageRecord.pinnedUntil > 0 } private inner class ReactionMeasureListener : V2ConversationItemLayout.OnMeasureListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt index 8a456665d4..b1e9dee530 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt @@ -35,7 +35,8 @@ class V2FooterPositionDelegate private constructor( binding.footerDate, binding.deliveryStatus, binding.footerExpiry, - binding.footerSpace + binding.footerSpace, + binding.footerPinned ), binding.bodyWrapper, binding.body, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 45f40efb96..0110af591d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -111,6 +111,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras +import org.thoughtcrime.securesms.database.model.databaseprotos.PinnedMessage import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent @@ -140,6 +141,7 @@ import org.thoughtcrime.securesms.stories.Stories.isFeatureEnabled import org.thoughtcrime.securesms.util.JsonUtils import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MessageConstraintsUtil +import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.isStory @@ -155,6 +157,7 @@ import kotlin.math.max import kotlin.math.min import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper), MessageTypes, RecipientIdDatabaseReference, ThreadIdDatabaseReference { @@ -217,6 +220,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat const val MESSAGE_EXTRAS = "message_extras" const val VOTES_UNREAD = "votes_unread" const val VOTES_LAST_SEEN = "votes_last_seen" + const val PINNED_UNTIL = "pinned_until" + const val PINNING_MESSAGE_ID = "pinning_message_id" + const val PINNED_AT = "pinned_at" const val QUOTE_NOT_PRESENT_ID = 0L const val QUOTE_TARGET_MISSING_ID = -1L @@ -224,6 +230,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat const val ADDRESSABLE_MESSAGE_LIMIT = 5 const val PARENT_STORY_MISSING_ID = -1L + const val PIN_FOREVER = Long.MAX_VALUE + const val CREATE_TABLE = """ CREATE TABLE $TABLE_NAME ( $ID INTEGER PRIMARY KEY AUTOINCREMENT, @@ -281,7 +289,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat $MESSAGE_EXTRAS BLOB DEFAULT NULL, $EXPIRE_TIMER_VERSION INTEGER DEFAULT 1 NOT NULL, $VOTES_UNREAD INTEGER DEFAULT 0, - $VOTES_LAST_SEEN INTEGER DEFAULT 0 + $VOTES_LAST_SEEN INTEGER DEFAULT 0, + $PINNED_UNTIL INTEGER DEFAULT 0, + $PINNING_MESSAGE_ID INTEGER DEFAULT 0, + $PINNED_AT INTEGER DEFAULT 0 ) """ @@ -312,7 +323,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat "CREATE INDEX IF NOT EXISTS $INDEX_THREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL", // This index is created specifically for getting the number of unread messages in a thread and therefore needs to be kept in sync with that query "CREATE INDEX IF NOT EXISTS $INDEX_THREAD_UNREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $ORIGINAL_MESSAGE_ID IS NULL AND $READ = 0", - "CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)" + "CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)", + "CREATE INDEX IF NOT EXISTS message_pinned_until_index ON $TABLE_NAME ($PINNED_UNTIL)", + "CREATE INDEX IF NOT EXISTS message_pinned_at_index ON $TABLE_NAME ($PINNED_AT)" ) private val MMS_PROJECTION_BASE = arrayOf( @@ -367,7 +380,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat REVISION_NUMBER, MESSAGE_EXTRAS, VOTES_UNREAD, - VOTES_LAST_SEEN + VOTES_LAST_SEEN, + PINNED_UNTIL ) private val MMS_PROJECTION: Array = MMS_PROJECTION_BASE + "NULL AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}" @@ -2027,7 +2041,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } - private fun rawQueryWithAttachments(where: String, arguments: Array?, reverse: Boolean = false, limit: Long = 0): Cursor { + /** + * Note: [reverse] and [orderBy] are mutually exclusive. If you want the order to be reversed, explicitly use 'ASC' or 'DESC' + */ + private fun rawQueryWithAttachments(where: String, arguments: Array?, reverse: Boolean = false, limit: Long = 0, orderBy: String = ""): Cursor { val database = databaseHelper.signalReadableDatabase var rawQueryString = """ SELECT @@ -2040,7 +2057,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat $TABLE_NAME.$ID """.toSingleLine() - if (reverse) { + if (orderBy.isNotEmpty()) { + rawQueryString += " ORDER BY $orderBy" + } else if (reverse) { rawQueryString += " ORDER BY $TABLE_NAME.$ID DESC" } @@ -2068,6 +2087,27 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } + fun getPinnedMessages(threadId: Long, orderByPinned: Boolean): List { + val cursor = rawQueryWithAttachments( + where = "$THREAD_ID = ? AND $PINNED_UNTIL > 0", + arguments = buildArgs(threadId), + reverse = true, + orderBy = if (orderByPinned) "$PINNED_AT ASC" else "" + ) + + return mmsReaderFor(cursor).use { reader -> + reader.mapNotNull { + if (!it.isMms) { + null + } else if (it.isPaymentNotification) { + SignalDatabase.payments.updateMessageWithPayment(it) as MmsMessageRecord + } else { + it as MmsMessageRecord + } + } + } + } + fun getRecentPendingMessages(): MmsReader { val now = System.currentTimeMillis() val oneDayAgo = now.milliseconds - 1.days @@ -2695,6 +2735,13 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat expiresIn = expiresIn, messageExtras = messageExtras ) + } else if (MessageTypes.isPinnedMessageUpdate(outboxType) && messageExtras != null) { + OutgoingMessage.pinMessage( + threadRecipient = threadRecipient, + sentTimeMillis = timestamp, + expiresIn = expiresIn, + messageExtras = messageExtras + ) } else { val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(outboxType)) { GiftBadge.ADAPTER.decode(Base64.decode(body)) @@ -2850,7 +2897,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat updateThread = updateThread, unarchive = true, poll = retrieved.poll, - pollTerminate = retrieved.messageExtras?.pollTerminate + pollTerminate = retrieved.messageExtras?.pollTerminate, + pinnedMessage = retrieved.messageExtras?.pinnedMessage ) if (messageId < 0) { @@ -3181,6 +3229,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat hasSpecialType = true } + if (message.messageExtras?.pinnedMessage != null) { + if (hasSpecialType) { + throw MmsException("Cannot insert message with multiple special types.") + } + type = type or MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE + hasSpecialType = true + } + val earlyDeliveryReceipts: Map = earlyDeliveryReceiptCache.remove(message.sentTimeMillis) if (earlyDeliveryReceipts.isNotEmpty()) { @@ -3296,7 +3352,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat updateThread = false, unarchive = false, poll = message.poll, - pollTerminate = message.messageExtras?.pollTerminate + pollTerminate = message.messageExtras?.pollTerminate, + pinnedMessage = message.messageExtras?.pinnedMessage ) if (messageId < 0) { @@ -3405,7 +3462,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat updateThread: Boolean, unarchive: Boolean, poll: Poll? = null, - pollTerminate: PollTerminate? = null + pollTerminate: PollTerminate? = null, + pinnedMessage: PinnedMessage? ): kotlin.Pair?> { val mentionsSelf = mentions.any { Recipient.resolved(it.recipientId).isSelf } val allAttachments: MutableList = mutableListOf() @@ -3471,6 +3529,31 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } + if (pinnedMessage != null) { + val pinnedUntil = if (pinnedMessage.pinDurationInSeconds == PIN_FOREVER) { + PIN_FOREVER + } else { + System.currentTimeMillis() + pinnedMessage.pinDurationInSeconds.seconds.inWholeMilliseconds + } + val rows = db + .update(TABLE_NAME) + .values( + PINNED_UNTIL to pinnedUntil, + PINNING_MESSAGE_ID to messageId, + PINNED_AT to System.currentTimeMillis() + ) + .where("$ID = ?", pinnedMessage.pinnedMessageId) + .run() + + if (rows <= 0) { + Log.w(TAG, "Failed to pin a message.") + } else { + enforcePinSizeLimit(threadId, RemoteConfig.pinLimit) + } + + AppDependencies.databaseObserver.notifyConversationListeners(threadId) + } + messageId to insertedAttachments } @@ -3487,6 +3570,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat threads.update(threadId, unarchive) } + if (pinnedMessage != null && pinnedMessage.pinDurationInSeconds != PIN_FOREVER) { + AppDependencies.pinnedMessageManager.scheduleIfNecessary() + } + return messageId to insertedAttachments } @@ -3544,6 +3631,31 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat return rowsDeleted } + /** + * Unpins the oldest pins if the thread exceeds the [limit] + */ + private fun enforcePinSizeLimit(threadId: Long, limit: Int) { + val pinnedList = readableDatabase + .select(PINNED_AT) + .from(TABLE_NAME) + .where("$THREAD_ID = ? AND $PINNED_UNTIL > 0", threadId) + .orderBy("$PINNED_AT DESC") + .run() + .readToList { cursor -> cursor.requireLong(PINNED_AT) } + + if (pinnedList.size > limit) { + val oldestPin = pinnedList[limit] + writableDatabase + .update(TABLE_NAME) + .values( + PINNED_UNTIL to 0, + PINNED_AT to 0 + ) + .where("$PINNED_AT > 0 AND $PINNED_AT <= ?", oldestPin) + .run() + } + } + fun deleteMessage(messageId: Long): Boolean { val threadId = getThreadIdForMessage(messageId) return deleteMessage(messageId, threadId) @@ -5084,6 +5196,33 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } + fun getOldestExpiringPinnedMessageTimestamp(): MessageRecord? { + val cursor = readableDatabase + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$PINNED_UNTIL > 0 AND $PINNED_UNTIL != ?", PIN_FOREVER) + .orderBy("$PINNED_UNTIL ASC, $ID ASC") + .limit(1) + .run() + + return mmsReaderFor(cursor).use { reader -> + reader.firstOrNull() + } + } + + fun getPinnedMessagesBefore(time: Long): List { + val cursor = readableDatabase + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$PINNED_UNTIL > 0 AND $PINNED_UNTIL <= ?", time) + .orderBy("$PINNED_UNTIL ASC, $ID ASC") + .run() + + return mmsReaderFor(cursor).use { reader -> + reader.filterNotNull() + } + } + fun getMessagesForNotificationState(stickyThreads: Collection): Cursor { val stickyQuery = StringBuilder() @@ -5306,6 +5445,15 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } + fun unpinMessage(messageId: Long, threadId: Long) { + writableDatabase.update(TABLE_NAME) + .values(PINNED_UNTIL to 0) + .where("$ID = ?", messageId) + .run() + + notifyConversationListeners(threadId) + } + @Throws(IOException::class) protected fun ?, I> removeFromDocument(messageId: Long, column: String, item: I, clazz: Class) { writableDatabase.withinTransaction { db -> @@ -5463,6 +5611,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat MessageType.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or MessageTypes.BASE_INBOX_TYPE MessageType.END_SESSION -> MessageTypes.END_SESSION_BIT or MessageTypes.BASE_INBOX_TYPE MessageType.POLL_TERMINATE -> MessageTypes.SPECIAL_TYPE_POLL_TERMINATE or MessageTypes.BASE_INBOX_TYPE + MessageType.PINNED_MESSAGE -> MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE or MessageTypes.BASE_INBOX_TYPE MessageType.GROUP_UPDATE -> { val isOnlyGroupLeave = this.groupContext?.let { GroupV2UpdateMessageUtil.isJustAGroupLeave(it) } ?: false @@ -6005,6 +6154,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat val originalMessageId: MessageId? = cursor.requireLong(ORIGINAL_MESSAGE_ID).let { if (it == 0L) null else MessageId(it) } val editCount = cursor.requireInt(REVISION_NUMBER) val isRead = cursor.requireBoolean(READ) + val pinnedUntil = cursor.requireLong(PINNED_UNTIL) val messageExtraBytes = cursor.requireBlob(MESSAGE_EXTRAS) val messageExtras = messageExtraBytes?.let { MessageExtras.ADAPTER.decode(it) } @@ -6099,6 +6249,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat originalMessageId, editCount, isRead, + pinnedUntil, messageExtras ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageType.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageType.kt index a3106152dd..6cf09d80c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageType.kt @@ -48,5 +48,8 @@ enum class MessageType { END_SESSION, /** A poll has ended **/ - POLL_TERMINATE + POLL_TERMINATE, + + /** A message has been pinned **/ + PINNED_MESSAGE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java index 5dc6cd4bc8..d2f9c7f489 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTypes.java @@ -123,6 +123,7 @@ public interface MessageTypes { long SPECIAL_TYPE_BLOCKED = 0xA00000000L; long SPECIAL_TYPE_UNBLOCKED = 0xB00000000L; long SPECIAL_TYPE_POLL_TERMINATE = 0xC00000000L; + long SPECIAL_TYPE_PINNED_MESSAGE = 0xD00000000L; long IGNORABLE_TYPESMASK_WHEN_COUNTING = END_SESSION_BIT | KEY_EXCHANGE_IDENTITY_UPDATE_BIT | KEY_EXCHANGE_IDENTITY_VERIFIED_BIT; @@ -170,6 +171,10 @@ public interface MessageTypes { return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_POLL_TERMINATE; } + static boolean isPinnedMessageUpdate(long type) { + return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_PINNED_MESSAGE; + } + static boolean isDraftMessageType(long type) { return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java index 7e99feb42c..8e488f04f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java @@ -76,6 +76,9 @@ public final class ThreadBodyUtil { } else if (MessageRecordUtil.hasPollTerminate(record)) { return record.getFromRecipient().isSelf() ? new ThreadBody(context.getString(R.string.Poll__you_poll_end, record.getMessageExtras().pollTerminate.question)) : new ThreadBody(context.getString(R.string.Poll__poll_end, record.getFromRecipient().getDisplayName(context), record.getMessageExtras().pollTerminate.question)); + } else if (MessageRecordUtil.hasPinnedMessageUpdate(record)) { + return record.getFromRecipient().isSelf() ? new ThreadBody(context.getString(R.string.PinnedMessage__you_pinned_a_message)) + : new ThreadBody(context.getString(R.string.PinnedMessage__s_pinned_a_message, record.getFromRecipient().getDisplayName(context))); } boolean hasImage = false; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 00870b7df4..6a579073bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -150,6 +150,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V292_AddPollTables import org.thoughtcrime.securesms.database.helpers.migration.V294_RemoveLastResortKeyTupleColumnConstraintMigration import org.thoughtcrime.securesms.database.helpers.migration.V295_AddLastRestoreKeyTypeTableIfMissingMigration import org.thoughtcrime.securesms.database.helpers.migration.V296_RemovePollVoteConstraint +import org.thoughtcrime.securesms.database.helpers.migration.V297_AddPinnedMessageColumns import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase /** @@ -306,10 +307,11 @@ object SignalDatabaseMigrations { // 293 to V293_LastResortKeyTupleTableMigration, - removed due to crashing on some devices. 294 to V294_RemoveLastResortKeyTupleColumnConstraintMigration, 295 to V295_AddLastRestoreKeyTypeTableIfMissingMigration, - 296 to V296_RemovePollVoteConstraint + 296 to V296_RemovePollVoteConstraint, + 297 to V297_AddPinnedMessageColumns ) - const val DATABASE_VERSION = 296 + const val DATABASE_VERSION = 297 @JvmStatic fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V297_AddPinnedMessageColumns.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V297_AddPinnedMessageColumns.kt new file mode 100644 index 0000000000..22f61fb49d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V297_AddPinnedMessageColumns.kt @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import org.thoughtcrime.securesms.database.SQLiteDatabase + +/** + * Adds the columns and indexes necessary for pinned messages + */ +@Suppress("ClassName") +object V297_AddPinnedMessageColumns : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE message ADD COLUMN pinned_until INTEGER DEFAULT 0") + db.execSQL("ALTER TABLE message ADD COLUMN pinning_message_id INTEGER DEFAULT 0") + db.execSQL("ALTER TABLE message ADD COLUMN pinned_at INTEGER DEFAULT 0") + + db.execSQL("CREATE INDEX message_pinned_until_index ON message (pinned_until)") + db.execSQL("CREATE INDEX message_pinned_at_index ON message (pinned_at)") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java index b193d6ae7d..b04693429a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DisplayRecord.java @@ -264,4 +264,8 @@ public abstract class DisplayRecord { public boolean isPollTerminate() { return MessageTypes.isPollTerminate(type); } + + public boolean isPinnedMessageUpdate() { + return MessageTypes.isPinnedMessageUpdate(type); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java index ebd7138790..d510521d3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java @@ -59,6 +59,7 @@ public class InMemoryMessageRecord extends MessageRecord { -1, null, 0, + 0, null); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 57f0806bd8..bcfbeb5f88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -113,6 +113,7 @@ public abstract class MessageRecord extends DisplayRecord { private final long receiptTimestamp; private final MessageId originalMessageId; private final int revisionNumber; + private final long pinnedUntil; private final MessageExtras messageExtras; protected Boolean isJumboji = null; @@ -135,6 +136,7 @@ public abstract class MessageRecord extends DisplayRecord { long receiptTimestamp, @Nullable MessageId originalMessageId, int revisionNumber, + long pinnedUntil, @Nullable MessageExtras messageExtras) { super(body, fromRecipient, toRecipient, dateSent, dateReceived, @@ -156,6 +158,7 @@ public abstract class MessageRecord extends DisplayRecord { this.receiptTimestamp = receiptTimestamp; this.originalMessageId = originalMessageId; this.revisionNumber = revisionNumber; + this.pinnedUntil = pinnedUntil; this.messageExtras = messageExtras; } @@ -297,6 +300,9 @@ public abstract class MessageRecord extends DisplayRecord { } else if (MessageRecordUtil.hasPollTerminate(this)) { return getFromRecipient().isSelf() ? staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_you_ended_the_poll, messageExtras.pollTerminate.question), Glyph.POLL) : staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_ended_the_poll, getFromRecipient().getDisplayName(context), messageExtras.pollTerminate.question), Glyph.POLL); + } else if (MessageRecordUtil.hasPinnedMessageUpdate(this)) { + return getFromRecipient().isSelf() ? staticUpdateDescriptionWithExpiration(context.getString(R.string.PinnedMessage__you_pinned_a_message), Glyph.PIN) + : staticUpdateDescriptionWithExpiration(context.getString(R.string.PinnedMessage__s_pinned_a_message, getFromRecipient().getDisplayName(context)), Glyph.PIN); } return null; @@ -740,7 +746,7 @@ public abstract class MessageRecord extends DisplayRecord { isProfileChange() || isGroupV1MigrationEvent() || isChatSessionRefresh() || isBadDecryptType() || isChangeNumber() || isReleaseChannelDonationRequest() || isThreadMergeEventType() || isSmsExportType() || isSessionSwitchoverEventType() || isPaymentsRequestToActivate() || isPaymentsActivated() || isReportedSpam() || isMessageRequestAccepted() || - isBlocked() || isUnblocked() || isUnsupported() || isPollTerminate(); + isBlocked() || isUnblocked() || isUnsupported() || isPollTerminate() || isPinnedMessageUpdate(); } public boolean isMediaPending() { @@ -775,6 +781,10 @@ public abstract class MessageRecord extends DisplayRecord { return MessageTypes.isChatSessionRefresh(type); } + public long getPinnedUntil() { + return pinnedUntil; + } + public boolean isInMemoryMessageRecord() { return false; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index 81a9f4871c..dd610ec7eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -120,12 +120,13 @@ public class MmsMessageRecord extends MessageRecord { @Nullable MessageId originalMessageId, int revisionNumber, boolean isRead, + long pinnedUntil, @Nullable MessageExtras messageExtras) { super(id, body, fromRecipient, fromDeviceId, toRecipient, dateSent, dateReceived, dateServer, threadId, Status.STATUS_NONE, hasDeliveryReceipt, mailbox, mismatches, failures, subscriptionId, expiresIn, expireStarted, expireTimerVersion, hasReadReceipt, - unidentified, reactions, remoteDelete, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, messageExtras); + unidentified, reactions, remoteDelete, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, pinnedUntil, messageExtras); this.slideDeck = slideDeck; this.quote = quote; @@ -338,7 +339,7 @@ public class MmsMessageRecord extends MessageRecord { getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras()); } public @NonNull MmsMessageRecord withoutQuote() { @@ -346,7 +347,7 @@ public class MmsMessageRecord extends MessageRecord { getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras()); } public @NonNull MmsMessageRecord withAttachments(@NonNull List attachments) { @@ -368,7 +369,7 @@ public class MmsMessageRecord extends MessageRecord { getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras()); } public @NonNull MmsMessageRecord withPayment(@NonNull Payment payment) { @@ -376,7 +377,7 @@ public class MmsMessageRecord extends MessageRecord { getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras()); } @@ -385,7 +386,7 @@ public class MmsMessageRecord extends MessageRecord { getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getPoll(), getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras()); } public @NonNull MmsMessageRecord withPoll(@Nullable PollRecord poll) { @@ -393,7 +394,7 @@ public class MmsMessageRecord extends MessageRecord { getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(), hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), poll, getScheduledDate(), getLatestRevisionId(), - getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras()); + getOriginalMessageId(), getRevisionNumber(), isRead(), getPinnedUntil(), getMessageExtras()); } private static @NonNull List updateContacts(@NonNull List contacts, @NonNull Map attachmentIdMap) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index 54032e7b6e..8632bca0f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.service.DeletedCallEventManager import org.thoughtcrime.securesms.service.ExpiringMessageManager import org.thoughtcrime.securesms.service.ExpiringStoriesManager import org.thoughtcrime.securesms.service.PendingRetryReceiptManager +import org.thoughtcrime.securesms.service.PinnedMessageManager import org.thoughtcrime.securesms.service.ScheduledMessageManager import org.thoughtcrime.securesms.service.TrimThreadsByDateManager import org.thoughtcrime.securesms.service.webrtc.SignalCallManager @@ -214,6 +215,11 @@ object AppDependencies { provider.provideScheduledMessageManager() } + @JvmStatic + val pinnedMessageManager: PinnedMessageManager by lazy { + provider.providePinnedMessageManager() + } + @JvmStatic val androidCallAudioManager: AudioManagerCompat by lazy { provider.provideAndroidCallAudioManager() @@ -430,6 +436,7 @@ object AppDependencies { fun provideDeadlockDetector(): DeadlockDetector fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations fun provideScheduledMessageManager(): ScheduledMessageManager + fun providePinnedMessageManager(): PinnedMessageManager fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network fun provideBillingApi(): BillingApi fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 93076dea1a..fca7bd8ad3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -66,6 +66,7 @@ import org.thoughtcrime.securesms.service.DeletedCallEventManager; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.ExpiringStoriesManager; import org.thoughtcrime.securesms.service.PendingRetryReceiptManager; +import org.thoughtcrime.securesms.service.PinnedMessageManager; import org.thoughtcrime.securesms.service.ScheduledMessageManager; import org.thoughtcrime.securesms.service.TrimThreadsByDateManager; import org.thoughtcrime.securesms.service.webrtc.SignalCallManager; @@ -271,6 +272,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { return new ScheduledMessageManager(context); } + @Override + public @NonNull PinnedMessageManager providePinnedMessageManager() { + return new PinnedMessageManager(context); + } + @Override public @NonNull Network provideLibsignalNetwork(@NonNull SignalServiceConfiguration config) { Network network = new Network(BuildConfig.LIBSIGNAL_NET_ENV, StandardUserAgentInterceptor.USER_AGENT); diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt index 0621ffb339..e61fa356b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt @@ -97,6 +97,7 @@ object SignalSymbols { CHEVRON_SQUARE_RIGHT('\uE02D'), CHEVRON_SQUARE_UP('\uE02E'), CHEVRON_SQUARE_DOWN('\uE02F'), + CREDIT_CARD('\uE127'), DROPDOWN_DOWN('\uE07F'), DROPDOWN_UP('\uE080'), DROPDOWN_TRIANGLE_DOWN('\uE082'), @@ -163,6 +164,7 @@ object SignalSymbols { PHONE('\uE063'), PHONE_FILL('\uE064'), PHOTO('\uE065'), + PIN('\uE12E'), PLAY('\uE067'), PLUS('\u002B'), PLUS_CIRCLE('\u2295'), diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java index 476c7c1f5d..8a2a91495d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java @@ -286,6 +286,7 @@ public class IndividualSendJob extends PushSendJob { SignalServiceDataMessage.GiftBadge giftBadge = getGiftBadgeFor(message); SignalServiceDataMessage.Payment payment = getPayment(message); List bodyRanges = getBodyRanges(message); + SignalServiceDataMessage.PinnedMessage pinnedMessage = getPinnedMessage(message); SignalServiceDataMessage.Builder mediaMessageBuilder = SignalServiceDataMessage.newBuilder() .withBody(message.getBody()) .withAttachments(serviceAttachments) @@ -301,7 +302,8 @@ public class IndividualSendJob extends PushSendJob { .asExpirationUpdate(message.isExpirationUpdate()) .asEndSessionMessage(message.isEndSession()) .withPayment(payment) - .withBodyRanges(bodyRanges); + .withBodyRanges(bodyRanges) + .withPinnedMessage(pinnedMessage); if (message.getParentStoryId() != null) { try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index c9a7b516e2..337d3dc14e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -289,6 +289,7 @@ public final class JobManagerFactories { put(ThreadUpdateJob.KEY, new ThreadUpdateJob.Factory()); put(TrimThreadJob.KEY, new TrimThreadJob.Factory()); put(TypingSendJob.KEY, new TypingSendJob.Factory()); + put(UnpinMessageJob.KEY, new UnpinMessageJob.Factory()); put(UploadAttachmentToArchiveJob.KEY, new UploadAttachmentToArchiveJob.Factory()); // Migrations diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index be47074659..6f314e2b10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -286,6 +286,7 @@ public final class PushGroupSendJob extends PushSendJob { List bodyRanges = getBodyRanges(message); Optional pollCreate = getPollCreate(message); Optional pollTerminate = getPollTerminate(message); + SignalServiceDataMessage.PinnedMessage pinnedMessage = getPinnedMessage(message); List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); List attachmentPointers = getAttachmentPointersFor(attachments); boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId)) @@ -368,7 +369,8 @@ public final class PushGroupSendJob extends PushSendJob { .withMentions(mentions) .withBodyRanges(bodyRanges) .withPollCreate(pollCreate.orElse(null)) - .withPollTerminate(pollTerminate.orElse(null)); + .withPollTerminate(pollTerminate.orElse(null)) + .withPinnedMessage(pinnedMessage); if (message.getParentStoryId() != null) { try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java index 5308f13b56..e4bd317adb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -7,7 +7,6 @@ package org.thoughtcrime.securesms.jobs; import android.content.Context; import android.graphics.Bitmap; -import android.os.Build; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -30,6 +29,7 @@ import org.thoughtcrime.securesms.blurhash.BlurHash; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.ContactModelMapper; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; +import org.thoughtcrime.securesms.database.MessageTable; import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.Mention; @@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.database.model.ParentStoryId; import org.thoughtcrime.securesms.database.model.StickerRecord; import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge; +import org.thoughtcrime.securesms.database.model.databaseprotos.PinnedMessage; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.events.PartProgressEvent; import org.thoughtcrime.securesms.jobmanager.Job; @@ -48,7 +49,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil; import org.thoughtcrime.securesms.keyvalue.CertificateType; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.linkpreview.LinkPreview; -import org.thoughtcrime.securesms.mms.DecryptableUri; import org.thoughtcrime.securesms.mms.OutgoingMessage; import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.mms.QuoteModel; @@ -60,9 +60,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.transport.RetryLaterException; import org.thoughtcrime.securesms.transport.UndeliverableMessageException; import org.signal.core.util.Base64; -import org.thoughtcrime.securesms.util.BitmapDecodingException; import org.thoughtcrime.securesms.util.RemoteConfig; -import org.thoughtcrime.securesms.util.ImageCompressionUtil; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress; @@ -77,10 +75,8 @@ import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulRespons import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; import org.whispersystems.signalservice.api.push.exceptions.RateLimitException; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; -import org.whispersystems.signalservice.internal.push.AttachmentPointer; import org.whispersystems.signalservice.internal.push.BodyRange; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.util.Collection; @@ -90,7 +86,6 @@ import java.util.List; import java.util.Locale; import java.util.Optional; import java.util.Set; -import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -530,6 +525,19 @@ public abstract class PushSendJob extends SendJob { }).collect(Collectors.toList()); } + protected @Nullable SignalServiceDataMessage.PinnedMessage getPinnedMessage(OutgoingMessage message) { + if (message.getMessageExtras() == null || message.getMessageExtras().pinnedMessage == null || ACI.parseOrNull(message.getMessageExtras().pinnedMessage.targetAuthorAci) == null) { + return null; + } + + PinnedMessage pinnedMessage = message.getMessageExtras().pinnedMessage; + if (pinnedMessage.pinDurationInSeconds == MessageTable.PIN_FOREVER) { + return new SignalServiceDataMessage.PinnedMessage(ACI.parseOrNull(pinnedMessage.targetAuthorAci), pinnedMessage.targetTimestamp, null, true); + } else { + return new SignalServiceDataMessage.PinnedMessage(ACI.parseOrNull(pinnedMessage.targetAuthorAci), pinnedMessage.targetTimestamp, (int) pinnedMessage.pinDurationInSeconds, null); + } + } + protected void rotateSenderCertificateIfNecessary() throws IOException { try { Collection requiredCertificateTypes = SignalStore.phoneNumberPrivacy() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/UnpinMessageJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/UnpinMessageJob.kt new file mode 100644 index 0000000000..23bd8815ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/UnpinMessageJob.kt @@ -0,0 +1,186 @@ +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.jobs.protos.UnpinJobData +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.messages.GroupSendUtil +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientUtil +import org.thoughtcrime.securesms.util.GroupUtil +import org.whispersystems.signalservice.api.crypto.ContentHint +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Companion.newBuilder +import kotlin.time.Duration.Companion.days + +/** + * Job to unpin a message sent either to 1:1 or group chat + */ +class UnpinMessageJob( + private val messageId: Long, + private val recipientIds: MutableList, + private val initialRecipientCount: Int, + parameters: Parameters +) : Job(parameters) { + + companion object { + const val KEY: String = "UnpinMessageJob" + private val TAG = Log.tag(UnpinMessageJob::class.java) + + fun create(messageId: Long): UnpinMessageJob? { + val message = SignalDatabase.messages.getMessageRecordOrNull(messageId) + if (message == null) { + Log.w(TAG, "Unable to find corresponding message") + return null + } + + val conversationRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId) + if (conversationRecipient == null) { + Log.w(TAG, "We have a message, but couldn't find the thread!") + return null + } + + val recipients = if (conversationRecipient.isGroup) { + conversationRecipient.participantIds.filter { it != Recipient.self().id }.map { it.toLong() } + } else { + listOf(conversationRecipient.id.toLong()) + } + + return UnpinMessageJob( + messageId = messageId, + recipientIds = recipients.toMutableList(), + initialRecipientCount = recipients.size, + parameters = Parameters.Builder() + .setQueue(conversationRecipient.id.toQueueKey()) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(1.days.inWholeMilliseconds) + .build() + ) + } + } + + override fun serialize(): ByteArray { + return UnpinJobData(messageId, recipientIds, initialRecipientCount).encode() + } + + override fun getFactoryKey(): String { + return KEY + } + + override fun run(): Result { + if (!SignalStore.account.isRegistered) { + Log.w(TAG, "Not registered. Skipping.") + return Result.failure() + } + + val message = SignalDatabase.messages.getMessageRecordOrNull(messageId) + if (message == null) { + Log.w(TAG, "Unable to find corresponding message") + return Result.failure() + } + + val conversationRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId) + if (conversationRecipient == null) { + Log.w(TAG, "We have a message, but couldn't find the thread!") + return Result.failure() + } + + val targetAuthor = message.fromRecipient + if (targetAuthor == null || !targetAuthor.hasServiceId) { + Log.w(TAG, "Unable to find target author") + return Result.failure() + } + + val targetSentTimestamp = message.dateSent + + val recipients = Recipient.resolvedList(recipientIds.filter { it != Recipient.self().id.toLong() }.map { RecipientId.from(it) }) + val registered = RecipientUtil.getEligibleForSending(recipients) + val unregistered = recipients - registered.toSet() + val completions: List = deliver(conversationRecipient, registered, message.threadId, targetAuthor, targetSentTimestamp) + + recipientIds.removeAll(unregistered.map { it.id.toLong() }) + recipientIds.removeAll(completions.map { it.id.toLong() }) + + Log.i(TAG, "Completed now: " + completions.size + ", Remaining: " + recipientIds.size) + + if (recipientIds.isNotEmpty()) { + Log.w(TAG, "Still need to send to " + recipientIds.size + " recipients. Retrying.") + return Result.retry(defaultBackoff()) + } + + return Result.success() + } + + private fun deliver(conversationRecipient: Recipient, destinations: List, threadId: Long, targetAuthor: Recipient, targetSentTimestamp: Long): List { + val dataMessageBuilder = newBuilder() + .withTimestamp(System.currentTimeMillis()) + .withUnpinnedMessage( + SignalServiceDataMessage.UnpinnedMessage( + targetAuthor = targetAuthor.requireServiceId(), + targetSentTimestamp = targetSentTimestamp + ) + ) + + if (conversationRecipient.isGroup) { + GroupUtil.setDataMessageGroupContext(context, dataMessageBuilder, conversationRecipient.requireGroupId().requirePush()) + } + + val dataMessage = dataMessageBuilder.build() + + val results = GroupSendUtil.sendResendableDataMessage( + context, + conversationRecipient.groupId.map { obj: GroupId -> obj.requireV2() }.orElse(null), + null, + destinations, + false, + ContentHint.RESENDABLE, + MessageId(messageId), + dataMessage, + false, + false, + null + ) + + val result = GroupSendJobHelper.getCompletedSends(destinations, results) + + for (unregistered in result.unregistered) { + SignalDatabase.recipients.markUnregistered(unregistered) + } + + if (result.completed.isNotEmpty() || destinations.isEmpty()) { + SignalDatabase.messages.unpinMessage( + messageId = messageId, + threadId = threadId + ) + } + + return result.completed + } + + override fun onFailure() { + if (recipientIds.size < initialRecipientCount) { + Log.w(TAG, "Only sent unpinned to " + recipientIds.size + "/" + initialRecipientCount + " recipients.") + } else { + Log.w(TAG, "Failed to send to all recipients.") + } + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): UnpinMessageJob { + val data = UnpinJobData.ADAPTER.decode(serializedData!!) + + return UnpinMessageJob( + messageId = data.messageId, + recipientIds = data.recipients.toMutableList(), + initialRecipientCount = data.initialRecipientCount, + parameters = parameters + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHintValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHintValues.java index e2ad0a6b39..0579f8b3b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHintValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHintValues.java @@ -8,6 +8,7 @@ import java.util.List; public class UiHintValues extends SignalStoreValues { private static final int NEVER_DISPLAY_PULL_TO_FILTER_TIP_THRESHOLD = 3; + private static final int HAS_SEEN_PINNED_MESSAGE_SHEET_THRESHOLD = 3; private static final String HAS_SEEN_GROUP_SETTINGS_MENU_TOAST = "uihints.has_seen_group_settings_menu_toast"; private static final String HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE = "uihints.has_confirmed_delete_for_everyone_once"; @@ -29,6 +30,7 @@ public class UiHintValues extends SignalStoreValues { private static final String HAS_SEEN_CHAT_FOLDERS_EDUCATION_SHEET = "uihints.has_seen_chat_folders_education_sheet"; private static final String HAS_SEEN_LINK_DEVICE_QR_EDUCATION_SHEET = "uihints.has_seen_link_device_qr_education_sheet"; private static final String HAS_DISMISSED_SAVE_STORAGE_WARNING = "uihints.has_dismissed_save_storage_warning"; + private static final String HAS_SEEN_PINNED_MESSAGE_SHEET = "uihints.has_seen_pinned_message_sheet"; UiHintValues(@NonNull KeyValueStore store) { super(store); @@ -218,4 +220,16 @@ public class UiHintValues extends SignalStoreValues { public void markDismissedSaveStorageWarning() { putBoolean(HAS_DISMISSED_SAVE_STORAGE_WARNING, true); } + + public boolean shouldDisplayPinnedSheet() { + return getSeenPinnedSheetCount() < HAS_SEEN_PINNED_MESSAGE_SHEET_THRESHOLD; + } + + public void incrementSeenPinnedSheetCount() { + putInteger(HAS_SEEN_PINNED_MESSAGE_SHEET, getSeenPinnedSheetCount() + 1); + } + + private int getSeenPinnedSheetCount() { + return getInteger(HAS_SEEN_PINNED_MESSAGE_SHEET, 0); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt index cb36196141..8d46bf4aad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt @@ -410,6 +410,10 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter Log.w(TAG, "Not yet implemented!", Exception()) } + override fun onViewPinnedMessage(messageId: Long) { + Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show() + } + interface Callback { fun onMessageDetailsFragmentDismissed() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt index 7bc8baa79d..108f7f63e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt @@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.contactshare.ContactModelMapper import org.thoughtcrime.securesms.crypto.ProfileKeyUtil import org.thoughtcrime.securesms.crypto.SecurityEvent import org.thoughtcrime.securesms.database.AttachmentTable +import org.thoughtcrime.securesms.database.MessageTable import org.thoughtcrime.securesms.database.MessageTable.InsertResult import org.thoughtcrime.securesms.database.MessageType import org.thoughtcrime.securesms.database.NoSuchMessageException @@ -42,6 +43,7 @@ import org.thoughtcrime.securesms.database.model.StickerRecord import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras +import org.thoughtcrime.securesms.database.model.databaseprotos.PinnedMessage import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate import org.thoughtcrime.securesms.database.model.toBodyRangeList import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -178,6 +180,8 @@ object DataMessageProcessor { message.pollCreate != null -> insertResult = handlePollCreate(context, envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime) message.pollTerminate != null -> insertResult = handlePollTerminate(context, envelope, metadata, message, senderRecipient, earlyMessageCacheEntry, threadRecipient, groupId, receivedTime) message.pollVote != null -> messageId = handlePollVote(context, envelope, message, senderRecipient, earlyMessageCacheEntry) + message.pinMessage != null -> insertResult = handlePinMessage(envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime, earlyMessageCacheEntry) + message.unpinMessage != null -> messageId = handleUnpinMessage(envelope, message, senderRecipient, threadRecipient, earlyMessageCacheEntry) } messageId = messageId ?: insertResult?.messageId?.let { MessageId(it) } @@ -1247,6 +1251,150 @@ object DataMessageProcessor { return messageId } + fun handlePinMessage( + envelope: Envelope, + metadata: EnvelopeMetadata, + message: DataMessage, + senderRecipient: Recipient, + threadRecipient: Recipient, + groupId: GroupId.V2?, + receivedTime: Long, + earlyMessageCacheEntry: EarlyMessageCacheEntry? = null + ): InsertResult? { + if (!RemoteConfig.receivePinnedMessages) { + log(envelope.timestamp!!, "Pinned message not allowed due to remote config.") + return null + } + + val pinMessage = message.pinMessage!! + log(envelope.timestamp!!, "[handlePinMessage] Pin message for " + pinMessage.targetSentTimestamp) + + handlePossibleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime) + + val targetAuthorServiceId: ServiceId = ACI.parseOrThrow(pinMessage.targetAuthorAciBinary!!) + if (targetAuthorServiceId.isUnknown) { + warn(envelope.timestamp!!, "[handlePinMessage] Unknown target author! Ignoring the message.") + return null + } + + val targetAuthor = Recipient.externalPush(targetAuthorServiceId) + + val targetMessage: MmsMessageRecord? = SignalDatabase.messages.getMessageFor(pinMessage.targetSentTimestamp!!, targetAuthor.id) as? MmsMessageRecord + if (targetMessage == null) { + warn(envelope.timestamp!!, "[handlePinMessage] Could not find matching message! Putting it in the early message cache. timestamp: ${pinMessage.targetSentTimestamp}") + if (earlyMessageCacheEntry != null) { + AppDependencies.earlyMessageCache.store(targetAuthor.id, pinMessage.targetSentTimestamp!!, earlyMessageCacheEntry) + PushProcessEarlyMessagesJob.enqueue() + } + return null + } + + if (targetMessage.isRemoteDelete) { + warn(envelope.timestamp!!, "[handlePinMessage] Found a matching message, but it's flagged as remotely deleted. timestamp: ${pinMessage.targetSentTimestamp}") + return null + } + + val targetThread = SignalDatabase.threads.getThreadRecord(targetMessage.threadId) + if (targetThread == null) { + warn(envelope.timestamp!!, "[handlePinMessage] Could not find a thread for the message! timestamp: ${pinMessage.targetSentTimestamp}") + return null + } + + val groupRecord = SignalDatabase.groups.getGroup(threadRecipient.id).orNull() + if (groupRecord != null && !groupRecord.members.contains(senderRecipient.id)) { + warn(envelope.timestamp!!, "[handlePinMessage] Sender is not in the group! timestamp: ${pinMessage.targetSentTimestamp}") + return null + } + + if (groupRecord == null && senderRecipient.id != threadRecipient.id && Recipient.self().id != senderRecipient.id) { + warn(envelope.timestamp!!, "[handlePinMessage] Sender is not a part of the 1:1 thread! timestamp: ${pinMessage.targetSentTimestamp}") + return null + } + + val duration = if (pinMessage.pinDurationForever == true) MessageTable.PIN_FOREVER else pinMessage.pinDurationSeconds!!.toLong() + val pinnedMessage = IncomingMessage( + type = MessageType.PINNED_MESSAGE, + from = senderRecipient.id, + sentTimeMillis = envelope.timestamp!!, + serverTimeMillis = envelope.serverTimestamp!!, + receivedTimeMillis = receivedTime, + expiresIn = message.expireTimerDuration.inWholeMilliseconds, + groupId = groupId, + isUnidentified = metadata.sealedSender, + serverGuid = UuidUtil.getStringUUID(envelope.serverGuid, envelope.serverGuidBinary), + messageExtras = MessageExtras(pinnedMessage = PinnedMessage(pinnedMessageId = targetMessage.id, targetAuthorAci = pinMessage.targetAuthorAciBinary!!, targetTimestamp = pinMessage.targetSentTimestamp!!, pinDurationInSeconds = duration)) + ) + + val insertResult: InsertResult? = SignalDatabase.messages.insertMessageInbox(pinnedMessage).orNull() + + return if (insertResult != null) { + log(envelope.timestamp!!, "Inserted a pinned message update at ${insertResult.messageId}") + insertResult + } else { + null + } + } + + fun handleUnpinMessage( + envelope: Envelope, + message: DataMessage, + senderRecipient: Recipient, + threadRecipient: Recipient, + earlyMessageCacheEntry: EarlyMessageCacheEntry? = null + ): MessageId? { + if (!RemoteConfig.receivePinnedMessages) { + log(envelope.timestamp!!, "Unpinning message is not allowed due to remote config.") + return null + } + + val unpinMessage = message.unpinMessage!! + log(envelope.timestamp!!, "[handleUnpinMessage] Unpin message for ${unpinMessage.targetSentTimestamp}") + + val targetAuthorServiceId: ServiceId = ACI.parseOrThrow(unpinMessage.targetAuthorAciBinary!!) + if (targetAuthorServiceId.isUnknown) { + warn(envelope.timestamp!!, "[handleUnpinMessage] Unknown target author! Ignoring the message.") + return null + } + + val targetAuthor = Recipient.externalPush(targetAuthorServiceId) + + val targetMessage: MmsMessageRecord? = SignalDatabase.messages.getMessageFor(unpinMessage.targetSentTimestamp!!, targetAuthor.id) as? MmsMessageRecord + if (targetMessage == null) { + warn(envelope.timestamp!!, "[handleUnpinMessage] Could not find matching message! Putting it in the early message cache. timestamp: ${unpinMessage.targetSentTimestamp}") + if (earlyMessageCacheEntry != null) { + AppDependencies.earlyMessageCache.store(targetAuthor.id, unpinMessage.targetSentTimestamp!!, earlyMessageCacheEntry) + PushProcessEarlyMessagesJob.enqueue() + } + return null + } + + if (targetMessage.isRemoteDelete) { + warn(envelope.timestamp!!, "[handleUnpinMessage] Found a matching message, but it's flagged as remotely deleted. timestamp: ${unpinMessage.targetSentTimestamp}") + return null + } + + val targetThread = SignalDatabase.threads.getThreadRecord(targetMessage.threadId) + if (targetThread == null) { + warn(envelope.timestamp!!, "[handleUnpinMessage] Could not find a thread for the message! timestamp: ${unpinMessage.targetSentTimestamp}") + return null + } + + val groupRecord = SignalDatabase.groups.getGroup(threadRecipient.id).orNull() + if (groupRecord != null && !groupRecord.members.contains(senderRecipient.id)) { + warn(envelope.timestamp!!, "[handleUnpinMessage] Sender is not in the group! timestamp: ${unpinMessage.targetSentTimestamp}") + return null + } + + if (groupRecord == null && senderRecipient.id != threadRecipient.id && Recipient.self().id != senderRecipient.id) { + warn(envelope.timestamp!!, "[handleUnpinMessage] Sender is not a part of the 1:1 thread! timestamp: ${unpinMessage.targetSentTimestamp}") + return null + } + + SignalDatabase.messages.unpinMessage(targetMessage.id, targetMessage.threadId) + + return MessageId(targetMessage.id) + } + fun notifyTypingStoppedFromIncomingMessage(context: Context, senderRecipient: Recipient, threadRecipientId: RecipientId, device: Int) { val threadId = SignalDatabase.threads.getThreadIdIfExistsFor(threadRecipientId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt index 2a4897c212..096cb64c7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt @@ -499,6 +499,17 @@ data class OutgoingMessage( ) } + @JvmStatic + fun pinMessage(threadRecipient: Recipient, sentTimeMillis: Long, expiresIn: Long, messageExtras: MessageExtras): OutgoingMessage { + return OutgoingMessage( + threadRecipient = threadRecipient, + sentTimeMillis = sentTimeMillis, + expiresIn = expiresIn, + messageExtras = messageExtras, + isSecure = true + ) + } + @JvmStatic fun quickReply( threadRecipient: Recipient, diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/PinnedMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/PinnedMessageManager.kt new file mode 100644 index 0000000000..d11ef2b046 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/PinnedMessageManager.kt @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.service + +import android.app.Application +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.annotation.WorkerThread +import org.signal.core.util.PendingIntentFlags +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * Manages waking up and unpinning pinned messages at the correct time + */ +class PinnedMessageManager( + val application: Application +) : TimedEventManager(application, "PinnedMessagesManager") { + + companion object { + private val TAG = Log.tag(PinnedMessageManager::class.java) + } + + private val messagesTable = SignalDatabase.messages + + init { + scheduleIfNecessary() + } + + @WorkerThread + override fun getNextClosestEvent(): Event? { + val oldestMessage: MmsMessageRecord? = messagesTable.getOldestExpiringPinnedMessageTimestamp() as? MmsMessageRecord + + if (oldestMessage == null) { + cancelAlarm(application, PinnedMessagesAlarm::class.java) + return null + } + + val delay = (oldestMessage.pinnedUntil - System.currentTimeMillis()).coerceAtLeast(0) + Log.i(TAG, "The next pinned message needs to be unpinned in $delay ms.") + + return Event(delay, oldestMessage.toRecipient.id, oldestMessage.threadId) + } + + @WorkerThread + override fun executeEvent(event: Event) { + val pinnedMessagesToUnpin = messagesTable.getPinnedMessagesBefore(System.currentTimeMillis()) + for (record in pinnedMessagesToUnpin) { + messagesTable.unpinMessage(messageId = record.id, threadId = record.threadId) + // TODO(michelle): Send sync message to linked device to unpin message (done to ensure consistency) + } + } + + @WorkerThread + override fun getDelayForEvent(event: Event): Long = event.delay + + @WorkerThread + override fun scheduleAlarm(application: Application, event: Event, delay: Long) { + val conversationIntent = ConversationIntents.createBuilderSync(application, event.recipientId, event.threadId).build() + + trySetExactAlarm( + application, + System.currentTimeMillis() + delay, + PinnedMessagesAlarm::class.java, + PendingIntent.getActivity(application, 0, conversationIntent, PendingIntentFlags.mutable()) + ) + } + + data class Event(val delay: Long, val recipientId: RecipientId, val threadId: Long) + + class PinnedMessagesAlarm : BroadcastReceiver() { + + companion object { + private val TAG = Log.tag(PinnedMessagesAlarm::class.java) + } + + override fun onReceive(context: Context?, intent: Intent?) { + Log.d(TAG, "onReceive()") + AppDependencies.pinnedMessageManager.scheduleIfNecessary() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt index d794b22df7..6cfe39ecd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt @@ -23,32 +23,23 @@ fun MessageRecord.isMediaMessage(): Boolean { slideDeck.stickerSlide == null } -fun MessageRecord.hasNonTextSlide(): Boolean = - isMms && (this as MmsMessageRecord).slideDeck.slides.any { slide -> slide !is TextSlide } +fun MessageRecord.hasNonTextSlide(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.slides.any { slide -> slide !is TextSlide } -fun MessageRecord.hasSticker(): Boolean = - isMms && (this as MmsMessageRecord).slideDeck.stickerSlide != null +fun MessageRecord.hasSticker(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.stickerSlide != null -fun MessageRecord.hasSharedContact(): Boolean = - isMms && (this as MmsMessageRecord).sharedContacts.isNotEmpty() +fun MessageRecord.hasSharedContact(): Boolean = isMms && (this as MmsMessageRecord).sharedContacts.isNotEmpty() -fun MessageRecord.hasLocation(): Boolean = - isMms && ((this as MmsMessageRecord).slideDeck.slides).any { slide -> slide.hasLocation() } +fun MessageRecord.hasLocation(): Boolean = isMms && ((this as MmsMessageRecord).slideDeck.slides).any { slide -> slide.hasLocation() } -fun MessageRecord.hasAudio(): Boolean = - isMms && (this as MmsMessageRecord).slideDeck.audioSlide != null +fun MessageRecord.hasAudio(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.audioSlide != null -fun MessageRecord.isCaptionlessMms(context: Context): Boolean = - isMms && isDisplayBodyEmpty(context) && (this as MmsMessageRecord).slideDeck.textSlide == null +fun MessageRecord.isCaptionlessMms(context: Context): Boolean = isMms && isDisplayBodyEmpty(context) && (this as MmsMessageRecord).slideDeck.textSlide == null -fun MessageRecord.hasThumbnail(): Boolean = - isMms && (this as MmsMessageRecord).slideDeck.thumbnailSlide != null +fun MessageRecord.hasThumbnail(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.thumbnailSlide != null -fun MessageRecord.isStoryReaction(): Boolean = - isMms && MessageTypes.isStoryReaction(type) +fun MessageRecord.isStoryReaction(): Boolean = isMms && MessageTypes.isStoryReaction(type) -fun MessageRecord.isStory(): Boolean = - isMms && (this as MmsMessageRecord).storyType.isStory +fun MessageRecord.isStory(): Boolean = isMms && (this as MmsMessageRecord).storyType.isStory fun MessageRecord.isBorderless(context: Context): Boolean { return isCaptionlessMms(context) && @@ -56,8 +47,7 @@ fun MessageRecord.isBorderless(context: Context): Boolean { (this as MmsMessageRecord).slideDeck.thumbnailSlide?.isBorderless == true } -fun MessageRecord.hasNoBubble(context: Context): Boolean = - hasSticker() || isBorderless(context) || (isTextOnly(context) && isJumbomoji(context) && (messageRanges?.ranges?.isEmpty() ?: true)) +fun MessageRecord.hasNoBubble(context: Context): Boolean = hasSticker() || isBorderless(context) || (isTextOnly(context) && isJumbomoji(context) && (messageRanges?.ranges?.isEmpty() ?: true)) fun MessageRecord.hasOnlyThumbnail(context: Context): Boolean { return hasThumbnail() && @@ -69,11 +59,9 @@ fun MessageRecord.hasOnlyThumbnail(context: Context): Boolean { !isViewOnceMessage() } -fun MessageRecord.hasDocument(): Boolean = - isMms && (this as MmsMessageRecord).slideDeck.documentSlide != null +fun MessageRecord.hasDocument(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.documentSlide != null -fun MessageRecord.isViewOnceMessage(): Boolean = - isMms && (this as MmsMessageRecord).isViewOnce +fun MessageRecord.isViewOnceMessage(): Boolean = isMms && (this as MmsMessageRecord).isViewOnce fun MessageRecord.hasExtraText(): Boolean { val hasTextSlide = isMms && (this as MmsMessageRecord).slideDeck.textSlide != null @@ -82,24 +70,19 @@ fun MessageRecord.hasExtraText(): Boolean { return hasTextSlide || hasOverflowText } -fun MessageRecord.hasQuote(): Boolean = - isMms && (this as MmsMessageRecord).quote != null +fun MessageRecord.hasQuote(): Boolean = isMms && (this as MmsMessageRecord).quote != null -fun MessageRecord.getQuote(): Quote? = - if (isMms) { - (this as MmsMessageRecord).quote - } else { - null - } +fun MessageRecord.getQuote(): Quote? = if (isMms) { + (this as MmsMessageRecord).quote +} else { + null +} -fun MessageRecord.hasLinkPreview(): Boolean = - isMms && (this as MmsMessageRecord).linkPreviews.isNotEmpty() +fun MessageRecord.hasLinkPreview(): Boolean = isMms && (this as MmsMessageRecord).linkPreviews.isNotEmpty() -fun MessageRecord.hasTextSlide(): Boolean = - isMms && (this as MmsMessageRecord).slideDeck.textSlide != null && this.slideDeck.textSlide?.uri != null +fun MessageRecord.hasTextSlide(): Boolean = isMms && (this as MmsMessageRecord).slideDeck.textSlide != null && this.slideDeck.textSlide?.uri != null -fun MessageRecord.requireTextSlide(): TextSlide = - requireNotNull((this as MmsMessageRecord).slideDeck.textSlide) +fun MessageRecord.requireTextSlide(): TextSlide = requireNotNull((this as MmsMessageRecord).slideDeck.textSlide) fun MessageRecord.hasPoll(): Boolean = isMms && (this as MmsMessageRecord).poll != null @@ -107,6 +90,8 @@ fun MessageRecord.getPoll(): PollRecord? = if (isMms) (this as MmsMessageRecord) fun MessageRecord.hasPollTerminate(): Boolean = this.isPollTerminate && this.messageExtras != null && this.messageExtras!!.pollTerminate != null +fun MessageRecord.hasPinnedMessageUpdate(): Boolean = this.isPinnedMessageUpdate && this.messageExtras != null && this.messageExtras!!.pinnedMessage != null + fun MessageRecord.hasBigImageLinkPreview(context: Context): Boolean { if (!hasLinkPreview()) { return false diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index bc3fd5b69c..06e67141e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1205,5 +1205,29 @@ object RemoteConfig { defaultValue = false, hotSwappable = true ) + + @JvmStatic + @get:JvmName("pinLimit") + val pinLimit: Int by remoteInt( + key = "global.pinnedMessageLimit", + defaultValue = 3, + hotSwappable = true + ) + + @JvmStatic + @get:JvmName("receivePinnedMessages") + val receivePinnedMessages: Boolean by remoteBoolean( + key = "android.receivePinnedMessages", + defaultValue = false, + hotSwappable = true + ) + + @JvmStatic + @get:JvmName("sendPinnedMessages") + val sendPinnedMessages: Boolean by remoteBoolean( + key = "android.sendPinnedMessages", + defaultValue = false, + hotSwappable = true + ) // endregion } diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index e974d4c3d0..d04f85a668 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -535,6 +535,7 @@ message MessageExtras { ProfileChangeDetails profileChangeDetails = 3; PaymentTombstone paymentTombstone = 4; PollTerminate pollTerminate = 5; + PinnedMessage pinnedMessage = 6; } } @@ -555,6 +556,13 @@ message PollTerminate { uint64 targetTimestamp = 3; } +message PinnedMessage { + uint64 pinnedMessageId = 1; + bytes targetAuthorAci = 2; + uint64 targetTimestamp = 3; + uint64 pinDurationInSeconds = 4; // Long.MAX_VALUE if pin is forever +} + message LocalRegistrationMetadata { bytes aciIdentityKeyPair = 1; bytes aciSignedPreKey = 2; diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index e17d8f186e..1c811b5051 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -251,4 +251,10 @@ message PollVoteJobData { uint32 voteCount = 4; bool isRemoval = 5; uint64 optionId = 6; +} + +message UnpinJobData { + uint64 messageId = 1; + repeated uint64 recipients = 2; + uint32 initialRecipientCount = 3; } \ No newline at end of file diff --git a/app/src/main/res/drawable/symbol_pin_filled_12.xml b/app/src/main/res/drawable/symbol_pin_filled_12.xml new file mode 100644 index 0000000000..e34968dc68 --- /dev/null +++ b/app/src/main/res/drawable/symbol_pin_filled_12.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/symbol_timer_80.xml b/app/src/main/res/drawable/symbol_timer_80.xml new file mode 100644 index 0000000000..69dbe0696e --- /dev/null +++ b/app/src/main/res/drawable/symbol_timer_80.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/layout/conversation_item_footer.xml b/app/src/main/res/layout/conversation_item_footer.xml index 1306dfe017..ce621b2c2c 100644 --- a/app/src/main/res/layout/conversation_item_footer.xml +++ b/app/src/main/res/layout/conversation_item_footer.xml @@ -54,7 +54,7 @@ android:visibility="gone" app:backgroundTint="@color/transparent_white_20" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/footer_date" + app:layout_constraintEnd_toStartOf="@id/footer_pinned" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toEndOf="@id/footer_revealed_dot" app:layout_constraintTop_toTopOf="parent" @@ -62,6 +62,17 @@ tools:text="1x" tools:visibility="visible" /> + + + + diff --git a/app/src/main/res/layout/pinned_messages_bottom_sheet.xml b/app/src/main/res/layout/pinned_messages_bottom_sheet.xml new file mode 100644 index 0000000000..5d636c1187 --- /dev/null +++ b/app/src/main/res/layout/pinned_messages_bottom_sheet.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/v2_conversation_fragment.xml b/app/src/main/res/layout/v2_conversation_fragment.xml index 899746b0bb..f7e02338ce 100644 --- a/app/src/main/res/layout/v2_conversation_fragment.xml +++ b/app/src/main/res/layout/v2_conversation_fragment.xml @@ -190,6 +190,13 @@ android:inflatedId="@+id/banner_compose_view" android:layout="@layout/conversation_list_banner_view" /> + + diff --git a/app/src/main/res/layout/v2_conversation_item_media_incoming.xml b/app/src/main/res/layout/v2_conversation_item_media_incoming.xml index de74d26886..2d9bb50e43 100644 --- a/app/src/main/res/layout/v2_conversation_item_media_incoming.xml +++ b/app/src/main/res/layout/v2_conversation_item_media_incoming.xml @@ -161,11 +161,23 @@ android:visibility="gone" app:layout_constraintBottom_toBottomOf="@id/conversation_item_footer_date" app:layout_constraintEnd_toEndOf="@id/conversation_item_expiration_timer" - app:layout_constraintStart_toStartOf="@id/conversation_item_footer_date" + app:layout_constraintStart_toStartOf="@id/conversation_item_footer_pinned" app:layout_constraintTop_toTopOf="@id/conversation_item_footer_date" tools:background="@color/blue_500" tools:visibility="visible" /> + + + + + + + app:constraint_referenced_ids="conversation_item_delivery_status,conversation_item_footer_date,conversation_item_footer_pinned" /> + app:constraint_referenced_ids="conversation_item_delivery_status,conversation_item_footer_date,conversation_item_footer_pinned" /> + + @string/PlaybackSpeedToggleTextView__p5x + + 1 + 7 + 30 + -1 + + + @string/ConversationFragment__24_hours + @string/ConversationFragment__7_days + @string/ConversationFragment__30_days + @string/ConversationFragment__forever + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a5cf650f1..17ef8c179f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -682,6 +682,22 @@ Group names Photo failed to download. Try again. + + Keep pinned for… + + 24 hours + + 7 days + + 30 days + + Forever + + Replace oldest pin? + + Pinning message will replace the oldest one + + Replace Safety Tips @@ -4466,6 +4482,10 @@ Payment details End poll + + Pin message + + Unpin message @@ -9006,5 +9026,42 @@ Thanks for your feedback! + + %1$s pinned a message + + You pinned a message + + Go to message + + Message not found + + Pinned + + Photo + + Video + + Sticker + + Voice message + + GIF + + View-once media + + Unpin message + + View all messages + + Unpin All Messages + + Pinning disappearing messages + + Disappearing messages will be unpinned when their timer expires and the message is removed from the chat. + + Got it + + Disappearing message + diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 398e623ba9..68db20423d 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.service.DeletedCallEventManager import org.thoughtcrime.securesms.service.ExpiringMessageManager import org.thoughtcrime.securesms.service.ExpiringStoriesManager import org.thoughtcrime.securesms.service.PendingRetryReceiptManager +import org.thoughtcrime.securesms.service.PinnedMessageManager import org.thoughtcrime.securesms.service.ScheduledMessageManager import org.thoughtcrime.securesms.service.TrimThreadsByDateManager import org.thoughtcrime.securesms.service.webrtc.SignalCallManager @@ -214,6 +215,10 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { return mockk(relaxed = true) } + override fun providePinnedMessageManager(): PinnedMessageManager { + return mockk(relaxed = true) + } + override fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network { return mockk(relaxed = true) } diff --git a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt index 984f22fc42..61b1790e8f 100644 --- a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt +++ b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt @@ -203,6 +203,7 @@ object FakeMessageRecords { null, 0, false, + 0, null ) } diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/DropdownMenus.kt b/core-ui/src/main/java/org/signal/core/ui/compose/DropdownMenus.kt index 1df16acd79..65072e8a36 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/DropdownMenus.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/DropdownMenus.kt @@ -5,22 +5,33 @@ package org.signal.core.ui.compose +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -81,6 +92,36 @@ object DropdownMenus { ) } + /** + * Properly styled menu item with a leading icon + */ + @Composable + fun ItemWithIcon( + menuController: MenuController, + @DrawableRes drawableResId: Int, + @StringRes stringResId: Int, + onClick: () -> Unit + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(onClick = { + onClick() + menuController.hide() + }) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Icon( + imageVector = ImageVector.vectorResource(id = drawableResId), + contentDescription = stringResource(stringResId) + ) + Text( + text = stringResource(stringResId), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + /** * Menu controller to hold menu display state and allow other components * to show and hide it. diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index 1fb1e05426..8d76fb9827 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -1279,6 +1279,31 @@ public class SignalServiceMessageSender { .build()); } + if (message.getPinnedMessage().isPresent()) { + SignalServiceDataMessage.PinnedMessage pinnedMessage = message.getPinnedMessage().get(); + if (Boolean.TRUE.equals(pinnedMessage.getForever())) { + builder.pinMessage(new DataMessage.PinMessage.Builder() + .targetAuthorAciBinary(pinnedMessage.getTargetAuthor().toByteString()) + .targetSentTimestamp(pinnedMessage.getTargetSentTimestamp()) + .pinDurationForever(true) + .build()); + } else { + builder.pinMessage(new DataMessage.PinMessage.Builder() + .targetAuthorAciBinary(pinnedMessage.getTargetAuthor().toByteString()) + .targetSentTimestamp(pinnedMessage.getTargetSentTimestamp()) + .pinDurationSeconds(pinnedMessage.getPinDurationInSeconds()) + .build()); + } + } + + if (message.getUnpinnedMessage().isPresent()) { + SignalServiceDataMessage.UnpinnedMessage unpinnedMessage = message.getUnpinnedMessage().get(); + builder.unpinMessage(new DataMessage.UnpinMessage.Builder() + .targetAuthorAciBinary(unpinnedMessage.getTargetAuthor().toByteString()) + .targetSentTimestamp(unpinnedMessage.getTargetSentTimestamp()) + .build()); + } + builder.timestamp(message.getTimestamp()); return builder; diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/EnvelopeContentValidator.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/EnvelopeContentValidator.kt index c479591c0d..e865591d0a 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/EnvelopeContentValidator.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/EnvelopeContentValidator.kt @@ -155,6 +155,14 @@ object EnvelopeContentValidator { return Result.Invalid("[DataMessage] Invalid poll vote!") } + if (dataMessage.pinMessage != null && (dataMessage.pinMessage.targetAuthorAciBinary.isNullOrInvalidAci() || dataMessage.pinMessage.targetSentTimestamp == null || (dataMessage.pinMessage.pinDurationSeconds == null && dataMessage.pinMessage.pinDurationForever == null))) { + return Result.Invalid("[DataMessage] Invalid pin message!") + } + + if (dataMessage.unpinMessage != null && (dataMessage.unpinMessage.targetAuthorAciBinary.isNullOrInvalidAci() || dataMessage.unpinMessage.targetSentTimestamp == null)) { + return Result.Invalid("[DataMessage] Invalid unpin message!") + } + return Result.Valid } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt index 7ce505f807..5347c5fe2f 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt @@ -53,7 +53,9 @@ class SignalServiceDataMessage private constructor( val bodyRanges: Optional>, val pollCreate: Optional, val pollVote: Optional, - val pollTerminate: Optional + val pollTerminate: Optional, + val pinnedMessage: Optional, + val unpinnedMessage: Optional ) { val isActivatePaymentsRequest: Boolean = payment.map { it.isActivationRequest }.orElse(false) val isPaymentsActivated: Boolean = payment.map { it.isActivation }.orElse(false) @@ -74,7 +76,9 @@ class SignalServiceDataMessage private constructor( this.remoteDelete.isPresent || this.pollCreate.isPresent || this.pollVote.isPresent || - this.pollTerminate.isPresent + this.pollTerminate.isPresent || + this.pinnedMessage.isPresent || + this.unpinnedMessage.isPresent val isGroupV2Update: Boolean = groupContext.isPresent && groupContext.get().hasSignedGroupChange() && !hasRenderableContent val isEmptyGroupV2Message: Boolean = isGroupV2Message && !isGroupV2Update && !hasRenderableContent @@ -106,6 +110,8 @@ class SignalServiceDataMessage private constructor( private var pollCreate: PollCreate? = null private var pollVote: PollVote? = null private var pollTerminate: PollTerminate? = null + private var pinnedMessage: PinnedMessage? = null + private var unpinnedMessage: UnpinnedMessage? = null fun withTimestamp(timestamp: Long): Builder { this.timestamp = timestamp @@ -244,6 +250,16 @@ class SignalServiceDataMessage private constructor( return this } + fun withPinnedMessage(pinnedMessage: PinnedMessage?): Builder { + this.pinnedMessage = pinnedMessage + return this + } + + fun withUnpinnedMessage(unpinnedMessage: UnpinnedMessage?): Builder { + this.unpinnedMessage = unpinnedMessage + return this + } + fun build(): SignalServiceDataMessage { if (timestamp == 0L) { timestamp = System.currentTimeMillis() @@ -275,7 +291,9 @@ class SignalServiceDataMessage private constructor( bodyRanges = bodyRanges.asOptional(), pollCreate = pollCreate.asOptional(), pollVote = pollVote.asOptional(), - pollTerminate = pollTerminate.asOptional() + pollTerminate = pollTerminate.asOptional(), + pinnedMessage = pinnedMessage.asOptional(), + unpinnedMessage = unpinnedMessage.asOptional() ) } } @@ -322,6 +340,8 @@ class SignalServiceDataMessage private constructor( data class PollCreate(val question: String, val allowMultiple: Boolean, val options: List) data class PollVote(val targetAuthor: ServiceId, val targetSentTimestamp: Long, val optionIndexes: List, val voteCount: Int) data class PollTerminate(val targetSentTimestamp: Long) + data class PinnedMessage(val targetAuthor: ServiceId, val targetSentTimestamp: Long, val pinDurationInSeconds: Int?, val forever: Boolean?) + data class UnpinnedMessage(val targetAuthor: ServiceId, val targetSentTimestamp: Long) companion object { @JvmStatic diff --git a/libsignal-service/src/main/protowire/SignalService.proto b/libsignal-service/src/main/protowire/SignalService.proto index 3b37748579..da4d44fae0 100644 --- a/libsignal-service/src/main/protowire/SignalService.proto +++ b/libsignal-service/src/main/protowire/SignalService.proto @@ -335,6 +335,20 @@ message DataMessage { optional uint32 voteCount = 4; // increment this by 1 each time you vote on a given poll } + message PinMessage { + optional bytes targetAuthorAciBinary = 1; // 16-byte UUID + optional uint64 targetSentTimestamp = 2; + oneof pinDuration { + uint32 pinDurationSeconds = 3; + bool pinDurationForever = 4; + } + } + + message UnpinMessage { + optional bytes targetAuthorAciBinary = 1; // 16-byte UUID + optional uint64 targetSentTimestamp = 2; + } + optional string body = 1; repeated AttachmentPointer attachments = 2; reserved /*groupV1*/ 3; @@ -360,7 +374,9 @@ message DataMessage { optional PollCreate pollCreate = 24; optional PollTerminate pollTerminate = 25; optional PollVote pollVote = 26; - // NEXT ID: 27 + optional PinMessage pinMessage = 27; + optional UnpinMessage unpinMessage = 28; + // NEXT ID: 29 } message NullMessage {