Add story and call link age-off delete syncing support.

This commit is contained in:
Cody Henthorne
2026-08-19 19:05:50 -04:00
parent 456b2b2f7b
commit 06b67305ba
16 changed files with 454 additions and 66 deletions
@@ -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<StorageId>): 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<CallLinkRoomId>) {
val queries = SqlUtil.buildCollectionQuery(ROOM_ID, roomIds.map { it.serialize() })
@@ -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()
}
@@ -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<StorageId>): 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)
@@ -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()
}
@@ -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()
}
@@ -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()
}
@@ -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()
@@ -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<SignalCallLinkReco
rootKey = callRootKey.keyBytes.toByteString()
adminPasskey = callLink.credentials.adminPassBytes.toByteString()
deletedAtTimestampMs = callLink.deletionTimestamp
}.build().toSignalCallLinkRecord(StorageId.forCallLink(keyGenerator.generate())).toOptional()
}.build().toSignalCallLinkRecord(localStorageId(callLink.recipientId, keyGenerator)).toOptional()
} else {
return Optional.empty<SignalCallLinkRecord>()
}
}
/**
* 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
@@ -84,7 +84,12 @@ class StoryDistributionListRecordProcessor : DefaultStorageRecordProcessor<Signa
throw InvalidGroupTypeException()
}
return StorageSyncModels.localToRemoteRecord(recordForSync).let { it.proto.storyDistributionList!!.toSignalStoryDistributionListRecord(it.id) }.asOptional()
val storageId = recordForSync.storageId ?: run {
Log.w(TAG, "Distribution list was missing a storage id, generating one. It was likely aged off locally while the remote still had it.")
keyGenerator.generate().also { SignalDatabase.recipients.updateStorageId(matching, it) }
}
return StorageSyncModels.localToRemoteRecord(recordForSync, storageId).let { it.proto.storyDistributionList!!.toSignalStoryDistributionListRecord(it.id) }.asOptional()
} else {
Log.d(TAG, "Could not find a matching record. Returning an empty.")
return Optional.empty()
@@ -0,0 +1,106 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database
import android.app.Application
import assertk.assertThat
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.testutil.RecipientTestRule
import org.whispersystems.signalservice.api.storage.StorageId
import java.util.concurrent.TimeUnit
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class CallLinkTableTest_storageIdAgeOff {
@get:Rule
val recipients = RecipientTestRule()
@Test
fun `given call links 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 = insertCallLink(rootKey = 1, deletedAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(46))
val recent = insertCallLink(rootKey = 2, deletedAt = System.currentTimeMillis())
SignalDatabase.callLinks.removeStorageIdsFromOldDeletedCallLinks(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(45))
assertThat(storageIdOf(old)).isNull()
assertThat(storageIdOf(recent)).isNotNull()
}
@Test
fun `given an old deleted call link and a live call link, when I age off storage ids, then I expect only the deleted one to lose its storage id`() {
val deleted = insertCallLink(rootKey = 1, deletedAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(46))
val live = insertCallLink(rootKey = 2)
SignalDatabase.callLinks.removeStorageIdsFromOldDeletedCallLinks(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(45))
assertThat(storageIdOf(deleted)).isNull()
assertThat(storageIdOf(live)).isNotNull()
}
@Test
fun `given a deleted call link and a live call link whose storage ids are local only, then I expect only the deleted one to lose its storage id`() {
val deleted = insertCallLink(rootKey = 1, deletedAt = System.currentTimeMillis())
val live = insertCallLink(rootKey = 2)
SignalDatabase.callLinks.removeStorageIdsFromLocalOnlyDeletedCallLinks(listOf(storageIdOf(deleted)!!, storageIdOf(live)!!))
assertThat(storageIdOf(deleted)).isNull()
assertThat(storageIdOf(live)).isNotNull()
}
@Test
fun `given a call link deleted one minute ago, when its storage id is local only, then I expect it to lose its storage id`() {
val deleted = insertCallLink(rootKey = 1, deletedAt = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(1))
SignalDatabase.callLinks.removeStorageIdsFromLocalOnlyDeletedCallLinks(listOf(storageIdOf(deleted)!!))
assertThat(storageIdOf(deleted)).isNull()
}
@Test
fun `given two deleted call links, when only one storage id is local only, then I expect only that one to lose its storage id`() {
val localOnly = insertCallLink(rootKey = 1, deletedAt = System.currentTimeMillis())
val stillRemote = insertCallLink(rootKey = 2, deletedAt = System.currentTimeMillis())
SignalDatabase.callLinks.removeStorageIdsFromLocalOnlyDeletedCallLinks(listOf(storageIdOf(localOnly)!!))
assertThat(storageIdOf(localOnly)).isNull()
assertThat(storageIdOf(stillRemote)).isNotNull()
}
private fun storageIdOf(recipientId: RecipientId): StorageId? {
return SignalDatabase.recipients.getContactStorageSyncIdsMap()[recipientId]
}
private fun insertCallLink(rootKey: Int, deletedAt: Long = 0L): RecipientId {
val rootKeyBytes = ByteArray(16) { rootKey.toByte() }
return SignalDatabase.callLinks.insertCallLink(
callLink = CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromBytes(rootKeyBytes),
credentials = CallLinkCredentials(
linkKeyBytes = rootKeyBytes,
adminPassBytes = if (deletedAt > 0) null else ByteArray(16) { 9 }
),
state = SignalCallLinkState(),
deletionTimestamp = deletedAt
),
deletionTimestamp = deletedAt
)
}
}
@@ -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<RecipientId> {
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<RecipientId> {
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()
}
}
@@ -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()
}
}
@@ -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()
@@ -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()
@@ -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
@@ -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]
}
}