From 7297f7a894cc75fc1baefa5f35a13e4b950083e1 Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Wed, 3 Dec 2025 13:59:23 -0500 Subject: [PATCH] Handle offline state when pinning messages. --- .../conversation/PinnedMessagesBottomSheet.kt | 17 ++- .../conversation/v2/ConversationFragment.kt | 39 ++++++- .../conversation/v2/ConversationRepository.kt | 96 ++++++++++++++-- .../conversation/v2/ConversationViewModel.kt | 26 +++-- .../securesms/conversation/v2/PinSendUtil.kt | 106 ++++++++++++++++++ .../securesms/jobs/UnpinMessageJob.kt | 9 +- .../messages/SignalServiceProtoUtil.kt | 4 +- app/src/main/res/values/strings.xml | 8 ++ 8 files changed, 279 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinSendUtil.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesBottomSheet.kt index 32009636ce..a3b617b843 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/PinnedMessagesBottomSheet.kt @@ -37,6 +37,7 @@ 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.NetworkUtil import org.thoughtcrime.securesms.util.StickyHeaderDecoration import org.thoughtcrime.securesms.util.fragments.findListener import org.thoughtcrime.securesms.util.visible @@ -120,8 +121,12 @@ class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() .setTitle(R.string.PinnedMessage__unpin_title) .setMessage(getString(R.string.PinnedMessage__unpin_body)) .setPositiveButton(R.string.PinnedMessage__unpin) { dialog, which -> - viewModel.unpinMessage() - dismissAllowingStateLoss() + if (NetworkUtil.isConnected(requireContext())) { + viewModel.unpinMessage() + dismissAllowingStateLoss() + } else { + showNetworkErrorDialog() + } } .setNegativeButton(android.R.string.cancel) { dialog, which -> dialog.dismiss() } .show() @@ -129,6 +134,14 @@ class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() unpinAll.visible = requireArguments().getBoolean(KEY_CAN_UNPIN) } + private fun showNetworkErrorDialog() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.PinnedMessage__couldnt_unpin_all) + .setMessage(getString(R.string.PinnedMessage__check_connection)) + .setPositiveButton(android.R.string.ok, null) + .show() + } + private fun initializeGiphyMp4(videoContainer: ViewGroup, list: RecyclerView): GiphyMp4ProjectionRecycler { val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation() val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews( 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 bbffd4e62d..46d4f2071c 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 @@ -407,6 +407,7 @@ class ConversationFragment : companion object { private val TAG = Log.tag(ConversationFragment::class.java) private val POLL_SPINNER_DELAY = 500.milliseconds + private val PIN_SPINNER_DELAY = 500.milliseconds private const val ACTION_PINNED_SHORTCUT = "action_pinned_shortcut" private const val SAVED_STATE_IS_SEARCH_REQUESTED = "is_search_requested" @@ -1728,7 +1729,23 @@ class ConversationFragment : duration = if (values[selection] == -1) kotlin.time.Duration.INFINITE else values[selection].days, threadRecipient = conversationMessage.threadRecipient ) - .subscribe() + .doOnSubscribe { + handler.postDelayed({ showSpinner() }, PIN_SPINNER_DELAY.inWholeMilliseconds) + } + .doFinally { + handler.removeCallbacksAndMessages(null) + hideSpinner() + } + .subscribeBy( + onError = { + Log.w(TAG, "Error received during pin message!", it) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.PinnedMessage__couldnt_pin) + .setMessage(getString(R.string.PinnedMessage__check_connection)) + .setPositiveButton(android.R.string.ok) { dialog: DialogInterface?, which: Int -> dialog!!.dismiss() } + .show() + } + ) dialog.dismiss() } .setNegativeButton(android.R.string.cancel) { dialog, _ -> @@ -1738,7 +1755,25 @@ class ConversationFragment : } private fun handleUnpinMessage(messageId: Long) { - viewModel.unpinMessage(messageId) + disposables += viewModel + .unpinMessage(messageId) + .doOnSubscribe { + handler.postDelayed({ showSpinner() }, PIN_SPINNER_DELAY.inWholeMilliseconds) + } + .doFinally { + handler.removeCallbacksAndMessages(null) + hideSpinner() + } + .subscribeBy( + onError = { + Log.w(TAG, "Error received during unpin message!", it) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.PinnedMessage__couldnt_unpin) + .setMessage(getString(R.string.PinnedMessage__check_connection)) + .setPositiveButton(android.R.string.ok) { dialog: DialogInterface?, which: Int -> dialog!!.dismiss() } + .show() + } + ) } private fun handleVideoCall() { 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 fb516bcc7f..cdc165f313 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 @@ -71,6 +71,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies.expiringMessageMa import org.thoughtcrime.securesms.groups.GroupNotAMemberException import org.thoughtcrime.securesms.jobs.GroupSendJobHelper import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob +import org.thoughtcrime.securesms.jobs.UnpinMessageJob import org.thoughtcrime.securesms.keyboard.KeyboardUtil import org.thoughtcrime.securesms.keyvalue.SignalStore.Companion.settings import org.thoughtcrime.securesms.linkpreview.LinkPreview @@ -307,6 +308,11 @@ class ConversationRepository( fun pinMessage(messageRecord: MessageRecord, duration: Duration, threadRecipient: Recipient): Completable { return Completable.create { emitter -> + val isGroup = threadRecipient.isPushV2Group + if (isGroup && threadRecipient.groupId.getOrNull()?.isV2 != true) { + emitter.tryOnError(Exception("Pin message failed - missing group id")) + } + val message = OutgoingMessage.pinMessage( threadRecipient = threadRecipient, sentTimeMillis = System.currentTimeMillis(), @@ -323,13 +329,89 @@ class ConversationRepository( 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() } + val possibleTargets: List = if (isGroup) { + SignalDatabase.groups.getGroupMembers(threadRecipient.requireGroupId().requireV2(), GroupTable.MemberSet.FULL_MEMBERS_EXCLUDING_SELF).map { it.resolve() }.distinctBy { it.id } + } else { + listOf(threadRecipient) + } + + val eligibleTargets = RecipientUtil.getEligibleForSending(possibleTargets) + val results = PinSendUtil.sendPinMessage(applicationContext, threadRecipient, message, eligibleTargets) + + val sendResults = GroupSendJobHelper.getCompletedSends(eligibleTargets, results) + + if (sendResults.completed.isNotEmpty() || possibleTargets.isEmpty()) { + val allocatedThreadId = SignalDatabase.threads.getOrCreateValidThreadId(threadRecipient, messageRecord.threadId, message.distributionType) + val outgoingMessage = applyUniversalExpireTimerIfNecessary(applicationContext, threadRecipient, message, allocatedThreadId) + val insertResult = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, allocatedThreadId, false, null) + + SignalDatabase.threads.update(threadId = allocatedThreadId, unarchive = true, syncThreadDelete = true) + databaseObserver.notifyConversationListeners(messageRecord.threadId) + if (outgoingMessage.expiresIn > 0) { + SignalDatabase.messages.markExpireStarted(insertResult.messageId) + expiringMessageManager.scheduleDeletion(insertResult.messageId, true, message.expiresIn) + } + + if (sendResults.skipped.isNotEmpty()) { + val messageRecord = SignalDatabase.messages.getMessageRecord(insertResult.messageId) + val filterRecipientIds = (sendResults.skipped - sendResults.completed.map { it.id }).toSet() + Log.i(TAG, "Some recipients skipped when sending pin message. Resending to $filterRecipientIds") + MessageSender.resendGroupMessage(applicationContext, messageRecord, filterRecipientIds) + } else { + SignalDatabase.messages.markAsSent(insertResult.messageId, true) + } + emitter.onComplete() + } else { + emitter.tryOnError(Exception("Pin message failed")) + } + }.subscribeOn(Schedulers.io()) + } + + fun unpinMessage(messageId: Long): Completable { + return Completable.create { emitter -> + val message = SignalDatabase.messages.getMessageRecordOrNull(messageId) + if (message == null) { + emitter.tryOnError(Exception("Unpin message failed - missing message")) + } + + val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(message!!.threadId) + if (threadRecipient == null) { + emitter.tryOnError(Exception("Unpin message failed - missing thread recipient")) + } + + val isGroup = threadRecipient!!.isPushV2Group + if (isGroup && threadRecipient.groupId.getOrNull()?.isV2 != true) { + emitter.tryOnError(Exception("Unpin message failed - missing group id")) + } + + Log.i(TAG, "Sending unpin message to ${threadRecipient.id}") + + val possibleTargets: List = if (isGroup) { + SignalDatabase.groups.getGroupMembers(threadRecipient.requireGroupId().requireV2(), GroupTable.MemberSet.FULL_MEMBERS_EXCLUDING_SELF).map { it.resolve() }.distinctBy { it.id } + } else { + listOf(threadRecipient) + } + + val eligibleTargets: List = RecipientUtil.getEligibleForSending(possibleTargets) + val results = PinSendUtil.sendUnpinMessage(applicationContext, threadRecipient, message.fromRecipient.requireServiceId(), message.dateSent, eligibleTargets) + val sendResults = GroupSendJobHelper.getCompletedSends(eligibleTargets, results) + + if (sendResults.completed.isNotEmpty() || possibleTargets.isEmpty()) { + SignalDatabase.messages.unpinMessage(messageId = messageId, threadId = message.threadId) + databaseObserver.notifyConversationListeners(message.threadId) + + if (sendResults.skipped.isNotEmpty()) { + val filterRecipientIds = (sendResults.skipped - sendResults.completed.map { it.id }).toSet() + Log.i(TAG, "Some recipients skipped when sending unpin message. Resending to $filterRecipientIds") + val unpinJob = UnpinMessageJob.create(messageId = messageId, initialRecipientIds = filterRecipientIds) + if (unpinJob != null) { + AppDependencies.jobManager.add(unpinJob) + } + } + emitter.onComplete() + } else { + emitter.tryOnError(Exception("Unpin message failed")) + } }.subscribeOn(Schedulers.io()) } 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 fe26e8c543..0dbb370705 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 @@ -77,7 +77,6 @@ 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 @@ -356,19 +355,22 @@ class ConversationViewModel( } fun pinMessage(messageRecord: MessageRecord, duration: Duration, threadRecipient: Recipient): Completable { - return repository - .pinMessage(messageRecord, duration, threadRecipient) - .observeOn(AndroidSchedulers.mainThread()) + return if (!NetworkUtil.isConnected(AppDependencies.application)) { + Completable.error(Exception("Connection required to pin message")) + } else { + 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 unpinMessage(messageId: Long): Completable { + return if (!NetworkUtil.isConnected(AppDependencies.application)) { + Completable.error(Exception("Connection required to unpin message")) + } else { + repository + .unpinMessage(messageId) + .observeOn(AndroidSchedulers.mainThread()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinSendUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinSendUtil.kt new file mode 100644 index 0000000000..5de34f03c5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinSendUtil.kt @@ -0,0 +1,106 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.content.Context +import org.signal.core.models.ServiceId +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil +import org.thoughtcrime.securesms.database.MessageTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.GroupRecord +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.groups.GroupAccessControl +import org.thoughtcrime.securesms.groups.GroupNotAMemberException +import org.thoughtcrime.securesms.messages.GroupSendUtil +import org.thoughtcrime.securesms.mms.OutgoingMessage +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.transport.UndeliverableMessageException +import org.thoughtcrime.securesms.util.GroupUtil +import org.whispersystems.signalservice.api.crypto.ContentHint +import org.whispersystems.signalservice.api.messages.SendMessageResult +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Companion.newBuilder +import java.io.IOException +import kotlin.jvm.optionals.getOrNull +import kotlin.time.Duration.Companion.milliseconds + +/** + * Functions used when pinning/unpinning messages + */ +object PinSendUtil { + + private val PIN_TERMINATE_TIMEOUT = 7000.milliseconds + + @Throws(IOException::class, GroupNotAMemberException::class, UndeliverableMessageException::class) + fun sendPinMessage(applicationContext: Context, threadRecipient: Recipient, message: OutgoingMessage, destinations: List): List { + val builder = newBuilder() + val groupId = if (threadRecipient.isPushV2Group) threadRecipient.requireGroupId().requireV2() else null + + if (groupId != null) { + val groupRecord: GroupRecord? = SignalDatabase.groups.getGroup(groupId).getOrNull() + if (groupRecord != null && groupRecord.attributesAccessControl == GroupAccessControl.ONLY_ADMINS && !groupRecord.isAdmin(Recipient.self())) { + throw UndeliverableMessageException("Non-admins cannot pin messages!") + } + GroupUtil.setDataMessageGroupContext(AppDependencies.application, builder, groupId) + } + + val sentTime = System.currentTimeMillis() + val message = builder + .withTimestamp(sentTime) + .withExpiration((message.expiresIn / 1000).toInt()) + .withProfileKey(ProfileKeyUtil.getSelfProfileKey().serialize()) + .withPinnedMessage( + SignalServiceDataMessage.PinnedMessage( + targetAuthor = ServiceId.parseOrThrow(message.messageExtras!!.pinnedMessage!!.targetAuthorAci), + targetSentTimestamp = message.messageExtras.pinnedMessage.targetTimestamp, + pinDurationInSeconds = message.messageExtras.pinnedMessage.pinDurationInSeconds.takeIf { it != MessageTable.PIN_FOREVER }?.toInt(), + forever = (message.messageExtras.pinnedMessage.pinDurationInSeconds == MessageTable.PIN_FOREVER).takeIf { it } + ) + ) + .build() + + return GroupSendUtil.sendUnresendableDataMessage( + applicationContext, + groupId, + destinations, + false, + ContentHint.DEFAULT, + message, + false + ) { System.currentTimeMillis() - sentTime > PIN_TERMINATE_TIMEOUT.inWholeMilliseconds } + } + + @Throws(IOException::class, GroupNotAMemberException::class, UndeliverableMessageException::class) + fun sendUnpinMessage(applicationContext: Context, threadRecipient: Recipient, targetAuthor: ServiceId, targetSentTimestamp: Long, destinations: List): List { + val builder = newBuilder() + val groupId = if (threadRecipient.isPushV2Group) threadRecipient.requireGroupId().requireV2() else null + if (groupId != null) { + val groupRecord: GroupRecord? = SignalDatabase.groups.getGroup(groupId).getOrNull() + if (groupRecord != null && groupRecord.attributesAccessControl == GroupAccessControl.ONLY_ADMINS && !groupRecord.isAdmin(Recipient.self())) { + throw UndeliverableMessageException("Non-admins cannot pin messages!") + } + + GroupUtil.setDataMessageGroupContext(AppDependencies.application, builder, groupId) + } + + val sentTime = System.currentTimeMillis() + val message = builder + .withTimestamp(sentTime) + .withProfileKey(ProfileKeyUtil.getSelfProfileKey().serialize()) + .withUnpinnedMessage( + SignalServiceDataMessage.UnpinnedMessage( + targetAuthor = targetAuthor, + targetSentTimestamp = targetSentTimestamp + ) + ) + .build() + + return GroupSendUtil.sendUnresendableDataMessage( + applicationContext, + groupId, + destinations, + false, + ContentHint.DEFAULT, + message, + false + ) { System.currentTimeMillis() - sentTime > PIN_TERMINATE_TIMEOUT.inWholeMilliseconds } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/UnpinMessageJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/UnpinMessageJob.kt index 1f1357096f..cd2887b575 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/UnpinMessageJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/UnpinMessageJob.kt @@ -34,7 +34,10 @@ class UnpinMessageJob( const val KEY: String = "UnpinMessageJob" private val TAG = Log.tag(UnpinMessageJob::class.java) - fun create(messageId: Long): UnpinMessageJob? { + /** + * If [initialRecipientIds] is set, the message will only be sent to those recipients. Otherwise, it is sent to everyone who is eligible. + */ + fun create(messageId: Long, initialRecipientIds: Set = emptySet()): UnpinMessageJob? { val message = SignalDatabase.messages.getMessageRecordOrNull(messageId) if (message == null) { Log.w(TAG, "Unable to find corresponding message") @@ -47,7 +50,9 @@ class UnpinMessageJob( return null } - val recipients = if (conversationRecipient.isGroup) { + val recipients = if (initialRecipientIds.isNotEmpty()) { + initialRecipientIds.map { it.toLong() } + } else if (conversationRecipient.isGroup) { conversationRecipient.participantIds.filter { it != Recipient.self().id }.map { it.toLong() } } else { listOf(conversationRecipient.id.toLong()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/SignalServiceProtoUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/SignalServiceProtoUtil.kt index c9718bcb6e..ede2277809 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SignalServiceProtoUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SignalServiceProtoUtil.kt @@ -54,7 +54,9 @@ object SignalServiceProtoUtil { hasRemoteDelete || pollCreate != null || pollVote != null || - pollTerminate != null + pollTerminate != null || + pinMessage != null || + unpinMessage != null } val DataMessage.hasDisallowedAnnouncementOnlyContent: Boolean diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a3681057e3..868d191679 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9070,6 +9070,14 @@ Unpin all messages? Messages will be unpinned for all members. + + Couldn\'t pin message + + Couldn\'t unpin message + + Couldn\'t unpin messages + + Check your connection and try again.