From 06b67305baffc311b1c2e9b23cc788ae80aa39bf Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 19 Aug 2026 12:32:52 -0400 Subject: [PATCH] Add story and call link age-off delete syncing support. --- .../securesms/database/CallLinkTable.kt | 45 ++++++++ .../securesms/database/ChatFolderTables.kt | 7 +- .../database/DistributionListTables.kt | 50 +++++++++ .../database/NotificationProfileTables.kt | 7 +- .../securesms/database/RecipientTable.kt | 6 +- .../securesms/database/StickerTables.kt | 7 +- .../securesms/jobs/StorageSyncJob.kt | 22 ++-- .../storage/CallLinkRecordProcessor.kt | 24 +++- .../StoryDistributionListRecordProcessor.kt | 7 +- .../CallLinkTableTest_storageIdAgeOff.kt | 106 ++++++++++++++++++ .../database/DistributionListTablesTest.kt | 105 +++++++++++++++++ .../database/NotificationProfileTablesTest.kt | 6 +- .../securesms/database/StickerTablesTest.kt | 6 +- .../securesms/jobs/StorageSyncJobTest.kt | 18 +++ .../storage/StorageSyncHelperTest.kt | 31 ----- ...stributionListRecordProcessorAgeOffTest.kt | 73 ++++++++++++ 16 files changed, 454 insertions(+), 66 deletions(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/database/CallLinkTableTest_storageIdAgeOff.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessorAgeOffTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt index 820bb6329d..f7930f7fa6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt @@ -4,6 +4,7 @@ import android.content.ContentValues import android.content.Context import android.database.Cursor import androidx.core.content.contentValuesOf +import org.signal.core.util.Base64 import org.signal.core.util.Serializer import org.signal.core.util.SqlUtil import org.signal.core.util.delete @@ -345,6 +346,50 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database } } + /** + * Removes storageIds from call links in [RecipientTable] that were deleted before [deletedBefore]. + */ + fun removeStorageIdsFromOldDeletedCallLinks(deletedBefore: Long): Int { + return writableDatabase + .update(RecipientTable.TABLE_NAME) + .values(RecipientTable.STORAGE_SERVICE_ID to null) + .where( + """ + ${RecipientTable.STORAGE_SERVICE_ID} NOT NULL AND ${RecipientTable.ID} IN ( + SELECT $RECIPIENT_ID + FROM $TABLE_NAME + WHERE $DELETION_TIMESTAMP > 0 AND $DELETION_TIMESTAMP < ? + ) + """, + deletedBefore + ) + .run() + } + + /** + * Removes storageIds of deleted call links whose storageIds are in the given collection. + */ + fun removeStorageIdsFromLocalOnlyDeletedCallLinks(storageIds: Collection): Int { + val values = contentValuesOf(RecipientTable.STORAGE_SERVICE_ID to null) + var updated = 0 + + SqlUtil.buildCollectionQuery( + RecipientTable.STORAGE_SERVICE_ID, + storageIds.map { Base64.encodeWithPadding(it.raw) }, + """ + ${RecipientTable.ID} IN ( + SELECT $RECIPIENT_ID + FROM $TABLE_NAME + WHERE $DELETION_TIMESTAMP > 0 + ) AND + """ + ).forEach { + updated += writableDatabase.update(RecipientTable.TABLE_NAME, values, it.where, it.whereArgs) + } + + return updated + } + fun deleteNonAdminCallLinks(roomIds: Set) { val queries = SqlUtil.buildCollectionQuery(ROOM_ID, roomIds.map { it.serialize() }) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt index 376da71c6b..dc219b6af8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt @@ -31,7 +31,6 @@ import org.thoughtcrime.securesms.database.ThreadTable.Companion.ID import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.storage.StorageSyncModels -import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord import org.whispersystems.signalservice.api.storage.StorageId import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord as RemoteChatFolderRecord @@ -545,13 +544,13 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat } /** - * Removes storageIds from folders that have been deleted for [RemoteConfig.messageQueueTime]. + * Removes storageIds from folders that were deleted before [deletedBefore]. Callers derive that cutoff from the message queue time. */ - fun removeStorageIdsFromOldDeletedFolders(now: Long): Int { + fun removeStorageIdsFromOldDeletedFolders(deletedBefore: Long): Int { return writableDatabase .update(ChatFolderTable.TABLE_NAME) .values(ChatFolderTable.STORAGE_SERVICE_ID to null) - .where("${ChatFolderTable.STORAGE_SERVICE_ID} NOT NULL AND ${ChatFolderTable.DELETED_TIMESTAMP_MS} > 0 AND ${ChatFolderTable.DELETED_TIMESTAMP_MS} < ?", now - RemoteConfig.messageQueueTime) + .where("${ChatFolderTable.STORAGE_SERVICE_ID} NOT NULL AND ${ChatFolderTable.DELETED_TIMESTAMP_MS} > 0 AND ${ChatFolderTable.DELETED_TIMESTAMP_MS} < ?", deletedBefore) .run() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt index 411747e60b..6a0da2f30b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt @@ -17,6 +17,7 @@ import org.signal.core.util.requireNonNullString import org.signal.core.util.requireObject import org.signal.core.util.requireString import org.signal.core.util.select +import org.signal.core.util.update import org.signal.core.util.withinTransaction import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.database.model.DistributionListPrivacyData @@ -28,6 +29,7 @@ import org.thoughtcrime.securesms.storage.StorageRecordUpdate import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.whispersystems.signalservice.api.push.DistributionId import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord +import org.whispersystems.signalservice.api.storage.StorageId import org.whispersystems.signalservice.api.storage.recipientServiceAddresses import java.util.UUID @@ -582,6 +584,54 @@ class DistributionListTables constructor(context: Context?, databaseHelper: Sign ) } + /** + * Removes storageIds from distribution lists in [RecipientTable] that were deleted before [deletedBefore]. + * + * Never touches "My Story". + */ + fun removeStorageIdsFromOldDeletedLists(deletedBefore: Long): Int { + return writableDatabase + .update(RecipientTable.TABLE_NAME) + .values(RecipientTable.STORAGE_SERVICE_ID to null) + .where( + """ + ${RecipientTable.STORAGE_SERVICE_ID} NOT NULL AND ${RecipientTable.ID} IN ( + SELECT ${ListTable.RECIPIENT_ID} + FROM ${ListTable.TABLE_NAME} + WHERE ${ListTable.TABLE_NAME}.${ListTable.ID} != ${DistributionListId.MY_STORY_ID} AND ${ListTable.DELETION_TIMESTAMP} > 0 AND ${ListTable.DELETION_TIMESTAMP} < ? + ) + """, + deletedBefore + ) + .run() + } + + /** + * Removes storageIds of deleted distribution lists whose storageIds are in the given collection. + * + * Never touches "My Story". + */ + fun removeStorageIdsFromLocalOnlyDeletedLists(storageIds: Collection): Int { + val values = contentValuesOf(RecipientTable.STORAGE_SERVICE_ID to null) + var updated = 0 + + SqlUtil.buildCollectionQuery( + RecipientTable.STORAGE_SERVICE_ID, + storageIds.map { Base64.encodeWithPadding(it.raw) }, + """ + ${RecipientTable.ID} IN ( + SELECT ${ListTable.RECIPIENT_ID} + FROM ${ListTable.TABLE_NAME} + WHERE ${ListTable.TABLE_NAME}.${ListTable.ID} != ${DistributionListId.MY_STORY_ID} AND ${ListTable.DELETION_TIMESTAMP} > 0 + ) AND + """ + ).forEach { + updated += writableDatabase.update(RecipientTable.TABLE_NAME, values, it.where, it.whereArgs) + } + + return updated + } + fun getRecipientIdForSyncRecord(record: SignalStoryDistributionListRecord): RecipientId? { val uuid: UUID = requireNotNull(UuidUtil.parseOrNull(record.proto.identifier)) { "Incoming record did not have a valid identifier." } val distributionId = DistributionId.from(uuid) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/NotificationProfileTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/NotificationProfileTables.kt index 7be7fdd87d..c12c0e7b52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/NotificationProfileTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/NotificationProfileTables.kt @@ -36,7 +36,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.storage.StorageSyncModels import org.thoughtcrime.securesms.storage.StorageSyncModels.toLocal -import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord import org.whispersystems.signalservice.api.storage.StorageId import java.time.DayOfWeek @@ -492,13 +491,13 @@ class NotificationProfileTables(context: Context, databaseHelper: SignalDatabase } /** - * Removes storageIds from notification profiles that have been deleted for [RemoteConfig.messageQueueTime]. + * Removes storageIds from notification profiles that were deleted before [deletedBefore]. */ - fun removeStorageIdsFromOldDeletedProfiles(now: Long): Int { + fun removeStorageIdsFromOldDeletedProfiles(deletedBefore: Long): Int { return writableDatabase .update(NotificationProfileTable.TABLE_NAME) .values(NotificationProfileTable.STORAGE_SERVICE_ID to null) - .where("${NotificationProfileTable.STORAGE_SERVICE_ID} NOT NULL AND ${NotificationProfileTable.DELETED_TIMESTAMP_MS} > 0 AND ${NotificationProfileTable.DELETED_TIMESTAMP_MS} < ?", now - RemoteConfig.messageQueueTime) + .where("${NotificationProfileTable.STORAGE_SERVICE_ID} NOT NULL AND ${NotificationProfileTable.DELETED_TIMESTAMP_MS} > 0 AND ${NotificationProfileTable.DELETED_TIMESTAMP_MS} < ?", deletedBefore) .run() } 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 3b67f5fa94..0d3d1098ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -1144,18 +1144,18 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } /** - * Removes storageIds from unregistered recipients who were unregistered more than [RemoteConfig.messageQueueTime] ago. + * Removes storageIds from unregistered recipients who were unregistered before [unregisteredBefore]. * * Never touches self: our own storageId backs the ACCOUNT record, so it always needs to be present. If self ever ends up with a stale * [UNREGISTERED_TIMESTAMP], clearing it here would leave us regenerating our storageId on every single storage sync. * * @return The number of rows affected. */ - fun removeStorageIdsFromOldUnregisteredRecipients(now: Long): Int { + fun removeStorageIdsFromOldUnregisteredRecipients(unregisteredBefore: Long): Int { return writableDatabase .update(TABLE_NAME) .values(STORAGE_SERVICE_ID to null) - .where("$STORAGE_SERVICE_ID NOT NULL AND $ID != ${Recipient.self().id.toLong()} AND $UNREGISTERED_TIMESTAMP > 0 AND $UNREGISTERED_TIMESTAMP < ?", now - RemoteConfig.messageQueueTime) + .where("$STORAGE_SERVICE_ID NOT NULL AND $ID != ${Recipient.self().id.toLong()} AND $UNREGISTERED_TIMESTAMP > 0 AND $UNREGISTERED_TIMESTAMP < ?", unregisteredBefore) .run() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/StickerTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/StickerTables.kt index 969cfe203e..6283dc72a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/StickerTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/StickerTables.kt @@ -47,7 +47,6 @@ import org.thoughtcrime.securesms.stickers.BlessedPacks import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.MediaUtil -import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord import org.whispersystems.signalservice.api.storage.StorageId import java.io.Closeable @@ -573,13 +572,13 @@ class StickerTables( } /** - * Removes storage ids from packs that have been deleted for longer than the message queue time. + * Removes storage ids from packs that were deleted before [deletedBefore]. */ - fun removeStorageIdsFromOldDeletedPacks(now: Long): Int { + fun removeStorageIdsFromOldDeletedPacks(deletedBefore: Long): Int { return writableDatabase .update(Pack.TABLE_NAME) .values(Pack.STORAGE_SERVICE_ID to null) - .where("${Pack.STORAGE_SERVICE_ID} NOT NULL AND ${Pack.DELETED_TIMESTAMP_MS} > 0 AND ${Pack.DELETED_TIMESTAMP_MS} < ?", now - RemoteConfig.messageQueueTime) + .where("${Pack.STORAGE_SERVICE_ID} NOT NULL AND ${Pack.DELETED_TIMESTAMP_MS} > 0 AND ${Pack.DELETED_TIMESTAMP_MS} < ?", deletedBefore) .run() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt index 0627a27375..18ed411b20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt @@ -307,9 +307,11 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc val updatedFolders = SignalDatabase.chatFolders.removeStorageIdsFromLocalOnlyDeletedFolders(idDifference.localOnlyIds) val updatedProfiles = SignalDatabase.notificationProfiles.removeStorageIdsFromLocalOnlyDeletedProfiles(idDifference.localOnlyIds) val updatedPacks = SignalDatabase.stickers.removeStorageIdsFromLocalOnlyDeletedPacks(idDifference.localOnlyIds) + val updatedLists = SignalDatabase.distributionLists.removeStorageIdsFromLocalOnlyDeletedLists(idDifference.localOnlyIds) + val updatedCallLinks = SignalDatabase.callLinks.removeStorageIdsFromLocalOnlyDeletedCallLinks(idDifference.localOnlyIds) - if (updatedRecipients > 0 || updatedFolders > 0 || updatedProfiles > 0 || updatedPacks > 0) { - Log.w(TAG, "Found $updatedRecipients recipients, $updatedFolders folders, $updatedProfiles notification profiles, $updatedPacks sticker packs that were deleted remotely but only marked unregistered/deleted locally. Removed those from local store. Recalculating diff.") + if (updatedRecipients > 0 || updatedFolders > 0 || updatedProfiles > 0 || updatedPacks > 0 || updatedLists > 0 || updatedCallLinks > 0) { + Log.w(TAG, "Found $updatedRecipients recipients, $updatedFolders folders, $updatedProfiles notification profiles, $updatedPacks sticker packs, $updatedLists distribution lists, $updatedCallLinks call links that were deleted remotely but only marked unregistered/deleted locally. Removed those from local store. Recalculating diff.") localStorageIdsBeforeMerge = getAllLocalStorageIds(self) idDifference = StorageSyncHelper.findIdDifference(remoteManifest.storageIds, localStorageIdsBeforeMerge) @@ -401,12 +403,16 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc stopwatch.split("known-unknowns") val remoteWriteOperation: WriteOperationResult = db.withinTransaction { - val removedUnregistered = SignalDatabase.recipients.removeStorageIdsFromOldUnregisteredRecipients(System.currentTimeMillis()) - val removedDeletedFolders = SignalDatabase.chatFolders.removeStorageIdsFromOldDeletedFolders(System.currentTimeMillis()) - val removedDeletedProfiles = SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis()) - val removedDeletedPacks = SignalDatabase.stickers.removeStorageIdsFromOldDeletedPacks(System.currentTimeMillis()) - if (removedUnregistered > 0 || removedDeletedFolders > 0 || removedDeletedProfiles > 0 || removedDeletedPacks > 0) { - Log.i(TAG, "Removed $removedUnregistered unregistered, $removedDeletedFolders folders, $removedDeletedProfiles notification profiles, $removedDeletedPacks sticker packs from storage service that have been deleted for longer than ${RemoteConfig.messageQueueTime.milliseconds.inWholeDays} days.") + val expiredBefore = System.currentTimeMillis() - RemoteConfig.messageQueueTime + val removedUnregistered = SignalDatabase.recipients.removeStorageIdsFromOldUnregisteredRecipients(expiredBefore) + val removedDeletedFolders = SignalDatabase.chatFolders.removeStorageIdsFromOldDeletedFolders(expiredBefore) + val removedDeletedProfiles = SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(expiredBefore) + val removedDeletedPacks = SignalDatabase.stickers.removeStorageIdsFromOldDeletedPacks(expiredBefore) + val removedDeletedLists = SignalDatabase.distributionLists.removeStorageIdsFromOldDeletedLists(expiredBefore) + val removedDeletedCallLinks = SignalDatabase.callLinks.removeStorageIdsFromOldDeletedCallLinks(expiredBefore) + + if (removedUnregistered > 0 || removedDeletedFolders > 0 || removedDeletedProfiles > 0 || removedDeletedPacks > 0 || removedDeletedLists > 0 || removedDeletedCallLinks > 0) { + Log.i(TAG, "Removed $removedUnregistered unregistered, $removedDeletedFolders folders, $removedDeletedProfiles notification profiles, $removedDeletedPacks sticker packs, $removedDeletedLists distribution lists, $removedDeletedCallLinks call links from storage service that have been deleted for longer than ${RemoteConfig.messageQueueTime.milliseconds.inWholeDays} days.") } self = freshSelf() diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessor.kt index 0fc7858984..ec2081cea3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessor.kt @@ -11,6 +11,7 @@ import org.signal.core.util.logging.Log import org.signal.core.util.toOptional import org.signal.ringrtc.CallLinkRootKey import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord import org.whispersystems.signalservice.api.storage.StorageId @@ -50,12 +51,33 @@ class CallLinkRecordProcessor : DefaultStorageRecordProcessor() } } + /** + * The storageId of a call link lives on its recipient. Returning the real one matters: [SignalCallLinkRecord] compares by id, so handing back a + * generated id would make every processed record look changed and trigger a pointless local update. + * + * Generating one is defensive only, and in particular is not how an aged-off tombstone recovers. Deleting a call link clears its admin key, so + * [getMatching] returns empty for that case and [insertLocal] restores the id from the remote record instead. + */ + private fun localStorageId(recipientId: RecipientId, keyGenerator: StorageKeyGenerator): StorageId { + val existing = SignalDatabase.recipients.getRecordForSync(recipientId)?.storageId + + if (existing != null) { + return StorageId.forCallLink(existing) + } + + Log.w(TAG, "Call link was missing a storageId, generating one.") + val generated = keyGenerator.generate() + SignalDatabase.recipients.updateStorageId(recipientId, generated) + + return StorageId.forCallLink(generated) + } + /** * A deleted record takes precedence over a non-deleted record * An earlier deletion takes precedence over a later deletion diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.kt index d7c3191ebb..855eb694dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.kt @@ -84,7 +84,12 @@ class StoryDistributionListRecordProcessor : DefaultStorageRecordProcessor 0) null else ByteArray(16) { 9 } + ), + state = SignalCallLinkState(), + deletionTimestamp = deletedAt + ), + deletionTimestamp = deletedAt + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/DistributionListTablesTest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/DistributionListTablesTest.kt index 69eecab4e5..53f89b30cd 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/DistributionListTablesTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/DistributionListTablesTest.kt @@ -1,6 +1,9 @@ package org.thoughtcrime.securesms.database import android.app.Application +import assertk.assertThat +import assertk.assertions.isNotNull +import assertk.assertions.isNull import org.junit.Assert import org.junit.Before import org.junit.Rule @@ -9,12 +12,15 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.signal.core.models.ServiceId.ACI +import org.signal.core.util.update import org.thoughtcrime.securesms.database.model.DistributionListId import org.thoughtcrime.securesms.database.model.DistributionListRecord import org.thoughtcrime.securesms.database.model.StoryType import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.testutil.RecipientTestRule +import org.whispersystems.signalservice.api.storage.StorageId import java.util.UUID +import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE, application = Application::class) @@ -86,6 +92,83 @@ class DistributionListTablesTest { Assert.fail("Expected an assertion error.") } + @Test + fun `given lists deleted long ago and recently, when I age off storage ids, then I expect only the old one to lose its storage id`() { + val old = insertList("old", deletedAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(46)) + val recent = insertList("recent", deletedAt = System.currentTimeMillis()) + + distributionDatabase.removeStorageIdsFromOldDeletedLists(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(45)) + + assertThat(storageIdOf(old)).isNull() + assertThat(storageIdOf(recent)).isNotNull() + } + + @Test + fun `given an old deleted list and a live list, when I age off storage ids, then I expect only the deleted one to lose its storage id`() { + val deleted = insertList("deleted", deletedAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(46)) + val live = insertList("live") + + distributionDatabase.removeStorageIdsFromOldDeletedLists(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(45)) + + assertThat(storageIdOf(deleted)).isNull() + assertThat(storageIdOf(live)).isNotNull() + } + + @Test + fun `given My Story is somehow tombstoned, when I age off storage ids, then I expect it to keep its storage id`() { + val deleted = insertList("deleted", deletedAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(46)) + val myStory = distributionDatabase.getRecipientId(DistributionListId.MY_STORY)!! + setDeletionTimestamp(DistributionListId.MY_STORY, System.currentTimeMillis() - TimeUnit.DAYS.toMillis(46)) + + distributionDatabase.removeStorageIdsFromOldDeletedLists(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(45)) + + assertThat(storageIdOf(deleted)).isNull() + assertThat(storageIdOf(myStory)).isNotNull() + } + + @Test + fun `given a deleted list and a live list whose storage ids are local only, then I expect only the deleted one to lose its storage id`() { + val deleted = insertList("deleted", deletedAt = System.currentTimeMillis()) + val live = insertList("live") + + distributionDatabase.removeStorageIdsFromLocalOnlyDeletedLists(listOf(storageIdOf(deleted)!!, storageIdOf(live)!!)) + + assertThat(storageIdOf(deleted)).isNull() + assertThat(storageIdOf(live)).isNotNull() + } + + @Test + fun `given a list deleted one minute ago, when its storage id is local only, then I expect it to lose its storage id`() { + val deleted = insertList("deleted", deletedAt = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1)) + + distributionDatabase.removeStorageIdsFromLocalOnlyDeletedLists(listOf(storageIdOf(deleted)!!)) + + assertThat(storageIdOf(deleted)).isNull() + } + + @Test + fun `given two deleted lists, when only one storage id is local only, then I expect only that one to lose its storage id`() { + val localOnly = insertList("localOnly", deletedAt = System.currentTimeMillis()) + val stillRemote = insertList("stillRemote", deletedAt = System.currentTimeMillis()) + + distributionDatabase.removeStorageIdsFromLocalOnlyDeletedLists(listOf(storageIdOf(localOnly)!!)) + + assertThat(storageIdOf(localOnly)).isNull() + assertThat(storageIdOf(stillRemote)).isNotNull() + } + + @Test + fun `given My Story is somehow tombstoned, when its storage id is local only, then I expect it to keep its storage id`() { + val deleted = insertList("deleted", deletedAt = System.currentTimeMillis()) + val myStory = distributionDatabase.getRecipientId(DistributionListId.MY_STORY)!! + setDeletionTimestamp(DistributionListId.MY_STORY, System.currentTimeMillis()) + + distributionDatabase.removeStorageIdsFromLocalOnlyDeletedLists(listOf(storageIdOf(deleted)!!, storageIdOf(myStory)!!)) + + assertThat(storageIdOf(deleted)).isNull() + assertThat(storageIdOf(myStory)).isNotNull() + } + private fun createRecipients(count: Int): List { return (0 until count).map { SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) @@ -95,4 +178,26 @@ class DistributionListTablesTest { private fun recipientList(vararg ids: Long): List { return ids.map { RecipientId.from(it) } } + + private fun insertList(name: String, deletedAt: Long? = null): RecipientId { + val listId = distributionDatabase.createList(name, emptyList())!! + + if (deletedAt != null) { + distributionDatabase.deleteList(listId, deletedAt) + } + + return distributionDatabase.getRecipientId(listId)!! + } + + private fun storageIdOf(recipientId: RecipientId): StorageId? { + return SignalDatabase.recipients.getContactStorageSyncIdsMap()[recipientId] + } + + private fun setDeletionTimestamp(listId: DistributionListId, timestamp: Long) { + distributionDatabase.writableDatabase + .update(DistributionListTables.ListTable.TABLE_NAME) + .values(DistributionListTables.ListTable.DELETION_TIMESTAMP to timestamp) + .where("${DistributionListTables.ListTable.ID} = ?", listId.serialize()) + .run() + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/NotificationProfileTablesTest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/NotificationProfileTablesTest.kt index 3ccad767e2..2f87a78e8b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/NotificationProfileTablesTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/NotificationProfileTablesTest.kt @@ -8,7 +8,6 @@ import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isTrue import assertk.assertions.single -import io.mockk.every import okio.ByteString.Companion.toByteString import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals @@ -29,7 +28,6 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.testutil.RecipientTestRule -import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.storage.SignalNotificationProfileRecord import org.whispersystems.signalservice.api.storage.StorageId import java.time.DayOfWeek @@ -50,8 +48,6 @@ class NotificationProfileTablesTest { @Before fun setUp() { - every { RemoteConfig.messageQueueTime } returns TimeUnit.DAYS.toMillis(45) - alice = SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) profile1 = NotificationProfile( @@ -312,7 +308,7 @@ class NotificationProfileTablesTest { ) SignalDatabase.notificationProfiles.insertNotificationProfileFromStorageSync(remoteRecord) - SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis()) + SignalDatabase.notificationProfiles.removeStorageIdsFromOldDeletedProfiles(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(45)) assertThat(SignalDatabase.notificationProfiles.getStorageSyncIds()).isEmpty() } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/StickerTablesTest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/StickerTablesTest.kt index 6a9e672285..7fd5762ad0 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/StickerTablesTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/StickerTablesTest.kt @@ -9,7 +9,6 @@ import assertk.assertions.isNotEqualTo import assertk.assertions.isNotNull import assertk.assertions.isNull import assertk.assertions.isTrue -import io.mockk.every import okio.ByteString.Companion.toByteString import org.junit.Before import org.junit.Rule @@ -24,7 +23,6 @@ import org.thoughtcrime.securesms.database.model.IncomingSticker import org.thoughtcrime.securesms.database.model.StickerPackId import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.testutil.RecipientTestRule -import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.storage.SignalStickerPackRecord import org.whispersystems.signalservice.api.storage.StorageId import java.io.ByteArrayInputStream @@ -48,8 +46,6 @@ class StickerTablesTest { @Before fun setUp() { - every { RemoteConfig.messageQueueTime } returns TimeUnit.DAYS.toMillis(45) - SignalDatabase.stickers.writableDatabase.deleteAll(StickerTables.Sticker.TABLE_NAME) SignalDatabase.stickers.writableDatabase.deleteAll(StickerTables.Pack.TABLE_NAME) } @@ -209,7 +205,7 @@ class StickerTablesTest { installPack(packId1, packKey1) SignalDatabase.stickers.uninstallPack(packId1) - SignalDatabase.stickers.removeStorageIdsFromOldDeletedPacks(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(46)) + SignalDatabase.stickers.removeStorageIdsFromOldDeletedPacks(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1)) assertThat(SignalDatabase.stickers.getStorageSyncIds()).isEmpty() assertThat(SignalDatabase.stickers.getPackForStorageSync(StickerPackId(packId1))!!.storageServiceId).isNull() diff --git a/app/src/test/java/org/thoughtcrime/securesms/jobs/StorageSyncJobTest.kt b/app/src/test/java/org/thoughtcrime/securesms/jobs/StorageSyncJobTest.kt index 8b59c0c488..293d414652 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/jobs/StorageSyncJobTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/jobs/StorageSyncJobTest.kt @@ -390,6 +390,24 @@ class StorageSyncJobTest { assertEquals(0, remoteStorage.records.count { it.proto.contact != null }) } + @Test + fun `given a contact was unregistered recently, when I run, then I keep their storage id`() { + val contact = recipients.createRecipient("Local Contact") + SignalDatabase.recipients.rotateStorageId(contact) + check(runJob(StorageSyncJob.forLocalChange()).isSuccess) + check(remoteStorage.records.count { it.proto.contact != null } == 1) + + SignalDatabase.recipients.markUnregistered(contact) + Recipient.live(contact).refresh() + remoteStorage.resetCounters() + + val result = runJob(StorageSyncJob.forLocalChange()) + + assertTrue(result.isSuccess) + assertNotNull(storageIdOf(contact)) + assertEquals(1, remoteStorage.records.count { it.proto.contact != null }) + } + @Test fun `given another device keeps undoing my write, when I run again, then I stop writing`() { stubIssueReporter() diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.kt b/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.kt index ef73d72a3c..c2fb39513d 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.kt @@ -1,43 +1,24 @@ package org.thoughtcrime.securesms.storage -import io.mockk.every -import io.mockk.mockkObject -import io.mockk.unmockkObject import okio.ByteString -import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test import org.signal.core.models.ServiceId.ACI import org.signal.core.models.ServiceId.ACI.Companion.parseOrThrow import org.thoughtcrime.securesms.storage.StorageSyncHelper.findIdDifference import org.thoughtcrime.securesms.storage.StorageSyncHelper.profileKeyChanged import org.thoughtcrime.securesms.testutil.TestHelpers -import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.storage.SignalContactRecord import org.whispersystems.signalservice.api.storage.SignalRecord import org.whispersystems.signalservice.api.storage.StorageId import org.whispersystems.signalservice.internal.storage.protos.ContactRecord -import kotlin.time.Duration.Companion.days class StorageSyncHelperTest { - @Before - fun setup() { - mockkObject(RemoteConfig) - } - - @After - fun tearDown() { - unmockkObject(RemoteConfig) - } - @Test fun findIdDifference_allOverlap() { - every { RemoteConfig.messageQueueTime } returns 45.days.inWholeMilliseconds - val result = findIdDifference(keyListOf(1, 2, 3), keyListOf(1, 2, 3)) assertTrue(result.localOnlyIds.isEmpty()) assertTrue(result.remoteOnlyIds.isEmpty()) @@ -46,8 +27,6 @@ class StorageSyncHelperTest { @Test fun findIdDifference_noOverlap() { - every { RemoteConfig.messageQueueTime } returns 45.days.inWholeMilliseconds - val result = findIdDifference(keyListOf(1, 2, 3), keyListOf(4, 5, 6)) TestHelpers.assertContentsEqual(keyListOf(1, 2, 3), result.remoteOnlyIds) TestHelpers.assertContentsEqual(keyListOf(4, 5, 6), result.localOnlyIds) @@ -56,8 +35,6 @@ class StorageSyncHelperTest { @Test fun findIdDifference_someOverlap() { - every { RemoteConfig.messageQueueTime } returns 45.days.inWholeMilliseconds - val result = findIdDifference(keyListOf(1, 2, 3), keyListOf(2, 3, 4)) TestHelpers.assertContentsEqual(keyListOf(1), result.remoteOnlyIds) TestHelpers.assertContentsEqual(keyListOf(4), result.localOnlyIds) @@ -66,8 +43,6 @@ class StorageSyncHelperTest { @Test fun findIdDifference_typeMismatch_allOverlap() { - every { RemoteConfig.messageQueueTime } returns 45.days.inWholeMilliseconds - val result = findIdDifference( keyListOf( mapOf( @@ -90,8 +65,6 @@ class StorageSyncHelperTest { @Test fun findIdDifference_typeMismatch_someOverlap() { - every { RemoteConfig.messageQueueTime } returns 45.days.inWholeMilliseconds - val result = findIdDifference( keyListOf( mapOf( @@ -116,8 +89,6 @@ class StorageSyncHelperTest { @Test fun test_ContactUpdate_equals_sameProfileKeys() { - every { RemoteConfig.messageQueueTime } returns 45.days.inWholeMilliseconds - val profileKey = ByteArray(32) val profileKeyCopy = profileKey.clone() @@ -135,8 +106,6 @@ class StorageSyncHelperTest { @Test fun test_ContactUpdate_equals_differentProfileKeys() { - every { RemoteConfig.messageQueueTime } returns 45.days.inWholeMilliseconds - val profileKey = ByteArray(32) val profileKeyCopy = profileKey.clone() profileKeyCopy[0] = 1 diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessorAgeOffTest.kt b/app/src/test/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessorAgeOffTest.kt new file mode 100644 index 0000000000..9f4543d912 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessorAgeOffTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.storage + +import android.app.Application +import assertk.assertThat +import assertk.assertions.isNotNull +import assertk.assertions.isPresent +import okio.ByteString.Companion.toByteString +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.util.UuidUtil +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.testutil.RecipientTestRule +import org.whispersystems.signalservice.api.push.DistributionId +import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.internal.storage.protos.StoryDistributionListRecord +import java.util.UUID +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class StoryDistributionListRecordProcessorAgeOffTest { + + @get:Rule + val recipients = RecipientTestRule() + + private val testSubject = StoryDistributionListRecordProcessor() + + @Test + fun `given an aged off list tombstone, when the remote manifest still has its id, then I expect a matching record with a fresh storage id`() { + val deletedAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(46) + val distributionId = DistributionId.from(UUID.randomUUID()) + val listId = insertAgedOffList(distributionId, deletedAt) + + val matching = testSubject.getMatching(remoteTombstone(distributionId, deletedAt)) { StorageSyncHelper.generateKey() } + + assertThat(matching).isPresent() + assertThat(storageIdOf(listId)).isNotNull() + } + + private fun insertAgedOffList(distributionId: DistributionId, deletedAt: Long): DistributionListId { + val listId = SignalDatabase.distributionLists.createList("test", emptyList(), distributionId = distributionId)!! + SignalDatabase.distributionLists.deleteList(listId, deletedAt) + SignalDatabase.distributionLists.removeStorageIdsFromOldDeletedLists(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(45)) + + return listId + } + + private fun remoteTombstone(distributionId: DistributionId, deletedAt: Long): SignalStoryDistributionListRecord { + return SignalStoryDistributionListRecord( + StorageId.forStoryDistributionList(byteArrayOf(1, 2, 3, 4)), + StoryDistributionListRecord() + .newBuilder() + .identifier(UuidUtil.toByteArray(distributionId.asUuid()).toByteString()) + .deletedAtTimestamp(deletedAt) + .build() + ) + } + + private fun storageIdOf(listId: DistributionListId): StorageId? { + val recipientId = SignalDatabase.distributionLists.getRecipientId(listId)!! + return SignalDatabase.recipients.getContactStorageSyncIdsMap()[recipientId] + } +}