Add call link support to storage service.

This commit is contained in:
Nicholas Tinsley
2024-09-09 13:15:32 -04:00
committed by Cody Henthorne
parent 1f2b5e90a3
commit e247d311d8
29 changed files with 645 additions and 83 deletions

View File

@@ -86,7 +86,8 @@ class CallLinkTableTest {
linkKeyBytes = roomId,
adminPassBytes = null
),
state = SignalCallLinkState()
state = SignalCallLinkState(),
deletionTimestamp = 0L
)
)

View File

@@ -47,7 +47,8 @@ fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId? {
name = callLink.name,
restrictions = callLink.restrictions.toLocal(),
expiration = Instant.ofEpochMilli(callLink.expirationMs)
)
),
deletionTimestamp = 0L
)
)
}

View File

@@ -62,7 +62,8 @@ private fun SignalCallRowPreview() {
restrictions = org.signal.ringrtc.CallLinkState.Restrictions.NONE,
expiration = Instant.MAX,
revoked = false
)
),
deletionTimestamp = 0L
)
}
Previews.Preview {

View File

@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.jobs.CallLinkUpdateSendJob
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.storage.StorageSyncHelper
/**
* Repository for performing update operations on call links:
@@ -65,6 +66,7 @@ class UpdateCallLinkRepository(
is UpdateCallLinkResult.Delete -> {
SignalDatabase.callLinks.markRevoked(credentials.roomId)
AppDependencies.jobManager.add(CallLinkUpdateSendJob(credentials.roomId))
StorageSyncHelper.scheduleSyncForDataChange()
}
else -> {}
}

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.internal.push.SyncMessage
/**
@@ -40,22 +41,25 @@ class CreateCallLinkRepository(
SignalDatabase.callLinks.insertCallLink(
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = credentials.roomId,
credentials = credentials,
state = it.state
roomId = it.credentials.roomId,
credentials = it.credentials,
state = it.state,
deletionTimestamp = 0L
)
)
AppDependencies.jobManager.add(
CallLinkUpdateSendJob(
credentials.roomId,
it.credentials.roomId,
SyncMessage.CallLinkUpdate.Type.UPDATE
)
)
StorageSyncHelper.scheduleSyncForDataChange()
EnsureCallLinkCreatedResult.Success(
Recipient.resolved(
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId).get()
SignalDatabase.recipients.getByCallLinkRoomId(it.credentials.roomId).get()
)
)
}

View File

@@ -39,7 +39,8 @@ class CreateCallLinkViewModel(
restrictions = Restrictions.ADMIN_APPROVAL,
revoked = false,
expiration = Instant.MAX
)
),
deletionTimestamp = 0L
)
)

View File

@@ -140,7 +140,7 @@ class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
viewModel.setDisplayRevocationDialog(false)
lifecycleDisposable += viewModel.delete().observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
when (it) {
is UpdateCallLinkResult.Update -> ActivityCompat.finishAfterTransition(requireActivity())
is UpdateCallLinkResult.Delete -> ActivityCompat.finishAfterTransition(requireActivity())
else -> {
Log.w(TAG, "Failed to revoke. $it")
toastFailure()
@@ -213,7 +213,8 @@ private fun CallLinkDetailsPreview() {
revoked = false,
restrictions = Restrictions.NONE,
expiration = Instant.MAX
)
),
deletionTimestamp = 0L
)
}

View File

@@ -150,7 +150,7 @@ class CallLogRepository(
updateCallLinkRepository.deleteCallLink(it.credentials!!)
}
).reduce(0) { acc, current ->
acc + (if (current is UpdateCallLinkResult.Update) 0 else 1)
acc + (if (current is UpdateCallLinkResult.Delete) 0 else 1)
}.doOnTerminate {
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
}.doOnDispose {

View File

@@ -10,7 +10,6 @@ import org.signal.core.util.delete
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.readToSet
import org.signal.core.util.readToSingleInt
import org.signal.core.util.readToSingleLong
import org.signal.core.util.readToSingleObject
@@ -18,7 +17,6 @@ import org.signal.core.util.requireBlob
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullBlob
import org.signal.core.util.requireNonNullString
import org.signal.core.util.select
import org.signal.core.util.update
@@ -34,6 +32,7 @@ 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.whispersystems.signalservice.api.storage.StorageId
import java.time.Instant
import java.time.temporal.ChronoUnit
@@ -55,6 +54,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
const val REVOKED = "revoked"
const val EXPIRATION = "expiration"
const val RECIPIENT_ID = "recipient_id"
const val DELETION_TIMESTAMP = "deletion_timestamp"
//language=sql
const val CREATE_TABLE = """
@@ -67,7 +67,8 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
$RESTRICTIONS INTEGER NOT NULL,
$REVOKED INTEGER NOT NULL,
$EXPIRATION INTEGER NOT NULL,
$RECIPIENT_ID INTEGER UNIQUE REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE
$RECIPIENT_ID INTEGER UNIQUE REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE,
$DELETION_TIMESTAMP INTEGER NOT NULL
)
"""
@@ -90,14 +91,23 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
}
fun insertCallLink(
callLink: CallLink
callLink: CallLink,
deletionTimestamp: Long = 0L,
storageId: StorageId? = null
): RecipientId {
val recipientId: RecipientId = writableDatabase.withinTransaction { db ->
val recipientId = SignalDatabase.recipients.getOrInsertFromCallLinkRoomId(callLink.roomId)
val recipientId = SignalDatabase.recipients.getOrInsertFromCallLinkRoomId(callLink.roomId, storageId = storageId?.raw)
val contentValues = CallLinkSerializer.serialize(callLink.copy(recipientId = recipientId)).apply {
put(DELETION_TIMESTAMP, deletionTimestamp)
if (deletionTimestamp > 0) {
put(REVOKED, true)
}
}
db
.insertInto(TABLE_NAME)
.values(CallLinkSerializer.serialize(callLink.copy(recipientId = recipientId)))
.values(contentValues)
.run()
recipientId
@@ -190,7 +200,8 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
linkKeyBytes = callLinkRootKey.keyBytes,
adminPassBytes = null
),
state = SignalCallLinkState()
state = SignalCallLinkState(),
deletionTimestamp = 0L
)
insertCallLink(link)
@@ -200,6 +211,62 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
}
}
fun insertOrUpdateCallLinkByRootKey(
callLinkRootKey: CallLinkRootKey,
adminPassKey: ByteArray?,
deletionTimestamp: Long = 0L,
storageId: StorageId? = null
) {
val roomId = CallLinkRoomId.fromBytes(callLinkRootKey.deriveRoomId())
writableDatabase.withinTransaction {
val callLink = getCallLinkByRoomId(roomId)
if (callLink == null) {
val link = CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = roomId,
credentials = CallLinkCredentials(
linkKeyBytes = callLinkRootKey.keyBytes,
adminPassBytes = adminPassKey
),
state = SignalCallLinkState(),
deletionTimestamp = 0L
)
insertCallLink(link, deletionTimestamp, storageId)
} else {
if (storageId != null) {
SignalDatabase.recipients.updateStorageId(callLink.recipientId, storageId.raw)
}
if (deletionTimestamp != 0L) {
writableDatabase.update(TABLE_NAME)
.values(
DELETION_TIMESTAMP to deletionTimestamp,
ADMIN_KEY to null,
REVOKED to true
)
.where("$ROOM_ID = ?", callLink.roomId.serialize())
.run()
} else 0
}
}
}
/**
* Returns a unix timestamp, or 0
*/
fun getDeletedTimestampByRoomId(
roomId: CallLinkRoomId
): Long {
return readableDatabase
.select(DELETION_TIMESTAMP)
.from(TABLE_NAME)
.where("$ROOM_ID = ?", roomId.serialize())
.run()
.readToSingleLong(defaultValue = 0)
}
fun getOrCreateCallLinkByRoomId(
callLinkRoomId: CallLinkRoomId
): CallLink {
@@ -209,7 +276,8 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
recipientId = RecipientId.UNKNOWN,
roomId = callLinkRoomId,
credentials = null,
state = SignalCallLinkState()
state = SignalCallLinkState(),
deletionTimestamp = 0L
)
insertCallLink(link)
return getCallLinkByRoomId(callLinkRoomId)!!
@@ -235,6 +303,15 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
}
}
fun getAll(): List<CallLink> {
return readableDatabase.select()
.from(TABLE_NAME)
.run()
.readToList {
CallLinkDeserializer.deserialize(it)
}
}
/**
* Puts the call link into the "revoked" state which will hide it from the UI and
* delete it after a few days.
@@ -244,25 +321,20 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
) {
writableDatabase.withinTransaction { db ->
db.update(TABLE_NAME)
.values(REVOKED to true)
.values(
REVOKED to true,
DELETION_TIMESTAMP to System.currentTimeMillis(),
ADMIN_KEY to null
)
.where("$ROOM_ID = ?", roomId.serialize())
.run()
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
}
}
val recipient = SignalDatabase.recipients.getByCallLinkRoomId(roomId)
/**
* Deletes the call link. This should only happen *after* we send out a sync message
* or receive a sync message which deletes the corresponding link.
*/
fun deleteCallLink(
roomId: CallLinkRoomId
) {
writableDatabase.withinTransaction { db ->
db.delete(TABLE_NAME)
.where("$ROOM_ID = ?", roomId.serialize())
.run()
if (recipient.isPresent) {
SignalDatabase.recipients.markNeedsSync(recipient.get())
}
}
}
@@ -279,7 +351,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
fun deleteNonAdminCallLinksOnOrBefore(timestamp: Long) {
writableDatabase.withinTransaction { db ->
db.delete(TABLE_NAME)
.where("EXISTS (SELECT 1 FROM ${CallTable.TABLE_NAME} WHERE ${CallTable.TIMESTAMP} <= ? AND ${CallTable.PEER} = $RECIPIENT_ID)", timestamp)
.where("$ADMIN_KEY IS NULL AND EXISTS (SELECT 1 FROM ${CallTable.TABLE_NAME} WHERE ${CallTable.TIMESTAMP} <= ? AND ${CallTable.PEER} = $RECIPIENT_ID)", timestamp)
.run()
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps(skipSync = true)
@@ -334,18 +406,6 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
}
}
fun getAdminCallLinkCredentialsOnOrBefore(timestamp: Long): Set<CallLinkCredentials> {
val query = """
SELECT $ROOT_KEY, $ADMIN_KEY FROM $TABLE_NAME
INNER JOIN ${CallTable.TABLE_NAME} ON ${CallTable.TABLE_NAME}.${CallTable.PEER} = $TABLE_NAME.$RECIPIENT_ID
WHERE ${CallTable.TIMESTAMP} <= $timestamp AND $ADMIN_KEY IS NOT NULL AND $REVOKED = 0
""".trimIndent()
return readableDatabase.query(query).readToSet {
CallLinkCredentials(it.requireNonNullBlob(ROOT_KEY), it.requireNonNullBlob(ADMIN_KEY))
}
}
private fun queryCallLinks(query: String?, offset: Int, limit: Int, asCount: Boolean): Cursor {
//language=sql
val noCallEvent = """
@@ -381,7 +441,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
val statement = """
SELECT $projection
FROM $TABLE_NAME
WHERE $noCallEvent AND NOT $REVOKED ${searchFilter?.where ?: ""} AND $ROOT_KEY IS NOT NULL
WHERE $noCallEvent AND NOT $REVOKED ${searchFilter?.where ?: ""} AND $ROOT_KEY IS NOT NULL AND $DELETION_TIMESTAMP = 0
ORDER BY $ID DESC
$limitOffset
""".trimIndent()
@@ -432,7 +492,8 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
Instant.ofEpochMilli(it).truncatedTo(ChronoUnit.DAYS)
}
}
)
),
deletionTimestamp = data.requireLong(DELETION_TIMESTAMP)
)
}
@@ -449,7 +510,8 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
val recipientId: RecipientId,
val roomId: CallLinkRoomId,
val credentials: CallLinkCredentials?,
val state: SignalCallLinkState
val state: SignalCallLinkState,
val deletionTimestamp: Long
) {
val avatarColor: AvatarColor = credentials?.let { AvatarColorHash.forCallLink(it.linkKeyBytes) } ?: AvatarColor.UNKNOWN
}

View File

@@ -574,13 +574,14 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
).recipientId
}
fun getOrInsertFromCallLinkRoomId(callLinkRoomId: CallLinkRoomId): RecipientId {
fun getOrInsertFromCallLinkRoomId(callLinkRoomId: CallLinkRoomId, storageId: ByteArray? = null): RecipientId {
return getOrInsertByColumn(
CALL_LINK_ROOM_ID,
callLinkRoomId.serialize(),
contentValuesOf(
TYPE to RecipientType.CALL_LINK.id,
CALL_LINK_ROOM_ID to callLinkRoomId.serialize(),
STORAGE_SERVICE_ID to Base64.encodeWithPadding(storageId ?: StorageSyncHelper.generateKey()),
PROFILE_SHARING to 1
)
).recipientId
@@ -1199,6 +1200,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
SELECT ${DistributionListTables.ListTable.ID}
FROM ${DistributionListTables.ListTable.TABLE_NAME}
)
OR
$CALL_LINK_ROOM_ID NOT NULL AND $CALL_LINK_ROOM_ID IN (
SELECT ${CallLinkTable.ROOM_ID}
FROM ${CallLinkTable.TABLE_NAME}
WHERE (${CallLinkTable.ADMIN_KEY} NOT NULL OR ${CallLinkTable.DELETION_TIMESTAMP} > 0) AND ${CallLinkTable.ROOT_KEY} NOT NULL
)
)
""",
RecipientType.INDIVIDUAL.id,
@@ -1217,6 +1224,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
RecipientType.INDIVIDUAL -> out[id] = StorageId.forContact(key)
RecipientType.GV1 -> out[id] = StorageId.forGroupV1(key)
RecipientType.DISTRIBUTION_LIST -> out[id] = StorageId.forStoryDistributionList(key)
RecipientType.CALL_LINK -> out[id] = StorageId.forCallLink(key)
else -> throw AssertionError()
}
}
@@ -3870,8 +3878,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
put(STORAGE_SERVICE_ID, Base64.encodeWithPadding(StorageSyncHelper.generateKey()))
}
val query = "$ID = ? AND ($TYPE IN (?, ?, ?) OR $REGISTERED = ? OR $ID = ?)"
val args = SqlUtil.buildArgs(recipientId, RecipientType.GV1.id, RecipientType.GV2.id, RecipientType.DISTRIBUTION_LIST.id, RegisteredState.REGISTERED.id, selfId.toLong())
val query = "$ID = ? AND ($TYPE IN (?, ?, ?, ?) OR $REGISTERED = ? OR $ID = ?)"
val args = SqlUtil.buildArgs(recipientId, RecipientType.GV1.id, RecipientType.GV2.id, RecipientType.DISTRIBUTION_LIST.id, RecipientType.CALL_LINK.id, RegisteredState.REGISTERED.id, selfId.toLong())
writableDatabase.update(TABLE_NAME, values, query, args).also { updateCount ->
Log.d(TAG, "[rotateStorageId] updateCount: $updateCount")

View File

@@ -102,6 +102,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V241_ExpireTimerVer
import org.thoughtcrime.securesms.database.helpers.migration.V242_MessageFullTextSearchEmojiSupportV2
import org.thoughtcrime.securesms.database.helpers.migration.V243_MessageFullTextSearchDisableSecureDelete
import org.thoughtcrime.securesms.database.helpers.migration.V244_AttachmentRemoteIv
import org.thoughtcrime.securesms.database.helpers.migration.V245_DeletionTimestampOnCallLinks
/**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
@@ -206,10 +207,11 @@ object SignalDatabaseMigrations {
241 to V241_ExpireTimerVersion,
242 to V242_MessageFullTextSearchEmojiSupportV2,
243 to V243_MessageFullTextSearchDisableSecureDelete,
244 to V244_AttachmentRemoteIv
244 to V244_AttachmentRemoteIv,
245 to V245_DeletionTimestampOnCallLinks
)
const val DATABASE_VERSION = 244
const val DATABASE_VERSION = 245
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {

View File

@@ -0,0 +1,18 @@
/*
* 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
/**
* Adds a deletion timestamp to the call links table, which is required for storage service syncing.
*/
object V245_DeletionTimestampOnCallLinks : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE call_link ADD COLUMN deletion_timestamp INTEGER DEFAULT 0 NOT NULL;")
}
}

