Various fixes for disappearing calls.

This commit is contained in:
Michelle Tang
2026-07-01 11:55:51 -04:00
parent 17d6fe918d
commit 300e5bf6f6
6 changed files with 111 additions and 24 deletions
@@ -10,9 +10,14 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.signal.ringrtc.CallId
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.Flag
import org.thoughtcrime.securesms.testing.RemoteConfigForTest
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.TestRemoteConfigFlag
@RemoteConfigForTest(flags = [Flag(TestRemoteConfigFlag.DISAPPEAR_MORE, "true")])
@RunWith(AndroidJUnit4::class)
class CallTableTest {
@@ -977,6 +982,80 @@ class CallTableTest {
// assertEquals(0, allCallEvents.size)
}
@Test
fun givenAMissedOneToOneCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val peer = harness.others[0]
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, System.currentTimeMillis(), peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, peer)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedNotificationProfileOneToOneCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val peer = harness.others[0]
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, System.currentTimeMillis(), peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED_NOTIFICATION_PROFILE)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, peer)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedGroupCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
SignalDatabase.recipients.setExpireMessagesForGroup(groupRecipientId, 60)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(callId, groupRecipientId, harness.others[1], System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAMissedNotificationProfileGroupCall_whenIMarkAllCallEventsRead_thenTimerShouldStart() {
val callId = 1L
val ringerAci = Recipient.resolved(harness.others[1]).requireAci()
SignalDatabase.recipients.setExpireMessagesForGroup(groupRecipientId, 60)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(callId, groupRecipientId, ringerAci, System.currentTimeMillis(), CallManager.RingUpdate.EXPIRED_REQUEST, true)
val readAt = System.currentTimeMillis()
SignalDatabase.calls.markAllCallEventsRead(readAt = readAt)
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
assertEquals(readAt, SignalDatabase.messages.getMessageRecord(call!!.messageId!!).expireStarted)
}
@Test
fun givenAnOutgoingOneToOneCallFromSync_whenInserted_thenTimerIsStarted() {
val callId = 1L
val peer = harness.others[0]
val timestamp = System.currentTimeMillis()
insertExpiringThread(peer)
SignalDatabase.calls.insertOneToOneCall(callId, timestamp, peer, CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.OUTGOING_RING, true)
val call = SignalDatabase.calls.getCallById(callId, peer)
val message = SignalDatabase.messages.getMessageRecord(call!!.messageId!!)
assertNotEquals(0L, message.expireStarted)
}
private fun insertTwoCallEvents() {
SignalDatabase.calls.insertAcceptedGroupCall(
1,
@@ -992,4 +1071,10 @@ class CallTableTest {
2000
)
}
private fun insertExpiringThread(recipientId: RecipientId) {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
MmsHelper.insert(recipient = Recipient.resolved(recipientId), expiresIn = 30_000L, threadId = threadId)
SignalDatabase.threads.update(threadId, false)
}
}
@@ -46,7 +46,8 @@ annotation class RawFlag(val key: String, val value: String)
*/
enum class TestRemoteConfigFlag(private val property: KProperty0<*>) {
INTERNAL_USER(RemoteConfig::internalUser),
DEFAULT_MAX_BACKOFF(RemoteConfig::defaultMaxBackoff);
DEFAULT_MAX_BACKOFF(RemoteConfig::defaultMaxBackoff),
DISAPPEAR_MORE(RemoteConfig::disappearMore);
val key: String
get() {
@@ -109,13 +109,11 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
"CREATE INDEX $CALL_LOG_INDEX ON $TABLE_NAME ($TIMESTAMP, $PEER, $EVENT, $TYPE, $DELETION_TIMESTAMP)"
)
}
fun markAllCallEventsRead(timestamp: Long = Long.MAX_VALUE) {
val proposedExpireStarted = if (timestamp == Long.MAX_VALUE) System.currentTimeMillis() else timestamp
fun markAllCallEventsRead(timestamp: Long = Long.MAX_VALUE, readAt: Long = System.currentTimeMillis()) {
val allUnreadMissedCalls = readableDatabase
.select(MESSAGE_ID)
.from(TABLE_NAME)
.where("$TIMESTAMP <= ? AND $READ != ? AND $EVENT = ?", timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED))
.where("$TIMESTAMP <= ? AND $READ != ? AND ($EVENT = ? OR $EVENT = ?)", timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED), Event.serialize(Event.MISSED_NOTIFICATION_PROFILE))
.run()
.readToList { cursor ->
cursor.requireLong(MESSAGE_ID)
@@ -131,9 +129,9 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
if (expiringCalls.isNotEmpty()) {
Log.i(TAG, "Found ${expiringCalls.size} calls that needs expiring.")
SignalDatabase.messages.markExpireStarted(expiringCalls.map { it.key to proposedExpireStarted })
SignalDatabase.messages.markExpireStarted(expiringCalls.map { it.key to readAt })
for ((messageId, expiresIn) in expiringCalls) {
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, proposedExpireStarted, expiresIn)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, readAt, expiresIn)
}
}
@@ -142,14 +140,13 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
}
}
fun markAllCallEventsWithPeerBeforeTimestampRead(peer: RecipientId, timestamp: Long): Call? {
val proposedExpireStarted = if (timestamp == Long.MAX_VALUE) System.currentTimeMillis() else timestamp
fun markAllCallEventsWithPeerBeforeTimestampRead(peer: RecipientId, timestamp: Long, readAt: Long = System.currentTimeMillis()): Call? {
val latestCallAsOfTimestamp = writableDatabase.withinTransaction { db ->
val unreadMissedCalls = db
.select(MESSAGE_ID)
.from(TABLE_NAME)
.where("$PEER = ? AND $TIMESTAMP <= ? AND $READ != ? AND $EVENT = ? AND $GROUP_CALL_ACTIVE = 0", peer.toLong(), timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED))
.where("$PEER = ? AND $TIMESTAMP <= ? AND $READ != ? AND ($EVENT = ? OR $EVENT = ?) AND $GROUP_CALL_ACTIVE = 0", peer.toLong(), timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED), Event.serialize(Event.MISSED_NOTIFICATION_PROFILE))
.run()
.readToList { cursor ->
cursor.requireLong(MESSAGE_ID)
@@ -164,9 +161,9 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
if (expiring.isNotEmpty()) {
Log.i(TAG, "Found ${expiring.size} calls that needs expiring.")
SignalDatabase.messages.markExpireStarted(expiring.map { it.key to proposedExpireStarted })
SignalDatabase.messages.markExpireStarted(expiring.map { it.key to readAt })
for ((messageId, expiresIn) in expiring) {
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, proposedExpireStarted, expiresIn)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, readAt, expiresIn)
}
}
@@ -191,7 +188,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
return readableDatabase
.count()
.from(TABLE_NAME)
.where("$EVENT = ? AND $READ = ?", Event.serialize(Event.MISSED), ReadState.serialize(ReadState.UNREAD))
.where("($EVENT = ? OR $EVENT = ?) AND $READ = ?", Event.serialize(Event.MISSED), Event.serialize(Event.MISSED_NOTIFICATION_PROFILE), ReadState.serialize(ReadState.UNREAD))
.run()
.readToSingleLong()
}
@@ -1057,7 +1054,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
RingUpdate.DECLINED_ON_ANOTHER_DEVICE -> {
when (call.event) {
Event.RINGING, Event.MISSED, Event.MISSED_NOTIFICATION_PROFILE, Event.GENERIC_GROUP_CALL -> updateEventFromRingState(ringId, Event.DECLINED)
Event.RINGING, Event.MISSED, Event.GENERIC_GROUP_CALL -> updateEventFromRingState(ringId, Event.DECLINED)
Event.JOINED -> updateEventFromRingState(ringId, Event.ACCEPTED)
Event.OUTGOING_RING -> Log.w(TAG, "Received DECLINED_ON_ANOTHER_DEVICE while in OUTGOING_RING state.")
else -> Unit
@@ -915,6 +915,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val threadId = threadIdResult.threadId
val dateReceived = System.currentTimeMillis()
val expiresIn = if (RemoteConfig.disappearMore) threads.getExpiresIn(threadId) else 0
val missed = MessageTypes.isMissedAudioCall(type) || MessageTypes.isMissedVideoCall(type)
val values = contentValuesOf(
FROM_RECIPIENT_ID to if (outgoing) Recipient.self().id.serialize() else recipientId.serialize(),
@@ -923,7 +924,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to dateReceived,
DATE_SENT to timestamp,
READ to 1,
NOTIFIED to if (MessageTypes.isMissedAudioCall(type) || MessageTypes.isMissedVideoCall(type)) 0 else 1,
NOTIFIED to if (missed) 0 else 1,
TYPE to type,
THREAD_ID to threadId,
EXPIRES_IN to expiresIn
@@ -938,8 +939,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
notifyConversationListeners(threadId)
TrimThreadJob.enqueueAsync(threadId)
// If inserting an outgoing call from a sync message, automatically start timer
if (expiresIn != 0L && outgoing && fromSync) {
// If inserting a call from a sync message, automatically start timer unless it was missed
if (expiresIn != 0L && !missed && fromSync) {
Log.i(TAG, "Starting expiration timer after inserting a call from a sync message.")
markExpireStarted(messageId, timestamp)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, timestamp, expiresIn)
@@ -997,7 +998,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val expiresIn = if (RemoteConfig.disappearMore) recipient.expiresInSeconds.seconds.inWholeMilliseconds else 0
val messageId: MessageId = writableDatabase.withinTransaction { db ->
val self = Recipient.self()
val markRead = joinedUuids.contains(self.requireServiceId().rawUuid) || self.id == sender
val selfCreated = self.id == sender
val markRead = joinedUuids.contains(self.requireServiceId().rawUuid) || selfCreated
val updateDetails: ByteArray = GroupCallUpdateDetails(
eraId = eraId,
startedCallUuid = Recipient.resolved(sender).requireServiceId().toString(),
@@ -1024,8 +1026,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val messageId = MessageId(db.insert(TABLE_NAME, null, values))
val isActiveCall = joinedUuids.isNotEmpty() || isIncomingGroupCallRingingOnLocalDevice
if (!isActiveCall) {
// Calls ringing from a linked (not local) device could be active
val isPotentialActiveCall = joinedUuids.isNotEmpty() || isIncomingGroupCallRingingOnLocalDevice || selfCreated
if (!isPotentialActiveCall) {
maybeCollapseMessage(db = db, messageId = messageId.id, threadId = threadId, dateReceived = timestamp, messageExtras = null, messageType = MessageTypes.GROUP_CALL_TYPE)
if (markRead && expiresIn != 0L) {
Log.d(TAG, "[insertGroupCall] Starting expiration timer for group call.")
@@ -1122,6 +1125,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val updateDetail = GroupCallUpdateDetailsUtil.parse(message.body)
val containsSelf = joinedUuids.contains(SignalStore.account.requireAci().rawUuid)
val selfCreated = updateDetail.startedCallUuid == SignalStore.account.requireAci().rawUuid.toString()
// Treat empty eraId from ring requests as matching for updating
val sameEraId = (updateDetail.eraId == eraId || updateDetail.eraId.isEmpty()) && !Util.isEmpty(eraId)
val inCallUuids = if (sameEraId) joinedUuids.map { it.toString() } else emptyList()
@@ -1140,7 +1144,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val updated = db.update(TABLE_NAME, contentValues, query.where, query.whereArgs) > 0
if (inCallUuids.isEmpty()) {
val acknowledgedCall = localJoined || event == Event.DECLINED
val acknowledgedCall = localJoined || event == Event.DECLINED || selfCreated
finalizeEndedGroupCallMessage(db, message, acknowledgedCall, logPrefix = "[updateGroupCall]")
}
@@ -1376,7 +1376,7 @@ object SyncMessageProcessor {
}
CallLogEvent.Type.MARKED_AS_READ -> {
SignalDatabase.calls.markAllCallEventsRead(timestamp)
SignalDatabase.calls.markAllCallEventsRead(timestamp, envelopeTimestamp)
}
CallLogEvent.Type.MARKED_AS_READ_IN_CONVERSATION -> {
@@ -1385,7 +1385,7 @@ object SyncMessageProcessor {
return
}
SignalDatabase.calls.markAllCallEventsWithPeerBeforeTimestampRead(peer, timestamp)
SignalDatabase.calls.markAllCallEventsWithPeerBeforeTimestampRead(peer, timestamp, envelopeTimestamp)
}
else -> log(envelopeTimestamp, "Synchronize call log event has an invalid type $eventType, ignoring.")
@@ -110,7 +110,7 @@ public class MarkReadReceiver extends BroadcastReceiver {
.collect(Collectors.toList()));
for (RecipientId peer : peers) {
CallTable.Call lastCallInThread = SignalDatabase.calls().markAllCallEventsWithPeerBeforeTimestampRead(peer, timestamp);
CallTable.Call lastCallInThread = SignalDatabase.calls().markAllCallEventsWithPeerBeforeTimestampRead(peer, timestamp, System.currentTimeMillis());
if (lastCallInThread != null) {
AppDependencies.getJobManager().add(CallLogEventSendJob.forMarkedAsReadInConversation(lastCallInThread));
}