diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallTableTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallTableTest.kt new file mode 100644 index 0000000000..ceb9450e30 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/CallTableTest.kt @@ -0,0 +1,750 @@ +package org.thoughtcrime.securesms.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.ringrtc.CallId +import org.signal.ringrtc.CallManager +import org.thoughtcrime.securesms.testing.SignalActivityRule + +@RunWith(AndroidJUnit4::class) +class CallTableTest { + + @get:Rule + val harness = SignalActivityRule() + + @Test + fun givenACall_whenISetTimestamp_thenIExpectUpdatedTimestamp() { + val callId = 1L + val now = System.currentTimeMillis() + SignalDatabase.calls.insertAcceptedGroupCall( + callId, + harness.others[0], + CallTable.Direction.INCOMING, + now + ) + + SignalDatabase.calls.setTimestamp(callId, -1L) + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(-1L, call?.timestamp) + + val messageRecord = SignalDatabase.messages.getMessageRecord(call!!.messageId!!) + assertEquals(-1L, messageRecord.dateReceived) + assertEquals(-1L, messageRecord.dateSent) + } + + @Test + fun givenPreExistingEvent_whenIDeleteGroupCall_thenIMarkDeletedAndSetTimestamp() { + val callId = 1L + val now = System.currentTimeMillis() + SignalDatabase.calls.insertAcceptedGroupCall( + callId, + harness.others[0], + CallTable.Direction.INCOMING, + now + ) + + val call = SignalDatabase.calls.getCallById(callId) + SignalDatabase.calls.deleteGroupCall(call!!) + + val deletedCall = SignalDatabase.calls.getCallById(callId) + val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp() + + assertEquals(CallTable.Event.DELETE, deletedCall?.event) + assertNotEquals(0L, oldestDeletionTimestamp) + assertNull(deletedCall!!.messageId) + } + + @Test + fun givenNoPreExistingEvent_whenIDeleteGroupCall_thenIInsertAndMarkCallDeleted() { + val callId = 1L + SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent( + callId, + harness.others[0], + CallTable.Direction.OUTGOING, + System.currentTimeMillis() + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + + val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp() + + assertEquals(CallTable.Event.DELETE, call?.event) + assertNotEquals(oldestDeletionTimestamp, 0) + assertNull(call?.messageId) + } + + @Test + fun givenNoPriorEvent_whenIInsertAcceptedOutgoingGroupCall_thenIExpectLocalRingerAndOutgoingRing() { + val callId = 1L + SignalDatabase.calls.insertAcceptedGroupCall( + callId, + harness.others[0], + CallTable.Direction.OUTGOING, + System.currentTimeMillis() + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.OUTGOING_RING, call?.event) + assertEquals(harness.self.id, call?.ringerRecipient) + assertNotNull(call?.messageId) + } + + @Test + fun givenNoPriorEvent_whenIInsertAcceptedIncomingGroupCall_thenIExpectJoined() { + val callId = 1L + SignalDatabase.calls.insertAcceptedGroupCall( + callId, + harness.others[0], + CallTable.Direction.INCOMING, + System.currentTimeMillis() + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.JOINED, call?.event) + assertNull(call?.ringerRecipient) + assertNotNull(call?.messageId) + } + + @Test + fun givenARingingCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() { + val callId = 1L + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + ringId = callId, + groupRecipientId = harness.others[0], + ringerRecipient = harness.others[1], + dateReceived = System.currentTimeMillis(), + ringState = CallManager.RingUpdate.REQUESTED + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.RINGING, call?.event) + + SignalDatabase.calls.acceptIncomingGroupCall( + call!! + ) + + val acceptedCall = SignalDatabase.calls.getCallById(callId) + assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event) + } + + @Test + fun givenAMissedCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() { + val callId = 1L + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + ringId = callId, + groupRecipientId = harness.others[0], + ringerRecipient = harness.others[1], + dateReceived = System.currentTimeMillis(), + ringState = CallManager.RingUpdate.EXPIRED_REQUEST + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.MISSED, call?.event) + + SignalDatabase.calls.acceptIncomingGroupCall( + call!! + ) + + val acceptedCall = SignalDatabase.calls.getCallById(callId) + assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event) + } + + @Test + fun givenADeclinedCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() { + val callId = 1L + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + ringId = callId, + groupRecipientId = harness.others[0], + ringerRecipient = harness.others[1], + dateReceived = System.currentTimeMillis(), + ringState = CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.DECLINED, call?.event) + + SignalDatabase.calls.acceptIncomingGroupCall( + call!! + ) + + val acceptedCall = SignalDatabase.calls.getCallById(callId) + assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event) + } + + @Test + fun givenAGenericGroupCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent( + groupRecipientId = harness.others[0], + sender = harness.others[1], + timestamp = System.currentTimeMillis(), + peekGroupCallEraId = "aaa", + peekJoinedUuids = emptyList(), + isCallFull = false + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event) + + SignalDatabase.calls.acceptIncomingGroupCall( + call!! + ) + + val acceptedCall = SignalDatabase.calls.getCallById(callId) + assertEquals(CallTable.Event.JOINED, acceptedCall?.event) + } + + @Test + fun givenNoPriorCallEvent_whenIReceiveAGroupCallUpdateMessage_thenIExpectAGenericGroupCall() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent( + groupRecipientId = harness.others[0], + sender = harness.others[1], + timestamp = System.currentTimeMillis(), + peekGroupCallEraId = "aaa", + peekJoinedUuids = emptyList(), + isCallFull = false + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event) + } + + @Test + fun givenAPriorCallEventWithNewerTimestamp_whenIReceiveAGroupCallUpdateMessage_thenIExpectAnUpdatedTimestamp() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + val now = System.currentTimeMillis() + SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent( + groupRecipientId = harness.others[0], + sender = harness.others[1], + timestamp = now, + peekGroupCallEraId = "aaa", + peekJoinedUuids = emptyList(), + isCallFull = false + ) + + SignalDatabase.calls.getCallById(callId).let { + assertNotNull(it) + assertEquals(now, it?.timestamp) + } + + SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent( + groupRecipientId = harness.others[0], + sender = harness.others[1], + timestamp = 1L, + peekGroupCallEraId = "aaa", + peekJoinedUuids = emptyList(), + isCallFull = false + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event) + assertEquals(1L, call?.timestamp) + } + + @Test + fun givenADeletedCallEvent_whenIReceiveARingUpdate_thenIIgnoreTheRingUpdate() { + val callId = 1L + SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent( + callId = callId, + recipientId = harness.others[0], + direction = CallTable.Direction.INCOMING, + timestamp = System.currentTimeMillis() + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + ringId = callId, + groupRecipientId = harness.others[0], + ringerRecipient = harness.others[1], + dateReceived = System.currentTimeMillis(), + ringState = CallManager.RingUpdate.REQUESTED + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.DELETE, call?.event) + } + + @Test + fun givenAGenericCallEvent_whenRingRequested_thenISetRingerAndMoveToRingingState() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + val now = System.currentTimeMillis() + SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent( + groupRecipientId = harness.others[0], + sender = harness.others[1], + timestamp = now, + peekGroupCallEraId = "aaa", + peekJoinedUuids = emptyList(), + isCallFull = false + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.REQUESTED + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.RINGING, call?.event) + assertEquals(harness.others[1], call?.ringerRecipient) + } + + @Test + fun givenAJoinedCallEvent_whenRingRequested_thenISetRingerAndMoveToRingingState() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + val now = System.currentTimeMillis() + SignalDatabase.calls.insertAcceptedGroupCall( + callId, + harness.others[0], + CallTable.Direction.INCOMING, + now + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.REQUESTED + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.ACCEPTED, call?.event) + assertEquals(harness.others[1], call?.ringerRecipient) + } + + @Test + fun givenAGenericCallEvent_whenRingExpired_thenISetRingerAndMoveToMissedState() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + val now = System.currentTimeMillis() + SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent( + groupRecipientId = harness.others[0], + sender = harness.others[1], + timestamp = now, + peekGroupCallEraId = "aaa", + peekJoinedUuids = emptyList(), + isCallFull = false + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.EXPIRED_REQUEST + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.MISSED, call?.event) + assertEquals(harness.others[1], call?.ringerRecipient) + } + + @Test + fun givenARingingCallEvent_whenRingExpired_thenISetRingerAndMoveToMissedState() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + val now = System.currentTimeMillis() + SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent( + groupRecipientId = harness.others[0], + sender = harness.others[1], + timestamp = now, + peekGroupCallEraId = "aaa", + peekJoinedUuids = emptyList(), + isCallFull = false + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.REQUESTED + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.EXPIRED_REQUEST + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.MISSED, call?.event) + assertEquals(harness.others[1], call?.ringerRecipient) + } + + @Test + fun givenAJoinedCallEvent_whenRingIsCancelledBecauseUserIsBusyLocally_thenIMoveToAcceptedState() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + val now = System.currentTimeMillis() + SignalDatabase.calls.insertAcceptedGroupCall( + callId, + harness.others[0], + CallTable.Direction.INCOMING, + now + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.BUSY_LOCALLY + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.ACCEPTED, call?.event) + } + + @Test + fun givenAJoinedCallEvent_whenRingIsCancelledBecauseUserIsBusyOnAnotherDevice_thenIMoveToAcceptedState() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + val now = System.currentTimeMillis() + SignalDatabase.calls.insertAcceptedGroupCall( + callId, + harness.others[0], + CallTable.Direction.INCOMING, + now + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.ACCEPTED, call?.event) + } + + @Test + fun givenARingingCallEvent_whenRingCancelledBecauseUserIsBusyLocally_thenIMoveToMissedState() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + val now = System.currentTimeMillis() + SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent( + groupRecipientId = harness.others[0], + sender = harness.others[1], + timestamp = now, + peekGroupCallEraId = "aaa", + peekJoinedUuids = emptyList(), + isCallFull = false + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.REQUESTED + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.BUSY_LOCALLY + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.MISSED, call?.event) + } + + @Test + fun givenARingingCallEvent_whenRingCancelledBecauseUserIsBusyOnAnotherDevice_thenIMoveToMissedState() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + val now = System.currentTimeMillis() + SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent( + groupRecipientId = harness.others[0], + sender = harness.others[1], + timestamp = now, + peekGroupCallEraId = "aaa", + peekJoinedUuids = emptyList(), + isCallFull = false + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.REQUESTED + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.MISSED, call?.event) + } + + @Test + fun givenACallEvent_whenRingIsAcceptedOnAnotherDevice_thenIMoveToAcceptedState() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + val now = System.currentTimeMillis() + SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent( + groupRecipientId = harness.others[0], + sender = harness.others[1], + timestamp = now, + peekGroupCallEraId = "aaa", + peekJoinedUuids = emptyList(), + isCallFull = false + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.ACCEPTED, call?.event) + } + + @Test + fun givenARingingCallEvent_whenRingDeclinedOnAnotherDevice_thenIMoveToDeclinedState() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.REQUESTED + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.DECLINED, call?.event) + } + + @Test + fun givenAMissedCallEvent_whenRingDeclinedOnAnotherDevice_thenIMoveToDeclinedState() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.EXPIRED_REQUEST + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.DECLINED, call?.event) + } + + @Test + fun givenAnOutgoingRingCallEvent_whenRingDeclinedOnAnotherDevice_thenIDoNotChangeState() { + val era = "aaa" + val callId = CallId.fromEra(era).longValue() + + SignalDatabase.calls.insertAcceptedGroupCall( + callId, + harness.others[0], + CallTable.Direction.OUTGOING, + System.currentTimeMillis() + ) + + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.OUTGOING_RING, call?.event) + } + + @Test + fun givenNoPriorEvent_whenRingRequested_thenICreateAnEventInTheRingingStateAndSetRinger() { + val callId = 1L + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.REQUESTED + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.RINGING, call?.event) + assertEquals(harness.others[1], call?.ringerRecipient) + assertNotNull(call?.messageId) + } + + @Test + fun givenNoPriorEvent_whenRingExpired_thenICreateAnEventInTheMissedStateAndSetRinger() { + val callId = 1L + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.EXPIRED_REQUEST + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.MISSED, call?.event) + assertEquals(harness.others[1], call?.ringerRecipient) + assertNotNull(call?.messageId) + } + + @Test + fun givenNoPriorEvent_whenRingCancelledByRinger_thenICreateAnEventInTheMissedStateAndSetRinger() { + val callId = 1L + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.CANCELLED_BY_RINGER + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.MISSED, call?.event) + assertEquals(harness.others[1], call?.ringerRecipient) + assertNotNull(call?.messageId) + } + + @Test + fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyLocally_thenICreateAnEventInTheMissedState() { + val callId = 1L + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.BUSY_LOCALLY + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.MISSED, call?.event) + assertNotNull(call?.messageId) + } + + @Test + fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyOnAnotherDevice_thenICreateAnEventInTheMissedState() { + val callId = 1L + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.MISSED, call?.event) + assertNotNull(call?.messageId) + } + + @Test + fun givenNoPriorEvent_whenRingAcceptedOnAnotherDevice_thenICreateAnEventInTheAcceptedState() { + val callId = 1L + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.ACCEPTED, call?.event) + assertNotNull(call?.messageId) + } + + @Test + fun givenNoPriorEvent_whenRingDeclinedOnAnotherDevice_thenICreateAnEventInTheDeclinedState() { + val callId = 1L + SignalDatabase.calls.insertOrUpdateGroupCallFromRingState( + callId, + harness.others[0], + harness.others[1], + System.currentTimeMillis(), + CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE + ) + + val call = SignalDatabase.calls.getCallById(callId) + assertNotNull(call) + assertEquals(CallTable.Event.DECLINED, call?.event) + assertNotNull(call?.messageId) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 650d35c910..211f8d7119 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -197,6 +197,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr .addNonBlocking(() -> ApplicationDependencies.getGiphyMp4Cache().onAppStart(this)) .addNonBlocking(this::ensureProfileUploaded) .addNonBlocking(() -> ApplicationDependencies.getExpireStoriesManager().scheduleIfNecessary()) + .addPostRender(() -> ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary()) .addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this)) .addPostRender(this::initializeExpiringMessageManager) .addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this))) @@ -214,7 +215,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr .addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded) .addPostRender(PnpInitializeDevicesJob::enqueueIfNecessary) .addPostRender(() -> ApplicationDependencies.getExoPlayerPool().getPoolStats().getMaxUnreserved()) - .addPostRender(() -> SignalDatabase.groupCallRings().removeOldRings()) .addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp()) .execute(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt index 7eb8cd77f1..ceaf1c1685 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt @@ -186,6 +186,10 @@ class CallLogAdapter( binding.callType.setImageResource(R.drawable.symbol_video_24) binding.callType.setOnClickListener { onStartVideoCallClicked(peer) } } + + CallTable.Type.GROUP_CALL, CallTable.Type.AD_HOC_CALL -> { + // TODO [alex] -- Group call button + } } binding.callType.visible = true diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt index 0cd5d7031a..49f1a2e76c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogContextMenu.kt @@ -72,7 +72,7 @@ class CallLogContextMenu( iconRes = R.drawable.symbol_info_24, title = fragment.getString(R.string.CallContextMenu__info) ) { - val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.call.messageId)) + val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.call.messageId!!)) fragment.startActivity(intent) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index 6be8bdd38f..2b02a9a16b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFi import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder import org.thoughtcrime.securesms.main.SearchBinder import org.thoughtcrime.securesms.recipients.Recipient @@ -167,6 +168,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal override fun onResume() { super.onResume() initializeSearchAction() + ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary() } private fun initializeTapToScrollToTop() { @@ -270,7 +272,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) { viewModel.toggleSelected(callLogRow.id) } else { - val intent = ConversationSettingsActivity.forCall(requireContext(), callLogRow.peer, longArrayOf(callLogRow.call.messageId)) + val intent = ConversationSettingsActivity.forCall(requireContext(), callLogRow.peer, longArrayOf(callLogRow.call.messageId!!)) startActivity(intent) } } 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 8020ea9513..196cadcd68 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 @@ -41,18 +41,18 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository { } fun deleteSelectedCallLogs( - selectedMessageIds: Set + selectedCallIds: Set ): Completable { return Completable.fromAction { - SignalDatabase.messages.deleteCallUpdates(selectedMessageIds) + SignalDatabase.calls.deleteCallEvents(selectedCallIds) }.observeOn(Schedulers.io()) } fun deleteAllCallLogsExcept( - selectedMessageIds: Set + selectedCallIds: Set ): Completable { return Completable.fromAction { - SignalDatabase.messages.deleteAllCallUpdatesExcept(selectedMessageIds) + SignalDatabase.calls.deleteAllCallEventsExcept(selectedCallIds) }.observeOn(Schedulers.io()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt index 81cabd964c..b279cb7b45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogRow.kt @@ -17,7 +17,7 @@ sealed class CallLogRow { val call: CallTable.Call, val peer: Recipient, val date: Long, - override val id: Id = Id.Call(call.messageId) + override val id: Id = Id.Call(call.callId) ) : CallLogRow() /** @@ -28,7 +28,7 @@ sealed class CallLogRow { } sealed class Id { - data class Call(val messageId: Long) : Id() + data class Call(val callId: Long) : Id() object ClearFilter : Id() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogStagedDeletion.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogStagedDeletion.kt index e9ea12200d..b710e851bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogStagedDeletion.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogStagedDeletion.kt @@ -28,15 +28,15 @@ class CallLogStagedDeletion( } isCommitted = true - val messageIds = stateSnapshot.selected() + val callIds = stateSnapshot.selected() .filterIsInstance() - .map { it.messageId } + .map { it.callId } .toSet() if (stateSnapshot.isExclusionary()) { - repository.deleteAllCallLogsExcept(messageIds).subscribe() + repository.deleteAllCallLogsExcept(callIds).subscribe() } else { - repository.deleteSelectedCallLogs(messageIds).subscribe() + repository.deleteSelectedCallLogs(callIds).subscribe() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt index f1e80b9367..178c64f927 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt @@ -12,6 +12,7 @@ import org.signal.core.util.logging.Log import org.signal.storageservice.protos.groups.local.DecryptedGroup import org.signal.storageservice.protos.groups.local.DecryptedPendingMember import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery +import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.GroupRecord @@ -39,12 +40,16 @@ class ConversationSettingsRepository( private val groupManagementRepository: GroupManagementRepository = GroupManagementRepository(context) ) { - fun getCallEvents(callMessageIds: LongArray): Single> { + fun getCallEvents(callMessageIds: LongArray): Single>> { return if (callMessageIds.isEmpty()) { Single.just(emptyList()) } else { Single.fromCallable { - SignalDatabase.messages.getMessages(callMessageIds.toList()).iterator().asSequence().toList() + val callMap = SignalDatabase.calls.getCalls(callMessageIds.toList()) + SignalDatabase.messages.getMessages(callMessageIds.toList()).iterator().asSequence() + .filter { callMap.containsKey(it.id) } + .map { callMap[it.id]!! to it } + .toList() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt index 25124e5886..f53b86e441 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt @@ -67,7 +67,7 @@ sealed class ConversationSettingsViewModel( } store.update(repository.getCallEvents(callMessageIds).toObservable()) { callRecords, state -> - state.copy(calls = callRecords.map { CallPreference.Model(it) }) + state.copy(calls = callRecords.map { (call, messageRecord) -> CallPreference.Model(call, messageRecord) }) } store.update(sharedMedia) { cursor, state -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/CallPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/CallPreference.kt index 06324e8e9b..87bd91aabb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/CallPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/CallPreference.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences import androidx.annotation.DrawableRes import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.MessageTypes import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.databinding.ConversationSettingsCallPreferenceItemBinding @@ -21,12 +22,14 @@ object CallPreference { } class Model( + val call: CallTable.Call, val record: MessageRecord ) : MappingModel { override fun areItemsTheSame(newItem: Model): Boolean = record.id == newItem.record.id override fun areContentsTheSame(newItem: Model): Boolean { - return record.type == newItem.record.type && + return call == newItem.call && + record.type == newItem.record.type && record.isOutgoing == newItem.record.isOutgoing && record.timestamp == newItem.record.timestamp && record.id == newItem.record.id @@ -35,30 +38,42 @@ object CallPreference { private class ViewHolder(binding: ConversationSettingsCallPreferenceItemBinding) : BindingViewHolder(binding) { override fun bind(model: Model) { - binding.callIcon.setImageResource(getCallIcon(model.record)) - binding.callType.text = getCallType(model.record) + binding.callIcon.setImageResource(getCallIcon(model.call)) + binding.callType.text = getCallType(model.call) binding.callTime.text = getCallTime(model.record) } @DrawableRes - private fun getCallIcon(messageRecord: MessageRecord): Int { - return when (messageRecord.type) { + private fun getCallIcon(call: CallTable.Call): Int { + return when (call.messageType) { MessageTypes.MISSED_VIDEO_CALL_TYPE, MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.drawable.symbol_missed_incoming_24 MessageTypes.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_downleft_24 MessageTypes.OUTGOING_AUDIO_CALL_TYPE, MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_upright_24 - else -> error("Unexpected type ${messageRecord.type}") + MessageTypes.GROUP_CALL_TYPE -> when { + call.event == CallTable.Event.MISSED -> R.drawable.symbol_missed_incoming_24 + call.direction == CallTable.Direction.INCOMING -> R.drawable.symbol_arrow_downleft_24 + call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_24 + else -> throw AssertionError() + } + else -> error("Unexpected type ${call.type}") } } - private fun getCallType(messageRecord: MessageRecord): String { - val id = when (messageRecord.type) { + private fun getCallType(call: CallTable.Call): String { + val id = when (call.messageType) { MessageTypes.MISSED_VIDEO_CALL_TYPE -> R.string.MessageRecord_missed_voice_call MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.string.MessageRecord_missed_video_call MessageTypes.INCOMING_AUDIO_CALL_TYPE -> R.string.MessageRecord_incoming_voice_call MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.string.MessageRecord_incoming_video_call MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> R.string.MessageRecord_outgoing_voice_call MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.string.MessageRecord_outgoing_video_call - else -> error("Unexpected type ${messageRecord.type}") + MessageTypes.GROUP_CALL_TYPE -> when { + call.event == CallTable.Event.MISSED -> R.string.CallPreference__missed_group_call + call.direction == CallTable.Direction.INCOMING -> R.string.CallPreference__incoming_group_call + call.direction == CallTable.Direction.OUTGOING -> R.string.CallPreference__outgoing_group_call + else -> throw AssertionError() + } + else -> error("Unexpected type ${call.messageType}") } return context.getString(id) 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 06cafb0dca..d1014739b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt @@ -2,24 +2,34 @@ package org.thoughtcrime.securesms.database import android.content.Context import android.database.Cursor +import androidx.annotation.Discouraged import androidx.core.content.contentValuesOf import org.signal.core.util.IntSerializer import org.signal.core.util.Serializer import org.signal.core.util.SqlUtil +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.readToSingleLong import org.signal.core.util.readToSingleObject import org.signal.core.util.requireLong import org.signal.core.util.requireObject import org.signal.core.util.select import org.signal.core.util.update import org.signal.core.util.withinTransaction +import org.signal.ringrtc.CallId +import org.signal.ringrtc.CallManager.RingUpdate import org.thoughtcrime.securesms.calls.log.CallLogFilter import org.thoughtcrime.securesms.calls.log.CallLogRow +import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobs.CallSyncEventJob import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.push.ServiceId import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent +import java.util.UUID /** * Contains details for each 1:1 call. @@ -37,16 +47,23 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl private const val TYPE = "type" private const val DIRECTION = "direction" private const val EVENT = "event" + private const val TIMESTAMP = "timestamp" + private const val RINGER = "ringer" + private const val DELETION_TIMESTAMP = "deletion_timestamp" + //language=sql val CREATE_TABLE = """ CREATE TABLE $TABLE_NAME ( $ID INTEGER PRIMARY KEY, $CALL_ID INTEGER NOT NULL UNIQUE, - $MESSAGE_ID INTEGER NOT NULL REFERENCES ${MessageTable.TABLE_NAME} (${MessageTable.ID}) ON DELETE CASCADE, - $PEER INTEGER NOT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE, + $MESSAGE_ID INTEGER DEFAULT NULL REFERENCES ${MessageTable.TABLE_NAME} (${MessageTable.ID}) ON DELETE SET NULL, + $PEER INTEGER DEFAULT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE, $TYPE INTEGER NOT NULL, $DIRECTION INTEGER NOT NULL, - $EVENT INTEGER NOT NULL + $EVENT INTEGER NOT NULL, + $TIMESTAMP INTEGER NOT NULL, + $RINGER INTEGER DEFAULT NULL, + $DELETION_TIMESTAMP INTEGER DEFAULT 0 ) """.trimIndent() @@ -56,7 +73,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl ) } - fun insertCall(callId: Long, timestamp: Long, peer: RecipientId, type: Type, direction: Direction, event: Event) { + fun insertOneToOneCall(callId: Long, timestamp: Long, peer: RecipientId, type: Type, direction: Direction, event: Event) { val messageType: Long = Call.getMessageType(type, direction, event) writableDatabase.withinTransaction { @@ -68,7 +85,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl PEER to peer.serialize(), TYPE to Type.serialize(type), DIRECTION to Direction.serialize(direction), - EVENT to Event.serialize(event) + EVENT to Event.serialize(event), + TIMESTAMP to timestamp ) writableDatabase.insert(TABLE_NAME, null, values) @@ -79,7 +97,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl Log.i(TAG, "Inserted call: $callId type: $type direction: $direction event:$event") } - fun updateCall(callId: Long, event: Event): Call? { + fun updateOneToOneCall(callId: Long, event: Event): Call? { return writableDatabase.withinTransaction { writableDatabase .update(TABLE_NAME) @@ -97,7 +115,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl if (call != null) { Log.i(TAG, "Updated call: $callId event: $event") - SignalDatabase.messages.updateCallLog(call.messageId, call.messageType) + SignalDatabase.messages.updateCallLog(call.messageId!!, call.messageType) ApplicationDependencies.getMessageNotifier().updateNotification(context) } @@ -131,7 +149,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl val cursor = readableDatabase .select() .from(TABLE_NAME) - .where(query.where, query.whereArgs) + .where("$EVENT != ${Event.serialize(Event.DELETE)} AND ${query.where}", query.whereArgs) .run() calls.putAll(cursor.readToList { c -> c.requireLong(MESSAGE_ID) to Call.deserialize(c) }) @@ -139,9 +157,536 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl return calls } + fun getOldestDeletionTimestamp(): Long { + return writableDatabase + .select(DELETION_TIMESTAMP) + .from(TABLE_NAME) + .where("$DELETION_TIMESTAMP > 0") + .orderBy("$DELETION_TIMESTAMP DESC") + .limit(1) + .run() + .readToSingleLong(0L) + } + + fun deleteCallEventsDeletedBefore(threshold: Long) { + writableDatabase + .delete(TABLE_NAME) + .where("$DELETION_TIMESTAMP <= ?", threshold) + .run() + } + + /** + * 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() { + val where = "$TYPE != ? AND $DELETION_TIMESTAMP = 0 AND $MESSAGE_ID IS NULL" + val type = Type.serialize(Type.AD_HOC_CALL) + + val toSync = writableDatabase.withinTransaction { db -> + val result = db + .select() + .from(TABLE_NAME) + .where(where, type) + .run() + .readToList { + Call.deserialize(it) + } + .toSet() + + db + .update(TABLE_NAME) + .values(DELETION_TIMESTAMP to System.currentTimeMillis()) + .where(where, type) + .run() + + result + } + + CallSyncEventJob.enqueueDeleteSyncEvents(toSync) + ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary() + } + + // region Group / Ad-Hoc Calling + + fun deleteGroupCall(call: Call) { + checkIsGroupOrAdHocCall(call) + + writableDatabase.withinTransaction { db -> + db + .update(TABLE_NAME) + .values( + EVENT to Event.serialize(Event.DELETE), + DELETION_TIMESTAMP to System.currentTimeMillis() + ) + .where("$CALL_ID = ?", call.callId) + .run() + + if (call.messageId != null) { + SignalDatabase.messages.deleteMessage(call.messageId) + } + } + + ApplicationDependencies.getMessageNotifier().updateNotification(context) + Log.d(TAG, "Marked group call event for deletion: ${call.callId}") + } + + fun insertDeletedGroupCallFromSyncEvent( + callId: Long, + recipientId: RecipientId?, + direction: Direction, + timestamp: Long + ) { + val type = if (recipientId != null) Type.GROUP_CALL else Type.AD_HOC_CALL + + writableDatabase + .insertInto(TABLE_NAME) + .values( + CALL_ID to callId, + MESSAGE_ID to null, + PEER to recipientId?.toLong(), + EVENT to Event.serialize(Event.DELETE), + TYPE to Type.serialize(type), + DIRECTION to Direction.serialize(direction), + TIMESTAMP to timestamp, + DELETION_TIMESTAMP to System.currentTimeMillis() + ) + .run() + + ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary() + } + + fun acceptIncomingGroupCall(call: Call) { + checkIsGroupOrAdHocCall(call) + check(call.direction == Direction.INCOMING) + + val newEvent = when (call.event) { + Event.RINGING, Event.MISSED, Event.DECLINED -> Event.ACCEPTED + Event.GENERIC_GROUP_CALL -> Event.JOINED + else -> { + Log.d(TAG, "Call in state ${call.event} cannot be transitioned by ACCEPTED") + return + } + } + + writableDatabase + .update(TABLE_NAME) + .values(EVENT to Event.serialize(newEvent)) + .run() + + ApplicationDependencies.getMessageNotifier().updateNotification(context) + Log.d(TAG, "Transitioned group call ${call.callId} from ${call.event} to $newEvent") + } + + fun insertAcceptedGroupCall( + callId: Long, + recipientId: RecipientId?, + direction: Direction, + timestamp: Long + ) { + val type = if (recipientId != null) Type.GROUP_CALL else Type.AD_HOC_CALL + val event = if (direction == Direction.OUTGOING) Event.OUTGOING_RING else Event.JOINED + val ringer = if (direction == Direction.OUTGOING) Recipient.self().id.toLong() else null + + writableDatabase.withinTransaction { db -> + val messageId: MessageId? = if (recipientId != null) { + SignalDatabase.messages.insertGroupCall( + groupRecipientId = recipientId, + sender = Recipient.self().id, + timestamp, + "", + emptyList(), + false + ) + } else { + null + } + + db + .insertInto(TABLE_NAME) + .values( + CALL_ID to callId, + MESSAGE_ID to messageId?.id, + PEER to recipientId?.toLong(), + EVENT to Event.serialize(event), + TYPE to Type.serialize(type), + DIRECTION to Direction.serialize(direction), + TIMESTAMP to timestamp, + RINGER to ringer + ) + .run() + } + } + + fun insertOrUpdateGroupCallFromExternalEvent( + groupRecipientId: RecipientId, + sender: RecipientId, + timestamp: Long, + messageGroupCallEraId: String? + ) { + insertOrUpdateGroupCallFromLocalEvent( + groupRecipientId, + sender, + timestamp, + messageGroupCallEraId, + emptyList(), + false + ) + } + + fun insertOrUpdateGroupCallFromLocalEvent( + groupRecipientId: RecipientId, + sender: RecipientId, + timestamp: Long, + peekGroupCallEraId: String?, + peekJoinedUuids: Collection, + isCallFull: Boolean + ) { + writableDatabase.withinTransaction { + if (peekGroupCallEraId.isNullOrEmpty()) { + Log.w(TAG, "Dropping local call event with null era id.") + return@withinTransaction + } + + val callId = CallId.fromEra(peekGroupCallEraId).longValue() + val call = getCallById(callId) + val messageId: MessageId = if (call != null) { + if (call.event == Event.DELETE) { + Log.d(TAG, "Dropping group call update for deleted call.") + return@withinTransaction + } + + if (call.type != Type.GROUP_CALL) { + Log.d(TAG, "Dropping unsupported update message for non-group-call call.") + return@withinTransaction + } + + if (call.messageId == null) { + Log.d(TAG, "Dropping group call update for call without an attached message.") + return@withinTransaction + } + + SignalDatabase.messages.updateGroupCall( + call.messageId, + peekGroupCallEraId, + peekJoinedUuids, + isCallFull + ) + } else { + SignalDatabase.messages.insertGroupCall( + groupRecipientId, + sender, + timestamp, + peekGroupCallEraId, + peekJoinedUuids, + isCallFull + ) + } + + insertCallEventFromGroupUpdate( + callId, + messageId, + sender, + groupRecipientId, + timestamp + ) + } + } + + private fun insertCallEventFromGroupUpdate( + callId: Long, + messageId: MessageId?, + sender: RecipientId, + groupRecipientId: RecipientId, + timestamp: Long + ) { + if (messageId != null) { + val call = getCallById(callId) + if (call == null) { + val direction = if (sender == Recipient.self().id) Direction.OUTGOING else Direction.INCOMING + + writableDatabase + .insertInto(TABLE_NAME) + .values( + CALL_ID to callId, + MESSAGE_ID to messageId.id, + PEER to groupRecipientId.toLong(), + EVENT to Event.serialize(Event.GENERIC_GROUP_CALL), + TYPE to Type.serialize(Type.GROUP_CALL), + DIRECTION to Direction.serialize(direction), + TIMESTAMP to timestamp, + RINGER to null + ) + .run() + + Log.d(TAG, "Inserted new call event from group call update message. Call Id: $callId") + } else { + if (timestamp < call.timestamp) { + setTimestamp(callId, timestamp) + Log.d(TAG, "Updated call event timestamp for call id $callId") + } + + if (call.messageId == null) { + setMessageId(callId, messageId) + Log.d(TAG, "Updated call event message id for newly inserted group call state: $callId") + } + } + } else { + Log.d(TAG, "Skipping call event processing for null era id.") + } + } + + /** + * Since this does not alter the call table, we can simply pass this directly through to the old handler. + */ + fun updateGroupCallFromPeek( + threadId: Long, + peekGroupCallEraId: String?, + peekJoinedUuids: Collection, + isCallFull: Boolean + ): Boolean { + return SignalDatabase.messages.updatePreviousGroupCall(threadId, peekGroupCallEraId, peekJoinedUuids, isCallFull) + } + + fun insertOrUpdateGroupCallFromRingState( + ringId: Long, + groupRecipientId: RecipientId, + ringerRecipient: RecipientId, + dateReceived: Long, + ringState: RingUpdate + ) { + handleGroupRingState(ringId, groupRecipientId, ringerRecipient, dateReceived, ringState) + } + + fun insertOrUpdateGroupCallFromRingState( + ringId: Long, + groupRecipientId: RecipientId, + ringerUUID: UUID, + dateReceived: Long, + ringState: RingUpdate + ) { + val ringerRecipient = Recipient.externalPush(ServiceId.from(ringerUUID)) + handleGroupRingState(ringId, groupRecipientId, ringerRecipient.id, dateReceived, ringState) + } + + fun isRingCancelled(ringId: Long): Boolean { + val call = getCallById(ringId) ?: return false + return call.event != Event.RINGING + } + + private fun handleGroupRingState( + ringId: Long, + groupRecipientId: RecipientId, + ringerRecipient: RecipientId, + dateReceived: Long, + ringState: RingUpdate + ) { + val call = getCallById(ringId) + if (call != null) { + if (call.event == Event.DELETE) { + Log.d(TAG, "Ignoring ring request for $ringId since its event has been deleted.") + return + } + + when (ringState) { + RingUpdate.REQUESTED -> { + when (call.event) { + Event.GENERIC_GROUP_CALL -> updateEventFromRingState(ringId, Event.RINGING, ringerRecipient) + Event.JOINED -> updateEventFromRingState(ringId, Event.ACCEPTED, ringerRecipient) + else -> Log.w(TAG, "Received a REQUESTED ring event while in ${call.event}. Ignoring.") + } + } + RingUpdate.EXPIRED_REQUEST, RingUpdate.CANCELLED_BY_RINGER -> { + when (call.event) { + Event.GENERIC_GROUP_CALL, Event.RINGING -> updateEventFromRingState(ringId, Event.MISSED, ringerRecipient) + Event.OUTGOING_RING -> Log.w(TAG, "Received an expiration or cancellation while in OUTGOING_RING state. Ignoring.") + else -> Unit + } + } + RingUpdate.BUSY_LOCALLY, RingUpdate.BUSY_ON_ANOTHER_DEVICE -> { + when (call.event) { + Event.JOINED -> updateEventFromRingState(ringId, Event.ACCEPTED) + Event.GENERIC_GROUP_CALL, Event.RINGING -> updateEventFromRingState(ringId, Event.MISSED) + else -> Log.w(TAG, "Received a busy event we can't process. Ignoring.") + } + } + RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE -> { + updateEventFromRingState(ringId, Event.ACCEPTED) + } + RingUpdate.DECLINED_ON_ANOTHER_DEVICE -> { + when (call.event) { + Event.RINGING, Event.MISSED -> updateEventFromRingState(ringId, Event.DECLINED) + Event.OUTGOING_RING -> Log.w(TAG, "Received DECLINED_ON_ANOTHER_DEVICE while in OUTGOING_RING state.") + else -> Unit + } + } + } + } else { + val event: Event = when (ringState) { + RingUpdate.REQUESTED -> Event.RINGING + RingUpdate.EXPIRED_REQUEST -> Event.MISSED + RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE -> { + Log.w(TAG, "Missed original ring request for $ringId") + Event.ACCEPTED + } + RingUpdate.DECLINED_ON_ANOTHER_DEVICE -> { + Log.w(TAG, "Missed original ring request for $ringId") + Event.DECLINED + } + RingUpdate.BUSY_LOCALLY, RingUpdate.BUSY_ON_ANOTHER_DEVICE -> { + Log.w(TAG, "Missed original ring request for $ringId") + Event.MISSED + } + RingUpdate.CANCELLED_BY_RINGER -> { + Log.w(TAG, "Missed original ring request for $ringId") + Event.MISSED + } + } + + createEventFromRingState(ringId, groupRecipientId, ringerRecipient, event, dateReceived) + } + } + + private fun updateEventFromRingState( + callId: Long, + event: Event, + ringerRecipient: RecipientId + ) { + writableDatabase + .update(TABLE_NAME) + .values( + EVENT to Event.serialize(event), + RINGER to ringerRecipient.serialize() + ) + .where("$CALL_ID = ?", callId) + .run() + + Log.d(TAG, "Updated ring state to $event") + } + + private fun updateEventFromRingState( + callId: Long, + event: Event + ) { + writableDatabase + .update(TABLE_NAME) + .values( + EVENT to Event.serialize(event) + ) + .where("$CALL_ID = ?", callId) + .run() + + Log.d(TAG, "Updated ring state to $event") + } + + private fun createEventFromRingState( + callId: Long, + groupRecipientId: RecipientId, + ringerRecipient: RecipientId, + event: Event, + timestamp: Long + ) { + val direction = if (ringerRecipient == Recipient.self().id) Direction.OUTGOING else Direction.INCOMING + + writableDatabase.withinTransaction { db -> + val messageId = SignalDatabase.messages.insertGroupCall( + groupRecipientId = groupRecipientId, + sender = ringerRecipient, + timestamp = timestamp, + eraId = "", + joinedUuids = emptyList(), + isCallFull = false + ) + + db + .insertInto(TABLE_NAME) + .values( + CALL_ID to callId, + MESSAGE_ID to messageId.id, + PEER to groupRecipientId.toLong(), + EVENT to Event.serialize(event), + TYPE to Type.serialize(Type.GROUP_CALL), + DIRECTION to Direction.serialize(direction), + TIMESTAMP to timestamp, + RINGER to ringerRecipient.toLong() + ) + .run() + } + + Log.d(TAG, "Inserted a new call event for $callId with event $event") + } + + fun setTimestamp(callId: Long, timestamp: Long) { + writableDatabase.withinTransaction { db -> + val call = getCallById(callId) + if (call == null || call.event == Event.DELETE) { + Log.d(TAG, "Refusing to update deleted call event.") + return@withinTransaction + } + + db + .update(TABLE_NAME) + .values(TIMESTAMP to timestamp) + .where("$CALL_ID = ?", callId) + .run() + + if (call.messageId != null) { + SignalDatabase.messages.updateCallTimestamps(call.messageId, timestamp) + } + } + } + + private fun setMessageId(callId: Long, messageId: MessageId) { + writableDatabase + .update(TABLE_NAME) + .values(MESSAGE_ID to messageId.id) + .where("$CALL_ID = ?", callId) + .run() + } + + fun deleteCallEvents(callIds: Set) { + val messageIds = getMessageIds(callIds) + SignalDatabase.messages.deleteCallUpdates(messageIds) + updateCallEventDeletionTimestamps() + } + + fun deleteAllCallEventsExcept(callIds: Set) { + val messageIds = getMessageIds(callIds) + SignalDatabase.messages.deleteAllCallUpdatesExcept(messageIds) + updateCallEventDeletionTimestamps() + } + + @Discouraged("Using this method is generally considered an error. Utilize other deletion methods instead of this.") + fun deleteAllCalls() { + Log.w(TAG, "Deleting all calls from the local database.") + writableDatabase + .delete(TABLE_NAME) + .run() + } + + private fun getMessageIds(callIds: Set): Set { + val queries = SqlUtil.buildCollectionQuery( + CALL_ID, + callIds, + "$MESSAGE_ID NOT NULL AND" + ) + + return queries.map { query -> + readableDatabase.select(MESSAGE_ID).from(TABLE_NAME).where(query.where, query.whereArgs).run().readToList { + it.requireLong(MESSAGE_ID) + } + }.flatten().toSet() + } + + private fun checkIsGroupOrAdHocCall(call: Call) { + check(call.type == Type.GROUP_CALL || call.type == Type.AD_HOC_CALL) + } + + // endregion + private fun getCallsCursor(isCount: Boolean, offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): Cursor { val filterClause = when (filter) { - CallLogFilter.ALL -> SqlUtil.buildQuery("") + CallLogFilter.ALL -> SqlUtil.buildQuery("$EVENT != ${Event.serialize(Event.DELETE)}") CallLogFilter.MISSED -> SqlUtil.buildQuery("$EVENT == ${Event.serialize(Event.MISSED)}") } @@ -233,12 +778,22 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl val type: Type, val direction: Direction, val event: Event, - val messageId: Long + val messageId: Long?, + val timestamp: Long, + val ringerRecipient: RecipientId? ) { val messageType: Long = getMessageType(type, direction, event) companion object Deserializer : Serializer { fun getMessageType(type: Type, direction: Direction, event: Event): Long { + if (type == Type.GROUP_CALL) { + return MessageTypes.GROUP_CALL_TYPE + } + + if (type == Type.AD_HOC_CALL) { + error("Ad-Hoc calls are not linked to messages.") + } + return if (direction == Direction.INCOMING && event == Event.MISSED) { if (type == Type.VIDEO_CALL) MessageTypes.MISSED_VIDEO_CALL_TYPE else MessageTypes.MISSED_AUDIO_CALL_TYPE } else if (direction == Direction.INCOMING) { @@ -259,7 +814,15 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl type = data.requireObject(TYPE, Type.Serializer), direction = data.requireObject(DIRECTION, Direction.Serializer), event = data.requireObject(EVENT, Event.Serializer), - messageId = data.requireLong(MESSAGE_ID) + messageId = data.requireLong(MESSAGE_ID).takeIf { it > 0L }, + timestamp = data.requireLong(TIMESTAMP), + ringerRecipient = data.requireLong(RINGER).let { + if (it > 0) { + RecipientId.from(it) + } else { + null + } + } ) } } @@ -267,7 +830,9 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl enum class Type(private val code: Int) { AUDIO_CALL(0), - VIDEO_CALL(1); + VIDEO_CALL(1), + GROUP_CALL(3), + AD_HOC_CALL(4); companion object Serializer : IntSerializer { override fun serialize(data: Type): Int = data.code @@ -276,6 +841,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl return when (data) { AUDIO_CALL.code -> AUDIO_CALL VIDEO_CALL.code -> VIDEO_CALL + GROUP_CALL.code -> GROUP_CALL + AD_HOC_CALL.code -> AD_HOC_CALL else -> throw IllegalArgumentException("Unknown type $data") } } @@ -286,6 +853,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl CallEvent.Type.UNKNOWN_TYPE -> null CallEvent.Type.AUDIO_CALL -> AUDIO_CALL CallEvent.Type.VIDEO_CALL -> VIDEO_CALL + CallEvent.Type.GROUP_CALL -> GROUP_CALL + CallEvent.Type.AD_HOC_CALL -> AD_HOC_CALL } } } @@ -318,22 +887,69 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl } enum class Event(private val code: Int) { + /** + * 1:1 Calls only. + */ ONGOING(0), + + /** + * 1:1 and Group Calls. + * + * Group calls: You accepted a ring. + */ ACCEPTED(1), + + /** + * 1:1 Calls only. + */ NOT_ACCEPTED(2), - MISSED(3); + + /** + * 1:1 and Group/Ad-Hoc Calls. + * + * Group calls: The remote ring has expired or was cancelled by the ringer. + */ + MISSED(3), + + /** + * 1:1 and Group/Ad-Hoc Calls. + */ + DELETE(4), + + /** + * Group/Ad-Hoc Calls only. + * + * Initial state. + */ + GENERIC_GROUP_CALL(5), + + /** + * Group Calls: User has joined the group call. + */ + JOINED(6), + + /** + * Group Calls: If a ring was requested by another user. + */ + RINGING(7), + + /** + * Group Calls: If you declined a ring. + */ + DECLINED(8), + + /** + * Group Calls: If you are ringing a group. + */ + OUTGOING_RING(9); companion object Serializer : IntSerializer { override fun serialize(data: Event): Int = data.code override fun deserialize(data: Int): Event { - return when (data) { - ONGOING.code -> ONGOING - ACCEPTED.code -> ACCEPTED - NOT_ACCEPTED.code -> NOT_ACCEPTED - MISSED.code -> MISSED - else -> throw IllegalArgumentException("Unknown type $data") - } + return values().firstOrNull { + it.code == data + } ?: throw IllegalArgumentException("Unknown event $data") } @JvmStatic @@ -342,6 +958,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl CallEvent.Event.UNKNOWN_ACTION -> null CallEvent.Event.ACCEPTED -> ACCEPTED CallEvent.Event.NOT_ACCEPTED -> NOT_ACCEPTED + CallEvent.Event.DELETE -> DELETE } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupCallRingTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupCallRingTable.kt deleted file mode 100644 index ada2c67b7e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupCallRingTable.kt +++ /dev/null @@ -1,98 +0,0 @@ -package org.thoughtcrime.securesms.database - -import android.content.ContentValues -import android.content.Context -import org.signal.core.util.CursorUtil -import org.signal.core.util.SqlUtil -import org.signal.ringrtc.CallManager -import java.util.concurrent.TimeUnit - -/** - * Track state of Group Call ring cancellations. - */ -class GroupCallRingTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) { - - companion object { - private val VALID_RING_DURATION = TimeUnit.MINUTES.toMillis(30) - - private const val TABLE_NAME = "group_call_ring" - - private const val ID = "_id" - private const val RING_ID = "ring_id" - private const val DATE_RECEIVED = "date_received" - private const val RING_STATE = "ring_state" - - @JvmField - val CREATE_TABLE = """ - CREATE TABLE $TABLE_NAME ( - $ID INTEGER PRIMARY KEY, - $RING_ID INTEGER UNIQUE, - $DATE_RECEIVED INTEGER, - $RING_STATE INTEGER - ) - """.trimIndent() - - @JvmField - val CREATE_INDEXES = arrayOf( - "CREATE INDEX date_received_index on $TABLE_NAME ($DATE_RECEIVED)" - ) - } - - fun isCancelled(ringId: Long): Boolean { - val db = databaseHelper.signalReadableDatabase - - db.query(TABLE_NAME, null, "$RING_ID = ?", SqlUtil.buildArgs(ringId), null, null, null).use { cursor -> - if (cursor.moveToFirst()) { - return CursorUtil.requireInt(cursor, RING_STATE) != 0 - } - } - - return false - } - - fun insertGroupRing(ringId: Long, dateReceived: Long, ringState: CallManager.RingUpdate) { - val db = databaseHelper.signalWritableDatabase - val values = ContentValues().apply { - put(RING_ID, ringId) - put(DATE_RECEIVED, dateReceived) - put(RING_STATE, ringState.toCode()) - } - db.insert(TABLE_NAME, null, values) - - removeOldRings() - } - - fun insertOrUpdateGroupRing(ringId: Long, dateReceived: Long, ringState: CallManager.RingUpdate) { - val db = databaseHelper.signalWritableDatabase - val values = ContentValues().apply { - put(RING_ID, ringId) - put(DATE_RECEIVED, dateReceived) - put(RING_STATE, ringState.toCode()) - } - db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_REPLACE) - - removeOldRings() - } - - fun removeOldRings() { - val db = databaseHelper.signalWritableDatabase - - db.delete(TABLE_NAME, "$DATE_RECEIVED < ?", SqlUtil.buildArgs(System.currentTimeMillis() - VALID_RING_DURATION)) - } - - fun deleteAll() { - databaseHelper.signalWritableDatabase.delete(TABLE_NAME, null, null) - } -} - -private fun CallManager.RingUpdate.toCode(): Int { - return when (this) { - CallManager.RingUpdate.REQUESTED -> 0 - CallManager.RingUpdate.EXPIRED_REQUEST -> 1 - CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE -> 2 - CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE -> 3 - CallManager.RingUpdate.BUSY_LOCALLY -> 4 - CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE -> 5 - CallManager.RingUpdate.CANCELLED_BY_RINGER -> 6 - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 704a9381b5..a47714192a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -38,7 +38,6 @@ import org.signal.core.util.SqlUtil.buildSingleCollectionQuery import org.signal.core.util.SqlUtil.buildTrueUpdateQuery import org.signal.core.util.SqlUtil.getNextAutoIncrementId import org.signal.core.util.delete -import org.signal.core.util.emptyIfNull import org.signal.core.util.exists import org.signal.core.util.forEach import org.signal.core.util.insertInto @@ -71,6 +70,7 @@ import org.thoughtcrime.securesms.conversation.MessageStyler import org.thoughtcrime.securesms.database.EarlyReceiptCache.Receipt import org.thoughtcrime.securesms.database.MentionUtil.UpdatedBodyAndMentions import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.calls import org.thoughtcrime.securesms.database.SignalDatabase.Companion.distributionLists import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groupReceipts import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groups @@ -400,6 +400,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat ($TYPE = ${MessageTypes.MISSED_AUDIO_CALL_TYPE}) OR ($TYPE = ${MessageTypes.MISSED_VIDEO_CALL_TYPE}) + OR + ($TYPE = ${MessageTypes.GROUP_CALL_TYPE}) )""".toSingleLine() @JvmStatic @@ -802,122 +804,111 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(MessageId(messageId)) } - fun insertOrUpdateGroupCall( + fun insertGroupCall( groupRecipientId: RecipientId, sender: RecipientId, timestamp: Long, - peekGroupCallEraId: String?, - peekJoinedUuids: Collection, + eraId: String, + joinedUuids: Collection, isCallFull: Boolean - ) { + ): MessageId { val recipient = Recipient.resolved(groupRecipientId) val threadId = threads.getOrCreateThreadIdFor(recipient) - val peerEraIdSameAsPrevious = updatePreviousGroupCall(threadId, peekGroupCallEraId, peekJoinedUuids, isCallFull) + val messageId: MessageId = writableDatabase.withinTransaction { db -> + val self = Recipient.self() + val markRead = joinedUuids.contains(self.requireServiceId().uuid()) || self.id == sender + val updateDetails: ByteArray = GroupCallUpdateDetails.newBuilder() + .setEraId(eraId) + .setStartedCallUuid(Recipient.resolved(sender).requireServiceId().toString()) + .setStartedCallTimestamp(timestamp) + .addAllInCallUuids(joinedUuids.map { it.toString() }) + .setIsCallFull(isCallFull) + .build() + .toByteArray() - writableDatabase.withinTransaction { db -> - if (!peerEraIdSameAsPrevious && !Util.isEmpty(peekGroupCallEraId)) { - val self = Recipient.self() - val markRead = peekJoinedUuids.contains(self.requireServiceId().uuid()) || self.id == sender - val updateDetails = GroupCallUpdateDetails.newBuilder() - .setEraId(peekGroupCallEraId.emptyIfNull()) - .setStartedCallUuid(Recipient.resolved(sender).requireServiceId().toString()) - .setStartedCallTimestamp(timestamp) - .addAllInCallUuids(peekJoinedUuids.map { it.toString() }.toList()) - .setIsCallFull(isCallFull) - .build() - .toByteArray() - - val values = contentValuesOf( - RECIPIENT_ID to sender.serialize(), - RECIPIENT_DEVICE_ID to 1, - DATE_RECEIVED to timestamp, - DATE_SENT to timestamp, - READ to if (markRead) 1 else 0, - BODY to Base64.encodeBytes(updateDetails), - TYPE to MessageTypes.GROUP_CALL_TYPE, - THREAD_ID to threadId - ) - - db.insert(TABLE_NAME, null, values) - - threads.incrementUnread(threadId, 1, 0) - } + val values = contentValuesOf( + RECIPIENT_ID to sender.serialize(), + RECIPIENT_DEVICE_ID to 1, + DATE_RECEIVED to timestamp, + DATE_SENT to timestamp, + READ to if (markRead) 1 else 0, + BODY to Base64.encodeBytes(updateDetails), + TYPE to MessageTypes.GROUP_CALL_TYPE, + THREAD_ID to threadId + ) + val messageId = MessageId(db.insert(TABLE_NAME, null, values)) + threads.incrementUnread(threadId, 1, 0) threads.update(threadId, true) + + messageId } notifyConversationListeners(threadId) TrimThreadJob.enqueueAsync(threadId) + + return messageId } - fun insertOrUpdateGroupCall( - groupRecipientId: RecipientId, - sender: RecipientId, - timestamp: Long, - messageGroupCallEraId: String? - ) { - val threadId = writableDatabase.withinTransaction { db -> - val recipient = Recipient.resolved(groupRecipientId) - val threadId = threads.getOrCreateThreadIdFor(recipient) - - val cursor = db - .select(*MMS_PROJECTION) - .from(TABLE_NAME) - .where("$TYPE = ? AND $THREAD_ID = ?", MessageTypes.GROUP_CALL_TYPE, threadId) - .orderBy("$DATE_RECEIVED DESC") - .limit(1) - .run() - - var sameEraId = false - - MmsReader(cursor).use { reader -> - val record: MessageRecord? = reader.firstOrNull() - - if (record != null) { - val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.body) - sameEraId = groupCallUpdateDetails.eraId == messageGroupCallEraId && !Util.isEmpty(messageGroupCallEraId) - - if (!sameEraId) { - db.update(TABLE_NAME) - .values(BODY to GroupCallUpdateDetailsUtil.createUpdatedBody(groupCallUpdateDetails, emptyList(), false)) - .where("$ID = ?", record.id) - .run() - } - } - } - - if (!sameEraId && !Util.isEmpty(messageGroupCallEraId)) { - val updateDetails = GroupCallUpdateDetails.newBuilder() - .setEraId(Util.emptyIfNull(messageGroupCallEraId)) - .setStartedCallUuid(Recipient.resolved(sender).requireServiceId().toString()) - .setStartedCallTimestamp(timestamp) - .addAllInCallUuids(emptyList()) - .setIsCallFull(false) - .build() - .toByteArray() - - val values = contentValuesOf( - RECIPIENT_ID to sender.serialize(), - RECIPIENT_DEVICE_ID to 1, - DATE_RECEIVED to timestamp, - DATE_SENT to timestamp, - READ to 0, - BODY to Base64.encodeBytes(updateDetails), - TYPE to MessageTypes.GROUP_CALL_TYPE, - THREAD_ID to threadId - ) - - db.insert(TABLE_NAME, null, values) - threads.incrementUnread(threadId, 1, 0) - } - - threads.update(threadId, true) - - threadId + /** + * Updates the timestamps associated with the given message id to the given ts + */ + fun updateCallTimestamps(messageId: Long, timestamp: Long) { + val message = try { + getMessageRecord(messageId = messageId) + } catch (e: NoSuchMessageException) { + error("Message $messageId does not exist") } - notifyConversationListeners(threadId) - TrimThreadJob.enqueueAsync(threadId) + val updateDetail = GroupCallUpdateDetailsUtil.parse(message.body) + val contentValues = contentValuesOf( + BODY to Base64.encodeBytes(updateDetail.toBuilder().setStartedCallTimestamp(timestamp).build().toByteArray()), + DATE_SENT to timestamp, + DATE_RECEIVED to timestamp + ) + + val query = buildTrueUpdateQuery(ID_WHERE, buildArgs(messageId), contentValues) + val updated = writableDatabase.update(TABLE_NAME, contentValues, query.where, query.whereArgs) > 0 + + if (updated) { + notifyConversationListeners(message.threadId) + } + } + + fun updateGroupCall( + messageId: Long, + eraId: String, + joinedUuids: Collection, + isCallFull: Boolean + ): MessageId { + writableDatabase.withinTransaction { db -> + val message = try { + getMessageRecord(messageId = messageId) + } catch (e: NoSuchMessageException) { + error("Message $messageId does not exist.") + } + + val updateDetail = GroupCallUpdateDetailsUtil.parse(message.body) + val containsSelf = joinedUuids.contains(SignalStore.account().requireAci().uuid()) + val sameEraId = updateDetail.eraId == eraId && !Util.isEmpty(eraId) + val inCallUuids = if (sameEraId) joinedUuids.map { it.toString() } else emptyList() + val contentValues = contentValuesOf( + BODY to GroupCallUpdateDetailsUtil.createUpdatedBody(updateDetail, inCallUuids, isCallFull) + ) + + if (sameEraId && containsSelf) { + contentValues.put(READ, 1) + } + + val query = buildTrueUpdateQuery(ID_WHERE, buildArgs(messageId), contentValues) + val updated = db.update(TABLE_NAME, contentValues, query.where, query.whereArgs) > 0 + + if (updated) { + notifyConversationListeners(message.threadId) + } + } + + return MessageId(messageId) } fun updatePreviousGroupCall(threadId: Long, peekGroupCallEraId: String?, peekJoinedUuids: Collection, isCallFull: Boolean): Boolean { @@ -3099,6 +3090,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat .where("$ID = ?", messageId) .run() + calls.updateCallEventDeletionTimestamps() threads.setLastScrolled(threadId, 0) val threadDeleted = threads.update(threadId, false) @@ -3356,6 +3348,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat if (deletes > 0) { Log.i(TAG, "Deleted $deletes abandoned messages") + calls.updateCallEventDeletionTimestamps() } return deletes @@ -3385,6 +3378,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat groupReceipts.deleteAllRows() mentions.deleteAllMentions() writableDatabase.delete(TABLE_NAME).run() + calls.updateCallEventDeletionTimestamps() OptimizeMessageSearchIndexJob.enqueue() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt index 59b2d31dc4..552f027974 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt @@ -65,7 +65,6 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data val emojiSearchTable: EmojiSearchTable = EmojiSearchTable(context, this) val messageSendLogTables: MessageSendLogTables = MessageSendLogTables(context, this) val avatarPickerDatabase: AvatarPickerDatabase = AvatarPickerDatabase(context, this) - val groupCallRingTable: GroupCallRingTable = GroupCallRingTable(context, this) val reactionTable: ReactionTable = ReactionTable(context, this) val notificationProfileDatabase: NotificationProfileDatabase = NotificationProfileDatabase(context, this) val donationReceiptTable: DonationReceiptTable = DonationReceiptTable(context, this) @@ -103,7 +102,6 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data db.execSQL(ChatColorsTable.CREATE_TABLE) db.execSQL(EmojiSearchTable.CREATE_TABLE) db.execSQL(AvatarPickerDatabase.CREATE_TABLE) - db.execSQL(GroupCallRingTable.CREATE_TABLE) db.execSQL(ReactionTable.CREATE_TABLE) db.execSQL(DonationReceiptTable.CREATE_TABLE) db.execSQL(StorySendTable.CREATE_TABLE) @@ -129,7 +127,6 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data executeStatements(db, MentionTable.CREATE_INDEXES) executeStatements(db, PaymentTable.CREATE_INDEXES) executeStatements(db, MessageSendLogTables.CREATE_INDEXES) - executeStatements(db, GroupCallRingTable.CREATE_INDEXES) executeStatements(db, NotificationProfileDatabase.CREATE_INDEXES) executeStatements(db, DonationReceiptTable.CREATE_INDEXS) executeStatements(db, StorySendTable.CREATE_INDEXS) @@ -389,11 +386,6 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data val emojiSearch: EmojiSearchTable get() = instance!!.emojiSearchTable - @get:JvmStatic - @get:JvmName("groupCallRings") - val groupCallRings: GroupCallRingTable - get() = instance!!.groupCallRingTable - @get:JvmStatic @get:JvmName("groupReceipts") val groupReceipts: GroupReceiptTable diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index fe6b67758a..dbede0ea21 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -31,8 +31,8 @@ import org.signal.libsignal.zkgroup.groups.GroupMasterKey import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.calls import org.thoughtcrime.securesms.database.SignalDatabase.Companion.drafts -import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groupCallRings import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groupReceipts import org.thoughtcrime.securesms.database.SignalDatabase.Companion.mentions import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messageLog @@ -380,6 +380,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa setLastScrolled(threadId, 0) update(threadId, false) notifyConversationListeners(threadId) + SignalDatabase.calls.updateCallEventDeletionTimestamps() } else { Log.i(TAG, "Trimming deleted no messages thread: $threadId") } @@ -1081,13 +1082,14 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa ConversationUtil.clearShortcuts(context, recipientIds) } + @SuppressLint("DiscouragedApi") fun deleteAllConversations() { writableDatabase.withinTransaction { db -> messageLog.deleteAll() messages.deleteAllThreads() drafts.clearAllDrafts() - groupCallRings.deleteAll() db.delete(TABLE_NAME, null, null) + calls.deleteAllCalls() } notifyConversationListListeners() 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 369eb71e3d..484bb042ed 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 @@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V178_ReportingToken import org.thoughtcrime.securesms.database.helpers.migration.V179_CleanupDanglingMessageSendLogMigration import org.thoughtcrime.securesms.database.helpers.migration.V180_RecipientNicknameMigration import org.thoughtcrime.securesms.database.helpers.migration.V181_ThreadTableForeignKeyCleanup +import org.thoughtcrime.securesms.database.helpers.migration.V182_CallTableMigration /** * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. @@ -45,7 +46,7 @@ object SignalDatabaseMigrations { val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass) - const val DATABASE_VERSION = 181 + const val DATABASE_VERSION = 182 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -180,6 +181,10 @@ object SignalDatabaseMigrations { if (oldVersion < 181) { V181_ThreadTableForeignKeyCleanup.migrate(context, db, oldVersion, newVersion) } + + if (oldVersion < 182) { + V182_CallTableMigration.migrate(context, db, oldVersion, newVersion) + } } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V182_CallTableMigration.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V182_CallTableMigration.kt new file mode 100644 index 0000000000..c8dba43e9c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V182_CallTableMigration.kt @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import net.zetetic.database.sqlcipher.SQLiteDatabase +import org.thoughtcrime.securesms.database.MessageTable +import org.thoughtcrime.securesms.database.RecipientTable + +/** + * Adds a new 'timestamp' column to CallTable and copies in the date_sent column data from + * the messages database. + * + * Adds a new 'ringer' column to the CallTable setting each entry to NULL. This is safe since up + * to this point we were not using the table for group calls. This is effectively a replacement for + * the GroupCallRing table. + * + * Removes the 'NOT NULL' condition on message_id and peer, as with ad-hoc calling in place, these + * can now be null. + */ +object V182_CallTableMigration : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL( + """ + CREATE TABLE call_tmp ( + _id INTEGER PRIMARY KEY, + call_id INTEGER NOT NULL UNIQUE, + message_id INTEGER DEFAULT NULL REFERENCES ${MessageTable.TABLE_NAME} (${MessageTable.ID}) ON DELETE SET NULL, + peer INTEGER DEFAULT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE, + type INTEGER NOT NULL, + direction INTEGER NOT NULL, + event INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + ringer INTEGER DEFAULT NULL, + deletion_timestamp INTEGER DEFAULT 0 + ) + """.trimIndent() + ) + + db.execSQL( + """ + INSERT INTO call_tmp + SELECT + _id, + call_id, + message_id, + peer, + type, + direction, + event, + (SELECT date_sent FROM message WHERE message._id = call.message_id) as timestamp, + NULL as ringer, + 0 as deletion_timestamp + FROM call + """.trimIndent() + ) + + db.execSQL("DROP TABLE group_call_ring") + db.execSQL("DROP TABLE call") + db.execSQL("ALTER TABLE call_tmp RENAME TO call") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index 48b29efd4b..f0967b6825 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; import org.thoughtcrime.securesms.push.SignalServiceTrustStore; import org.thoughtcrime.securesms.recipients.LiveRecipientCache; import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager; +import org.thoughtcrime.securesms.service.DeletedCallEventManager; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.ExpiringStoriesManager; import org.thoughtcrime.securesms.service.PendingRetryReceiptManager; @@ -112,6 +113,7 @@ public class ApplicationDependencies { private static volatile ViewOnceMessageManager viewOnceMessageManager; private static volatile ExpiringStoriesManager expiringStoriesManager; private static volatile ExpiringMessageManager expiringMessageManager; + private static volatile DeletedCallEventManager deletedCallEventManager; private static volatile Payments payments; private static volatile SignalCallManager signalCallManager; private static volatile ShakeToReport shakeToReport; @@ -430,6 +432,18 @@ public class ApplicationDependencies { return expiringMessageManager; } + public static @NonNull DeletedCallEventManager getDeletedCallEventManager() { + if (deletedCallEventManager == null) { + synchronized (LOCK) { + if (deletedCallEventManager == null) { + deletedCallEventManager = provider.provideDeletedCallEventManager(); + } + } + } + + return deletedCallEventManager; + } + public static @NonNull ScheduledMessageManager getScheduledMessageManager() { if (scheduledMessagesManager == null) { synchronized (LOCK) { @@ -691,6 +705,7 @@ public class ApplicationDependencies { @NonNull ViewOnceMessageManager provideViewOnceMessageManager(); @NonNull ExpiringStoriesManager provideExpiringStoriesManager(); @NonNull ExpiringMessageManager provideExpiringMessageManager(); + @NonNull DeletedCallEventManager provideDeletedCallEventManager(); @NonNull TypingStatusRepository provideTypingStatusRepository(); @NonNull TypingStatusSender provideTypingStatusSender(); @NonNull DatabaseObserver provideDatabaseObserver(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 2158b938c0..1ac9f334fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -56,6 +56,7 @@ import org.thoughtcrime.securesms.push.SecurityEventListener; import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; import org.thoughtcrime.securesms.recipients.LiveRecipientCache; import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager; +import org.thoughtcrime.securesms.service.DeletedCallEventManager; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.ExpiringStoriesManager; import org.thoughtcrime.securesms.service.PendingRetryReceiptManager; @@ -225,6 +226,11 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr return new ExpiringMessageManager(context); } + @Override + public @NonNull DeletedCallEventManager provideDeletedCallEventManager() { + return new DeletedCallEventManager(context); + } + @Override public @NonNull ScheduledMessageManager provideScheduledMessageManager() { return new ScheduledMessageManager(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CallSyncEventJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/CallSyncEventJob.kt new file mode 100644 index 0000000000..18b3f37cb4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CallSyncEventJob.kt @@ -0,0 +1,128 @@ +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.signal.ringrtc.CallId +import org.thoughtcrime.securesms.database.CallTable +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.JsonJobData +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.ringrtc.RemotePeer +import org.thoughtcrime.securesms.service.webrtc.CallEventSyncMessageUtil +import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage +import java.util.Optional +import java.util.concurrent.TimeUnit + +/** + * Sends a sync event for the given call when the user first joins. + */ +class CallSyncEventJob private constructor( + parameters: Parameters, + private val conversationRecipientId: RecipientId, + private val callId: Long, + private val direction: CallTable.Direction, + private val event: CallTable.Event +) : BaseJob(parameters) { + + companion object { + private val TAG = Log.tag(CallSyncEventJob::class.java) + + const val KEY = "CallSyncEventJob" + + private const val KEY_CALL_ID = "call_id" + private const val KEY_CONVERSATION_ID = "conversation_id" + private const val KEY_DIRECTION = "direction" + private const val KEY_EVENT = "event" + + @JvmStatic + fun createForJoin(conversationRecipientId: RecipientId, callId: Long, isIncoming: Boolean): CallSyncEventJob { + return CallSyncEventJob( + getParameters(conversationRecipientId), + conversationRecipientId, + callId, + if (isIncoming) CallTable.Direction.INCOMING else CallTable.Direction.OUTGOING, + CallTable.Event.ACCEPTED + ) + } + + @JvmStatic + fun createForDelete(conversationRecipientId: RecipientId, callId: Long, isIncoming: Boolean): CallSyncEventJob { + return CallSyncEventJob( + getParameters(conversationRecipientId), + conversationRecipientId, + callId, + if (isIncoming) CallTable.Direction.INCOMING else CallTable.Direction.OUTGOING, + CallTable.Event.DELETE + ) + } + + @JvmStatic + fun enqueueDeleteSyncEvents(deletedCalls: Set) { + for (call in deletedCalls) { + ApplicationDependencies.getJobManager().add( + createForDelete( + call.peer, + call.callId, + call.direction == CallTable.Direction.INCOMING + ) + ) + } + } + + private fun getParameters(conversationRecipientId: RecipientId): Parameters { + return Parameters.Builder() + .setQueue(conversationRecipientId.toQueueKey()) + .setLifespan(TimeUnit.MINUTES.toMillis(5)) + .setMaxAttempts(3) + .addConstraint(NetworkConstraint.KEY) + .build() + } + } + + override fun serialize(): ByteArray? { + return JsonJobData.Builder() + .putLong(KEY_CALL_ID, callId) + .putString(KEY_CONVERSATION_ID, conversationRecipientId.serialize()) + .putInt(KEY_EVENT, CallTable.Event.serialize(event)) + .putInt(KEY_DIRECTION, CallTable.Direction.serialize(direction)) + .serialize() + } + + override fun getFactoryKey(): String = KEY + + override fun onFailure() = Unit + + override fun onRun() { + val inputTimestamp = JsonJobData.deserialize(inputData).getLongOrDefault(GroupCallUpdateSendJob.KEY_SYNC_TIMESTAMP, System.currentTimeMillis()) + val syncTimestamp = if (inputTimestamp == 0L) System.currentTimeMillis() else inputTimestamp + val syncMessage = CallEventSyncMessageUtil.createAcceptedSyncMessage( + RemotePeer(conversationRecipientId, CallId(callId)), + syncTimestamp, + direction == CallTable.Direction.OUTGOING, + true + ) + + try { + ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(SignalServiceSyncMessage.forCallEvent(syncMessage), Optional.empty()) + } catch (e: Exception) { + Log.w(TAG, "Unable to send call event sync message for $callId", e) + } + } + + override fun onShouldRetry(e: Exception): Boolean = false + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): CallSyncEventJob { + val data = JsonJobData.deserialize(serializedData) + + return CallSyncEventJob( + parameters, + RecipientId.from(data.getString(KEY_CONVERSATION_ID)), + data.getLong(KEY_CALL_ID), + CallTable.Direction.deserialize(data.getInt(KEY_DIRECTION)), + CallTable.Event.deserialize(data.getInt(KEY_EVENT)) + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java index 43d5178e16..4847e82e14 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupCallUpdateSendJob.java @@ -42,12 +42,15 @@ public class GroupCallUpdateSendJob extends BaseJob { private static final String KEY_ERA_ID = "era_id"; private static final String KEY_RECIPIENTS = "recipients"; private static final String KEY_INITIAL_RECIPIENT_COUNT = "initial_recipient_count"; + static final String KEY_SYNC_TIMESTAMP = "sync_timestamp"; private final RecipientId recipientId; private final String eraId; private final List recipients; private final int initialRecipientCount; + private long syncTimestamp; + @WorkerThread public static @NonNull GroupCallUpdateSendJob create(@NonNull RecipientId recipientId, @Nullable String eraId) { Recipient conversationRecipient = Recipient.resolved(recipientId); @@ -65,6 +68,7 @@ public class GroupCallUpdateSendJob extends BaseJob { eraId, recipientIds, recipientIds.size(), + 0L, new Parameters.Builder() .setQueue(conversationRecipient.getId().toQueueKey()) .setLifespan(TimeUnit.MINUTES.toMillis(5)) @@ -76,6 +80,7 @@ public class GroupCallUpdateSendJob extends BaseJob { @Nullable String eraId, @NonNull List recipients, int initialRecipientCount, + long syncTimestamp, @NonNull Parameters parameters) { super(parameters); @@ -84,6 +89,7 @@ public class GroupCallUpdateSendJob extends BaseJob { this.eraId = eraId; this.recipients = recipients; this.initialRecipientCount = initialRecipientCount; + this.syncTimestamp = syncTimestamp; } @Override @@ -92,6 +98,7 @@ public class GroupCallUpdateSendJob extends BaseJob { .putString(KEY_ERA_ID, eraId) .putString(KEY_RECIPIENTS, RecipientId.toSerializedList(recipients)) .putInt(KEY_INITIAL_RECIPIENT_COUNT, initialRecipientCount) + .putLong(KEY_SYNC_TIMESTAMP, syncTimestamp) .serialize(); } @@ -125,6 +132,10 @@ public class GroupCallUpdateSendJob extends BaseJob { Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying."); throw new RetryLaterException(); } + + setOutputData(new JsonJobData.Builder() + .putLong(KEY_SYNC_TIMESTAMP, syncTimestamp) + .serialize()); } @Override @@ -166,6 +177,7 @@ public class GroupCallUpdateSendJob extends BaseJob { if (includesSelf) { results.add(ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(dataMessage)); + syncTimestamp = dataMessage.getTimestamp(); } return GroupSendJobHelper.getCompletedSends(destinations, results).completed; @@ -182,8 +194,9 @@ public class GroupCallUpdateSendJob extends BaseJob { String eraId = data.getString(KEY_ERA_ID); List recipients = RecipientId.fromSerializedList(data.getString(KEY_RECIPIENTS)); int initialRecipientCount = data.getInt(KEY_INITIAL_RECIPIENT_COUNT); + long syncTimestamp = data.getLongOrDefault(KEY_SYNC_TIMESTAMP, 0L); - return new GroupCallUpdateSendJob(recipientId, eraId, recipients, initialRecipientCount, parameters); + return new GroupCallUpdateSendJob(recipientId, eraId, recipients, initialRecipientCount, syncTimestamp, parameters); } } } 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 b71f5f66b5..cca2057e06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -92,6 +92,7 @@ public final class JobManagerFactories { put(AvatarGroupsV1DownloadJob.KEY, new AvatarGroupsV1DownloadJob.Factory()); put(AvatarGroupsV2DownloadJob.KEY, new AvatarGroupsV2DownloadJob.Factory()); put(BoostReceiptRequestResponseJob.KEY, new BoostReceiptRequestResponseJob.Factory()); + put(CallSyncEventJob.KEY, new CallSyncEventJob.Factory()); put(CheckServiceReachabilityJob.KEY, new CheckServiceReachabilityJob.Factory()); put(CleanPreKeysJob.KEY, new CleanPreKeysJob.Factory()); put(ClearFallbackKbsEnclaveJob.KEY, new ClearFallbackKbsEnclaveJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt index e268552a3f..95fc4834bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt @@ -934,7 +934,7 @@ object DataMessageProcessor { val groupRecipientId = SignalDatabase.recipients.getOrInsertFromPossiblyMigratedGroupId(groupId) - SignalDatabase.messages.insertOrUpdateGroupCall( + SignalDatabase.calls.insertOrUpdateGroupCallFromExternalEvent( groupRecipientId, senderRecipientId, envelope.serverTimestamp, diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index a2cecd8f80..d476bf62d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.messages; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Color; -import android.os.Build; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -316,7 +315,7 @@ public class MessageContentProcessor { else if (message.getGiftBadge().isPresent()) messageId = handleGiftMessage(content, message, senderRecipient, threadRecipient, receivedTime); else if (isMediaMessage) messageId = handleMediaMessage(content, message, smsMessageId, senderRecipient, threadRecipient, receivedTime); else if (message.getBody().isPresent()) messageId = handleTextMessage(content, message, smsMessageId, groupId, senderRecipient, threadRecipient, receivedTime); - else if (Build.VERSION.SDK_INT > 19 && message.getGroupCallUpdate().isPresent()) handleGroupCallUpdateMessage(content, message, groupId, senderRecipient); + else if (message.getGroupCallUpdate().isPresent()) handleGroupCallUpdateMessage(content, message, groupId, senderRecipient); if (groupId.isPresent() && groupDatabase.isUnknownGroup(groupId.get())) { handleUnknownGroupMessage(content, message.getGroupContext().get(), senderRecipient); @@ -369,7 +368,14 @@ public class MessageContentProcessor { else if (syncMessage.getOutgoingPaymentMessage().isPresent()) handleSynchronizeOutgoingPayment(content, syncMessage.getOutgoingPaymentMessage().get()); else if (syncMessage.getKeys().isPresent()) handleSynchronizeKeys(syncMessage.getKeys().get(), content.getTimestamp()); else if (syncMessage.getContacts().isPresent()) handleSynchronizeContacts(syncMessage.getContacts().get(), content.getTimestamp()); - else if (syncMessage.getCallEvent().isPresent()) handleSynchronizeCallEvent(syncMessage.getCallEvent().get(), content.getTimestamp()); + else if (syncMessage.getCallEvent().isPresent()) { + SyncMessage.CallEvent.Type type = syncMessage.getCallEvent().get().getType(); + if (type == SyncMessage.CallEvent.Type.GROUP_CALL || type == SyncMessage.CallEvent.Type.AD_HOC_CALL) { + handleSynchronizeGroupOrAdHocCallEvent(syncMessage.getCallEvent().get(), content.getTimestamp()); + } else { + handleSynchronizeCallEvent(syncMessage.getCallEvent().get(), content.getTimestamp()); + } + } else warn(String.valueOf(content.getTimestamp()), "Contains no known sync types..."); } else if (content.getCallMessage().isPresent()) { log(String.valueOf(content.getTimestamp()), "Got call message..."); @@ -753,7 +759,7 @@ public class MessageContentProcessor { RecipientId groupRecipientId = SignalDatabase.recipients().getOrInsertFromPossiblyMigratedGroupId(groupId.get()); - SignalDatabase.messages().insertOrUpdateGroupCall(groupRecipientId, + SignalDatabase.calls().insertOrUpdateGroupCallFromExternalEvent(groupRecipientId, senderRecipient.getId(), content.getServerReceivedTimestamp(), message.getGroupCallUpdate().get().getEraId()); @@ -1274,12 +1280,12 @@ public class MessageContentProcessor { CallTable.Direction direction = CallTable.Direction.from(callEvent.getDirection()); CallTable.Event event = CallTable.Event.from(callEvent.getEvent()); - if (timestamp == 0 || type == null || direction == null || event == null || !callEvent.hasPeerUuid()) { - warn(envelopeTimestamp, "Call event sync message is not valid, ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasPeerUuid()); + if (timestamp == 0 || type == null || direction == null || event == null || !callEvent.hasConversationId()) { + warn(envelopeTimestamp, "Call event sync message is not valid, ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId()); return; } - ServiceId serviceId = ServiceId.fromByteString(callEvent.getPeerUuid()); + ServiceId serviceId = ServiceId.fromByteString(callEvent.getConversationId()); RecipientId recipientId = RecipientId.from(serviceId); log(envelopeTimestamp, "Synchronize call event call: " + callId); @@ -1294,10 +1300,79 @@ public class MessageContentProcessor { if (typeMismatch || directionMismatch || eventDowngrade || peerMismatch) { warn(envelopeTimestamp, "Call event sync message is not valid for existing call record, ignoring. type: " + type + " direction: " + direction + " event: " + event + " peerMismatch: " + peerMismatch); } else { - SignalDatabase.calls().updateCall(callId, event); + SignalDatabase.calls().updateOneToOneCall(callId, event); } } else { - SignalDatabase.calls().insertCall(callId, timestamp, recipientId, type, direction, event); + SignalDatabase.calls().insertOneToOneCall(callId, timestamp, recipientId, type, direction, event); + } + } + + private void handleSynchronizeGroupOrAdHocCallEvent(@NonNull SyncMessage.CallEvent callEvent, long envelopeTimestamp) + throws BadGroupIdException + { + if (!callEvent.hasId()) { + log(envelopeTimestamp, "Synchronize group/ad-hoc call event missing call id, ignoring."); + return; + } + + if (!FeatureFlags.adHocCalling() && callEvent.getType() == SyncMessage.CallEvent.Type.AD_HOC_CALL) { + log(envelopeTimestamp, "Ad-Hoc calling is not currently supported by this client, ignoring."); + return; + } + + long callId = callEvent.getId(); + long timestamp = callEvent.getTimestamp(); + CallTable.Type type = CallTable.Type.from(callEvent.getType()); + CallTable.Direction direction = CallTable.Direction.from(callEvent.getDirection()); + CallTable.Event event = CallTable.Event.from(callEvent.getEvent()); + + if (timestamp == 0 || type == null || direction == null || event == null || !callEvent.hasConversationId()) { + warn(envelopeTimestamp, "Group/Ad-hoc call event sync message is not valid, ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId()); + return; + } + + CallTable.Call call = SignalDatabase.calls().getCallById(callId); + if (call != null) { + if (call.getType() != type) { + warn(envelopeTimestamp, "Group/Ad-hoc call event type mismatch, ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId()); + return; + } + + switch (event) { + case DELETE: + SignalDatabase.calls().deleteGroupCall(call); + break; + case ACCEPTED: + if (call.getTimestamp() < callEvent.getTimestamp()) { + SignalDatabase.calls().setTimestamp(call.getCallId(), callEvent.getTimestamp()); + } + + if (callEvent.getDirection() == SyncMessage.CallEvent.Direction.INCOMING) { + SignalDatabase.calls().acceptIncomingGroupCall(call); + } else { + warn(envelopeTimestamp, "Invalid direction OUTGOING for event ACCEPTED"); + } + + break; + case NOT_ACCEPTED: + default: + warn("Unsupported event type " + event + ". Ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId()); + } + } else { + GroupId groupId = GroupId.push(callEvent.getConversationId().toByteArray()); + RecipientId recipientId = Recipient.externalGroupExact(groupId).getId(); + + switch (event) { + case DELETE: + SignalDatabase.calls().insertDeletedGroupCallFromSyncEvent(callEvent.getId(), recipientId, direction, timestamp); + break; + case ACCEPTED: + SignalDatabase.calls().insertAcceptedGroupCall(callEvent.getId(), recipientId, direction, timestamp); + break; + case NOT_ACCEPTED: + default: + warn("Unsupported event type " + event + ". Ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId()); + } } } @@ -1334,7 +1409,7 @@ public class MessageContentProcessor { } else if (dataMessage.isGroupV2Update()) { handleSynchronizeSentGv2Update(content, message); threadId = SignalDatabase.threads().getOrCreateThreadIdFor(getSyncMessageDestination(message)); - } else if (Build.VERSION.SDK_INT > 19 && dataMessage.getGroupCallUpdate().isPresent()) { + } else if (dataMessage.getGroupCallUpdate().isPresent()) { handleGroupCallUpdateMessage(content, dataMessage, GroupUtil.idFromGroupContext(dataMessage.getGroupContext()), senderRecipient); } else if (dataMessage.isEmptyGroupV2Message()) { warn(content.getTimestamp(), "Empty GV2 message! Doing nothing."); 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 497175a748..c9a93681a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt @@ -84,6 +84,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.EarlyMessageCacheEntry +import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.IdentityUtil import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.TextSecurePreferences @@ -980,22 +981,30 @@ object SyncMessageProcessor { private fun handleSynchronizeCallEvent(callEvent: SyncMessage.CallEvent, envelopeTimestamp: Long) { if (!callEvent.hasId()) { - log(envelopeTimestamp, "Synchronize call event missing call id, ignoring.") + log(envelopeTimestamp, "Synchronize call event missing call id, ignoring. type: ${callEvent.type}") return } + if (callEvent.type == SyncMessage.CallEvent.Type.GROUP_CALL || callEvent.type == SyncMessage.CallEvent.Type.AD_HOC_CALL) { + handleSynchronizeGroupOrAdHocCallEvent(callEvent, envelopeTimestamp) + } else { + handleSynchronizeOneToOneCallEvent(callEvent, envelopeTimestamp) + } + } + + private fun handleSynchronizeOneToOneCallEvent(callEvent: SyncMessage.CallEvent, envelopeTimestamp: Long) { val callId: Long = callEvent.id val timestamp: Long = callEvent.timestamp val type: CallTable.Type? = CallTable.Type.from(callEvent.type) val direction: CallTable.Direction? = CallTable.Direction.from(callEvent.direction) val event: CallTable.Event? = CallTable.Event.from(callEvent.event) - if (timestamp == 0L || type == null || direction == null || event == null || !callEvent.hasPeerUuid()) { - warn(envelopeTimestamp, "Call event sync message is not valid, ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasPeerUuid()) + if (timestamp == 0L || type == null || direction == null || event == null || !callEvent.hasConversationId()) { + warn(envelopeTimestamp, "Call event sync message is not valid, ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId()) return } - val serviceId = ServiceId.fromByteString(callEvent.peerUuid) + val serviceId = ServiceId.fromByteString(callEvent.conversationId) val recipientId = RecipientId.from(serviceId) log(envelopeTimestamp, "Synchronize call event call: $callId") @@ -1010,10 +1019,62 @@ object SyncMessageProcessor { if (typeMismatch || directionMismatch || eventDowngrade || peerMismatch) { warn(envelopeTimestamp, "Call event sync message is not valid for existing call record, ignoring. type: $type direction: $direction event: $event peerMismatch: $peerMismatch") } else { - SignalDatabase.calls.updateCall(callId, event) + SignalDatabase.calls.updateOneToOneCall(callId, event) } } else { - SignalDatabase.calls.insertCall(callId, timestamp, recipientId, type, direction, event) + SignalDatabase.calls.insertOneToOneCall(callId, timestamp, recipientId, type, direction, event) + } + } + + @Throws(BadGroupIdException::class) + private fun handleSynchronizeGroupOrAdHocCallEvent(callEvent: SyncMessage.CallEvent, envelopeTimestamp: Long) { + if (!FeatureFlags.adHocCalling() && callEvent.type == SyncMessage.CallEvent.Type.AD_HOC_CALL) { + log(envelopeTimestamp, "Ad-Hoc calling is not currently supported by this client, ignoring.") + return + } + + val callId: Long = callEvent.id + val timestamp: Long = callEvent.timestamp + val type: CallTable.Type? = CallTable.Type.from(callEvent.type) + val direction: CallTable.Direction? = CallTable.Direction.from(callEvent.direction) + val event: CallTable.Event? = CallTable.Event.from(callEvent.event) + + if (timestamp == 0L || type == null || direction == null || event == null || !callEvent.hasConversationId()) { + warn(envelopeTimestamp, "Group/Ad-hoc call event sync message is not valid, ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId()) + return + } + + val call = SignalDatabase.calls.getCallById(callId) + + if (call != null) { + if (call.type !== type) { + warn(envelopeTimestamp, "Group/Ad-hoc call event type mismatch, ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId()) + return + } + when (event) { + CallTable.Event.DELETE -> SignalDatabase.calls.deleteGroupCall(call) + CallTable.Event.ACCEPTED -> { + if (call.timestamp < callEvent.timestamp) { + SignalDatabase.calls.setTimestamp(call.callId, callEvent.timestamp) + } + if (callEvent.direction == SyncMessage.CallEvent.Direction.INCOMING) { + SignalDatabase.calls.acceptIncomingGroupCall(call) + } else { + warn(envelopeTimestamp, "Invalid direction OUTGOING for event ACCEPTED") + } + } + CallTable.Event.NOT_ACCEPTED -> warn("Unsupported event type " + event + ". Ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId()) + else -> warn("Unsupported event type " + event + ". Ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId()) + } + } else { + val groupId: GroupId = GroupId.push(callEvent.conversationId.toByteArray()) + val recipientId = Recipient.externalGroupExact(groupId).id + when (event) { + CallTable.Event.DELETE -> SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(callEvent.id, recipientId, direction, timestamp) + CallTable.Event.ACCEPTED -> SignalDatabase.calls.insertAcceptedGroupCall(callEvent.id, recipientId, direction, timestamp) + CallTable.Event.NOT_ACCEPTED -> warn("Unsupported event type " + event + ". Ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId()) + else -> warn("Unsupported event type " + event + ". Ignoring. timestamp: " + timestamp + " type: " + type + " direction: " + direction + " event: " + event + " hasPeer: " + callEvent.hasConversationId()) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/DeletedCallEventManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/DeletedCallEventManager.kt new file mode 100644 index 0000000000..502a7a54d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/DeletedCallEventManager.kt @@ -0,0 +1,70 @@ +package org.thoughtcrime.securesms.service + +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.annotation.WorkerThread +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import java.util.concurrent.TimeUnit + +/** + * Manages deleting call events 8 hours after they've been marked deleted. + */ +class DeletedCallEventManager( + application: Application +) : TimedEventManager(application, "ExpiringCallEventsManager") { + + companion object { + private val TAG = Log.tag(DeletedCallEventManager::class.java) + + private val CALL_EVENT_DELETION_LIFESPAN = TimeUnit.HOURS.toMillis(8) + } + + init { + scheduleIfNecessary() + } + + @WorkerThread + override fun getNextClosestEvent(): Event? { + val oldestTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp() + if (oldestTimestamp <= 0) return null + + val timeSinceSend = System.currentTimeMillis() - oldestTimestamp + val delay = (CALL_EVENT_DELETION_LIFESPAN - timeSinceSend).coerceAtLeast(0) + Log.i(TAG, "The oldest call event needs to be deleted in $delay ms.") + + return Event(delay) + } + + @WorkerThread + override fun executeEvent(event: Event) { + val threshold = System.currentTimeMillis() - CALL_EVENT_DELETION_LIFESPAN + val deletes = SignalDatabase.calls.deleteCallEventsDeletedBefore(threshold) + Log.i(TAG, "Deleted $deletes call events before $threshold") + } + + @WorkerThread + override fun getDelayForEvent(event: Event): Long = event.delay + + @WorkerThread + override fun scheduleAlarm(application: Application, event: Event, delay: Long) { + setAlarm(application, delay, DeleteCallEventsAlarm::class.java) + } + + data class Event(val delay: Long) + + class DeleteCallEventsAlarm : BroadcastReceiver() { + + companion object { + private val TAG = Log.tag(DeleteCallEventsAlarm::class.java) + } + + override fun onReceive(context: Context?, intent: Intent?) { + Log.d(TAG, "onReceive()") + ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallEventSyncMessageUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallEventSyncMessageUtil.kt index a0e96a90fc..cd8abe98b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallEventSyncMessageUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallEventSyncMessageUtil.kt @@ -1,6 +1,9 @@ package org.thoughtcrime.securesms.service.webrtc +import com.google.protobuf.ByteString +import org.thoughtcrime.securesms.database.model.toProtoByteString import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.ringrtc.RemotePeer import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent @@ -10,27 +13,70 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMe object CallEventSyncMessageUtil { @JvmStatic fun createAcceptedSyncMessage(remotePeer: RemotePeer, timestamp: Long, isOutgoing: Boolean, isVideoCall: Boolean): CallEvent { - return CallEvent - .newBuilder() - .setPeerUuid(Recipient.resolved(remotePeer.id).requireServiceId().toByteString()) - .setId(remotePeer.callId.longValue()) - .setTimestamp(timestamp) - .setType(if (isVideoCall) CallEvent.Type.VIDEO_CALL else CallEvent.Type.AUDIO_CALL) - .setDirection(if (isOutgoing) CallEvent.Direction.OUTGOING else CallEvent.Direction.INCOMING) - .setEvent(CallEvent.Event.ACCEPTED) - .build() + return createCallEvent( + remotePeer.id, + remotePeer.callId.longValue(), + timestamp, + isOutgoing, + isVideoCall, + CallEvent.Event.ACCEPTED + ) } @JvmStatic fun createNotAcceptedSyncMessage(remotePeer: RemotePeer, timestamp: Long, isOutgoing: Boolean, isVideoCall: Boolean): CallEvent { + return createCallEvent( + remotePeer.id, + remotePeer.callId.longValue(), + timestamp, + isOutgoing, + isVideoCall, + CallEvent.Event.NOT_ACCEPTED + ) + } + + @JvmStatic + fun createDeleteCallEvent(remotePeer: RemotePeer, timestamp: Long, isOutgoing: Boolean, isVideoCall: Boolean): CallEvent { + return createCallEvent( + remotePeer.id, + remotePeer.callId.longValue(), + timestamp, + isOutgoing, + isVideoCall, + CallEvent.Event.DELETE + ) + } + + private fun createCallEvent( + recipientId: RecipientId, + callId: Long, + timestamp: Long, + isOutgoing: Boolean, + isVideoCall: Boolean, + event: CallEvent.Event + ): CallEvent { + val recipient = Recipient.resolved(recipientId) + val isGroupCall = recipient.isGroup + val conversationId: ByteString = if (isGroupCall) { + recipient.requireGroupId().decodedId.toProtoByteString() + } else { + recipient.requireServiceId().toByteString() + } + return CallEvent .newBuilder() - .setPeerUuid(Recipient.resolved(remotePeer.id).requireServiceId().toByteString()) - .setId(remotePeer.callId.longValue()) + .setConversationId(conversationId) + .setId(callId) .setTimestamp(timestamp) - .setType(if (isVideoCall) CallEvent.Type.VIDEO_CALL else CallEvent.Type.AUDIO_CALL) + .setType( + when { + isGroupCall -> CallEvent.Type.GROUP_CALL + isVideoCall -> CallEvent.Type.VIDEO_CALL + else -> CallEvent.Type.AUDIO_CALL + } + ) .setDirection(if (isOutgoing) CallEvent.Direction.OUTGOING else CallEvent.Direction.INCOMING) - .setEvent(CallEvent.Event.NOT_ACCEPTED) + .setEvent(event) .build() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java index 2bc2d46dc7..97f34b8179 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java @@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.CallParticipantId; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.ringrtc.Camera; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState; @@ -148,8 +149,9 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor { return currentState; } - String eraId = WebRtcUtil.getGroupCallEraId(groupCall); - webRtcInteractor.sendGroupCallMessage(currentState.getCallInfoState().getCallRecipient(), eraId); + boolean remoteUserRangTheCall = currentState.getCallSetupState(RemotePeer.GROUP_CALL_ID).getRingerRecipient() != Recipient.self(); + String eraId = WebRtcUtil.getGroupCallEraId(groupCall); + webRtcInteractor.sendGroupCallMessage(currentState.getCallInfoState().getCallRecipient(), eraId, remoteUserRangTheCall, true); List members = new ArrayList<>(peekInfo.getJoinedMembers()); if (!members.contains(SignalStore.account().requireAci().uuid())) { @@ -176,7 +178,7 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor { } String eraId = WebRtcUtil.getGroupCallEraId(groupCall); - webRtcInteractor.sendGroupCallMessage(currentState.getCallInfoState().getCallRecipient(), eraId); + webRtcInteractor.sendGroupCallMessage(currentState.getCallInfoState().getCallRecipient(), eraId, false, false); List members = Stream.of(currentState.getCallInfoState().getRemoteCallParticipants()).map(p -> p.getRecipient().requireServiceId().uuid()).toList(); webRtcInteractor.updateGroupCallUpdateMessage(currentState.getCallInfoState().getCallRecipient().getId(), eraId, members, false); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java index c48b807d77..09d20f1110 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IdleActionProcessor.java @@ -102,9 +102,9 @@ public class IdleActionProcessor extends WebRtcActionProcessor { } if (ringUpdate != CallManager.RingUpdate.REQUESTED) { - SignalDatabase.groupCallRings().insertOrUpdateGroupRing(ringId, System.currentTimeMillis(), ringUpdate); + SignalDatabase.calls().insertOrUpdateGroupCallFromRingState(ringId, remotePeerGroup.getId(), sender, System.currentTimeMillis(), ringUpdate); return currentState; - } else if (SignalDatabase.groupCallRings().isCancelled(ringId)) { + } else if (SignalDatabase.calls().isRingCancelled(ringId)) { try { Log.i(TAG, "Incoming ring request for already cancelled ring: " + ringId); webRtcInteractor.getCallManager().cancelGroupRing(groupId.getDecodedId(), ringId, null); @@ -118,7 +118,7 @@ public class IdleActionProcessor extends WebRtcActionProcessor { if (activeProfile != null && !(activeProfile.isRecipientAllowed(remotePeerGroup.getId()) || activeProfile.getAllowAllCalls())) { try { Log.i(TAG, "Incoming ring request for profile restricted recipient"); - SignalDatabase.groupCallRings().insertOrUpdateGroupRing(ringId, System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST); + SignalDatabase.calls().insertOrUpdateGroupCallFromRingState(ringId, remotePeerGroup.getId(), sender, System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST); webRtcInteractor.getCallManager().cancelGroupRing(groupId.getDecodedId(), ringId, CallManager.RingCancelReason.DeclinedByUser); } catch (CallException e) { Log.w(TAG, "Error while trying to cancel ring: " + ringId, e); @@ -135,7 +135,7 @@ public class IdleActionProcessor extends WebRtcActionProcessor { protected @NonNull WebRtcServiceState handleReceivedGroupCallPeekForRingingCheck(@NonNull WebRtcServiceState currentState, @NonNull GroupCallRingCheckInfo info, @NonNull PeekInfo peekInfo) { Log.i(tag, "handleReceivedGroupCallPeekForRingingCheck(): recipient: " + info.getRecipientId() + " ring: " + info.getRingId()); - if (SignalDatabase.groupCallRings().isCancelled(info.getRingId())) { + if (SignalDatabase.calls().isRingCancelled(info.getRingId())) { try { Log.i(TAG, "Ring was cancelled while getting peek info ring: " + info.getRingId()); webRtcInteractor.getCallManager().cancelGroupRing(info.getGroupId().getDecodedId(), info.getRingId(), null); @@ -147,11 +147,11 @@ public class IdleActionProcessor extends WebRtcActionProcessor { if (peekInfo.getDeviceCount() == 0) { Log.i(TAG, "No one in the group call, mark as expired and do not ring"); - SignalDatabase.groupCallRings().insertOrUpdateGroupRing(info.getRingId(), System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST); + SignalDatabase.calls().insertOrUpdateGroupCallFromRingState(info.getRingId(), info.getRecipientId(), info.getRingerUuid(), System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST); return currentState; } else if (peekInfo.getJoinedMembers().contains(Recipient.self().requireServiceId().uuid())) { Log.i(TAG, "We are already in the call, mark accepted on another device and do not ring"); - SignalDatabase.groupCallRings().insertOrUpdateGroupRing(info.getRingId(), System.currentTimeMillis(), CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE); + SignalDatabase.calls().insertOrUpdateGroupCallFromRingState(info.getRingId(), info.getRecipientId(), info.getRingerUuid(), System.currentTimeMillis(), CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE); return currentState; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java index 80dbcc2a6d..4dbb5c2462 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingCallActionProcessor.java @@ -176,12 +176,12 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor { activePeer.localRinging(); - SignalDatabase.calls().insertCall(remotePeer.getCallId().longValue(), - System.currentTimeMillis(), - remotePeer.getId(), + SignalDatabase.calls().insertOneToOneCall(remotePeer.getCallId().longValue(), + System.currentTimeMillis(), + remotePeer.getId(), currentState.getCallSetupState(activePeer).isRemoteVideoOffer() ? CallTable.Type.VIDEO_CALL : CallTable.Type.AUDIO_CALL, - CallTable.Direction.INCOMING, - CallTable.Event.ONGOING); + CallTable.Direction.INCOMING, + CallTable.Event.ONGOING); webRtcInteractor.updatePhoneState(LockManager.PhoneState.INTERACTIVE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java index c55dd04bfb..b057ba246c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/IncomingGroupCallActionProcessor.java @@ -58,7 +58,7 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro boolean updateForCurrentRingId = ringId == currentState.getCallSetupState(RemotePeer.GROUP_CALL_ID).getRingId(); boolean isCurrentlyRinging = currentState.getCallInfoState().getGroupCallState().isRinging(); - if (SignalDatabase.groupCallRings().isCancelled(ringId)) { + if (SignalDatabase.calls().isRingCancelled(ringId)) { try { Log.i(TAG, "Ignoring incoming ring request for already cancelled ring: " + ringId); webRtcInteractor.getCallManager().cancelGroupRing(groupId.getDecodedId(), ringId, null); @@ -69,7 +69,11 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro } if (ringUpdate != CallManager.RingUpdate.REQUESTED) { - SignalDatabase.groupCallRings().insertOrUpdateGroupRing(ringId, System.currentTimeMillis(), ringUpdate); + SignalDatabase.calls().insertOrUpdateGroupCallFromRingState(ringId, + remotePeerGroup.getId(), + sender, + System.currentTimeMillis(), + ringUpdate); if (updateForCurrentRingId && isCurrentlyRinging) { Log.i(TAG, "Cancelling current ring: " + ringId); @@ -104,7 +108,14 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro Log.i(TAG, "Requesting new ring: " + ringId); - SignalDatabase.groupCallRings().insertGroupRing(ringId, System.currentTimeMillis(), ringUpdate); + Recipient ringerRecipient = Recipient.externalPush(ServiceId.from(sender)); + SignalDatabase.calls().insertOrUpdateGroupCallFromRingState( + ringId, + remotePeerGroup.getId(), + ringerRecipient.getId(), + System.currentTimeMillis(), + ringUpdate + ); currentState = WebRtcVideoUtil.initializeVideo(context, webRtcInteractor.getCameraEventListener(), currentState, RemotePeer.GROUP_CALL_ID.longValue()); @@ -138,7 +149,7 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro .changeCallSetupState(RemotePeer.GROUP_CALL_ID) .isRemoteVideoOffer(true) .ringId(ringId) - .ringerRecipient(Recipient.externalPush(ServiceId.from(sender))) + .ringerRecipient(ringerRecipient) .commit() .changeCallInfoState() .activePeer(new RemotePeer(currentState.getCallInfoState().getCallRecipient().getId(), RemotePeer.GROUP_CALL_ID)) @@ -226,10 +237,13 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro Recipient recipient = currentState.getCallInfoState().getCallRecipient(); Optional groupId = recipient.getGroupId(); long ringId = currentState.getCallSetupState(RemotePeer.GROUP_CALL_ID).getRingId(); + Recipient ringer = currentState.getCallSetupState(RemotePeer.GROUP_CALL_ID).getRingerRecipient(); - SignalDatabase.groupCallRings().insertOrUpdateGroupRing(ringId, - System.currentTimeMillis(), - CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE); + SignalDatabase.calls().insertOrUpdateGroupCallFromRingState(ringId, + recipient.getId(), + ringer.getId(), + System.currentTimeMillis(), + CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE); try { webRtcInteractor.getCallManager().cancelGroupRing(groupId.get().getDecodedId(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java index 8fae7790c3..9e227f7017 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/OutgoingCallActionProcessor.java @@ -85,12 +85,12 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor { RecipientUtil.setAndSendUniversalExpireTimerIfNecessary(context, Recipient.resolved(remotePeer.getId()), SignalDatabase.threads().getThreadIdIfExistsFor(remotePeer.getId())); - SignalDatabase.calls().insertCall(remotePeer.getCallId().longValue(), - System.currentTimeMillis(), - remotePeer.getId(), + SignalDatabase.calls().insertOneToOneCall(remotePeer.getCallId().longValue(), + System.currentTimeMillis(), + remotePeer.getId(), isVideoCall ? CallTable.Type.VIDEO_CALL : CallTable.Type.AUDIO_CALL, - CallTable.Direction.OUTGOING, - CallTable.Event.ONGOING); + CallTable.Direction.OUTGOING, + CallTable.Event.ONGOING); EglBaseWrapper.replaceHolder(EglBaseWrapper.OUTGOING_PLACEHOLDER, remotePeer.getCallId().longValue()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index f23b45ec55..78864a31a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -39,6 +39,8 @@ import org.thoughtcrime.securesms.events.GroupCallPeekEvent; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobs.CallSyncEventJob; import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -349,8 +351,8 @@ private void processStateless(@NonNull Function1 ApplicationDependencies.getJobManager().add(GroupCallUpdateSendJob.create(recipient.getId(), groupCallEraId))); + public void sendGroupCallUpdateMessage(@NonNull Recipient recipient, @Nullable String groupCallEraId, boolean isIncoming, boolean isJoinEvent) { + SignalExecutors.BOUNDED.execute(() -> { + GroupCallUpdateSendJob updateSendJob = GroupCallUpdateSendJob.create(recipient.getId(), groupCallEraId); + JobManager.Chain chain = ApplicationDependencies.getJobManager().startChain(updateSendJob); + + if (isJoinEvent && groupCallEraId != null) { + chain.then(CallSyncEventJob.createForJoin( + recipient.getId(), + CallId.fromEra(groupCallEraId).longValue(), + isIncoming + )); + } else if (isJoinEvent) { + Log.w(TAG, "Can't send join event sync message without an era id."); + } + + chain.enqueue(); + }); } public void updateGroupCallUpdateMessage(@NonNull RecipientId groupId, @Nullable String groupCallEraId, @NonNull Collection joinedMembers, boolean isCallFull) { - SignalExecutors.BOUNDED.execute(() -> SignalDatabase.messages().insertOrUpdateGroupCall(groupId, - Recipient.self().getId(), - System.currentTimeMillis(), - groupCallEraId, - joinedMembers, - isCallFull)); + SignalExecutors.BOUNDED.execute(() -> SignalDatabase.calls().insertOrUpdateGroupCallFromLocalEvent(groupId, + Recipient.self().getId(), + System.currentTimeMillis(), + groupCallEraId, + joinedMembers, + isCallFull)); } public void sendCallMessage(@NonNull final RemotePeer remotePeer, @@ -935,7 +952,7 @@ private void processStateless(@NonNull Function1 { @@ -952,7 +969,7 @@ private void processStateless(@NonNull Function1 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java index 846c33bf4b..a12cea3ccf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java @@ -54,6 +54,7 @@ import org.whispersystems.signalservice.api.messages.calls.HangupMessage; import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; import org.whispersystems.signalservice.api.messages.calls.OfferMessage; import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; +import org.whispersystems.signalservice.api.push.ServiceId; import java.util.Collection; import java.util.List; @@ -798,9 +799,11 @@ public abstract class WebRtcActionProcessor { if (ringUpdate != RingUpdate.BUSY_LOCALLY && ringUpdate != RingUpdate.BUSY_ON_ANOTHER_DEVICE) { webRtcInteractor.getCallManager().cancelGroupRing(groupId.getDecodedId(), ringId, CallManager.RingCancelReason.Busy); } - SignalDatabase.groupCallRings().insertOrUpdateGroupRing(ringId, - System.currentTimeMillis(), - ringUpdate == RingUpdate.REQUESTED ? RingUpdate.BUSY_LOCALLY : ringUpdate); + SignalDatabase.calls().insertOrUpdateGroupCallFromRingState(ringId, + remotePeerGroup.getId(), + sender, + System.currentTimeMillis(), + ringUpdate == RingUpdate.REQUESTED ? RingUpdate.BUSY_LOCALLY : ringUpdate); } catch (CallException e) { Log.w(tag, "Unable to cancel ring", e); } @@ -826,7 +829,7 @@ public abstract class WebRtcActionProcessor { Recipient recipient = currentState.getCallInfoState().getCallRecipient(); if (recipient != null && currentState.getCallInfoState().getGroupCallState().isConnected()) { - webRtcInteractor.sendGroupCallMessage(recipient, WebRtcUtil.getGroupCallEraId(groupCall)); + webRtcInteractor.sendGroupCallMessage(recipient, WebRtcUtil.getGroupCallEraId(groupCall), false, false); } currentState = currentState.builder() diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java index 5f5b9ce836..a39b867f6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcInteractor.java @@ -84,8 +84,8 @@ public class WebRtcInteractor { signalCallManager.sendCallMessage(remotePeer, callMessage); } - void sendGroupCallMessage(@NonNull Recipient recipient, @Nullable String groupCallEraId) { - signalCallManager.sendGroupCallUpdateMessage(recipient, groupCallEraId); + void sendGroupCallMessage(@NonNull Recipient recipient, @Nullable String groupCallEraId, boolean isIncoming, boolean isJoinEvent) { + signalCallManager.sendGroupCallUpdateMessage(recipient, groupCallEraId, isIncoming, isJoinEvent); } void updateGroupCallUpdateMessage(@NonNull RecipientId groupId, @Nullable String groupCallEraId, @NonNull Collection joinedMembers, boolean isCallFull) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index bafba59856..217132791e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -109,6 +109,7 @@ public final class FeatureFlags { private static final String CALLS_TAB = "android.calls.tab"; private static final String TEXT_FORMATTING_SPOILER_SEND = "android.textFormatting.spoilerSend"; private static final String EXPORT_ACCOUNT_DATA = "android.exportAccountData"; + private static final String AD_HOC_CALLING = "android.calling.ad.hoc"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -173,7 +174,8 @@ public final class FeatureFlags { @VisibleForTesting static final Set NOT_REMOTE_CAPABLE = SetUtil.newHashSet( - PHONE_NUMBER_PRIVACY + PHONE_NUMBER_PRIVACY, + AD_HOC_CALLING ); /** @@ -612,6 +614,13 @@ public final class FeatureFlags { return getBoolean(EXPORT_ACCOUNT_DATA, false); } + /** + * Whether or not ad-hoc calling is enabled + */ + public static boolean adHocCalling() { + return getBoolean(AD_HOC_CALLING, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 084da8d916..6440c367f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5830,5 +5830,13 @@ Continue Button + + + Missed group call + + Incoming group call + + Outgoing group call + diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java index 3464dee8ef..d5c26521e5 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.payments.Payments; import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess; import org.thoughtcrime.securesms.recipients.LiveRecipientCache; import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager; +import org.thoughtcrime.securesms.service.DeletedCallEventManager; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.ExpiringStoriesManager; import org.thoughtcrime.securesms.service.PendingRetryReceiptManager; @@ -136,6 +137,11 @@ public class MockApplicationDependencyProvider implements ApplicationDependencie return null; } + @Override + public @NonNull DeletedCallEventManager provideDeletedCallEventManager() { + return null; + } + @Override public @NonNull TypingStatusRepository provideTypingStatusRepository() { return null; diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index 3c0f67434f..76b2503642 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -607,6 +607,8 @@ message SyncMessage { UNKNOWN_TYPE = 0; AUDIO_CALL = 1; VIDEO_CALL = 2; + GROUP_CALL = 3; + AD_HOC_CALL = 4; } enum Direction { @@ -619,14 +621,15 @@ message SyncMessage { UNKNOWN_ACTION = 0; ACCEPTED = 1; NOT_ACCEPTED = 2; + DELETE = 3; } - optional bytes peerUuid = 1; - optional uint64 id = 2; - optional uint64 timestamp = 3; - optional Type type = 4; - optional Direction direction = 5; - optional Event event = 6; + optional bytes conversationId = 1; + optional uint64 id = 2; + optional uint64 timestamp = 3; + optional Type type = 4; + optional Direction direction = 5; + optional Event event = 6; } optional Sent sent = 1;