Add support for versioned expiration timers.

Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
Cody Henthorne 2024-08-27 07:41:35 -04:00 committed by Nicholas Tinsley
parent 4152294b57
commit 1f196f74ff
43 changed files with 392 additions and 139 deletions

View file

@ -77,7 +77,7 @@ class EditMessageSyncProcessorTest {
.build()
).build()
).build()
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage?.expireTimer ?: 0)
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage?.expireTimer ?: 0, content.dataMessage?.expireTimerVersion ?: 1)
val syncTextMessage = TestMessage(
envelope = MessageContentFuzzer.envelope(originalTimestamp),
content = syncContent,
@ -112,7 +112,7 @@ class EditMessageSyncProcessorTest {
testResult.runSync(listOf(syncTextMessage, syncEditMessage))
SignalDatabase.recipients.setExpireMessages(toRecipient.id, (content.dataMessage?.expireTimer ?: 0) / 1000)
SignalDatabase.recipients.setExpireMessages(toRecipient.id, (content.dataMessage?.expireTimer ?: 0) / 1000, content.dataMessage?.expireTimerVersion ?: 1)
val originalTextMessage = OutgoingMessage(
threadRecipient = toRecipient,
sentTimeMillis = originalTimestamp,

View file

@ -141,7 +141,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, false))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, false, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()

View file

@ -96,6 +96,7 @@ class ConversationElementGenerator {
0,
0,
0,
0,
false,
true,
null,

View file

@ -11,7 +11,8 @@ object AppCapabilities {
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
return AccountAttributes.Capabilities(
storage = storageCapable,
deleteSync = true
deleteSync = true,
expireTimerVersion = true
)
}
}

View file

@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ExpirationTimerUtil
import java.io.IOException
private val TAG: String = Log.tag(ExpireTimerSettingsRepository::class.java)
@ -38,8 +39,8 @@ class ExpireTimerSettingsRepository(val context: Context) {
consumer.invoke(Result.failure(e))
}
} else {
SignalDatabase.recipients.setExpireMessages(recipientId, newExpirationTime)
val outgoingMessage = OutgoingMessage.expirationUpdateMessage(Recipient.resolved(recipientId), System.currentTimeMillis(), newExpirationTime * 1000L)
val expireTimerVersion = ExpirationTimerUtil.setExpirationTimer(recipientId, newExpirationTime)
val outgoingMessage = OutgoingMessage.expirationUpdateMessage(Recipient.resolved(recipientId), System.currentTimeMillis(), newExpirationTime * 1000L, expireTimerVersion)
MessageSender.send(context, outgoingMessage, getThreadId(recipientId), MessageSender.SendType.SIGNAL, null, null)
consumer.invoke(Result.success(newExpirationTime))
}

View file

