Handle offline state when pinning messages.

This commit is contained in:
Michelle Tang
2025-12-03 13:59:23 -05:00
committed by jeffrey-signal
parent 0f5b790461
commit 7297f7a894
8 changed files with 279 additions and 26 deletions

View File

@@ -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(

View File

@@ -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() {

View File

@@ -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<Recipient> = 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<Recipient> = 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<Recipient> = 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())
}

View File

@@ -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())
}
}

View File

@@ -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<Recipient>): List<SendMessageResult?> {
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<Recipient>): List<SendMessageResult?> {
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 }
}
}

View File

@@ -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<RecipientId> = 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())

View File

@@ -54,7 +54,9 @@ object SignalServiceProtoUtil {
hasRemoteDelete ||
pollCreate != null ||
pollVote != null ||
pollTerminate != null
pollTerminate != null ||
pinMessage != null ||
unpinMessage != null
}
val DataMessage.hasDisallowedAnnouncementOnlyContent: Boolean

View File

@@ -9070,6 +9070,14 @@
<string name="PinnedMessage__unpin_title">Unpin all messages?</string>
<!-- Body of dialog to unpin all messages -->
<string name="PinnedMessage__unpin_body">Messages will be unpinned for all members.</string>
<!-- Dialog title when failing to pin a message -->
<string name="PinnedMessage__couldnt_pin">Couldn\'t pin message</string>
<!-- Dialog title when failing to unpin a message -->
<string name="PinnedMessage__couldnt_unpin">Couldn\'t unpin message</string>
<!-- Dialog title when failing to unpin all messages -->
<string name="PinnedMessage__couldnt_unpin_all">Couldn\'t unpin messages</string>
<!-- Dialog body when failing to pin a message -->
<string name="PinnedMessage__check_connection">Check your connection and try again.</string>
<!-- EOF -->
</resources>