View File

@@ -53,7 +53,6 @@ class CallLinkUpdateSendJob private constructor(
.type(
when (callLinkUpdateType) {
CallLinkUpdate.Type.UPDATE -> CallLinkUpdateSendJobData.Type.UPDATE
CallLinkUpdate.Type.DELETE -> CallLinkUpdateSendJobData.Type.DELETE
}
)
.build()
@@ -83,10 +82,6 @@ class CallLinkUpdateSendJob private constructor(
AppDependencies.signalServiceMessageSender
.sendSyncMessage(SignalServiceSyncMessage.forCallLinkUpdate(callLinkUpdate))
if (callLinkUpdateType == CallLinkUpdate.Type.DELETE) {
SignalDatabase.callLinks.deleteCallLink(callLinkRoomId)
}
}
override fun onShouldRetry(e: Exception): Boolean {
@@ -102,7 +97,6 @@ class CallLinkUpdateSendJob private constructor(
val jobData = CallLinkUpdateSendJobData.ADAPTER.decode(serializedData!!)
val type: CallLinkUpdate.Type = when (jobData.type) {
CallLinkUpdateSendJobData.Type.UPDATE, null -> CallLinkUpdate.Type.UPDATE
CallLinkUpdateSendJobData.Type.DELETE -> CallLinkUpdate.Type.DELETE
}
return CallLinkUpdateSendJob(

View File

@@ -87,6 +87,7 @@ import org.thoughtcrime.securesms.migrations.StorageServiceSystemNameMigrationJo
import org.thoughtcrime.securesms.migrations.StoryViewedReceiptsStateMigrationJob;
import org.thoughtcrime.securesms.migrations.SubscriberIdMigrationJob;
import org.thoughtcrime.securesms.migrations.Svr2MirrorMigrationJob;
import org.thoughtcrime.securesms.migrations.SyncCallLinksMigrationJob;
import org.thoughtcrime.securesms.migrations.SyncDistributionListsMigrationJob;
import org.thoughtcrime.securesms.migrations.SyncKeysMigrationJob;
import org.thoughtcrime.securesms.migrations.TrimByLengthSettingsMigrationJob;
@@ -299,6 +300,7 @@ public final class JobManagerFactories {
put(StorageServiceSystemNameMigrationJob.KEY, new StorageServiceSystemNameMigrationJob.Factory());
put(StoryViewedReceiptsStateMigrationJob.KEY, new StoryViewedReceiptsStateMigrationJob.Factory());
put(Svr2MirrorMigrationJob.KEY, new Svr2MirrorMigrationJob.Factory());
put(SyncCallLinksMigrationJob.KEY, new SyncCallLinksMigrationJob.Factory());
put(SyncDistributionListsMigrationJob.KEY, new SyncDistributionListsMigrationJob.Factory());
put(SyncKeysMigrationJob.KEY, new SyncKeysMigrationJob.Factory());
put(TrimByLengthSettingsMigrationJob.KEY, new TrimByLengthSettingsMigrationJob.Factory());

View File

@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.migrations.StorageServiceMigrationJob;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.storage.AccountRecordProcessor;
import org.thoughtcrime.securesms.storage.CallLinkRecordProcessor;
import org.thoughtcrime.securesms.storage.ContactRecordProcessor;
import org.thoughtcrime.securesms.storage.GroupV1RecordProcessor;
import org.thoughtcrime.securesms.storage.GroupV2RecordProcessor;
@@ -42,6 +43,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.RequestMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord;
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record;
@@ -287,7 +289,7 @@ public class StorageSyncJob extends BaseJob {
db.beginTransaction();
try {
Log.i(TAG, "[Remote Sync] Remote-Only :: Contacts: " + remoteOnly.contacts.size() + ", GV1: " + remoteOnly.gv1.size() + ", GV2: " + remoteOnly.gv2.size() + ", Account: " + remoteOnly.account.size() + ", DLists: " + remoteOnly.storyDistributionLists.size());
Log.i(TAG, "[Remote Sync] Remote-Only :: Contacts: " + remoteOnly.contacts.size() + ", GV1: " + remoteOnly.gv1.size() + ", GV2: " + remoteOnly.gv2.size() + ", Account: " + remoteOnly.account.size() + ", DLists: " + remoteOnly.storyDistributionLists.size() + ", call links: " + remoteOnly.callLinkRecords.size());
processKnownRecords(context, remoteOnly);
@@ -411,6 +413,7 @@ public class StorageSyncJob extends BaseJob {
new GroupV2RecordProcessor(context).process(records.gv2, StorageSyncHelper.KEY_GENERATOR);
new AccountRecordProcessor(context, freshSelf()).process(records.account, StorageSyncHelper.KEY_GENERATOR);
new StoryDistributionListRecordProcessor().process(records.storyDistributionLists, StorageSyncHelper.KEY_GENERATOR);
new CallLinkRecordProcessor().process(records.callLinkRecords, StorageSyncHelper.KEY_GENERATOR);
}
private static @NonNull List<StorageId> getAllLocalStorageIds(@NonNull Recipient self) {
@@ -468,6 +471,18 @@ public class StorageSyncJob extends BaseJob {
throw new MissingRecipientModelError("Missing local recipient model! Type: " + id.getType());
}
break;
case CALL_LINK:
RecipientRecord callLinkRecord = recipientTable.getByStorageId(id.getRaw());
if (callLinkRecord != null) {
if (callLinkRecord.getCallLinkRoomId() != null) {
records.add(StorageSyncModels.localToRemoteRecord(callLinkRecord));
} else {
throw new MissingRecipientModelError("Missing local recipient model (no CallLinkRoomId)! Type: " + id.getType());
}
} else {
throw new MissingRecipientModelError("Missing local recipient model! Type: " + id.getType());
}
break;
default:
SignalStorageRecord unknown = storageIdDatabase.getById(id.getRaw());
if (unknown != null) {
@@ -501,6 +516,7 @@ public class StorageSyncJob extends BaseJob {
final List<SignalAccountRecord> account = new LinkedList<>();
final List<SignalStorageRecord> unknown = new LinkedList<>();
final List<SignalStoryDistributionListRecord> storyDistributionLists = new LinkedList<>();
final List<SignalCallLinkRecord> callLinkRecords = new LinkedList<>();
StorageRecordCollection(Collection<SignalStorageRecord> records) {
for (SignalStorageRecord record : records) {
@@ -514,6 +530,8 @@ public class StorageSyncJob extends BaseJob {
account.add(record.getAccount().get());
} else if (record.getStoryDistributionList().isPresent()) {
storyDistributionLists.add(record.getStoryDistributionList().get());
} else if (record.getCallLink().isPresent()) {
callLinkRecords.add(record.getCallLink().get());
} else if (record.getId().isUnknown()) {
unknown.add(record);
} else {

View File

@@ -1336,12 +1336,6 @@ object SyncMessageProcessor {
}
val roomId = CallLinkRoomId.fromCallLinkRootKey(callLinkRootKey)
if (callLinkUpdate.type == CallLinkUpdate.Type.DELETE) {
log(envelopeTimestamp, "Synchronize call link deletion.")
SignalDatabase.callLinks.deleteCallLink(roomId)
return
}
if (SignalDatabase.callLinks.callLinkExists(roomId)) {
log(envelopeTimestamp, "Synchronize call link for a link we already know about. Updating credentials.")
@@ -1362,9 +1356,12 @@ object SyncMessageProcessor {
linkKeyBytes = callLinkRootKey.keyBytes,
adminPassBytes = callLinkUpdate.adminPassKey?.toByteArray()
),
state = SignalCallLinkState()
state = SignalCallLinkState(),
deletionTimestamp = 0L
)
)
StorageSyncHelper.scheduleSyncForDataChange()
}
AppDependencies.jobManager.add(RefreshCallLinkDetailsJob(callLinkUpdate))

View File

@@ -155,9 +155,10 @@ public class ApplicationMigrations {
static final int EXPIRE_TIMER_CAPABILITY_2 = 111;
// static final int BACKFILL_DIGESTS = 112;
static final int BACKFILL_DIGESTS_V2 = 113;
static final int CALL_LINK_STORAGE_SYNC = 114;
}
public static final int CURRENT_VERSION = 113;
public static final int CURRENT_VERSION = 114;
/**
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
@@ -708,6 +709,10 @@ public class ApplicationMigrations {
jobs.put(Version.BACKFILL_DIGESTS_V2, new BackfillDigestsMigrationJob());
}
if (lastSeenVersion < Version.CALL_LINK_STORAGE_SYNC) {
jobs.put(Version.CALL_LINK_STORAGE_SYNC, new SyncCallLinksMigrationJob());
}
return jobs;
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.migrations
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
/**
* Marks all call links as needing to be synced by storage service.
*/
internal class SyncCallLinksMigrationJob @JvmOverloads constructor(parameters: Parameters = Parameters.Builder().build()) : MigrationJob(parameters) {
companion object {
const val KEY = "SyncCallLinksMigrationJob"
private val TAG = Log.tag(SyncCallLinksMigrationJob::class)
}
override fun getFactoryKey(): String = KEY
override fun isUiBlocking(): Boolean = false
override fun performMigration() {
if (SignalStore.account.aci == null) {
Log.w(TAG, "Self not available yet.")
return
}
val callLinkRecipients = SignalDatabase.callLinks.getAll().map { it.recipientId }.filter {
try {
Recipient.resolved(it)
true
} catch (e: Exception) {
Log.e(TAG, "Unable to resolve recipient: $it")
false
}
}
SignalDatabase.recipients.markNeedsSync(callLinkRecipients)
StorageSyncHelper.scheduleSyncForDataChange()
}
override fun shouldRetry(e: Exception): Boolean = false
class Factory : Job.Factory<SyncCallLinksMigrationJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): SyncCallLinksMigrationJob {
return SyncCallLinksMigrationJob(parameters)
}
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.storage
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord
import java.util.Optional
internal class CallLinkRecordProcessor : DefaultStorageRecordProcessor<SignalCallLinkRecord>() {
companion object {
private val TAG = Log.tag(CallLinkRecordProcessor::class)
}
override fun compare(o1: SignalCallLinkRecord?, o2: SignalCallLinkRecord?): Int {
return if (o1?.rootKey.contentEquals(o2?.rootKey)) {
0
} else {
1
}
}
internal override fun isInvalid(remote: SignalCallLinkRecord): Boolean {
return remote.adminPassKey.isNotEmpty() && remote.deletionTimestamp > 0L
}
internal override fun getMatching(remote: SignalCallLinkRecord, keyGenerator: StorageKeyGenerator): Optional<SignalCallLinkRecord> {
Log.d(TAG, "Attempting to get matching record...")
val rootKey = CallLinkRootKey(remote.rootKey)
val roomId = CallLinkRoomId.fromCallLinkRootKey(rootKey)
val callLink = SignalDatabase.callLinks.getCallLinkByRoomId(roomId)
if (callLink != null && callLink.credentials?.adminPassBytes != null) {
val builder = SignalCallLinkRecord.Builder(keyGenerator.generate(), null).apply {
setRootKey(rootKey.keyBytes)
setAdminPassKey(callLink.credentials.adminPassBytes)
setDeletedTimestamp(callLink.deletionTimestamp)
}
return Optional.of(builder.build())
} else {
return Optional.empty<SignalCallLinkRecord>()
}
}
/**
* A deleted record takes precedence over a non-deleted record
* An earlier deletion takes precedence over a later deletion
* Other fields should not change, except for the clearing of the admin passkey on deletion
*/
internal override fun merge(remote: SignalCallLinkRecord, local: SignalCallLinkRecord, keyGenerator: StorageKeyGenerator): SignalCallLinkRecord {
return if (remote.isDeleted() && local.isDeleted()) {
if (remote.deletionTimestamp < local.deletionTimestamp) {
remote
} else {
local
}
} else if (remote.isDeleted()) {
remote
} else if (local.isDeleted()) {
local
} else {
remote
}
}
override fun insertLocal(record: SignalCallLinkRecord) {
insertOrUpdateRecord(record)
}
override fun updateLocal(update: StorageRecordUpdate<SignalCallLinkRecord>) {
insertOrUpdateRecord(update.new)
}
private fun insertOrUpdateRecord(record: SignalCallLinkRecord) {
val rootKey = CallLinkRootKey(record.rootKey)
SignalDatabase.callLinks.insertOrUpdateCallLinkByRootKey(
callLinkRootKey = rootKey,
adminPassKey = record.adminPassKey,
deletionTimestamp = record.deletionTimestamp,
storageId = record.id
)
}
}

View File

@@ -7,6 +7,7 @@ import com.annimon.stream.Stream;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme;
import org.thoughtcrime.securesms.database.CallLinkTable;
import org.thoughtcrime.securesms.database.GroupTable;
import org.thoughtcrime.securesms.database.IdentityTable;
import org.thoughtcrime.securesms.database.RecipientTable;
@@ -20,8 +21,10 @@ import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord;
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record;
@@ -35,6 +38,7 @@ import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record;
import java.util.Currency;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
public final class StorageSyncModels {
@@ -63,6 +67,7 @@ public final class StorageSyncModels {
case GV1: return SignalStorageRecord.forGroupV1(localToRemoteGroupV1(settings, rawStorageId));
case GV2: return SignalStorageRecord.forGroupV2(localToRemoteGroupV2(settings, rawStorageId, settings.getSyncExtras().getGroupMasterKey()));
case DISTRIBUTION_LIST: return SignalStorageRecord.forStoryDistributionList(localToRemoteStoryDistributionList(settings, rawStorageId));
case CALL_LINK: return SignalStorageRecord.forCallLink(localToRemoteCallLink(settings, rawStorageId));
default: throw new AssertionError("Unsupported type!");
}
}
@@ -226,6 +231,32 @@ public final class StorageSyncModels {
.build();
}
private static @NonNull SignalCallLinkRecord localToRemoteCallLink(@NonNull RecipientRecord recipient, @NonNull byte[] rawStorageId) {
CallLinkRoomId callLinkRoomId = recipient.getCallLinkRoomId();
if (callLinkRoomId == null) {
throw new AssertionError("Must have a callLinkRoomId!");
}
CallLinkTable.CallLink callLink = SignalDatabase.callLinks().getCallLinkByRoomId(callLinkRoomId);
if (callLink == null) {
throw new AssertionError("Must have a call link record!");
}
if (callLink.getCredentials() == null) {
throw new AssertionError("Must have call link credentials!");
}
long deletedTimestamp = Math.max(0, SignalDatabase.callLinks().getDeletedTimestampByRoomId(callLinkRoomId));
byte[] adminPassword = deletedTimestamp > 0 ? new byte[]{} : Objects.requireNonNull(callLink.getCredentials().getAdminPassBytes(), "Non-deleted call link requires admin pass!");
return new SignalCallLinkRecord.Builder(rawStorageId, null)
.setRootKey(callLink.getCredentials().getLinkKeyBytes())
.setAdminPassKey(adminPassword)
.setDeletedTimestamp(deletedTimestamp)
.build();
}
private static @NonNull SignalStoryDistributionListRecord localToRemoteStoryDistributionList(@NonNull RecipientRecord recipient, @NonNull byte[] rawStorageId) {
DistributionListId distributionListId = recipient.getDistributionListId();

View File

@@ -144,6 +144,11 @@ public final class StorageSyncValidations {
throw new DuplicateDistributionListIdError();
}
ids = manifest.getStorageIdsByType().get(ManifestRecord.Identifier.Type.CALL_LINK.getValue());
if (ids.size() != new HashSet<>(ids).size()) {
throw new DuplicateCallLinkError();
}
throw new DuplicateRawIdAcrossTypesError();
}
@@ -196,6 +201,9 @@ public final class StorageSyncValidations {
private static final class DuplicateDistributionListIdError extends Error {
}
private static final class DuplicateCallLinkError extends Error {
}
private static final class DuplicateInsertInWriteError extends Error {
}

View File

@@ -41,7 +41,7 @@ message CallLogEventSendJobData {
message CallLinkUpdateSendJobData {
enum Type {
UPDATE = 0;
DELETE = 1;
reserved 1; // was DELETE, superseded by storage service
}
string callLinkRoomId = 1;

View File

@@ -0,0 +1,108 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.storage
import okio.ByteString.Companion.EMPTY
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.BeforeClass
import org.junit.Test
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.testutil.EmptyLogger
import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.internal.storage.protos.CallLinkRecord
/**
* See [CallLinkRecordProcessor]
*/
class CallLinkRecordProcessorTest {
companion object {
val STORAGE_ID: StorageId = StorageId.forCallLink(byteArrayOf(1, 2, 3, 4))
@JvmStatic
@BeforeClass
fun setUpClass() {
Log.initialize(EmptyLogger())
}
}
private val testSubject = CallLinkRecordProcessor()
private val mockCredentials = CallLinkCredentials(
"root key".toByteArray(),
"admin pass".toByteArray()
)
@Test
fun `Given a valid proto with only an admin pass key and not a deletion timestamp, assert valid`() {
// GIVEN
val proto = CallLinkRecord.Builder().apply {
rootKey = mockCredentials.linkKeyBytes.toByteString()
adminPasskey = mockCredentials.adminPassBytes!!.toByteString()
deletedAtTimestampMs = 0L
}.build()
val record = SignalCallLinkRecord(STORAGE_ID, proto)
// WHEN
val result = testSubject.isInvalid(record)
// THEN
assertFalse(result)
}
@Test
fun `Given a valid proto with only a deletion timestamp and not an admin pass key, assert valid`() {
// GIVEN
val proto = CallLinkRecord.Builder().apply {
rootKey = mockCredentials.linkKeyBytes.toByteString()
adminPasskey = EMPTY
deletedAtTimestampMs = System.currentTimeMillis()
}.build()
val record = SignalCallLinkRecord(STORAGE_ID, proto)
// WHEN
val result = testSubject.isInvalid(record)
// THEN
assertFalse(result)
}
@Test
fun `Given a proto with both an admin pass key and a deletion timestamp, assert invalid`() {
// GIVEN
val proto = CallLinkRecord.Builder().apply {
rootKey = mockCredentials.linkKeyBytes.toByteString()
adminPasskey = mockCredentials.adminPassBytes!!.toByteString()
deletedAtTimestampMs = System.currentTimeMillis()
}.build()
assertThrows(IllegalStateException::class.java) {
SignalCallLinkRecord(STORAGE_ID, proto)
}
}
@Test
fun `Given a proto with neither an admin pass key nor a deletion timestamp, assert valid`() {
// GIVEN
val proto = CallLinkRecord.Builder().apply {
rootKey = mockCredentials.linkKeyBytes.toByteString()
adminPasskey = EMPTY
deletedAtTimestampMs = 0L
}.build()
val record = SignalCallLinkRecord(STORAGE_ID, proto)
// WHEN
val result = testSubject.isInvalid(record)
// THEN
assertFalse(result)
}
}