From 1f196f74ffeb15064d8d406b914ca183997159f4 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Tue, 27 Aug 2024 07:41:35 -0400 Subject: [PATCH] Add support for versioned expiration timers. Co-authored-by: Greyson Parrelli --- .../messages/EditMessageSyncProcessorTest.kt | 4 +- .../securesms/testing/SignalActivityRule.kt | 2 +- .../test/ConversationElementGenerator.kt | 1 + .../thoughtcrime/securesms/AppCapabilities.kt | 3 +- .../expire/ExpireTimerSettingsRepository.kt | 5 +- .../InternalConversationSettingsFragment.kt | 3 +- .../securesms/database/GroupTable.kt | 4 +- .../securesms/database/MessageTable.kt | 13 ++++- .../securesms/database/RecipientTable.kt | 48 ++++++++++++++++++- .../database/RecipientTableCursorUtil.kt | 4 +- .../helpers/SignalDatabaseMigrations.kt | 6 ++- .../migration/V241_ExpireTimerVersion.kt | 26 ++++++++++ .../database/model/InMemoryMessageRecord.java | 1 + .../database/model/MessageRecord.java | 7 +++ .../database/model/MmsMessageRecord.java | 13 ++--- .../database/model/RecipientRecord.kt | 9 ++-- .../securesms/jobs/IndividualSendJob.java | 1 + .../jobs/MultiDeviceContactSyncJob.kt | 10 +++- .../jobs/MultiDeviceContactUpdateJob.java | 18 ++++--- .../jobs/MultiDeviceProfileKeyUpdateJob.java | 1 + .../securesms/jobs/RefreshOwnProfileJob.java | 5 ++ .../messages/DataMessageProcessor.kt | 33 +++++++++---- .../messages/SyncMessageProcessor.kt | 32 +++++++++---- .../migrations/ApplicationMigrations.java | 7 ++- .../securesms/mms/OutgoingMessage.kt | 12 +++-- .../notifications/RemoteReplyReceiver.java | 8 ++-- .../securesms/recipients/Recipient.kt | 6 ++- .../securesms/recipients/RecipientCreator.kt | 1 + .../securesms/recipients/RecipientUtil.java | 15 +++--- .../securesms/sharing/MultiShareSender.java | 18 ++++--- .../securesms/sms/MessageSender.java | 8 +++- .../securesms/util/ExpirationTimerUtil.kt | 33 +++++++++++++ .../database/RecipientDatabaseTestUtils.kt | 5 +- .../securesms/database/TestMms.kt | 39 ++++++++------- .../securesms/database/FakeMessageRecords.kt | 2 + .../api/SignalServiceMessageSender.java | 1 + .../api/account/AccountAttributes.kt | 3 +- .../api/messages/SignalServiceDataMessage.kt | 8 ++++ .../messages/multidevice/DeviceContact.java | 27 +++++++---- .../DeviceContactsInputStream.java | 27 ++++++----- .../api/profiles/SignalServiceProfile.java | 14 ++++-- .../src/main/protowire/SignalService.proto | 46 +++++++++--------- .../DeviceContactsInputStreamTest.java | 2 + 43 files changed, 392 insertions(+), 139 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V241_ExpireTimerVersion.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/ExpirationTimerUtil.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/EditMessageSyncProcessorTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/EditMessageSyncProcessorTest.kt index 6875886c7c..e29877a2ca 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/EditMessageSyncProcessorTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/EditMessageSyncProcessorTest.kt @@ -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, diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt index 412bd83295..7d129c3364 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt @@ -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() diff --git a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt index 47997cc23b..d350ba235b 100644 --- a/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt +++ b/app/src/debug/java/org/thoughtcrime/securesms/components/settings/app/internal/conversation/test/ConversationElementGenerator.kt @@ -96,6 +96,7 @@ class ConversationElementGenerator { 0, 0, 0, + 0, false, true, null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.kt b/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.kt index 03bfe84e93..54ca7cdc87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.kt @@ -11,7 +11,8 @@ object AppCapabilities { fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities { return AccountAttributes.Capabilities( storage = storageCapable, - deleteSync = true + deleteSync = true, + expireTimerVersion = true ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsRepository.kt index 0661bb40b6..32c5fe90e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/expire/ExpireTimerSettingsRepository.kt @@ -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)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt index 6a4ffada0f..94885129f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt @@ -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!" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt index 53f58215c2..0f38d0e027 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 076d6a1f8a..f18f4f56af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index e1ad0b45f0..c80708c0e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -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. } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt index e5630d5667..61b346682d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt @@ -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()) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 0a7c79a597..3e2e1b72c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V241_ExpireTimerVersion.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V241_ExpireTimerVersion.kt new file mode 100644 index 0000000000..a94517c069 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V241_ExpireTimerVersion.kt @@ -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 + """ + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java index 3f332eb576..187e318ec0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java @@ -48,6 +48,7 @@ public class InMemoryMessageRecord extends MessageRecord { -1, 0, System.currentTimeMillis(), + 1, false, false, Collections.emptyList(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index a339d9121f..6df9cd1fa5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -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 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 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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index 04e495083c..812480f443 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -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 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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt index 7d6953c650..2168cd3c1a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientRecord.kt @@ -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 ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java index ce570b87f4..9fa17f3887 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java @@ -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)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactSyncJob.kt index b0e0293f22..e3f2ed09a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactSyncJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactSyncJob.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java index ac6dd890ae..0c5797978f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java @@ -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 archived = SignalDatabase.threads().getArchivedRecipients(); for (Recipient recipient : recipients) { - Optional identity = AppDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipient.getId()); - Optional verified = getVerifiedMessage(recipient, identity); - Optional name = Optional.ofNullable(recipient.isSystemContact() ? recipient.getDisplayName(context) : recipient.getGroupName(context)); - Optional profileKey = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey()); - boolean blocked = recipient.isBlocked(); - Optional expireTimer = recipient.getExpiresInSeconds() > 0 ? Optional.of(recipient.getExpiresInSeconds()) : Optional.empty(); - Optional inboxPosition = Optional.ofNullable(inboxPositions.get(recipient.getId())); + Optional identity = AppDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipient.getId()); + Optional verified = getVerifiedMessage(recipient, identity); + Optional name = Optional.ofNullable(recipient.isSystemContact() ? recipient.getDisplayName(context) : recipient.getGroupName(context)); + Optional profileKey = ProfileKeyUtil.profileKeyOptional(recipient.getProfileKey()); + boolean blocked = recipient.isBlocked(); + Optional expireTimer = recipient.getExpiresInSeconds() > 0 ? Optional.of(recipient.getExpiresInSeconds()) : Optional.empty(); + Optional expireTimerVersion = Optional.of(recipient.getExpireTimerVersion()); + Optional 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()))); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java index df964d4281..8e907a3853 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java @@ -84,6 +84,7 @@ public class MultiDeviceProfileKeyUpdateJob extends BaseJob { profileKey, Optional.empty(), Optional.empty(), + Optional.empty(), false)); out.close(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java index 7333543939..1ef45f467f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt index ee092022de..e3f6a70779 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt @@ -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 = 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt index 419ad18013..7945a1e819 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index 083e47c363..b092f88c50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt index 3859640d4c..f28afb8a7c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt @@ -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 = 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 = 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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index e1fc8aa36d..5be44ad2b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt index 4e5e233829..b6bc37f98d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt index b3c5ad4574..cba6f90ac1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientCreator.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java index 314c38c3d1..577221e9e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientUtil.java @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java index fe62d72757..c6a77cc02f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java @@ -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 mentions = getValidMentionsForRecipient(recipient, multiShareArgs.getMentions()); - MessageSendType sendType = MessageSendType.SignalMessageSendType.INSTANCE; - long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds()); - List contacts = multiShareArgs.getSharedContacts(); - SlideDeck slideDeck = new SlideDeck(primarySlideDeck); + long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient); + List mentions = getValidMentionsForRecipient(recipient, multiShareArgs.getMentions()); + MessageSendType sendType = MessageSendType.SignalMessageSendType.INSTANCE; + long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds()); + int expireTimerVersion = recipient.getExpireTimerVersion(); + List 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 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()), diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java index 6312bf5d7a..47048dde9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ExpirationTimerUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ExpirationTimerUtil.kt new file mode 100644 index 0000000000..a018a4870d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ExpirationTimerUtil.kt @@ -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 + } + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt index 749ef4a50a..e1c09fba59 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/RecipientDatabaseTestUtils.kt @@ -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, diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt b/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt index 8b87edfe20..ac4090d60f 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/TestMms.kt @@ -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()) diff --git a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt index bb6b48b6bb..12611faef3 100644 --- a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt +++ b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt @@ -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, diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index cb05d76330..95ac92370f 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -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())); diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/AccountAttributes.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/AccountAttributes.kt index 487d044a78..fa89e9a61f 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/AccountAttributes.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/account/AccountAttributes.kt @@ -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 ) } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt index c6bbd8ff25..f5ab96c919 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt @@ -34,6 +34,7 @@ class SignalServiceDataMessage private constructor( val body: Optional, val isEndSession: Boolean, val expiresInSeconds: Int, + val expireTimerVersion: Int, val isExpirationUpdate: Boolean, val profileKey: Optional, 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, diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContact.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContact.java index a78afdcc76..638cf7c755 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContact.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContact.java @@ -21,6 +21,7 @@ public class DeviceContact { private final Optional verified; private final Optional profileKey; private final Optional expirationTimer; + private final Optional expirationTimerVersion; private final Optional inboxPosition; private final boolean archived; @@ -32,6 +33,7 @@ public class DeviceContact { Optional verified, Optional profileKey, Optional expirationTimer, + Optional expirationTimerVersion, Optional 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 getAvatar() { @@ -83,6 +86,10 @@ public class DeviceContact { return expirationTimer; } + public Optional getExpirationTimerVersion() { + return expirationTimerVersion; + } + public Optional getInboxPosition() { return inboxPosition; } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java index b710090b8b..46055b5040 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java @@ -47,16 +47,17 @@ public class DeviceContactsInputStream extends ChunkedInputStream { throw new IOException("Missing contact address!"); } - Optional aci = Optional.ofNullable(ACI.parseOrNull(details.aci)); - Optional e164 = Optional.ofNullable(details.number); - Optional name = Optional.ofNullable(details.name); - Optional avatar = Optional.empty(); - Optional color = details.color != null ? Optional.of(details.color) : Optional.empty(); - Optional verified = Optional.empty(); - Optional profileKey = Optional.empty(); - Optional expireTimer = Optional.empty(); - Optional inboxPosition = Optional.empty(); - boolean archived = false; + Optional aci = Optional.ofNullable(ACI.parseOrNull(details.aci)); + Optional e164 = Optional.ofNullable(details.number); + Optional name = Optional.ofNullable(details.name); + Optional avatar = Optional.empty(); + Optional color = details.color != null ? Optional.of(details.color) : Optional.empty(); + Optional verified = Optional.empty(); + Optional profileKey = Optional.empty(); + Optional expireTimer = Optional.empty(); + Optional expireTimerVersion = Optional.empty(); + Optional 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); } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java index 370a2da21c..606250c99f 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/profiles/SignalServiceProfile.java @@ -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() { diff --git a/libsignal-service/src/main/protowire/SignalService.proto b/libsignal-service/src/main/protowire/SignalService.proto index 39e2b986f6..ab4ea42eb3 100644 --- a/libsignal-service/src/main/protowire/SignalService.proto +++ b/libsignal-service/src/main/protowire/SignalService.proto @@ -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 { diff --git a/libsignal-service/src/test/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStreamTest.java b/libsignal-service/src/test/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStreamTest.java index 801ccf3c73..3a39ddfe4a 100644 --- a/libsignal-service/src/test/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStreamTest.java +++ b/libsignal-service/src/test/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStreamTest.java @@ -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 );