diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallLinkTableTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallLinkTableTest.kt index 80048e1584..4e0130a7ce 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallLinkTableTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallLinkTableTest.kt @@ -86,7 +86,8 @@ class CallLinkTableTest { linkKeyBytes = roomId, adminPassBytes = null ), - state = SignalCallLinkState() + state = SignalCallLinkState(), + deletionTimestamp = 0L ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallLinkTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallLinkTableBackupExtensions.kt index 0c2a3fc033..9af0f228ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallLinkTableBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallLinkTableBackupExtensions.kt @@ -47,7 +47,8 @@ fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId? { name = callLink.name, restrictions = callLink.restrictions.toLocal(), expiration = Instant.ofEpochMilli(callLink.expirationMs) - ) + ), + deletionTimestamp = 0L ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/SignalCallRow.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/SignalCallRow.kt index cb3756981e..80c800b44b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/SignalCallRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/SignalCallRow.kt @@ -62,7 +62,8 @@ private fun SignalCallRowPreview() { restrictions = org.signal.ringrtc.CallLinkState.Restrictions.NONE, expiration = Instant.MAX, revoked = false - ) + ), + deletionTimestamp = 0L ) } Previews.Preview { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/UpdateCallLinkRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/UpdateCallLinkRepository.kt index eb525629e7..a9b573ec08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/UpdateCallLinkRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/UpdateCallLinkRepository.kt @@ -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 -> {} } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkRepository.kt index 237c65dfc1..d1a0ea1614 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkRepository.kt @@ -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() ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkViewModel.kt index 8520c24c93..95d6fd2e37 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/create/CreateCallLinkViewModel.kt @@ -39,7 +39,8 @@ class CreateCallLinkViewModel( restrictions = Restrictions.ADMIN_APPROVAL, revoked = false, expiration = Instant.MAX - ) + ), + deletionTimestamp = 0L ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt index c32561d298..08306a0105 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt @@ -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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt index 0da883bccd..e0f147e047 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRepository.kt @@ -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 { 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 8d5c7c11fd..8aa59dbd67 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt @@ -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 { + 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 { - 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 } 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 c80708c0e2..1728d1b0c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -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") diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 6ce97bae95..99ffcad3ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V245_DeletionTimestampOnCallLinks.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V245_DeletionTimestampOnCallLinks.kt new file mode 100644 index 0000000000..cd8e6cae5b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V245_DeletionTimestampOnCallLinks.kt @@ -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;") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CallLinkUpdateSendJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/CallLinkUpdateSendJob.kt index 0d09dc8280..203660fdcb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/CallLinkUpdateSendJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CallLinkUpdateSendJob.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 94cbc9195f..4bf58aa0e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java index 93d8c4d70e..3f219b985b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java @@ -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 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 account = new LinkedList<>(); final List unknown = new LinkedList<>(); final List storyDistributionLists = new LinkedList<>(); + final List callLinkRecords = new LinkedList<>(); StorageRecordCollection(Collection 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt index c862152aa5..19ccbac318 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt @@ -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)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index e75b57d2db..645e0edbc9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/SyncCallLinksMigrationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/migrations/SyncCallLinksMigrationJob.kt new file mode 100644 index 0000000000..d8c1f31c8f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/SyncCallLinksMigrationJob.kt @@ -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 { + override fun create(parameters: Parameters, serializedData: ByteArray?): SyncCallLinksMigrationJob { + return SyncCallLinksMigrationJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessor.kt new file mode 100644 index 0000000000..adb77a5673 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessor.kt @@ -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() { + + 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 { + 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() + } + } + + /** + * 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) { + 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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java index a61554d735..a3f120023d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java index b9deaf52c2..981e56f515 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java @@ -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 { } diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index b281fb856c..efd28552e1 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -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; diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessorTest.kt new file mode 100644 index 0000000000..7de669e1cb --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessorTest.kt @@ -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) + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalCallLinkRecord.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalCallLinkRecord.kt new file mode 100644 index 0000000000..f937a6798e --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalCallLinkRecord.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.storage + +import okio.ByteString.Companion.toByteString +import org.whispersystems.signalservice.internal.storage.protos.CallLinkRecord +import java.io.IOException +import java.util.LinkedList + +/** + * A record in storage service that represents a call link that was already created. + */ +class SignalCallLinkRecord(private val id: StorageId, private val proto: CallLinkRecord) : SignalRecord { + + val rootKey: ByteArray = proto.rootKey.toByteArray() + val adminPassKey: ByteArray = proto.adminPasskey.toByteArray() + val deletionTimestamp: Long = proto.deletedAtTimestampMs + + init { + if (deletionTimestamp != 0L && adminPassKey.isNotEmpty()) { + throw IllegalStateException("Cannot have nonzero deletion timestamp ($deletionTimestamp) and admin passkey!") + } + } + + fun toProto(): CallLinkRecord { + return proto + } + + override fun getId(): StorageId { + return id + } + + override fun asStorageRecord(): SignalStorageRecord { + return SignalStorageRecord.forCallLink(this) + } + + override fun describeDiff(other: SignalRecord?): String { + return when (other) { + is SignalCallLinkRecord -> { + val diff = LinkedList() + if (!rootKey.contentEquals(other.rootKey)) { + diff.add("RootKey") + } + + if (!adminPassKey.contentEquals(other.adminPassKey)) { + diff.add("AdminPassKey") + } + + if (deletionTimestamp != other.deletionTimestamp) { + diff.add("DeletionTimestamp") + } + + diff.toString() + } + + null -> { + "Other was null!" + } + + else -> { + "Different class. ${this::class.java.getSimpleName()} | ${other::class.java.getSimpleName()}" + } + } + } + + fun isDeleted(): Boolean { + return deletionTimestamp > 0 + } + + class Builder(rawId: ByteArray, serializedUnknowns: ByteArray?) { + private var id: StorageId = StorageId.forCallLink(rawId) + private var builder: CallLinkRecord.Builder + + init { + if (serializedUnknowns != null) { + this.builder = parseUnknowns(serializedUnknowns) + } else { + this.builder = CallLinkRecord.Builder() + } + } + + fun setRootKey(rootKey: ByteArray): Builder { + builder.rootKey = rootKey.toByteString() + return this + } + + fun setAdminPassKey(adminPasskey: ByteArray): Builder { + builder.adminPasskey = adminPasskey.toByteString() + return this + } + + fun setDeletedTimestamp(deletedTimestamp: Long): Builder { + builder.deletedAtTimestampMs = deletedTimestamp + return this + } + + fun build(): SignalCallLinkRecord { + return SignalCallLinkRecord(id, builder.build()) + } + + companion object { + fun parseUnknowns(serializedUnknowns: ByteArray): CallLinkRecord.Builder { + return try { + CallLinkRecord.ADAPTER.decode(serializedUnknowns).newBuilder() + } catch (e: IOException) { + CallLinkRecord.Builder() + } + } + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java index d5ab0246f4..ed2f0f76cb 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java @@ -50,7 +50,9 @@ public final class SignalStorageModels { return SignalStorageRecord.forAccount(id, new SignalAccountRecord(id, record.account)); } else if (record.storyDistributionList != null && type == ManifestRecord.Identifier.Type.STORY_DISTRIBUTION_LIST.getValue()) { return SignalStorageRecord.forStoryDistributionList(id, new SignalStoryDistributionListRecord(id, record.storyDistributionList)); - } else { + } else if (record.callLink != null && type == ManifestRecord.Identifier.Type.CALL_LINK.getValue()) { + return SignalStorageRecord.forCallLink(id, new SignalCallLinkRecord(id, record.callLink)); + }else { if (StorageId.isKnownType(type)) { Log.w(TAG, "StorageId is of known type (" + type + "), but the data is bad! Falling back to unknown."); } @@ -71,6 +73,8 @@ public final class SignalStorageModels { builder.account(record.getAccount().get().toProto()); } else if (record.getStoryDistributionList().isPresent()) { builder.storyDistributionList(record.getStoryDistributionList().get().toProto()); + } else if (record.getCallLink().isPresent()) { + builder.callLink(record.getCallLink().get().toProto()); } else { throw new InvalidStorageWriteError(); } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.java index 5e21e33d57..2e39298e9e 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.java @@ -1,6 +1,9 @@ package org.whispersystems.signalservice.api.storage; +import org.jetbrains.annotations.NotNull; +import org.whispersystems.signalservice.internal.storage.protos.CallLinkRecord; + import java.util.Objects; import java.util.Optional; @@ -12,13 +15,14 @@ public class SignalStorageRecord implements SignalRecord { private final Optional groupV1; private final Optional groupV2; private final Optional account; + private final Optional callLink; public static SignalStorageRecord forStoryDistributionList(SignalStoryDistributionListRecord storyDistributionList) { return forStoryDistributionList(storyDistributionList.getId(), storyDistributionList); } public static SignalStorageRecord forStoryDistributionList(StorageId key, SignalStoryDistributionListRecord storyDistributionList) { - return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(storyDistributionList)); + return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(storyDistributionList), Optional.empty()); } public static SignalStorageRecord forContact(SignalContactRecord contact) { @@ -26,7 +30,7 @@ public class SignalStorageRecord implements SignalRecord { } public static SignalStorageRecord forContact(StorageId key, SignalContactRecord contact) { - return new SignalStorageRecord(key, Optional.of(contact), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); + return new SignalStorageRecord(key, Optional.of(contact), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); } public static SignalStorageRecord forGroupV1(SignalGroupV1Record groupV1) { @@ -34,7 +38,7 @@ public class SignalStorageRecord implements SignalRecord { } public static SignalStorageRecord forGroupV1(StorageId key, SignalGroupV1Record groupV1) { - return new SignalStorageRecord(key, Optional.empty(), Optional.of(groupV1), Optional.empty(), Optional.empty(), Optional.empty()); + return new SignalStorageRecord(key, Optional.empty(), Optional.of(groupV1), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); } public static SignalStorageRecord forGroupV2(SignalGroupV2Record groupV2) { @@ -42,7 +46,7 @@ public class SignalStorageRecord implements SignalRecord { } public static SignalStorageRecord forGroupV2(StorageId key, SignalGroupV2Record groupV2) { - return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.of(groupV2), Optional.empty(), Optional.empty()); + return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.of(groupV2), Optional.empty(), Optional.empty(), Optional.empty()); } public static SignalStorageRecord forAccount(SignalAccountRecord account) { @@ -50,19 +54,31 @@ public class SignalStorageRecord implements SignalRecord { } public static SignalStorageRecord forAccount(StorageId key, SignalAccountRecord account) { - return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(account), Optional.empty()); + return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(account), Optional.empty(), Optional.empty()); + } + + @NotNull + public static SignalStorageRecord forCallLink(@NotNull SignalCallLinkRecord callLink) { + return forCallLink(callLink.getId(), callLink); + } + + @NotNull + public static SignalStorageRecord forCallLink(StorageId key, @NotNull SignalCallLinkRecord callLink) { + return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(callLink)); } public static SignalStorageRecord forUnknown(StorageId key) { - return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); + return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); } + private SignalStorageRecord(StorageId id, Optional contact, Optional groupV1, Optional groupV2, Optional account, - Optional storyDistributionList) + Optional storyDistributionList, + Optional callLink) { this.id = id; this.contact = contact; @@ -70,6 +86,7 @@ public class SignalStorageRecord implements SignalRecord { this.groupV2 = groupV2; this.account = account; this.storyDistributionList = storyDistributionList; + this.callLink = callLink; } @Override @@ -111,8 +128,12 @@ public class SignalStorageRecord implements SignalRecord { return storyDistributionList; } + public Optional getCallLink() { + return callLink; + } + public boolean isUnknown() { - return !contact.isPresent() && !groupV1.isPresent() && !groupV2.isPresent() && !account.isPresent() && !storyDistributionList.isPresent(); + return !contact.isPresent() && !groupV1.isPresent() && !groupV2.isPresent() && !account.isPresent() && !storyDistributionList.isPresent() && !callLink.isPresent(); } @Override @@ -124,11 +145,12 @@ public class SignalStorageRecord implements SignalRecord { Objects.equals(contact, that.contact) && Objects.equals(groupV1, that.groupV1) && Objects.equals(groupV2, that.groupV2) && - Objects.equals(storyDistributionList, that.storyDistributionList); + Objects.equals(storyDistributionList, that.storyDistributionList) && + Objects.equals(callLink, that.callLink); } @Override public int hashCode() { - return Objects.hash(id, contact, groupV1, groupV2, storyDistributionList); + return Objects.hash(id, contact, groupV1, groupV2, storyDistributionList, callLink); } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java index bc15097abf..214395fbfd 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java @@ -31,6 +31,10 @@ public class StorageId { return new StorageId(ManifestRecord.Identifier.Type.ACCOUNT.getValue(), Preconditions.checkNotNull(raw)); } + public static StorageId forCallLink(byte[] raw) { + return new StorageId(ManifestRecord.Identifier.Type.CALL_LINK.getValue(), Preconditions.checkNotNull(raw)); + } + public static StorageId forType(byte[] raw, int type) { return new StorageId(type, raw); } diff --git a/libsignal-service/src/main/protowire/SignalService.proto b/libsignal-service/src/main/protowire/SignalService.proto index ab4ea42eb3..c16f9b24a0 100644 --- a/libsignal-service/src/main/protowire/SignalService.proto +++ b/libsignal-service/src/main/protowire/SignalService.proto @@ -630,7 +630,7 @@ message SyncMessage { message CallLinkUpdate { enum Type { UPDATE = 0; - DELETE = 1; + reserved 1; // was DELETE, superseded by storage service } optional bytes rootKey = 1; diff --git a/libsignal-service/src/main/protowire/StorageService.proto b/libsignal-service/src/main/protowire/StorageService.proto index ac26d384f0..6faad055af 100644 --- a/libsignal-service/src/main/protowire/StorageService.proto +++ b/libsignal-service/src/main/protowire/StorageService.proto @@ -50,6 +50,7 @@ message ManifestRecord { GROUPV2 = 3; ACCOUNT = 4; STORY_DISTRIBUTION_LIST = 5; + CALL_LINK = 7; } bytes raw = 1; @@ -69,6 +70,7 @@ message StorageRecord { GroupV2Record groupV2 = 3; AccountRecord account = 4; StoryDistributionListRecord storyDistributionList = 5; + CallLinkRecord callLink = 7; } } @@ -227,3 +229,9 @@ message StoryDistributionListRecord { bool allowsReplies = 5; bool isBlockList = 6; } + +message CallLinkRecord { + bytes rootKey = 1; + bytes adminPasskey = 2; + uint64 deletedAtTimestampMs = 3; +} \ No newline at end of file