diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallLinkTableTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallLinkTableTest.kt new file mode 100644 index 0000000000..80048e1584 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallLinkTableTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import junit.framework.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.thoughtcrime.securesms.calls.log.CallLogFilter +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials +import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId +import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState +import org.thoughtcrime.securesms.testing.SignalActivityRule + +@RunWith(AndroidJUnit4::class) +class CallLinkTableTest { + + companion object { + private val ROOM_ID_A = byteArrayOf(1, 2, 3, 4) + private val ROOM_ID_B = byteArrayOf(2, 2, 3, 4) + private const val TIMESTAMP_A = 1000L + private const val TIMESTAMP_B = 2000L + } + + @get:Rule + val harness = SignalActivityRule(createGroup = true) + + @Test + fun givenTwoNonAdminCallLinks_whenIDeleteBeforeFirst_thenIExpectNeitherDeleted() { + insertTwoNonAdminCallLinksWithEvents() + SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(TIMESTAMP_A - 500) + val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL) + assertEquals(2, callEvents.size) + } + + @Test + fun givenTwoNonAdminCallLinks_whenIDeleteOnFirst_thenIExpectFirstDeleted() { + insertTwoNonAdminCallLinksWithEvents() + SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(TIMESTAMP_A) + val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL) + assertEquals(1, callEvents.size) + assertEquals(TIMESTAMP_B, callEvents.first().record.timestamp) + } + + @Test + fun givenTwoNonAdminCallLinks_whenIDeleteAfterFirstAndBeforeSecond_thenIExpectFirstDeleted() { + insertTwoNonAdminCallLinksWithEvents() + SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(TIMESTAMP_B - 500) + val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL) + assertEquals(1, callEvents.size) + assertEquals(TIMESTAMP_B, callEvents.first().record.timestamp) + } + + @Test + fun givenTwoNonAdminCallLinks_whenIDeleteOnSecond_thenIExpectBothDeleted() { + insertTwoNonAdminCallLinksWithEvents() + SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(TIMESTAMP_B) + val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL) + assertEquals(0, callEvents.size) + } + + @Test + fun givenTwoNonAdminCallLinks_whenIDeleteAfterSecond_thenIExpectBothDeleted() { + insertTwoNonAdminCallLinksWithEvents() + SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(TIMESTAMP_B + 500) + val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL) + assertEquals(0, callEvents.size) + } + + private fun insertTwoNonAdminCallLinksWithEvents() { + insertCallLinkWithEvent(ROOM_ID_A, 1000) + insertCallLinkWithEvent(ROOM_ID_B, 2000) + } + + private fun insertCallLinkWithEvent(roomId: ByteArray, timestamp: Long) { + SignalDatabase.callLinks.insertCallLink( + CallLinkTable.CallLink( + recipientId = RecipientId.UNKNOWN, + roomId = CallLinkRoomId.fromBytes(roomId), + credentials = CallLinkCredentials( + linkKeyBytes = roomId, + adminPassBytes = null + ), + state = SignalCallLinkState() + ) + ) + + val callLinkRecipient = SignalDatabase.recipients.getByCallLinkRoomId(CallLinkRoomId.fromBytes(roomId)).get() + + SignalDatabase.calls.insertAcceptedGroupCall( + 1, + callLinkRecipient, + CallTable.Direction.INCOMING, + timestamp + ) + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallTableTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallTableTest.kt index 9836db3e9e..19254bbfc4 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallTableTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallTableTest.kt @@ -10,6 +10,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.signal.ringrtc.CallId import org.signal.ringrtc.CallManager +import org.thoughtcrime.securesms.calls.log.CallLogFilter import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.testing.SignalActivityRule @@ -751,4 +752,74 @@ class CallTableTest { assertEquals(CallTable.Event.DECLINED, call?.event) assertNotNull(call?.messageId) } + + @Test + fun givenTwoCalls_whenIDeleteBeforeCallB_thenOnlyDeleteCallA() { + insertTwoCallEvents() + + SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(1500) + + val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL) + assertEquals(1, allCallEvents.size) + assertEquals(2, allCallEvents.first().record.callId) + } + + @Test + fun givenTwoCalls_whenIDeleteBeforeCallA_thenIDoNotDeleteAnyCalls() { + insertTwoCallEvents() + + SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(500) + + val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL) + assertEquals(2, allCallEvents.size) + assertEquals(2, allCallEvents[0].record.callId) + assertEquals(1, allCallEvents[1].record.callId) + } + + @Test + fun givenTwoCalls_whenIDeleteOnCallA_thenIOnlyDeleteCallA() { + insertTwoCallEvents() + + SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(1000) + + val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL) + assertEquals(1, allCallEvents.size) + assertEquals(2, allCallEvents.first().record.callId) + } + + @Test + fun givenTwoCalls_whenIDeleteOnCallB_thenIDeleteBothCalls() { + insertTwoCallEvents() + + SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(2000) + + val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL) + assertEquals(0, allCallEvents.size) + } + + @Test + fun givenTwoCalls_whenIDeleteAfterCallB_thenIDeleteBothCalls() { + insertTwoCallEvents() + + SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(2500) + + val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL) + assertEquals(0, allCallEvents.size) + } + + private fun insertTwoCallEvents() { + SignalDatabase.calls.insertAcceptedGroupCall( + 1, + groupRecipientId, + CallTable.Direction.INCOMING, + 1000 + ) + + SignalDatabase.calls.insertAcceptedGroupCall( + 2, + groupRecipientId, + CallTable.Direction.OUTGOING, + 2000 + ) + } } 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 3bcdbd03ec..c36a237f9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallLinkTable.kt @@ -10,6 +10,7 @@ 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.readToSingleObject import org.signal.core.util.requireBlob @@ -226,6 +227,16 @@ 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) + .run() + + SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps(skipSync = true) + } + } + fun getAdminCallLinks(roomIds: Set): Set { val queries = SqlUtil.buildCollectionQuery(ROOM_ID, roomIds) @@ -274,6 +285,18 @@ 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 = """ diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt index 34348b2a04..0e8481bc90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt @@ -56,7 +56,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl const val TYPE = "type" private const val DIRECTION = "direction" const val EVENT = "event" - private const val TIMESTAMP = "timestamp" + const val TIMESTAMP = "timestamp" private const val RINGER = "ringer" private const val DELETION_TIMESTAMP = "deletion_timestamp" @@ -227,7 +227,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl * If a call link has been revoked, or if we do not have a CallLink table entry for an AD_HOC_CALL type * event, we mark it deleted. */ - fun updateAdHocCallEventDeletionTimestamps() { + fun updateAdHocCallEventDeletionTimestamps(skipSync: Boolean = false) { //language=sql val statement = """ UPDATE $TABLE_NAME @@ -245,7 +245,10 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl Call.deserialize(it) }.toSet() - CallSyncEventJob.enqueueDeleteSyncEvents(toSync) + if (!skipSync) { + CallSyncEventJob.enqueueDeleteSyncEvents(toSync) + } + ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary() ApplicationDependencies.getDatabaseObserver().notifyCallUpdateObservers() } @@ -254,7 +257,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl * If a non-ad-hoc call has been deleted from the message database, then we need to * set its deletion_timestamp to now. */ - fun updateCallEventDeletionTimestamps() { + fun updateCallEventDeletionTimestamps(skipSync: Boolean = false) { val where = "$TYPE != ? AND $DELETION_TIMESTAMP = 0 AND $MESSAGE_ID IS NULL" val type = Type.serialize(Type.AD_HOC_CALL) @@ -281,7 +284,10 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl result } - CallSyncEventJob.enqueueDeleteSyncEvents(toSync) + if (!skipSync) { + CallSyncEventJob.enqueueDeleteSyncEvents(toSync) + } + ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary() ApplicationDependencies.getDatabaseObserver().notifyCallUpdateObservers() } @@ -800,6 +806,20 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl .run() } + fun deleteNonAdHocCallEventsOnOrBefore(timestamp: Long) { + val messageIdsOnOrBeforeTimestamp = """ + SELECT $MESSAGE_ID FROM $TABLE_NAME WHERE $TIMESTAMP <= $timestamp AND $MESSAGE_ID IS NOT NULL + """.trimIndent() + + writableDatabase.withinTransaction { db -> + db.delete(MessageTable.TABLE_NAME) + .where("${MessageTable.ID} IN ($messageIdsOnOrBeforeTimestamp)") + .run() + + updateCallEventDeletionTimestamps(skipSync = true) + } + } + fun deleteNonAdHocCallEvents(callRowIds: Set) { val messageIds = getMessageIds(callRowIds) SignalDatabase.messages.deleteCallUpdates(messageIds) 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 328e4732a8..631a10da79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt @@ -108,6 +108,7 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos.StoryM import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.Blocked import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallLinkUpdate +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallLogEvent import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.Configuration import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.FetchLatest import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.MessageRequestResponse @@ -150,6 +151,7 @@ object SyncMessageProcessor { syncMessage.hasContacts() -> handleSynchronizeContacts(syncMessage.contacts, envelope.timestamp) syncMessage.hasCallEvent() -> handleSynchronizeCallEvent(syncMessage.callEvent, envelope.timestamp) syncMessage.hasCallLinkUpdate() -> handleSynchronizeCallLink(syncMessage.callLinkUpdate, envelope.timestamp) + syncMessage.hasCallLogEvent() -> handleSynchronizeCallLogEvent(syncMessage.callLogEvent, envelope.timestamp) else -> warn(envelope.timestamp, "Contains no known sync types...") } } @@ -1162,6 +1164,16 @@ object SyncMessageProcessor { } } + private fun handleSynchronizeCallLogEvent(callLogEvent: CallLogEvent, envelopeTimestamp: Long) { + if (callLogEvent.type != CallLogEvent.Type.CLEAR) { + log(envelopeTimestamp, "Synchronize call log event has an invalid type ${callLogEvent.type}, ignoring.") + return + } + + SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(callLogEvent.timestamp) + SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(callLogEvent.timestamp) + } + private fun handleSynchronizeCallLink(callLinkUpdate: CallLinkUpdate, envelopeTimestamp: Long) { if (!callLinkUpdate.hasRootKey()) { log(envelopeTimestamp, "Synchronize call link missing root key, ignoring.") @@ -1185,21 +1197,20 @@ object SyncMessageProcessor { callLinkUpdate.adminPassKey?.toByteArray() ) ) - - return - } - - SignalDatabase.callLinks.insertCallLink( - CallLinkTable.CallLink( - recipientId = RecipientId.UNKNOWN, - roomId = roomId, - credentials = CallLinkCredentials( - linkKeyBytes = callLinkRootKey.keyBytes, - adminPassBytes = callLinkUpdate.adminPassKey?.toByteArray() - ), - state = SignalCallLinkState() + } else { + log(envelopeTimestamp, "Synchronize call link for a link we do not know about. Inserting.") + SignalDatabase.callLinks.insertCallLink( + CallLinkTable.CallLink( + recipientId = RecipientId.UNKNOWN, + roomId = roomId, + credentials = CallLinkCredentials( + linkKeyBytes = callLinkRootKey.keyBytes, + adminPassBytes = callLinkUpdate.adminPassKey?.toByteArray() + ), + state = SignalCallLinkState() + ) ) - ) + } ApplicationDependencies.getJobManager().add(RefreshCallLinkDetailsJob(callLinkUpdate)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/SignalCallLinkManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/SignalCallLinkManager.kt index 89ccd772e6..1b4869a6e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/SignalCallLinkManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/SignalCallLinkManager.kt @@ -137,7 +137,6 @@ class SignalCallLinkManager( credentials: CallLinkCredentials ): Single { return Single.create { emitter -> - callManager.readCallLink( SignalStore.internalValues().groupCallingServer(), requestCallLinkAuthCredentialPresentation(credentials.linkKeyBytes).serialize(), diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index fe8af67d85..d47017424b 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -15,4 +15,8 @@ message CallSyncEventJobRecord { message CallSyncEventJobData { repeated CallSyncEventJobRecord records = 1; +} + +message CallLinkRefreshSinceTimestampJobData { + uint64 timestamp = 1; } \ No newline at end of file diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index 71273d2b16..11735b4f1b 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -626,6 +626,15 @@ message SyncMessage { optional bytes adminPassKey = 2; } + message CallLogEvent { + enum Type { + CLEAR = 0; + } + + optional Type type = 1; + optional uint64 timestamp = 2; + } + optional Sent sent = 1; optional Contacts contacts = 2; reserved /*groups*/ 3; @@ -646,6 +655,7 @@ message SyncMessage { optional PniChangeNumber pniChangeNumber = 18; optional CallEvent callEvent = 19; optional CallLinkUpdate callLinkUpdate = 20; + optional CallLogEvent callLogEvent = 21; } message AttachmentPointer {