Support pinned messages in backups.

This commit is contained in:
Michelle Tang
2025-12-16 11:35:39 -05:00
committed by jeffrey-signal
parent b99fec4274
commit b65079ec20
26 changed files with 124 additions and 23 deletions

View File

@@ -216,6 +216,11 @@ class ArchiveImportExportTests {
runTests { it.startsWith("chat_item_poll_") }
}
// @Test
fun chatItemPinMessage() {
runTests { it.startsWith("chat_item_pin_message_") }
}
// @Test
fun notificationProfiles() {
runTests { it.startsWith("notification_profile_") }

View File

@@ -139,6 +139,10 @@ object ExportSkips {
return log(sentTimestamp, "Poll was not in a group chat.")
}
fun pinMessageIsInvalid(sentTimestamp: Long): String {
return log(sentTimestamp, "Pin message update was invalid.")
}
fun individualChatUpdateInWrongTypeOfChat(sentTimestamp: Long): String {
return log(sentTimestamp, "A chat update that only makes sense for individual chats was found in a different kind of chat.")
}

View File

@@ -65,7 +65,10 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
${MessageTable.MISMATCHED_IDENTITIES},
${MessageTable.TYPE},
${MessageTable.MESSAGE_EXTRAS},
${MessageTable.VIEW_ONCE}
${MessageTable.VIEW_ONCE},
${MessageTable.PINNED_UNTIL},
${MessageTable.PINNING_MESSAGE_ID},
${MessageTable.PINNED_AT}
)
WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1
""".trimMargin()
@@ -155,7 +158,10 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
MessageTable.TYPE,
MessageTable.MESSAGE_EXTRAS,
MessageTable.VIEW_ONCE,
PARENT_STORY_ID
PARENT_STORY_ID,
MessageTable.PINNED_UNTIL,
MessageTable.PINNING_MESSAGE_ID,
MessageTable.PINNED_AT
)
.from("${MessageTable.TABLE_NAME} INDEXED BY $dateReceivedIndex")
.where("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND ($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds}) AND $DATE_RECEIVED >= $lastSeenReceivedTime $cutoffQuery")

View File

@@ -9,6 +9,7 @@ import android.database.Cursor
import okio.ByteString.Companion.toByteString
import org.json.JSONArray
import org.json.JSONException
import org.signal.core.models.ServiceId
import org.signal.core.util.Base64
import org.signal.core.util.EventTimer
import org.signal.core.util.Hex
@@ -53,6 +54,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
import org.thoughtcrime.securesms.backup.v2.proto.LearnedProfileChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
import org.thoughtcrime.securesms.backup.v2.proto.PaymentNotification
import org.thoughtcrime.securesms.backup.v2.proto.PinMessageUpdate
import org.thoughtcrime.securesms.backup.v2.proto.Poll
import org.thoughtcrime.securesms.backup.v2.proto.PollTerminateUpdate
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
@@ -413,6 +415,16 @@ class ChatItemArchiveExporter(
transformTimer.emit("poll")
}
MessageTypes.isPinnedMessageUpdate(record.type) -> {
val pinMessageUpdate = record.toRemotePinMessageUpdate(exportState)
if (pinMessageUpdate == null) {
Log.w(TAG, ExportSkips.pinMessageIsInvalid(record.dateSent))
continue
}
builder.updateMessage = ChatUpdateMessage(pinMessage = pinMessageUpdate)
transformTimer.emit("pin-message")
}
else -> {
val attachments = extraData.attachmentsById[record.id]
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker }
@@ -595,6 +607,7 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
expiresInMs = record.expiresIn.takeIf { it > 0 }
revisions = emptyList()
sms = record.type.isSmsType()
pinDetails = record.toPinDetails()
when (direction) {
Direction.DIRECTIONLESS -> {
directionless = ChatItem.DirectionlessMessageDetails()
@@ -847,6 +860,27 @@ private fun BackupMessageRecord.toRemotePollTerminateUpdate(): PollTerminateUpda
)
}
private fun BackupMessageRecord.toPinDetails(): ChatItem.PinDetails? {
return if (this.pinnedAt == 0L || this.pinnedUntil == 0L) {
null
} else {
ChatItem.PinDetails(
pinnedAtTimestamp = this.pinnedAt,
pinExpiresAtTimestamp = this.pinnedUntil.takeIf { it != MessageTable.PIN_FOREVER },
pinNeverExpires = (this.pinnedUntil == MessageTable.PIN_FOREVER).takeIf { it }
)
}
}
private fun BackupMessageRecord.toRemotePinMessageUpdate(exportState: ExportState): PinMessageUpdate? {
val pinMessage = this.messageExtras?.pinnedMessage ?: return null
val authorId = exportState.aciToRecipientId[ServiceId.ACI.parseOrNull(pinMessage.targetAuthorAci).toString()] ?: return null
return PinMessageUpdate(
targetSentTimestamp = pinMessage.targetTimestamp,
authorId = authorId
)
}
private fun BackupMessageRecord.toRemoteSharedContact(attachments: List<DatabaseAttachment>?): Contact? {
if (this.sharedContacts.isNullOrEmpty()) {
return null
@@ -1591,7 +1625,8 @@ private fun Long.isDirectionlessType(): Boolean {
MessageTypes.isGroupUpdate(this) ||
MessageTypes.isGroupV1MigrationEvent(this) ||
MessageTypes.isGroupQuit(this) ||
MessageTypes.isPollTerminate(this)
MessageTypes.isPollTerminate(this) ||
MessageTypes.isPinnedMessageUpdate(this)
}
private fun Long.isIdentityVerifyType(): Boolean {
@@ -1776,6 +1811,8 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
messageExtras = messageExtras.parseMessageExtras(),
viewOnce = this.requireBoolean(MessageTable.VIEW_ONCE),
parentStoryId = this.requireLong(MessageTable.PARENT_STORY_ID),
pinnedAt = this.requireLong(MessageTable.PINNED_AT),
pinnedUntil = this.requireLong(MessageTable.PINNED_UNTIL),
messageExtrasSize = messageExtras?.size ?: 0
)
}
@@ -1816,6 +1853,8 @@ private class BackupMessageRecord(
val baseType: Long,
val messageExtras: MessageExtras?,
val viewOnce: Boolean,
val pinnedAt: Long,
val pinnedUntil: Long,
private val messageExtrasSize: Int
) {
val estimatedSizeInBytes: Int = (body?.length ?: 0) +

View File

@@ -64,6 +64,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescrip
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.database.model.databaseprotos.PaymentTombstone
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
@@ -137,7 +138,10 @@ class ChatItemArchiveImporter(
MessageTable.LATEST_REVISION_ID,
MessageTable.REVISION_NUMBER,
MessageTable.PARENT_STORY_ID,
MessageTable.NOTIFIED
MessageTable.NOTIFIED,
MessageTable.PINNED_UNTIL,
MessageTable.PINNING_MESSAGE_ID,
MessageTable.PINNED_AT
)
private val REACTION_COLUMNS = arrayOf(
@@ -343,6 +347,32 @@ class ChatItemArchiveImporter(
SignalDatabase.polls.endPoll(pollId = pollId, endingMessageId = endPollMessageId)
}
}
} else if (this.updateMessage.pinMessage != null) {
followUps += { pinUpdateMessageId ->
val targetAuthorId = importState.remoteToLocalRecipientId[updateMessage.pinMessage.authorId]
if (targetAuthorId != null) {
val pinnedMessageId = SignalDatabase.messages.getMessageFor(updateMessage.pinMessage.targetSentTimestamp, targetAuthorId)?.id ?: -1
val messageExtras = MessageExtras(
pinnedMessage = PinnedMessage(
pinnedMessageId = pinnedMessageId,
targetAuthorAci = recipients.getRecord(targetAuthorId).aci!!.toByteString(),
targetTimestamp = updateMessage.pinMessage.targetSentTimestamp
)
)
db.update(MessageTable.TABLE_NAME)
.values(MessageTable.MESSAGE_EXTRAS to messageExtras.encode())
.where("${MessageTable.ID} = ?", pinUpdateMessageId)
.run()
if (pinnedMessageId != -1L) {
db.update(MessageTable.TABLE_NAME)
.values(MessageTable.PINNING_MESSAGE_ID to pinUpdateMessageId)
.where("${MessageTable.ID} = ?", pinnedMessageId)
.run()
}
}
}
}
}
@@ -645,6 +675,12 @@ class ChatItemArchiveImporter(
contentValues.put(MessageTable.REMOTE_DELETED, 0)
contentValues.put(MessageTable.PARENT_STORY_ID, 0)
if (this.pinDetails != null) {
val pinnedUntil = if (this.pinDetails.pinNeverExpires == true) MessageTable.PIN_FOREVER else this.pinDetails.pinExpiresAtTimestamp
contentValues.put(MessageTable.PINNED_UNTIL, pinnedUntil ?: 0)
contentValues.put(MessageTable.PINNED_AT, this.pinDetails.pinnedAtTimestamp)
}
when {
this.standardMessage != null -> contentValues.addStandardMessage(this.standardMessage)
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.REMOTE_DELETED, 1)
@@ -846,6 +882,9 @@ class ChatItemArchiveImporter(
updateMessage.pollTerminate != null -> {
typeFlags = MessageTypes.SPECIAL_TYPE_POLL_TERMINATE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
}
updateMessage.pinMessage != null -> {
typeFlags = MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
}
updateMessage.sessionSwitchover != null -> {
typeFlags = MessageTypes.SESSION_SWITCHOVER_TYPE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
val sessionSwitchoverDetails = SessionSwitchoverEvent(e164 = updateMessage.sessionSwitchover.e164.toString()).encode()

View File

@@ -460,6 +460,14 @@ message ChatItem {
message DirectionlessMessageDetails {
}
message PinDetails {
uint64 pinnedAtTimestamp = 1;
oneof pinExpiry {
uint64 pinExpiresAtTimestamp = 2; // timestamp when the pin should expire
bool pinNeverExpires = 3;
}
}
uint64 chatId = 1; // conversation id
uint64 authorId = 2; // recipient id
uint64 dateSent = 3;
@@ -488,6 +496,8 @@ message ChatItem {
DirectStoryReplyMessage directStoryReplyMessage = 19; // group story reply messages are not backed up
Poll poll = 20;
}
PinDetails pinDetails = 21; // only set if message is pinned
}
message SendStatus {
@@ -898,6 +908,7 @@ message ChatUpdateMessage {
GroupCall groupCall = 8;
LearnedProfileChatUpdate learnedProfileChange = 9;
PollTerminateUpdate pollTerminate = 10;
PinMessageUpdate pinMessage = 11;
}
}
@@ -1268,6 +1279,11 @@ message PollTerminateUpdate {
string question = 2; // Between 1-100 characters
}
message PinMessageUpdate {
uint64 targetSentTimestamp = 1;
uint64 authorId = 2; // recipient id
}
message StickerPack {
bytes packId = 1;
bytes packKey = 2;

View File

@@ -15,7 +15,7 @@ androidx-window = "1.3.0"
glide = "4.15.1"
gradle = "8.9.0"
kotlin = "2.2.20"
libsignal-client = "0.86.6"
libsignal-client = "0.86.8"
mp4parser = "1.9.39"
android-gradle-plugin = "8.10.1"
accompanist = "0.28.0"

View File

@@ -15400,28 +15400,20 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="57b3cf8f247f1990211110734a7d1af413db145c8f17eb1b2cdc9b9321188c2b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.signal" name="libsignal-android" version="0.86.6">
<artifact name="libsignal-android-0.86.6.aar">
<md5 value="2eea67b3317b3c6dcd470dc5cbf35523" origin="Generated by Gradle"/>
<sha1 value="0377f9b317e71f380ec5678495388fbdd06d54c2" origin="Generated by Gradle"/>
<sha256 value="4528ea6373cf15dba008effa33e24ee1006e358ab83e8fc2678734e44e6987db" origin="Generated by Gradle"/>
<component group="org.signal" name="libsignal-android" version="0.86.8">
<artifact name="libsignal-android-0.86.8.aar">
<sha256 value="a9fc5fd6bf12fea69318f6c992f8f52804950bc7888ae3df3d7bad6b1c18e8a0" origin="Generated by Gradle"/>
</artifact>
<artifact name="libsignal-android-0.86.6.module">
<md5 value="45ce4a01f32a0e164996aa94a0d32d92" origin="Generated by Gradle"/>
<sha1 value="9da5a2d8d3db6c5a3d61af6a73529ba79d9fc608" origin="Generated by Gradle"/>
<sha256 value="944c7b8eda64a98c418713ed846ea539c6510e689239627ab4e7e75bceb6da7e" origin="Generated by Gradle"/>
<artifact name="libsignal-android-0.86.8.module">
<sha256 value="4ce19af1fc9442c8eb231eb1f2523557291fc9e9dce2520b45d4ab7c6e9642d7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.signal" name="libsignal-client" version="0.86.6">
<artifact name="libsignal-client-0.86.6.jar">
<md5 value="1322e053a71d63ea708ef837c1eedf68" origin="Generated by Gradle"/>
<sha1 value="39c8ca5e7d6e7a8d0e20107b9dcfb3da3b55fcef" origin="Generated by Gradle"/>
<sha256 value="2124c5215f71209ec6ac3508f43ef6f3af74718911baa1d6185b9930d21ed5b7" origin="Generated by Gradle"/>
<component group="org.signal" name="libsignal-client" version="0.86.8">
<artifact name="libsignal-client-0.86.8.jar">
<sha256 value="bac0edb5e25a73686aa418a6e43e0e81df1744e2751957a5dcb960e03d59a1c3" origin="Generated by Gradle"/>
</artifact>
<artifact name="libsignal-client-0.86.6.module">
<md5 value="1efa21eae93805299d1c29f07997109b" origin="Generated by Gradle"/>
<sha1 value="acef64b2c3b02a0aa264853301fd86e9bd30b58d" origin="Generated by Gradle"/>
<sha256 value="03cc60c8bbf9b114bfed8bf97aba68158d0e0ffc3cba8ec6f0af7c8c1e151bb1" origin="Generated by Gradle"/>
<artifact name="libsignal-client-0.86.8.module">
<sha256 value="b9e8afcc22d50a4818f9d56cb40beda66c6532710c432a27be6b05c4ba5301af" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.signal" name="ringrtc-android" version="2.60.5">