@ -341,8 +341,9 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
return if (capabilities != null) {
TextUtils.concat(
colorize("DeleteSync", capabilities.deleteSync),
", ",
colorize("DeleteSync", capabilities.deleteSync)
colorize("Expire Timer Version", capabilities.versionedExpirationTimer)
)
} else {
"Recipient not found!"

View file

@ -716,7 +716,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
}
if (groupState?.disappearingMessagesTimer != null) {
recipients.setExpireMessages(groupRecipientId, groupState.disappearingMessagesTimer!!.duration)
recipients.setExpireMessagesForGroup(groupRecipientId, groupState.disappearingMessagesTimer!!.duration)
}
if (groupId.isMms || Recipient.resolved(groupRecipientId).isProfileSharing) {
@ -843,7 +843,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
}
if (decryptedGroup.disappearingMessagesTimer != null) {
recipients.setExpireMessages(groupRecipientId, decryptedGroup.disappearingMessagesTimer!!.duration)
recipients.setExpireMessagesForGroup(groupRecipientId, decryptedGroup.disappearingMessagesTimer!!.duration)
}
if (groupId.isMms || Recipient.resolved(groupRecipientId).isProfileSharing) {

View file

@ -169,6 +169,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
const val SMS_SUBSCRIPTION_ID = "subscription_id"
const val EXPIRES_IN = "expires_in"
const val EXPIRE_STARTED = "expire_started"
const val EXPIRE_TIMER_VERSION = "expire_timer_version"
const val NOTIFIED = "notified"
const val NOTIFIED_TIMESTAMP = "notified_timestamp"
const val UNIDENTIFIED = "unidentified"
@ -264,7 +265,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
$LATEST_REVISION_ID INTEGER DEFAULT NULL REFERENCES $TABLE_NAME ($ID) ON DELETE CASCADE,
$ORIGINAL_MESSAGE_ID INTEGER DEFAULT NULL REFERENCES $TABLE_NAME ($ID) ON DELETE CASCADE,
$REVISION_NUMBER INTEGER DEFAULT 0,
$MESSAGE_EXTRAS BLOB DEFAULT NULL
$MESSAGE_EXTRAS BLOB DEFAULT NULL,
$EXPIRE_TIMER_VERSION INTEGER DEFAULT 1 NOT NULL
)
"""
@ -321,6 +323,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
SMS_SUBSCRIPTION_ID,
EXPIRES_IN,
EXPIRE_STARTED,
EXPIRE_TIMER_VERSION,
NOTIFIED,
QUOTE_ID,
QUOTE_AUTHOR,
@ -2404,6 +2407,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val timestamp = cursor.requireLong(DATE_SENT)
val subscriptionId = cursor.requireInt(SMS_SUBSCRIPTION_ID)
val expiresIn = cursor.requireLong(EXPIRES_IN)
val expireTimerVersion = cursor.requireInt(EXPIRE_TIMER_VERSION)
val viewOnce = cursor.requireLong(VIEW_ONCE) == 1L
val threadId = cursor.requireLong(THREAD_ID)
val threadRecipient = Recipient.resolved(threads.getRecipientIdForThreadId(threadId)!!)
@ -2480,7 +2484,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
OutgoingMessage.expirationUpdateMessage(
threadRecipient = threadRecipient,
sentTimeMillis = timestamp,
expiresIn = expiresIn
expiresIn = expiresIn,
expireTimerVersion = expireTimerVersion
)
} else if (MessageTypes.isPaymentsNotification(outboxType)) {
OutgoingMessage.paymentNotificationMessage(
@ -2539,6 +2544,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
attachments = attachments,
timestamp = timestamp,
expiresIn = expiresIn,
expireTimerVersion = expireTimerVersion,
viewOnce = viewOnce,
distributionType = distributionType,
storyType = storyType,
@ -2971,6 +2977,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
contentValues.put(DATE_RECEIVED, editedMessage?.dateReceived ?: System.currentTimeMillis())
contentValues.put(SMS_SUBSCRIPTION_ID, message.subscriptionId)
contentValues.put(EXPIRES_IN, editedMessage?.expiresIn ?: message.expiresIn)
contentValues.put(EXPIRE_TIMER_VERSION, editedMessage?.expireTimerVersion ?: message.expireTimerVersion)
contentValues.put(VIEW_ONCE, message.isViewOnce)
contentValues.put(FROM_RECIPIENT_ID, Recipient.self().id.serialize())
contentValues.put(FROM_DEVICE_ID, SignalStore.account.deviceId)
@ -5214,6 +5221,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val subscriptionId = cursor.requireInt(SMS_SUBSCRIPTION_ID)
val expiresIn = cursor.requireLong(EXPIRES_IN)
val expireStarted = cursor.requireLong(EXPIRE_STARTED)
val expireTimerVersion = cursor.requireInt(EXPIRE_TIMER_VERSION)
val unidentified = cursor.requireBoolean(UNIDENTIFIED)
val isViewOnce = cursor.requireBoolean(VIEW_ONCE)
val remoteDelete = cursor.requireBoolean(REMOTE_DELETED)
@ -5296,6 +5304,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
subscriptionId,
expiresIn,
expireStarted,
expireTimerVersion,
isViewOnce,
hasReadReceipt,
quote,

View file

@ -25,6 +25,7 @@ import org.signal.core.util.orNull
import org.signal.core.util.readToList
import org.signal.core.util.readToSet
import org.signal.core.util.readToSingleBoolean
import org.signal.core.util.readToSingleInt
import org.signal.core.util.readToSingleLong
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireBlob
@ -166,6 +167,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
const val CALL_VIBRATE = "call_vibrate"
const val MUTE_UNTIL = "mute_until"
const val MESSAGE_EXPIRATION_TIME = "message_expiration_time"
const val MESSAGE_EXPIRATION_TIME_VERSION = "message_expiration_time_version"
const val SEALED_SENDER_MODE = "sealed_sender_mode"
const val STORAGE_SERVICE_ID = "storage_service_id"
const val STORAGE_SERVICE_PROTO = "storage_service_proto"
@ -263,7 +265,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
$NICKNAME_GIVEN_NAME TEXT DEFAULT NULL,
$NICKNAME_FAMILY_NAME TEXT DEFAULT NULL,
$NICKNAME_JOINED_NAME TEXT DEFAULT NULL,
$NOTE TEXT DEFAULT NULL
$NOTE TEXT DEFAULT NULL,
$MESSAGE_EXPIRATION_TIME_VERSION INTEGER DEFAULT 1 NOT NULL
)
"""
@ -307,6 +310,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
CALL_VIBRATE,
MUTE_UNTIL,
MESSAGE_EXPIRATION_TIME,
MESSAGE_EXPIRATION_TIME_VERSION,
SEALED_SENDER_MODE,
STORAGE_SERVICE_ID,
MENTION_SETTING,
@ -410,6 +414,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
fun maskCapabilitiesToLong(capabilities: SignalServiceProfile.Capabilities): Long {
var value: Long = 0
value = Bitmask.update(value, Capabilities.DELETE_SYNC, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isDeleteSync).serialize().toLong())
value = Bitmask.update(value, Capabilities.VERSIONED_EXPIRATION_TIMER, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isVersionedExpirationTimer).serialize().toLong())
return value
}
}
@ -1473,7 +1478,27 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
StorageSyncHelper.scheduleSyncForDataChange()
}
fun setExpireMessages(id: RecipientId, expiration: Int) {
fun setExpireMessagesAndIncrementVersion(id: RecipientId, expiration: Int): Int {
val version = writableDatabase.rawQuery(
"""
UPDATE $TABLE_NAME
SET $MESSAGE_EXPIRATION_TIME = $expiration,
$MESSAGE_EXPIRATION_TIME_VERSION = MIN($MESSAGE_EXPIRATION_TIME_VERSION + 1, ${Int.MAX_VALUE})
WHERE $ID = ${id.serialize()}
RETURNING $MESSAGE_EXPIRATION_TIME_VERSION
""",
null
).readToSingleInt(defaultValue = 1)
AppDependencies.databaseObserver.notifyRecipientChanged(id)
return version
}
/**
* Sets the expiration timer without incrementing the version. Will eventually be removed once everyone has the ability to understand expireTimerVersions.
*/
fun setExpireMessagesWithoutIncrementingVersion(id: RecipientId, expiration: Int) {
val values = ContentValues(1).apply {
put(MESSAGE_EXPIRATION_TIME, expiration)
}
@ -1482,6 +1507,23 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
}
/**
* Groups do not have an expireTimerVersion, and therefore we do not need to provide them.
*/
fun setExpireMessagesForGroup(id: RecipientId, expiration: Int) {
setExpireMessages(id, expiration, 1)
}
fun setExpireMessages(id: RecipientId, expiration: Int, expirationVersion: Int) {
val values = contentValuesOf(
MESSAGE_EXPIRATION_TIME to expiration,
MESSAGE_EXPIRATION_TIME_VERSION to expirationVersion
)
if (update(id, values)) {
AppDependencies.databaseObserver.notifyRecipientChanged(id)
}
}
fun setSealedSenderAccessMode(id: RecipientId, sealedSenderAccessMode: SealedSenderAccessMode) {
val values = ContentValues(1).apply {
put(SEALED_SENDER_MODE, sealedSenderAccessMode.mode)
@ -3988,6 +4030,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
AVATAR_COLOR to primaryRecord.avatarColor.serialize(),
CUSTOM_CHAT_COLORS_ID to Optional.ofNullable(primaryRecord.chatColors).or(Optional.ofNullable(secondaryRecord.chatColors)).map { colors: ChatColors? -> colors!!.id.longValue }.orElse(null),
MESSAGE_EXPIRATION_TIME to if (primaryRecord.expireMessages > 0) primaryRecord.expireMessages else secondaryRecord.expireMessages,
MESSAGE_EXPIRATION_TIME_VERSION to max(primaryRecord.expireTimerVersion, secondaryRecord.expireTimerVersion),
REGISTERED to RegisteredState.REGISTERED.id,
SYSTEM_GIVEN_NAME to secondaryRecord.systemProfileName.givenName,
SYSTEM_FAMILY_NAME to secondaryRecord.systemProfileName.familyName,
@ -4591,6 +4634,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
// const val PNP = 7
// const val PAYMENT_ACTIVATION = 8
const val DELETE_SYNC = 9
const val VERSIONED_EXPIRATION_TIMER = 10
// IMPORTANT: We cannot sore more than 32 capabilities in the bitmask.
}

View file

@ -135,6 +135,7 @@ object RecipientTableCursorUtil {
messageRingtone = Util.uri(cursor.requireString(RecipientTable.MESSAGE_RINGTONE)),
callRingtone = Util.uri(cursor.requireString(RecipientTable.CALL_RINGTONE)),
expireMessages = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME),
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
registered = RegisteredState.fromId(cursor.requireInt(RecipientTable.REGISTERED)),
profileKey = profileKey,
expiringProfileKeyCredential = expiringProfileKeyCredential,
@ -175,7 +176,8 @@ object RecipientTableCursorUtil {
val capabilities = cursor.requireLong(RecipientTable.CAPABILITIES)
return RecipientRecord.Capabilities(
rawBits = capabilities,
deleteSync = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.DELETE_SYNC, Capabilities.BIT_LENGTH).toInt())
deleteSync = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.DELETE_SYNC, Capabilities.BIT_LENGTH).toInt()),
versionedExpirationTimer = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.VERSIONED_EXPIRATION_TIMER, Capabilities.BIT_LENGTH).toInt())
)
}

View file

@ -98,6 +98,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V237_ResetGroupForc
import org.thoughtcrime.securesms.database.helpers.migration.V238_AddGroupSendEndorsementsColumns
import org.thoughtcrime.securesms.database.helpers.migration.V239_MessageFullTextSearchEmojiSupport
import org.thoughtcrime.securesms.database.helpers.migration.V240_MessageFullTextSearchSecureDelete
import org.thoughtcrime.securesms.database.helpers.migration.V241_ExpireTimerVersion
/**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
@ -198,10 +199,11 @@ object SignalDatabaseMigrations {
237 to V237_ResetGroupForceUpdateTimestamps,
238 to V238_AddGroupSendEndorsementsColumns,
239 to V239_MessageFullTextSearchEmojiSupport,
240 to V240_MessageFullTextSearchSecureDelete
240 to V240_MessageFullTextSearchSecureDelete,
241 to V241_ExpireTimerVersion
)
const val DATABASE_VERSION = 240
const val DATABASE_VERSION = 241
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {

View file

@ -0,0 +1,26 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import net.zetetic.database.sqlcipher.SQLiteDatabase
/**
* Migration for new field tracking expiration timer version.
*/
object V241_ExpireTimerVersion : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE message ADD COLUMN expire_timer_version INTEGER DEFAULT 1 NOT NULL;")
db.execSQL("ALTER TABLE recipient ADD COLUMN message_expiration_time_version INTEGER DEFAULT 1 NOT NULL;")
db.execSQL(
"""
UPDATE recipient
SET message_expiration_time_version = 2
WHERE message_expiration_time > 0
"""
)
}
}

View file

@ -48,6 +48,7 @@ public class InMemoryMessageRecord extends MessageRecord {
-1,
0,
System.currentTimeMillis(),
1,
false,
false,
Collections.emptyList(),

View file

@ -99,6 +99,7 @@ public abstract class MessageRecord extends DisplayRecord {
private final int subscriptionId;
private final long expiresIn;
private final long expireStarted;
private final int expireTimerVersion;
private final boolean unidentified;
private final List<ReactionRecord> reactions;
private final long serverTimestamp;
@ -119,6 +120,7 @@ public abstract class MessageRecord extends DisplayRecord {
int subscriptionId,
long expiresIn,
long expireStarted,
int expireTimerVersion,
boolean hasReadReceipt,
boolean unidentified,
@NonNull List<ReactionRecord> reactions,
@ -140,6 +142,7 @@ public abstract class MessageRecord extends DisplayRecord {
this.subscriptionId = subscriptionId;
this.expiresIn = expiresIn;
this.expireStarted = expireStarted;
this.expireTimerVersion = expireTimerVersion;
this.unidentified = unidentified;
this.reactions = reactions;
this.serverTimestamp = dateServer;
@ -754,6 +757,10 @@ public abstract class MessageRecord extends DisplayRecord {
return expireStarted;
}
public int getExpireTimerVersion() {
return expireTimerVersion;
}
public boolean isUnidentified() {
return unidentified;
}

View file

@ -94,6 +94,7 @@ public class MmsMessageRecord extends MessageRecord {
int subscriptionId,
long expiresIn,
long expireStarted,
int expireTimerVersion,
boolean viewOnce,
boolean hasReadReceipt,
@Nullable Quote quote,
@ -121,7 +122,7 @@ public class MmsMessageRecord extends MessageRecord {
{
super(id, body, fromRecipient, fromDeviceId, toRecipient,
dateSent, dateReceived, dateServer, threadId, Status.STATUS_NONE, hasDeliveryReceipt,
mailbox, mismatches, failures, subscriptionId, expiresIn, expireStarted, hasReadReceipt,
mailbox, mismatches, failures, subscriptionId, expiresIn, expireStarted, expireTimerVersion, hasReadReceipt,
unidentified, reactions, remoteDelete, notifiedTimestamp, viewed, receiptTimestamp, originalMessageId, revisionNumber, messageExtras);
this.slideDeck = slideDeck;
@ -323,7 +324,7 @@ public class MmsMessageRecord extends MessageRecord {
public @NonNull MmsMessageRecord withReactions(@NonNull List<ReactionRecord> reactions) {
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
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(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
@ -331,7 +332,7 @@ public class MmsMessageRecord extends MessageRecord {
public @NonNull MmsMessageRecord withoutQuote() {
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
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(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
@ -353,7 +354,7 @@ public class MmsMessageRecord extends MessageRecord {
SlideDeck slideDeck = MessageTable.MmsReader.buildSlideDeck(slideAttachments);
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), slideDeck,
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
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(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
@ -361,7 +362,7 @@ public class MmsMessageRecord extends MessageRecord {
public @NonNull MmsMessageRecord withPayment(@NonNull Payment payment) {
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
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(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
@ -370,7 +371,7 @@ public class MmsMessageRecord extends MessageRecord {
public @NonNull MmsMessageRecord withCall(@Nullable CallTable.Call call) {
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
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, getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());

View file

@ -43,6 +43,7 @@ data class RecipientRecord(
val messageRingtone: Uri?,
val callRingtone: Uri?,
val expireMessages: Int,
val expireTimerVersion: Int,
val registered: RegisteredState,
val profileKey: ByteArray?,
val expiringProfileKeyCredential: ExpiringProfileKeyCredential?,
@ -119,13 +120,15 @@ data class RecipientRecord(
data class Capabilities(
val rawBits: Long,
val deleteSync: Recipient.Capability
val deleteSync: Recipient.Capability,
val versionedExpirationTimer: Recipient.Capability
) {
companion object {
@JvmField
val UNKNOWN = Capabilities(
0,
Recipient.Capability.UNKNOWN
rawBits = 0,
deleteSync = Recipient.Capability.UNKNOWN,
versionedExpirationTimer = Recipient.Capability.UNKNOWN
)
}
}

View file

@ -266,6 +266,7 @@ public class IndividualSendJob extends PushSendJob {
.withAttachments(serviceAttachments)
.withTimestamp(message.getSentTimeMillis())
.withExpiration((int)(message.getExpiresIn() / 1000))
.withExpireTimerVersion(message.getExpireTimerVersion())
.withViewOnce(message.isViewOnce())
.withProfileKey(profileKey.orElse(null))
.withSticker(sticker.orElse(null))

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.isAbsent
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.InvalidMessageException
import org.thoughtcrime.securesms.database.IdentityTable.VerifiedStatus
@ -92,7 +93,14 @@ class MultiDeviceContactSyncJob(parameters: Parameters, private val attachmentPo
}
if (contact.expirationTimer.isPresent) {
recipients.setExpireMessages(recipient.id, contact.expirationTimer.get())
if (contact.expirationTimerVersion.isPresent && contact.expirationTimerVersion.get() > recipient.expireTimerVersion) {
recipients.setExpireMessages(recipient.id, contact.expirationTimer.get(), contact.expirationTimerVersion.orElse(1))
} else if (contact.expirationTimerVersion.isAbsent()) {
// TODO [expireVersion] After unsupported builds expire, we can remove this branch
recipients.setExpireMessagesWithoutIncrementingVersion(recipient.id, contact.expirationTimer.get())
} else {
Log.w(TAG, "[ContactSync] ${recipient.id} was synced with an old expiration timer. Ignoring. Recieved: ${contact.expirationTimerVersion.get()} Current: ${recipient.expireTimerVersion}")
}
}
if (contact.profileKey.isPresent) {

View file

@ -170,6 +170,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey()),
recipient.getExpiresInSeconds() > 0 ? Optional.of(recipient.getExpiresInSeconds())
: Optional.empty(),
Optional.of(recipient.getExpireTimerVersion()),
Optional.ofNullable(inboxPositions.get(recipientId)),
archived.contains(recipientId)));
@ -219,13 +220,14 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
Set<RecipientId> archived = SignalDatabase.threads().getArchivedRecipients();
for (Recipient recipient : recipients) {
Optional<IdentityRecord> identity = AppDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipient.getId());
Optional<VerifiedMessage> verified = getVerifiedMessage(recipient, identity);
Optional<String> name = Optional.ofNullable(recipient.isSystemContact() ? recipient.getDisplayName(context) : recipient.getGroupName(context));
Optional<ProfileKey> profileKey = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey());
boolean blocked = recipient.isBlocked();
Optional<Integer> expireTimer = recipient.getExpiresInSeconds() > 0 ? Optional.of(recipient.getExpiresInSeconds()) : Optional.empty();
Optional<Integer> inboxPosition = Optional.ofNullable(inboxPositions.get(recipient.getId()));
Optional<IdentityRecord> identity = AppDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipient.getId());
Optional<VerifiedMessage> verified = getVerifiedMessage(recipient, identity);
Optional<String> name = Optional.ofNullable(recipient.isSystemContact() ? recipient.getDisplayName(context) : recipient.getGroupName(context));
Optional<ProfileKey> profileKey = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey());
boolean blocked = recipient.isBlocked();
Optional<Integer> expireTimer = recipient.getExpiresInSeconds() > 0 ? Optional.of(recipient.getExpiresInSeconds()) : Optional.empty();
Optional<Integer> expireTimerVersion = Optional.of(recipient.getExpireTimerVersion());
Optional<Integer> inboxPosition = Optional.ofNullable(inboxPositions.get(recipient.getId()));
out.write(new DeviceContact(recipient.getAci(),
recipient.getE164(),
@ -235,6 +237,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
verified,
profileKey,
expireTimer,
expireTimerVersion,
inboxPosition,
archived.contains(recipient.getId())));
}
@ -252,6 +255,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
Optional.empty(),
ProfileKeyUtil.profileKeyOptionalOrThrow(self.getProfileKey()),
self.getExpiresInSeconds() > 0 ? Optional.of(self.getExpiresInSeconds()) : Optional.empty(),
Optional.of(self.getExpireTimerVersion()),
Optional.ofNullable(inboxPositions.get(self.getId())),
archived.contains(self.getId())));
}

View file

@ -84,6 +84,7 @@ public class MultiDeviceProfileKeyUpdateJob extends BaseJob {
profileKey,
Optional.empty(),
Optional.empty(),
Optional.empty(),
false));
out.close();

View file

@ -221,6 +221,11 @@ public class RefreshOwnProfileJob extends BaseJob {
AppDependencies.getJobManager().add(new MultiDeviceProfileContentUpdateJob());
}
if (!Recipient.self().getVersionedExpirationTimerCapability().isSupported() && capabilities.isVersionedExpirationTimer()) {
Log.d(TAG, "Transitioned to versioned expiration timer capable, notify linked devices in case we were the last one");
AppDependencies.getJobManager().add(new MultiDeviceProfileContentUpdateJob());
}
SignalDatabase.recipients().setCapabilities(Recipient.self().getId(), capabilities);
}

View file

@ -158,7 +158,7 @@ object DataMessageProcessor {
when {
message.isInvalid -> handleInvalidMessage(context, senderRecipient.id, groupId, envelope.timestamp!!)
message.isEndSession -> insertResult = handleEndSessionMessage(context, senderRecipient.id, envelope, metadata)
message.isExpirationUpdate -> insertResult = handleExpirationUpdate(envelope, metadata, senderRecipient.id, threadRecipient.id, groupId, message.expireTimerDuration, receivedTime, false)
message.isExpirationUpdate -> insertResult = handleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient.id, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime, false)
message.isStoryReaction -> insertResult = handleStoryReaction(context, envelope, metadata, message, senderRecipient.id, groupId)
message.reaction != null -> messageId = handleReaction(context, envelope, message, senderRecipient.id, earlyMessageCacheEntry)
message.hasRemoteDelete -> messageId = handleRemoteDelete(context, envelope, message, senderRecipient.id, earlyMessageCacheEntry)
@ -321,10 +321,11 @@ object DataMessageProcessor {
private fun handleExpirationUpdate(
envelope: Envelope,
metadata: EnvelopeMetadata,
senderRecipientId: RecipientId,
senderRecipient: Recipient,
threadRecipientId: RecipientId,
groupId: GroupId.V2?,
expiresIn: Duration,
expireTimerVersion: Int?,
receivedTime: Long,
sideEffect: Boolean
): InsertResult? {
@ -340,10 +341,15 @@ object DataMessageProcessor {
return null
}
if (expireTimerVersion != null && expireTimerVersion < senderRecipient.expireTimerVersion) {
log(envelope.timestamp!!, "Old expireTimerVersion. Received: $expireTimerVersion, Current: ${senderRecipient.expireTimerVersion}. Ignoring.")
return null
}
try {
val mediaMessage = IncomingMessage(
type = MessageType.EXPIRATION_UPDATE,
from = senderRecipientId,
from = senderRecipient.id,
sentTimeMillis = envelope.timestamp!! - if (sideEffect) 1 else 0,
serverTimeMillis = envelope.serverTimestamp!!,
receivedTimeMillis = receivedTime,
@ -353,7 +359,13 @@ object DataMessageProcessor {
)
val insertResult: InsertResult? = SignalDatabase.messages.insertMessageInbox(mediaMessage, -1).orNull()
SignalDatabase.recipients.setExpireMessages(threadRecipientId, expiresIn.inWholeSeconds.toInt())
if (expireTimerVersion != null) {
SignalDatabase.recipients.setExpireMessages(threadRecipientId, expiresIn.inWholeSeconds.toInt(), expireTimerVersion)
} else {
// TODO [expireVersion] After unsupported builds expire, we can remove this branch
SignalDatabase.recipients.setExpireMessagesWithoutIncrementingVersion(threadRecipientId, expiresIn.inWholeSeconds.toInt())
}
if (insertResult != null) {
return insertResult
@ -372,15 +384,16 @@ object DataMessageProcessor {
fun handlePossibleExpirationUpdate(
envelope: Envelope,
metadata: EnvelopeMetadata,
senderRecipientId: RecipientId,
senderRecipient: Recipient,
threadRecipient: Recipient,
groupId: GroupId.V2?,
expiresIn: Duration,
expireTimerVersion: Int?,
receivedTime: Long
) {
if (threadRecipient.expiresInSeconds.toLong() != expiresIn.inWholeSeconds) {
if (threadRecipient.expiresInSeconds.toLong() != expiresIn.inWholeSeconds || ((expireTimerVersion ?: -1) > threadRecipient.expireTimerVersion)) {
warn(envelope.timestamp!!, "Message expire time didn't match thread expire time. Handling timer update.")
handleExpirationUpdate(envelope, metadata, senderRecipientId, threadRecipient.id, groupId, expiresIn, receivedTime, true)
handleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient.id, groupId, expiresIn, expireTimerVersion, receivedTime, true)
}
}
@ -741,7 +754,7 @@ object DataMessageProcessor {
threadRecipient = senderRecipient
}
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient.id, threadRecipient, groupId, message.expireTimerDuration, receivedTime)
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime)
if (message.hasGroupContext) {
parentStoryId = GroupReply(storyMessageId.id)
@ -892,7 +905,7 @@ object DataMessageProcessor {
val attachments: List<Attachment> = message.attachments.toPointersWithinLimit()
val messageRanges: BodyRangeList? = if (message.bodyRanges.isNotEmpty()) message.bodyRanges.asSequence().take(BODY_RANGE_PROCESSING_LIMIT).filter { it.mentionAci == null }.toList().toBodyRangeList() else null
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient.id, threadRecipient, groupId, message.expireTimerDuration, receivedTime)
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime)
val mediaMessage = IncomingMessage(
type = MessageType.NORMAL,
@ -972,7 +985,7 @@ object DataMessageProcessor {
val body = message.body ?: ""
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient.id, threadRecipient, groupId, message.expireTimerDuration, receivedTime)
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime)
notifyTypingStoppedFromIncomingMessage(context, senderRecipient, threadRecipient.id, metadata.sourceDeviceId)

View file

@ -353,6 +353,7 @@ object SyncMessageProcessor {
body = body,
timestamp = sent.timestamp!!,
expiresIn = targetMessage.expiresIn,
expireTimerVersion = targetMessage.expireTimerVersion,
isSecure = true,
bodyRanges = bodyRanges,
messageToEdit = targetMessage.id
@ -366,6 +367,7 @@ object SyncMessageProcessor {
sentTimeMillis = sent.timestamp!!,
body = body,
expiresIn = targetMessage.expiresIn,
expireTimerVersion = targetMessage.expireTimerVersion,
isUrgent = true,
isSecure = true,
bodyRanges = bodyRanges,
@ -429,6 +431,7 @@ object SyncMessageProcessor {
attachments = syncAttachments.ifEmpty { (targetMessage as? MmsMessageRecord)?.slideDeck?.asAttachments() ?: emptyList() },
timestamp = sent.timestamp!!,
expiresIn = targetMessage.expiresIn,
expireTimerVersion = targetMessage.expireTimerVersion,
viewOnce = viewOnce,
quote = quote,
contacts = sharedContacts,
@ -676,13 +679,26 @@ object SyncMessageProcessor {
}
val recipient: Recipient = getSyncMessageDestination(sent)
val expirationUpdateMessage: OutgoingMessage = OutgoingMessage.expirationUpdateMessage(recipient, if (sideEffect) sent.timestamp!! - 1 else sent.timestamp!!, sent.message!!.expireTimerDuration.inWholeMilliseconds)
val threadId: Long = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
val messageId: Long = SignalDatabase.messages.insertMessageOutbox(expirationUpdateMessage, threadId, false, null)
val expirationUpdateMessage: OutgoingMessage = OutgoingMessage.expirationUpdateMessage(
threadRecipient = recipient,
sentTimeMillis = if (sideEffect) sent.timestamp!! - 1 else sent.timestamp!!,
expiresIn = sent.message!!.expireTimerDuration.inWholeMilliseconds,
expireTimerVersion = sent.message!!.expireTimerVersion ?: 1
)
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.recipients.setExpireMessages(recipient.id, sent.message!!.expireTimerDuration.inWholeSeconds.toInt())
if (sent.message?.expireTimerVersion == null) {
// TODO [expireVersion] After unsupported builds expire, we can remove this branch
SignalDatabase.recipients.setExpireMessagesWithoutIncrementingVersion(recipient.id, sent.message!!.expireTimerDuration.inWholeSeconds.toInt())
val messageId: Long = SignalDatabase.messages.insertMessageOutbox(expirationUpdateMessage, threadId, false, null)
SignalDatabase.messages.markAsSent(messageId, true)
} else if (sent.message!!.expireTimerVersion!! >= recipient.expireTimerVersion) {
SignalDatabase.recipients.setExpireMessages(recipient.id, sent.message!!.expireTimerDuration.inWholeSeconds.toInt(), sent.message!!.expireTimerVersion!!)
val messageId: Long = SignalDatabase.messages.insertMessageOutbox(expirationUpdateMessage, threadId, false, null)
SignalDatabase.messages.markAsSent(messageId, true)
} else {
warn(sent.timestamp!!, "[SynchronizeExpiration] Ignoring expire timer update with old version. Received: ${sent.message!!.expireTimerVersion}, Current: ${recipient.expireTimerVersion}")
}
return threadId
}
@ -749,7 +765,7 @@ object SyncMessageProcessor {
isSecure = true
)
if (recipient.expiresInSeconds != dataMessage.expireTimerDuration.inWholeSeconds.toInt()) {
if (recipient.expiresInSeconds != dataMessage.expireTimerDuration.inWholeSeconds.toInt() || ((dataMessage.expireTimerVersion ?: -1) > recipient.expireTimerVersion)) {
handleSynchronizeSentExpirationUpdate(sent, sideEffect = true)
}
@ -814,7 +830,7 @@ object SyncMessageProcessor {
isSecure = true
)
if (recipient.expiresInSeconds != dataMessage.expireTimerDuration.inWholeSeconds.toInt()) {
if (recipient.expiresInSeconds != dataMessage.expireTimerDuration.inWholeSeconds.toInt() || ((dataMessage.expireTimerVersion ?: -1) > recipient.expireTimerVersion)) {
handleSynchronizeSentExpirationUpdate(sent, sideEffect = true)
}
@ -861,7 +877,7 @@ object SyncMessageProcessor {
val expiresInMillis = dataMessage.expireTimerDuration.inWholeMilliseconds
val bodyRanges = dataMessage.bodyRanges.filter { it.mentionAci == null }.toBodyRangeList()
if (recipient.expiresInSeconds != dataMessage.expireTimerDuration.inWholeSeconds.toInt()) {
if (recipient.expiresInSeconds != dataMessage.expireTimerDuration.inWholeSeconds.toInt() || ((dataMessage.expireTimerVersion ?: -1) > recipient.expireTimerVersion)) {
handleSynchronizeSentExpirationUpdate(sent, sideEffect = true)
}

View file

@ -151,9 +151,10 @@ public class ApplicationMigrations {
static final int CONTACT_LINK_REBUILD = 106;
static final int DELETE_SYNC_CAPABILITY = 107;
static final int REBUILD_MESSAGE_FTS_INDEX_5 = 108;
static final int EXPIRE_TIMER_CAPABILITY = 109;
}
public static final int CURRENT_VERSION = 108;
public static final int CURRENT_VERSION = 109;
/**
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
@ -688,6 +689,10 @@ public class ApplicationMigrations {
jobs.put(Version.REBUILD_MESSAGE_FTS_INDEX_5, new RebuildMessageSearchIndexMigrationJob());
}
if (lastSeenVersion < Version.EXPIRE_TIMER_CAPABILITY) {
jobs.put(Version.EXPIRE_TIMER_CAPABILITY, new AttributesMigrationJob());
}
return jobs;
}

View file

@ -25,6 +25,7 @@ data class OutgoingMessage(
val body: String = "",
val distributionType: Int = ThreadTable.DistributionTypes.DEFAULT,
val expiresIn: Long = 0L,
val expireTimerVersion: Int = threadRecipient.expireTimerVersion,
val isViewOnce: Boolean = false,
val outgoingQuote: QuoteModel? = null,
val storyType: StoryType = StoryType.NONE,
@ -70,6 +71,7 @@ data class OutgoingMessage(
attachments: List<Attachment> = emptyList(),
timestamp: Long,
expiresIn: Long = 0L,
expireTimerVersion: Int = 1,
viewOnce: Boolean = false,
distributionType: Int = ThreadTable.DistributionTypes.DEFAULT,
storyType: StoryType = StoryType.NONE,
@ -92,6 +94,7 @@ data class OutgoingMessage(
attachments = attachments,
sentTimeMillis = timestamp,
expiresIn = expiresIn,
expireTimerVersion = expireTimerVersion,
isViewOnce = viewOnce,
distributionType = distributionType,
storyType = storyType,
@ -119,6 +122,7 @@ data class OutgoingMessage(
body: String? = "",
timestamp: Long,
expiresIn: Long = 0L,
expiresTimerVersion: Int = 1,
viewOnce: Boolean = false,
storyType: StoryType = StoryType.NONE,
linkPreviews: List<LinkPreview> = emptyList(),
@ -132,6 +136,7 @@ data class OutgoingMessage(
attachments = slideDeck.asAttachments(),
sentTimeMillis = timestamp,
expiresIn = expiresIn,
expireTimerVersion = expiresTimerVersion,
isViewOnce = viewOnce,
storyType = storyType,
linkPreviews = linkPreviews,
@ -143,8 +148,8 @@ data class OutgoingMessage(
val subscriptionId = -1
fun withExpiry(expiresIn: Long): OutgoingMessage {
return copy(expiresIn = expiresIn)
fun withExpiry(expiresIn: Long, expireTimerVersion: Int): OutgoingMessage {
return copy(expiresIn = expiresIn, expireTimerVersion = expireTimerVersion)
}
fun stripAttachments(): OutgoingMessage {
@ -351,12 +356,13 @@ data class OutgoingMessage(
* Helper for creating expiration update messages.
*/
@JvmStatic
fun expirationUpdateMessage(threadRecipient: Recipient, sentTimeMillis: Long, expiresIn: Long): OutgoingMessage {
fun expirationUpdateMessage(threadRecipient: Recipient, sentTimeMillis: Long, expiresIn: Long, expireTimerVersion: Int): OutgoingMessage {
return OutgoingMessage(
threadRecipient = threadRecipient,
sentTimeMillis = sentTimeMillis,
expiresIn = expiresIn,
isExpirationUpdate = true,
expireTimerVersion = expireTimerVersion,
isUrgent = false,
isSecure = true
)

View file

@ -75,9 +75,10 @@ public class RemoteReplyReceiver extends BroadcastReceiver {
SignalExecutors.BOUNDED.execute(() -> {
long threadId;
Recipient recipient = Recipient.resolved(recipientId);
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds());
ParentStoryId parentStoryId = groupStoryId != Long.MIN_VALUE ? ParentStoryId.deserialize(groupStoryId) : null;
Recipient recipient = Recipient.resolved(recipientId);
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds());
int expireTimerVersion = recipient.getExpireTimerVersion();
ParentStoryId parentStoryId = groupStoryId != Long.MIN_VALUE ? ParentStoryId.deserialize(groupStoryId) : null;
switch (replyMethod) {
case GroupMessage: {
@ -86,6 +87,7 @@ public class RemoteReplyReceiver extends BroadcastReceiver {
new LinkedList<>(),
System.currentTimeMillis(),
expiresIn,
expireTimerVersion,
false,
0,
StoryType.NONE,

View file

@ -84,6 +84,7 @@ class Recipient(
private val messageRingtoneUri: Uri? = null,
private val callRingtoneUri: Uri? = null,
val expiresInSeconds: Int = 0,
val expireTimerVersion: Int = 1,
private val registeredValue: RegisteredState = RegisteredState.UNKNOWN,
val profileKey: ByteArray? = null,
val expiringProfileKeyCredential: ExpiringProfileKeyCredential? = null,
@ -314,9 +315,12 @@ class Recipient(
/** The notification channel, if both set and supported by the system. Otherwise null. */
val notificationChannel: String? = if (!NotificationChannels.supported()) null else notificationChannelValue
/** The user's payment capability. */
/** The user's capability to handle synchronizing deletes across linked devices. */
val deleteSyncCapability: Capability = capabilities.deleteSync
/** The user's capability to handle tracking an expire timer version. */
val versionedExpirationTimerCapability: Capability = capabilities.versionedExpirationTimer
/** The state around whether we can send sealed sender to this user. */
val sealedSenderAccessMode: SealedSenderAccessMode = if (pni.isPresent && pni == serviceId) {
SealedSenderAccessMode.DISABLED

View file

@ -167,6 +167,7 @@ object RecipientCreator {
callVibrate = record.callVibrateState,
isBlocked = record.isBlocked,
expiresInSeconds = record.expireMessages,
expireTimerVersion = record.expireTimerVersion,
participantIdsValue = participantIds ?: LinkedList(),
isActiveGroup = groupRecord.map { it.isActive }.orElse(false),
profileName = record.signalProfileName,

View file

@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.OutgoingMessage;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.ExpirationTimerUtil;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
@ -324,21 +325,23 @@ public class RecipientUtil {
/**
* Checks if a universal timer is set and if the thread should have it set on it. Attempts to abort quickly and perform
* minimal database access.
*
* @return The new expire timer version if the timer was set, otherwise null.
*/
@WorkerThread
public static boolean setAndSendUniversalExpireTimerIfNecessary(@NonNull Context context, @NonNull Recipient recipient, long threadId) {
public static @Nullable Integer setAndSendUniversalExpireTimerIfNecessary(@NonNull Context context, @NonNull Recipient recipient, long threadId) {
int defaultTimer = SignalStore.settings().getUniversalExpireTimer();
if (defaultTimer == 0 || recipient.isGroup() || recipient.isDistributionList() || recipient.getExpiresInSeconds() != 0 || !recipient.isRegistered()) {
return false;
return null;
}
if (threadId == -1 || SignalDatabase.messages().canSetUniversalTimer(threadId)) {
SignalDatabase.recipients().setExpireMessages(recipient.getId(), defaultTimer);
OutgoingMessage outgoingMessage = OutgoingMessage.expirationUpdateMessage(recipient, System.currentTimeMillis(), defaultTimer * 1000L);
int expireTimerVersion = ExpirationTimerUtil.setExpirationTimer(recipient.getId(), defaultTimer);
OutgoingMessage outgoingMessage = OutgoingMessage.expirationUpdateMessage(recipient, System.currentTimeMillis(), defaultTimer * 1000L, expireTimerVersion);
MessageSender.send(context, outgoingMessage, SignalDatabase.threads().getOrCreateThreadIdFor(recipient), MessageSender.SendType.SIGNAL, null, null);
return true;
return expireTimerVersion;
}
return false;
return null;
}
@WorkerThread

View file

@ -105,12 +105,13 @@ public final class MultiShareSender {
for (ContactSearchKey.RecipientSearchKey recipientSearchKey : multiShareArgs.getRecipientSearchKeys()) {
Recipient recipient = Recipient.resolved(recipientSearchKey.getRecipientId());
long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient);
List<Mention> mentions = getValidMentionsForRecipient(recipient, multiShareArgs.getMentions());
MessageSendType sendType = MessageSendType.SignalMessageSendType.INSTANCE;
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds());
List<Contact> contacts = multiShareArgs.getSharedContacts();
SlideDeck slideDeck = new SlideDeck(primarySlideDeck);
long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient);
List<Mention> mentions = getValidMentionsForRecipient(recipient, multiShareArgs.getMentions());
MessageSendType sendType = MessageSendType.SignalMessageSendType.INSTANCE;
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds());
int expireTimerVersion = recipient.getExpireTimerVersion();
List<Contact> contacts = multiShareArgs.getSharedContacts();
SlideDeck slideDeck = new SlideDeck(primarySlideDeck);
boolean needsSplit = message != null &&
message.length() > sendType.calculateCharacters(message).maxPrimaryMessageSize;
@ -138,6 +139,7 @@ public final class MultiShareSender {
sendType,
threadId,
expiresIn,
expireTimerVersion,
multiShareArgs.isViewOnce(),
mentions,
recipientSearchKey.isStory(),
@ -182,6 +184,7 @@ public final class MultiShareSender {
@NonNull MessageSendType sendType,
long threadId,
long expiresIn,
int expireTimerVersion,
boolean isViewOnce,
@NonNull List<Mention> validatedMentions,
boolean isStory,
@ -221,6 +224,7 @@ public final class MultiShareSender {
body,
sentTimestamps.getMillis(0),
0L,
1,
false,
storyType.toTextStoryType(),
buildLinkPreviews(context, multiShareArgs.getLinkPreview()),
@ -260,6 +264,7 @@ public final class MultiShareSender {
body,
sentTimestamps.getMillis(i),
0L,
1,
false,
storyType,
Collections.emptyList(),
@ -277,6 +282,7 @@ public final class MultiShareSender {
body,
sentTimestamps.getMillis(0),
expiresIn,
expireTimerVersion,
isViewOnce,
StoryType.NONE,
buildLinkPreviews(context, multiShareArgs.getLinkPreview()),

View file

@ -513,8 +513,12 @@ public class MessageSender {
}
private static @NonNull OutgoingMessage applyUniversalExpireTimerIfNecessary(@NonNull Context context, @NonNull Recipient recipient, @NonNull OutgoingMessage outgoingMessage, long threadId) {
if (!outgoingMessage.isExpirationUpdate() && outgoingMessage.getExpiresIn() == 0 && RecipientUtil.setAndSendUniversalExpireTimerIfNecessary(context, recipient, threadId)) {
return outgoingMessage.withExpiry(TimeUnit.SECONDS.toMillis(SignalStore.settings().getUniversalExpireTimer()));
if (!outgoingMessage.isExpirationUpdate() && outgoingMessage.getExpiresIn() == 0) {
Integer expireTimerVersion = RecipientUtil.setAndSendUniversalExpireTimerIfNecessary(context, recipient, threadId);
if (expireTimerVersion != null) {
return outgoingMessage.withExpiry(TimeUnit.SECONDS.toMillis(SignalStore.settings().getUniversalExpireTimer()), expireTimerVersion);
}
}
return outgoingMessage;
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.util
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* This exists as a temporary shim to improve the callsites where we'll be setting the expiration timer.
*
* Until the versions that don't understand expiration timers expire, we'll have to check capabilities before incrementing the version.
*
* After those old clients expire, we can remove this shim entirely and call the RecipientTable methods directly.
*/
object ExpirationTimerUtil {
@JvmStatic
fun setExpirationTimer(recipientId: RecipientId, expirationTimeSeconds: Int): Int {
val selfCapable = Recipient.self().versionedExpirationTimerCapability == Recipient.Capability.SUPPORTED
val recipientCapable = Recipient.resolved(recipientId).let { it.versionedExpirationTimerCapability == Recipient.Capability.SUPPORTED || it.expireTimerVersion > 2 }
return if (selfCapable && recipientCapable) {
SignalDatabase.recipients.setExpireMessagesAndIncrementVersion(recipientId, expirationTimeSeconds)
} else {
SignalDatabase.recipients.setExpireMessagesWithoutIncrementingVersion(recipientId, expirationTimeSeconds)
1
}
}
}

View file

@ -44,6 +44,7 @@ object RecipientDatabaseTestUtils {
messageRingtone: Uri = Uri.EMPTY,
callRingtone: Uri = Uri.EMPTY,
expireMessages: Int = 0,
expireTimerVersion: Int = 1,
registered: RecipientTable.RegisteredState = RecipientTable.RegisteredState.REGISTERED,
profileKey: ByteArray = Random.nextBytes(32),
expiringProfileKeyCredential: ExpiringProfileKeyCredential? = null,
@ -107,6 +108,7 @@ object RecipientDatabaseTestUtils {
messageRingtone = messageRingtone,
callRingtone = callRingtone,
expireMessages = expireMessages,
expireTimerVersion = expireTimerVersion,
registered = registered,
profileKey = profileKey,
expiringProfileKeyCredential = expiringProfileKeyCredential,
@ -124,7 +126,8 @@ object RecipientDatabaseTestUtils {
sealedSenderAccessMode = sealedSenderAccessMode,
capabilities = RecipientRecord.Capabilities(
rawBits = capabilities,
deleteSync = Recipient.Capability.deserialize(Bitmask.read(capabilities, RecipientTable.Capabilities.DELETE_SYNC, RecipientTable.Capabilities.BIT_LENGTH).toInt())
deleteSync = Recipient.Capability.deserialize(Bitmask.read(capabilities, RecipientTable.Capabilities.DELETE_SYNC, RecipientTable.Capabilities.BIT_LENGTH).toInt()),
versionedExpirationTimer = Recipient.Capability.deserialize(Bitmask.read(capabilities, RecipientTable.Capabilities.VERSIONED_EXPIRATION_TIMER, RecipientTable.Capabilities.BIT_LENGTH).toInt())
),
storageId = storageId,
mentionSetting = mentionSetting,

View file

@ -20,6 +20,7 @@ object TestMms {
sentTimeMillis: Long = System.currentTimeMillis(),
receivedTimestampMillis: Long = System.currentTimeMillis(),
expiresIn: Long = 0,
expireTimerVersion: Int = 1,
viewOnce: Boolean = false,
distributionType: Int = ThreadTable.DistributionTypes.DEFAULT,
type: Long = MessageTypes.BASE_INBOX_TYPE,
@ -29,23 +30,24 @@ object TestMms {
storyType: StoryType = StoryType.NONE
): Long {
val message = OutgoingMessage(
recipient,
body,
emptyList(),
sentTimeMillis,
expiresIn,
viewOnce,
distributionType,
storyType,
null,
false,
null,
emptyList(),
emptyList(),
emptyList(),
emptySet(),
emptySet(),
null
recipient = recipient,
body = body,
attachments = emptyList(),
timestamp = sentTimeMillis,
expiresIn = expiresIn,
expireTimerVersion = expireTimerVersion,
viewOnce = viewOnce,
distributionType = distributionType,
storyType = storyType,
parentStoryId = null,
isStoryReaction = false,
quote = null,
contacts = emptyList(),
previews = emptyList(),
mentions = emptyList(),
networkFailures = emptySet(),
mismatches = emptySet(),
giftBadge = null
)
return insert(
@ -61,7 +63,7 @@ object TestMms {
)
}
fun insert(
private fun insert(
db: SQLiteDatabase,
message: OutgoingMessage,
recipientId: RecipientId = message.threadRecipient.id,
@ -81,6 +83,7 @@ object TestMms {
put(MessageTable.DATE_RECEIVED, receivedTimestampMillis)
put(MessageTable.SMS_SUBSCRIPTION_ID, message.subscriptionId)
put(MessageTable.EXPIRES_IN, message.expiresIn)
put(MessageTable.EXPIRE_TIMER_VERSION, message.expireTimerVersion)
put(MessageTable.VIEW_ONCE, message.isViewOnce)
put(MessageTable.FROM_RECIPIENT_ID, recipientId.serialize())
put(MessageTable.TO_RECIPIENT_ID, recipientId.serialize())

View file

@ -141,6 +141,7 @@ object FakeMessageRecords {
subscriptionId: Int = -1,
expiresIn: Long = -1,
expireStarted: Long = -1,
expireTimerVersion: Int = individualRecipient.expireTimerVersion,
viewOnce: Boolean = false,
hasReadReceipt: Boolean = false,
quote: Quote? = null,
@ -178,6 +179,7 @@ object FakeMessageRecords {
subscriptionId,
expiresIn,
expireStarted,
expireTimerVersion,
viewOnce,
hasReadReceipt,
quote,

View file

@ -1059,6 +1059,7 @@ public class SignalServiceMessageSender {
if (message.getExpiresInSeconds() > 0) {
builder.expireTimer(message.getExpiresInSeconds());
}
builder.expireTimerVersion(message.getExpireTimerVersion());
if (message.getProfileKey().isPresent()) {
builder.profileKey(ByteString.of(message.getProfileKey().get()));

View file

@ -55,6 +55,7 @@ class AccountAttributes @JsonCreator constructor(
data class Capabilities @JsonCreator constructor(
@JsonProperty val storage: Boolean,
@JsonProperty val deleteSync: Boolean
@JsonProperty val deleteSync: Boolean,
@JsonProperty val expireTimerVersion: Boolean
)
}

View file

@ -34,6 +34,7 @@ class SignalServiceDataMessage private constructor(
val body: Optional<String>,
val isEndSession: Boolean,
val expiresInSeconds: Int,
val expireTimerVersion: Int,
val isExpirationUpdate: Boolean,
val profileKey: Optional<ByteArray>,
val isProfileKeyUpdate: Boolean,
@ -79,6 +80,7 @@ class SignalServiceDataMessage private constructor(
private var body: String? = null
private var endSession: Boolean = false
private var expiresInSeconds: Int = 0
private var expireTimerVersion: Int = 1
private var expirationUpdate: Boolean = false
private var profileKey: ByteArray? = null
private var profileKeyUpdate: Boolean = false
@ -133,6 +135,11 @@ class SignalServiceDataMessage private constructor(
return this
}
fun withExpireTimerVersion(expireTimerVersion: Int): Builder {
this.expireTimerVersion = expireTimerVersion
return this
}
fun withProfileKey(profileKey: ByteArray?): Builder {
this.profileKey = profileKey
return this
@ -225,6 +232,7 @@ class SignalServiceDataMessage private constructor(
body = body.emptyIfStringEmpty(),
isEndSession = endSession,
expiresInSeconds = expiresInSeconds,
expireTimerVersion = expireTimerVersion,
isExpirationUpdate = expirationUpdate,
profileKey = profileKey.asOptional(),
isProfileKeyUpdate = profileKeyUpdate,

View file

@ -21,6 +21,7 @@ public class DeviceContact {
private final Optional<VerifiedMessage> verified;
private final Optional<ProfileKey> profileKey;
private final Optional<Integer> expirationTimer;
private final Optional<Integer> expirationTimerVersion;
private final Optional<Integer> inboxPosition;
private final boolean archived;
@ -32,6 +33,7 @@ public class DeviceContact {
Optional<VerifiedMessage> verified,
Optional<ProfileKey> profileKey,
Optional<Integer> expirationTimer,
Optional<Integer> expirationTimerVersion,
Optional<Integer> inboxPosition,
boolean archived)
{
@ -39,16 +41,17 @@ public class DeviceContact {
throw new IllegalArgumentException("Must have either ACI or E164");
}
this.aci = aci;
this.e164 = e164;
this.name = name;
this.avatar = avatar;
this.color = color;
this.verified = verified;
this.profileKey = profileKey;
this.expirationTimer = expirationTimer;
this.inboxPosition = inboxPosition;
this.archived = archived;
this.aci = aci;
this.e164 = e164;
this.name = name;
this.avatar = avatar;
this.color = color;
this.verified = verified;
this.profileKey = profileKey;
this.expirationTimer = expirationTimer;
this.expirationTimerVersion = expirationTimerVersion;
this.inboxPosition = inboxPosition;
this.archived = archived;
}
public Optional<DeviceContactAvatar> getAvatar() {
@ -83,6 +86,10 @@ public class DeviceContact {
return expirationTimer;
}
public Optional<Integer> getExpirationTimerVersion() {
return expirationTimerVersion;
}
public Optional<Integer> getInboxPosition() {
return inboxPosition;
}

View file

@ -47,16 +47,17 @@ public class DeviceContactsInputStream extends ChunkedInputStream {
throw new IOException("Missing contact address!");
}
Optional<ACI> aci = Optional.ofNullable(ACI.parseOrNull(details.aci));
Optional<String> e164 = Optional.ofNullable(details.number);
Optional<String> name = Optional.ofNullable(details.name);
Optional<DeviceContactAvatar> avatar = Optional.empty();
Optional<String> color = details.color != null ? Optional.of(details.color) : Optional.empty();
Optional<VerifiedMessage> verified = Optional.empty();
Optional<ProfileKey> profileKey = Optional.empty();
Optional<Integer> expireTimer = Optional.empty();
Optional<Integer> inboxPosition = Optional.empty();
boolean archived = false;
Optional<ACI> aci = Optional.ofNullable(ACI.parseOrNull(details.aci));
Optional<String> e164 = Optional.ofNullable(details.number);
Optional<String> name = Optional.ofNullable(details.name);
Optional<DeviceContactAvatar> avatar = Optional.empty();
Optional<String> color = details.color != null ? Optional.of(details.color) : Optional.empty();
Optional<VerifiedMessage> verified = Optional.empty();
Optional<ProfileKey> profileKey = Optional.empty();
Optional<Integer> expireTimer = Optional.empty();
Optional<Integer> expireTimerVersion = Optional.empty();
Optional<Integer> inboxPosition = Optional.empty();
boolean archived = false;
if (details.avatar != null && details.avatar.length != null) {
long avatarLength = details.avatar.length;
@ -103,13 +104,17 @@ public class DeviceContactsInputStream extends ChunkedInputStream {
expireTimer = Optional.of(details.expireTimer);
}
if (details.expireTimerVersion != null && details.expireTimerVersion > 0) {
expireTimerVersion = Optional.of(details.expireTimerVersion);
}
if (details.inboxPosition != null) {
inboxPosition = Optional.of(details.inboxPosition);
}
archived = details.archived;
return new DeviceContact(aci, e164, name, avatar, color, verified, profileKey, expireTimer, inboxPosition, archived);
return new DeviceContact(aci, e164, name, avatar, color, verified, profileKey, expireTimer, expireTimerVersion, inboxPosition, archived);
}
}

View file

@ -195,12 +195,16 @@ public class SignalServiceProfile {
@JsonProperty
private boolean deleteSync;
@JsonProperty
private boolean versionedExpirationTimer;
@JsonCreator
public Capabilities() {}
public Capabilities(boolean storage, boolean deleteSync) {
this.storage = storage;
this.deleteSync = deleteSync;
public Capabilities(boolean storage, boolean deleteSync, boolean versionedExpirationTimer) {
this.storage = storage;
this.deleteSync = deleteSync;
this.versionedExpirationTimer = versionedExpirationTimer;
}
public boolean isStorage() {
@ -210,6 +214,10 @@ public class SignalServiceProfile {
public boolean isDeleteSync() {
return deleteSync;
}
public boolean isVersionedExpirationTimer() {
return versionedExpirationTimer;
}
}
public ExpiringProfileKeyCredentialResponse getExpiringProfileKeyCredentialResponse() {

View file

@ -332,6 +332,7 @@ message DataMessage {
optional GroupContextV2 groupV2 = 15;
optional uint32 flags = 4;
optional uint32 expireTimer = 5;
optional uint32 expireTimerVersion = 23;
optional bytes profileKey = 6;
optional uint64 timestamp = 7;
optional Quote quote = 8;
@ -793,17 +794,18 @@ message ContactDetails {
optional uint32 length = 2;
}
optional string number = 1;
optional string aci = 9;
optional string name = 2;
optional Avatar avatar = 3;
optional string color = 4;
optional Verified verified = 5;
optional bytes profileKey = 6;
reserved /*blocked*/ 7;
optional uint32 expireTimer = 8;
optional uint32 inboxPosition = 10;
optional bool archived = 11;
optional string number = 1;
optional string aci = 9;
optional string name = 2;
optional Avatar avatar = 3;
optional string color = 4;
optional Verified verified = 5;
optional bytes profileKey = 6;
reserved /*blocked*/ 7;
optional uint32 expireTimer = 8;
optional uint32 expireTimerVersion = 12;
optional uint32 inboxPosition = 10;
optional bool archived = 11;
}
message GroupDetails {
@ -817,17 +819,17 @@ message GroupDetails {
optional string e164 = 2;
}
optional bytes id = 1;
optional string name = 2;
repeated string membersE164 = 3;
repeated Member members = 9;
optional Avatar avatar = 4;
optional bool active = 5 [default = true];
optional uint32 expireTimer = 6;
optional string color = 7;
optional bool blocked = 8;
optional uint32 inboxPosition = 10;
optional bool archived = 11;
optional bytes id = 1;
optional string name = 2;
repeated string membersE164 = 3;
repeated Member members = 9;
optional Avatar avatar = 4;
optional bool active = 5 [default = true];
optional uint32 expireTimer = 6;
optional string color = 7;
optional bool blocked = 8;
optional uint32 inboxPosition = 10;
optional bool archived = 11;
}
message PaymentAddress {

View file

@ -40,6 +40,7 @@ public class DeviceContactsInputStreamTest {
Optional.of(generateProfileKey()),
Optional.of(0),
Optional.of(0),
Optional.of(0),
false
);
@ -53,6 +54,7 @@ public class DeviceContactsInputStreamTest {
Optional.of(generateProfileKey()),
Optional.of(0),
Optional.of(0),
Optional.of(0),
false
);