diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt index 12acc4f3f9..59134e6eca 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ImportExportTest.kt @@ -9,6 +9,7 @@ import android.Manifest import android.app.UiAutomation import android.os.Environment import androidx.test.platform.app.InstrumentationRegistry +import io.mockk.InternalPlatformDsl.toArray import okio.ByteString.Companion.toByteString import org.junit.Assert import org.junit.Before @@ -23,6 +24,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.AccountData import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo import org.thoughtcrime.securesms.backup.v2.proto.BodyRange import org.thoughtcrime.securesms.backup.v2.proto.Call +import org.thoughtcrime.securesms.backup.v2.proto.CallChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.Chat import org.thoughtcrime.securesms.backup.v2.proto.ChatItem import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage @@ -32,6 +34,8 @@ import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.FilePointer import org.thoughtcrime.securesms.backup.v2.proto.Frame import org.thoughtcrime.securesms.backup.v2.proto.Group +import org.thoughtcrime.securesms.backup.v2.proto.GroupCallChatUpdate +import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate import org.thoughtcrime.securesms.backup.v2.proto.Quote @@ -668,12 +672,62 @@ class ImportExportTest { ) } + var sentTime = 0L + val individualCallChatItems = individualCalls.map { call -> + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = sentTime++, + sms = false, + incoming = ChatItem.IncomingMessageDetails( + dateReceived = sentTime + 1, + dateServerSent = sentTime, + read = true, + sealedSender = true + ), + updateMessage = ChatUpdateMessage( + callingMessage = CallChatUpdate( + callMessage = IndividualCallChatUpdate( + type = IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL + ) + ) + ) + ) + }.toTypedArray() + + val startedAci = TestRecipientUtils.nextAci().toByteString() + val groupCallChatItems = groupCalls.map { call -> + ChatItem( + chatId = 1, + authorId = selfRecipient.id, + dateSent = sentTime++, + sms = false, + incoming = ChatItem.IncomingMessageDetails( + dateReceived = sentTime + 1, + dateServerSent = sentTime, + read = true, + sealedSender = true + ), + updateMessage = ChatUpdateMessage( + callingMessage = CallChatUpdate( + groupCall = GroupCallChatUpdate( + startedCallAci = startedAci, + startedCallTimestamp = 0, + endedCallTimestamp = 0, + localUserJoined = GroupCallChatUpdate.LocalUserJoined.JOINED, + inCallAcis = emptyList() + ) + ) + ) + ) + }.toTypedArray() + importExport( *standardFrames, Recipient( id = 3, contact = Contact( - aci = TestRecipientUtils.nextAci().toByteString(), + aci = startedAci, pni = TestRecipientUtils.nextPni().toByteString(), username = "cool.01", e164 = 141255501234, @@ -698,8 +752,21 @@ class ImportExportTest { name = "Cool test group" ) ), + Chat( + id = 1, + recipientId = 3, + archived = true, + pinnedOrder = 1, + expirationTimerMs = 1.days.inWholeMilliseconds, + muteUntilMs = System.currentTimeMillis(), + markedUnread = true, + dontNotifyForMentionsIfMuted = true, + wallpaper = null + ), *individualCalls.toArray(), - *groupCalls.toArray() + *groupCalls.toArray(), + *individualCallChatItems, + *groupCallChatItems ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt index b1cd0e8f25..f6646be505 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt @@ -200,6 +200,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: } } MessageTypes.isCallLog(record.type) -> { + builder.sms = false val call = calls.getCallByMessageId(record.id) if (call != null) { builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callId = call.callId)) @@ -232,12 +233,23 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: .withoutNulls() .map { obj: UUID? -> ACI.from(obj!!).toByteString() } .toList() + + val localUserJoined: GroupCallChatUpdate.LocalUserJoined = if (groupCallUpdateDetails.localUserJoined) { + GroupCallChatUpdate.LocalUserJoined.JOINED + } else if (groupCallUpdateDetails.endedCallTimestamp == 0L) { + GroupCallChatUpdate.LocalUserJoined.UNKNOWN + } else { + GroupCallChatUpdate.LocalUserJoined.DID_NOT_JOIN + } + builder.updateMessage = ChatUpdateMessage( callingMessage = CallChatUpdate( groupCall = GroupCallChatUpdate( startedCallAci = ACI.from(UuidUtil.parseOrThrow(groupCallUpdateDetails.startedCallUuid)).toByteString(), startedCallTimestamp = groupCallUpdateDetails.startedCallTimestamp, - inCallAcis = joinedMembers + inCallAcis = joinedMembers, + localUserJoined = localUserJoined, + endedCallTimestamp = groupCallUpdateDetails.endedCallTimestamp ) ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt index 0e74e35538..12337a2920 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt @@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet import org.thoughtcrime.securesms.database.documents.NetworkFailure import org.thoughtcrime.securesms.database.documents.NetworkFailureSet +import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription @@ -460,6 +461,10 @@ class ChatItemImportInserter( IndividualCallChatUpdate.Type.UNKNOWN -> typeFlags } } + updateMessage.callingMessage.groupCall != null -> { + typeFlags = MessageTypes.GROUP_CALL_TYPE + this.put(MessageTable.BODY, GroupCallUpdateDetailsUtil.createBodyFromBackup(updateMessage.callingMessage.groupCall)) + } } // Calls don't use the incoming/outgoing flags, so we overwrite the flags here this.put(MessageTable.TYPE, typeFlags) 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 da4557f780..488ce8ed0b 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 @@ -305,7 +305,7 @@ class CallLogAdapter( val color = ContextCompat.getColor( context, - if (call.record.event.isMissedCall()) { + if (call.record.isDisplayedAsMissedCallInUi) { R.color.signal_colorError } else { R.color.signal_colorOnSurfaceVariant @@ -371,11 +371,11 @@ class CallLogAdapter( private fun getCallStateDrawableRes(call: CallTable.Call): Int { return when (call.messageType) { MessageTypes.MISSED_VIDEO_CALL_TYPE, MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.drawable.symbol_missed_incoming_compact_16 - MessageTypes.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_downleft_compact_16 + MessageTypes.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) R.drawable.symbol_missed_incoming_compact_16 else R.drawable.symbol_arrow_downleft_compact_16 MessageTypes.OUTGOING_AUDIO_CALL_TYPE, MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_upright_compact_16 MessageTypes.GROUP_CALL_TYPE -> when { call.type == CallTable.Type.AD_HOC_CALL -> R.drawable.symbol_link_compact_16 - call.event.isMissedCall() -> R.drawable.symbol_missed_incoming_compact_16 + call.isDisplayedAsMissedCallInUi -> R.drawable.symbol_missed_incoming_compact_16 call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.drawable.symbol_group_compact_16 call.direction == CallTable.Direction.INCOMING -> R.drawable.symbol_arrow_downleft_compact_16 call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_compact_16 @@ -389,23 +389,19 @@ class CallLogAdapter( @StringRes private fun getCallStateStringRes(call: CallTable.Call): Int { return when (call.messageType) { - MessageTypes.MISSED_VIDEO_CALL_TYPE, - MessageTypes.MISSED_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.MISSED) R.string.CallLogAdapter__missed else R.string.CallLogAdapter__missed_notification_profile - MessageTypes.INCOMING_AUDIO_CALL_TYPE -> R.string.CallLogAdapter__incoming - MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.string.CallLogAdapter__incoming + MessageTypes.MISSED_VIDEO_CALL_TYPE, MessageTypes.MISSED_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.MISSED) R.string.CallLogAdapter__missed else R.string.CallLogAdapter__missed_notification_profile MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> R.string.CallLogAdapter__outgoing MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.string.CallLogAdapter__outgoing MessageTypes.GROUP_CALL_TYPE -> when { call.type == CallTable.Type.AD_HOC_CALL -> R.string.CallLogAdapter__call_link - call.event == CallTable.Event.MISSED -> R.string.CallLogAdapter__missed call.event == CallTable.Event.MISSED_NOTIFICATION_PROFILE -> R.string.CallLogAdapter__missed_notification_profile + call.isDisplayedAsMissedCallInUi -> R.string.CallLogAdapter__missed call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call call.direction == CallTable.Direction.INCOMING -> R.string.CallLogAdapter__incoming call.direction == CallTable.Direction.OUTGOING -> R.string.CallLogAdapter__outgoing else -> throw AssertionError() } - - else -> error("Unexpected type ${call.messageType}") + else -> if (call.isDisplayedAsMissedCallInUi) R.string.CallLogAdapter__missed else R.string.CallLogAdapter__incoming } } } 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 28df51273f..42159fafdb 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 @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.MessageTypes @@ -46,10 +47,10 @@ object CallPreference { 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.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) R.drawable.symbol_missed_incoming_24 else R.drawable.symbol_arrow_downleft_24 MessageTypes.OUTGOING_AUDIO_CALL_TYPE, MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_upright_24 MessageTypes.GROUP_CALL_TYPE -> when { - call.event.isMissedCall() -> R.drawable.symbol_missed_incoming_24 + call.isDisplayedAsMissedCallInUi -> R.drawable.symbol_missed_incoming_24 call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.drawable.symbol_group_24 call.direction == CallTable.Direction.INCOMING -> R.drawable.symbol_arrow_downleft_24 call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_24 @@ -61,15 +62,14 @@ object CallPreference { private fun getCallType(call: CallTable.Call): String { val id = when (call.messageType) { - MessageTypes.MISSED_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.MISSED) R.string.MessageRecord_missed_voice_call else R.string.MessageRecord_missed_voice_call_notification_profile - MessageTypes.MISSED_VIDEO_CALL_TYPE -> if (call.event == CallTable.Event.MISSED) R.string.MessageRecord_missed_video_call else R.string.MessageRecord_missed_video_call_notification_profile - MessageTypes.INCOMING_AUDIO_CALL_TYPE -> R.string.MessageRecord_incoming_voice_call - MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.string.MessageRecord_incoming_video_call + MessageTypes.MISSED_AUDIO_CALL_TYPE -> getMissedCallString(false, call.event) + MessageTypes.MISSED_VIDEO_CALL_TYPE -> getMissedCallString(true, call.event) + MessageTypes.INCOMING_AUDIO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) getMissedCallString(false, call.event) else R.string.MessageRecord_incoming_voice_call + MessageTypes.INCOMING_VIDEO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) getMissedCallString(true, call.event) else 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 MessageTypes.GROUP_CALL_TYPE -> when { - call.event == CallTable.Event.MISSED -> R.string.CallPreference__missed_group_call - call.event == CallTable.Event.MISSED_NOTIFICATION_PROFILE -> R.string.CallPreference__missed_group_call_notification_profile + call.isDisplayedAsMissedCallInUi -> if (call.event == CallTable.Event.MISSED_NOTIFICATION_PROFILE) R.string.CallPreference__missed_group_call_notification_profile else R.string.CallPreference__missed_group_call call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call call.direction == CallTable.Direction.INCOMING -> R.string.CallPreference__incoming_group_call call.direction == CallTable.Direction.OUTGOING -> R.string.CallPreference__outgoing_group_call @@ -81,6 +81,23 @@ object CallPreference { return context.getString(id) } + @StringRes + private fun getMissedCallString(isVideo: Boolean, callEvent: CallTable.Event): Int { + return if (callEvent == CallTable.Event.MISSED_NOTIFICATION_PROFILE) { + if (isVideo) { + R.string.MessageRecord_missed_video_call_notification_profile + } else { + R.string.MessageRecord_missed_voice_call_notification_profile + } + } else { + if (isVideo) { + R.string.MessageRecord_missed_video_call + } else { + R.string.MessageRecord_missed_voice_call + } + } + } + private fun getCallTime(messageRecord: MessageRecord): String { return DateUtils.getOnlyTimeString(context, messageRecord.timestamp) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 84bb5180ee..1b323f4ad6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord; import org.thoughtcrime.securesms.database.model.LiveUpdateMessage; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.UpdateDescription; +import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; import org.thoughtcrime.securesms.groups.LiveGroup; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.LiveRecipient; @@ -447,11 +448,14 @@ public final class ConversationUpdateItem extends FrameLayout } }); } else if (conversationMessage.getMessageRecord().isGroupCall()) { - UpdateDescription updateDescription = MessageRecord.getGroupCallUpdateDescription(getContext(), conversationMessage.getMessageRecord().getBody(), true); - Collection acis = updateDescription.getMentioned(); + GroupCallUpdateDetails groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(conversationMessage.getMessageRecord().getBody()); + boolean isRingingOnLocalDevice = groupCallUpdateDetails.isRingingOnLocalDevice; + boolean endedRecently = GroupCallUpdateDetailsUtil.checkCallEndedRecently(groupCallUpdateDetails); + UpdateDescription updateDescription = MessageRecord.getGroupCallUpdateDescription(getContext(), conversationMessage.getMessageRecord().getBody(), true); + Collection acis = updateDescription.getMentioned(); int text = 0; - if (Util.hasItems(acis)) { + if (Util.hasItems(acis) || isRingingOnLocalDevice) { if (acis.contains(SignalStore.account().requireAci())) { text = R.string.ConversationUpdateItem_return_to_call; } else if (GroupCallUpdateDetailsUtil.parse(conversationMessage.getMessageRecord().getBody()).isCallFull) { @@ -459,6 +463,8 @@ public final class ConversationUpdateItem extends FrameLayout } else { text = R.string.ConversationUpdateItem_join_call; } + } else if (endedRecently) { + text = R.string.ConversationUpdateItem_call_back; } if (text != 0 && conversationRecipient.isGroup() && conversationRecipient.isActiveGroup()) { 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 48d8e96154..ec3a21e9e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt @@ -18,11 +18,13 @@ import org.signal.core.util.readToList import org.signal.core.util.readToMap import org.signal.core.util.readToSingleLong import org.signal.core.util.readToSingleObject +import org.signal.core.util.requireBoolean import org.signal.core.util.requireLong import org.signal.core.util.requireNonNullString import org.signal.core.util.requireObject import org.signal.core.util.requireString import org.signal.core.util.select +import org.signal.core.util.toInt import org.signal.core.util.toSingleLine import org.signal.core.util.update import org.signal.core.util.withinTransaction @@ -64,6 +66,22 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl const val DELETION_TIMESTAMP = "deletion_timestamp" const val READ = "read" + /** + * Whether a given call event was joined by the local user + * + * Used to determine if a group call in the "GENERIC_GROUP_CALL" state is to be + * displayed as a missed call in the ui + */ + const val LOCAL_JOINED = "local_joined" + + /** + * Whether a given call event is currently considered active. + * + * Used to determine if a group call in the "GENERIC_GROUP_CALL" state is to be + * displayed as a missed call in the ui + */ + const val GROUP_CALL_ACTIVE = "group_call_active" + //language=sql const val CREATE_TABLE = """ CREATE TABLE $TABLE_NAME ( @@ -78,6 +96,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl $RINGER INTEGER DEFAULT NULL, $DELETION_TIMESTAMP INTEGER DEFAULT 0, $READ INTEGER DEFAULT 1, + $LOCAL_JOINED INTEGER DEFAULT 0, + $GROUP_CALL_ACTIVE INTEGER DEFAULT 0, UNIQUE ($CALL_ID, $PEER) ON CONFLICT FAIL ) """ @@ -468,6 +488,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl timestamp, "", emptyList(), + false, false ) } else { @@ -484,7 +505,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl TYPE to Type.serialize(type), DIRECTION to Direction.serialize(direction), TIMESTAMP to timestamp, - RINGER to ringer + RINGER to ringer, + LOCAL_JOINED to true ) .run(SQLiteDatabase.CONFLICT_ABORT) } @@ -508,6 +530,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl timestamp, "", emptyList(), + false, false ) } else { @@ -524,7 +547,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl TYPE to Type.serialize(type), DIRECTION to Direction.serialize(Direction.INCOMING), TIMESTAMP to timestamp, - RINGER to null + RINGER to null, + LOCAL_JOINED to false ) .run(SQLiteDatabase.CONFLICT_ABORT) } @@ -601,7 +625,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl call.messageId, peekGroupCallEraId, peekJoinedUuids, - isCallFull + isCallFull, + call.event == Event.RINGING ) } else { SignalDatabase.messages.insertGroupCall( @@ -610,16 +635,19 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl timestamp, peekGroupCallEraId, peekJoinedUuids, - isCallFull + isCallFull, + false ) } insertCallEventFromGroupUpdate( - callId, - messageId, - sender, - groupRecipient.id, - timestamp + callId = callId, + messageId = messageId, + sender = sender, + groupRecipientId = groupRecipient.id, + timestamp = timestamp, + didLocalUserJoin = peekJoinedUuids.contains(Recipient.self().requireServiceId().rawUuid), + isGroupCallActive = peekJoinedUuids.isNotEmpty() ) } } @@ -660,7 +688,9 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl messageId: MessageId?, sender: RecipientId, groupRecipientId: RecipientId, - timestamp: Long + timestamp: Long, + didLocalUserJoin: Boolean, + isGroupCallActive: Boolean ) { if (messageId != null) { val call = getCallById(callId, groupRecipientId) @@ -677,7 +707,9 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl TYPE to Type.serialize(Type.GROUP_CALL), DIRECTION to Direction.serialize(direction), TIMESTAMP to timestamp, - RINGER to null + RINGER to null, + LOCAL_JOINED to didLocalUserJoin, + GROUP_CALL_ACTIVE to isGroupCallActive ) .run(SQLiteDatabase.CONFLICT_ABORT) @@ -692,6 +724,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl setMessageId(callId, messageId) Log.d(TAG, "Updated call event message id for newly inserted group call state: $callId") } + + updateGroupCallState(call, didLocalUserJoin, isGroupCallActive) } } else { Log.d(TAG, "Skipping call event processing for null era id.") @@ -701,7 +735,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl } /** - * Since this does not alter the call table, we can simply pass this directly through to the old handler. + * Update necessary call info from peek */ fun updateGroupCallFromPeek( threadId: Long, @@ -709,7 +743,26 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl peekJoinedUuids: Collection, isCallFull: Boolean ): Boolean { - val sameEraId = SignalDatabase.messages.updatePreviousGroupCall(threadId, peekGroupCallEraId, peekJoinedUuids, isCallFull) + val callId = peekGroupCallEraId?.let { CallId.fromEra(it) } + val recipientId = SignalDatabase.threads.getRecipientIdForThreadId(threadId) + val call = if (callId != null && recipientId != null) { + getCallById(callId.longValue(), recipientId) + } else { + null + } + + val sameEraId = SignalDatabase.messages.updatePreviousGroupCall( + threadId = threadId, + peekGroupCallEraId = peekGroupCallEraId, + peekJoinedUuids = peekJoinedUuids, + isCallFull = isCallFull, + isRingingOnLocalDevice = call?.event == Event.RINGING + ) + + if (call != null) { + updateGroupCallState(call, peekJoinedUuids) + } + ApplicationDependencies.getDatabaseObserver().notifyCallUpdateObservers() return sameEraId } @@ -742,6 +795,35 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl return call.event != Event.RINGING && call.event != Event.GENERIC_GROUP_CALL } + private fun updateGroupCallState( + call: Call, + peekJoinedUuids: Collection + ) { + updateGroupCallState( + call, + peekJoinedUuids.contains(Recipient.self().requireServiceId().rawUuid), + peekJoinedUuids.isNotEmpty() + ) + } + + private fun updateGroupCallState( + call: Call, + hasLocalUserJoined: Boolean, + isGroupCallActive: Boolean + ) { + writableDatabase.update(TABLE_NAME) + .values( + LOCAL_JOINED to (call.didLocalUserJoin || hasLocalUserJoined), + GROUP_CALL_ACTIVE to isGroupCallActive + ) + .where( + "$CALL_ID = ? AND $PEER = ?", + call.callId, + call.peer.toLong() + ) + .run() + } + private fun handleGroupRingState( ringId: Long, groupRecipientId: RecipientId, @@ -893,7 +975,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl timestamp = timestamp, eraId = "", joinedUuids = emptyList(), - isCallFull = false + isCallFull = false, + isIncomingGroupCallRingingOnLocalDevice = event == Event.RINGING ) db @@ -1074,9 +1157,10 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl // endregion private fun getCallsCursor(isCount: Boolean, offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): Cursor { + val isMissedGenericGroupCall = "$EVENT = ${Event.serialize(Event.GENERIC_GROUP_CALL)} AND $LOCAL_JOINED = ${false.toInt()} AND $GROUP_CALL_ACTIVE = ${false.toInt()}" val filterClause: SqlUtil.Query = when (filter) { CallLogFilter.ALL -> SqlUtil.buildQuery("$DELETION_TIMESTAMP = 0") - CallLogFilter.MISSED -> SqlUtil.buildQuery("($EVENT = ${Event.serialize(Event.MISSED)} OR $EVENT = ${Event.serialize(Event.MISSED_NOTIFICATION_PROFILE)}) AND $DELETION_TIMESTAMP = 0") + CallLogFilter.MISSED -> SqlUtil.buildQuery("($EVENT = ${Event.serialize(Event.MISSED)} OR $EVENT = ${Event.serialize(Event.MISSED_NOTIFICATION_PROFILE)} OR $EVENT = ${Event.serialize(Event.NOT_ACCEPTED)} OR $EVENT = ${Event.serialize(Event.DECLINED)} OR ($isMissedGenericGroupCall)) AND $DELETION_TIMESTAMP = 0") CallLogFilter.AD_HOC -> SqlUtil.buildQuery("$TYPE = ${Type.serialize(Type.AD_HOC_CALL)} AND $DELETION_TIMESTAMP = 0") } @@ -1112,16 +1196,28 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl val projection = if (isCount) { "COUNT(*)," } else { - "p.$ID, p.$TIMESTAMP, $EVENT, $DIRECTION, $PEER, p.$TYPE, $CALL_ID, $MESSAGE_ID, $RINGER, children, in_period, ${MessageTable.BODY}," + "p.$ID, p.$TIMESTAMP, $EVENT, $DIRECTION, $PEER, p.$TYPE, $CALL_ID, $MESSAGE_ID, $RINGER, $LOCAL_JOINED, $GROUP_CALL_ACTIVE, children, in_period, ${MessageTable.BODY}," } + // Group call events by those we consider missed or not missed to build out our call log aggregation. val eventTypeSubQuery = """ - ($TABLE_NAME.$EVENT = c.$EVENT AND ($TABLE_NAME.$EVENT = ${Event.serialize(Event.MISSED)} OR $TABLE_NAME.$EVENT = ${Event.serialize(Event.MISSED_NOTIFICATION_PROFILE)})) OR - ( + ($TABLE_NAME.$EVENT = c.$EVENT AND ( + $TABLE_NAME.$EVENT = ${Event.serialize(Event.MISSED)} OR + $TABLE_NAME.$EVENT = ${Event.serialize(Event.MISSED_NOTIFICATION_PROFILE)} OR + $TABLE_NAME.$EVENT = ${Event.serialize(Event.NOT_ACCEPTED)} OR + $TABLE_NAME.$EVENT = ${Event.serialize(Event.DECLINED)} OR + ($TABLE_NAME.$isMissedGenericGroupCall) + )) OR ( $TABLE_NAME.$EVENT != ${Event.serialize(Event.MISSED)} AND c.$EVENT != ${Event.serialize(Event.MISSED)} AND $TABLE_NAME.$EVENT != ${Event.serialize(Event.MISSED_NOTIFICATION_PROFILE)} AND - c.$EVENT != ${Event.serialize(Event.MISSED_NOTIFICATION_PROFILE)} + c.$EVENT != ${Event.serialize(Event.MISSED_NOTIFICATION_PROFILE)} AND + $TABLE_NAME.$EVENT != ${Event.serialize(Event.NOT_ACCEPTED)} AND + c.$EVENT != ${Event.serialize(Event.NOT_ACCEPTED)} AND + $TABLE_NAME.$EVENT != ${Event.serialize(Event.DECLINED)} AND + c.$EVENT != ${Event.serialize(Event.DECLINED)} AND + (NOT ($TABLE_NAME.$isMissedGenericGroupCall)) AND + (NOT (c.$isMissedGenericGroupCall)) ) """ @@ -1131,6 +1227,8 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl LOWER( COALESCE( NULLIF(${GroupTable.TABLE_NAME}.${GroupTable.TITLE}, ''), + NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.NICKNAME_JOINED_NAME}, ''), + NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.NICKNAME_GIVEN_NAME}, ''), NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_JOINED_NAME}, ''), NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_GIVEN_NAME}, ''), NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_JOINED_NAME}, ''), @@ -1141,7 +1239,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl FROM ( WITH cte AS ( SELECT - $ID, $TIMESTAMP, $EVENT, $DIRECTION, $PEER, $TYPE, $CALL_ID, $MESSAGE_ID, $RINGER, + $ID, $TIMESTAMP, $EVENT, $DIRECTION, $PEER, $TYPE, $CALL_ID, $MESSAGE_ID, $RINGER, $LOCAL_JOINED, $GROUP_CALL_ACTIVE, ( SELECT $ID @@ -1225,10 +1323,20 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl } fun markRingingCallsAsMissed() { - writableDatabase.update(TABLE_NAME) - .values(EVENT to Event.serialize(Event.MISSED)) - .where("$EVENT = ?", Event.serialize(Event.RINGING)) - .run() + writableDatabase.withinTransaction { db -> + val messageIds: List = db.select(MESSAGE_ID) + .from(TABLE_NAME) + .where("$EVENT = ? AND $MESSAGE_ID != NULL", Event.serialize(Event.RINGING)) + .run() + .readToList { it.requireLong(MESSAGE_ID) } + + db.update(TABLE_NAME) + .values(EVENT to Event.serialize(Event.MISSED)) + .where("$EVENT = ?", Event.serialize(Event.RINGING)) + .run() + + SignalDatabase.messages.clearIsRingingOnLocalDeviceFlag(messageIds) + } } fun getCallsCount(searchTerm: String?, filter: CallLogFilter): Int { @@ -1288,6 +1396,10 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl .run() } + /** + * @param isGroupCallActive - Whether the group call currently contains users. Only valid for group calls. + * @param didLocalUserJoin - Determines whether the local user joined this call. Only valid for group calls. + */ data class Call( val callId: Long, val peer: RecipientId, @@ -1296,11 +1408,20 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl val event: Event, val messageId: Long?, val timestamp: Long, - val ringerRecipient: RecipientId? + val ringerRecipient: RecipientId?, + val isGroupCallActive: Boolean, + val didLocalUserJoin: Boolean ) { val messageType: Long = getMessageType(type, direction, event) + val isDisplayedAsMissedCallInUi = isDisplayedAsMissedCallInUi(this) + companion object Deserializer : Serializer { + + private fun isDisplayedAsMissedCallInUi(call: Call): Boolean { + return call.event in Event.DISPLAY_AS_MISSED_CALL || (call.event == Event.GENERIC_GROUP_CALL && !call.didLocalUserJoin && !call.isGroupCallActive) + } + fun getMessageType(type: Type, direction: Direction, event: Event): Long { if (type == Type.GROUP_CALL || type == Type.AD_HOC_CALL) { return MessageTypes.GROUP_CALL_TYPE @@ -1334,7 +1455,9 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl } else { null } - } + }, + isGroupCallActive = data.requireBoolean(GROUP_CALL_ACTIVE), + didLocalUserJoin = data.requireBoolean(LOCAL_JOINED) ) } } @@ -1482,6 +1605,14 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl } companion object Serializer : IntSerializer { + + val DISPLAY_AS_MISSED_CALL = listOf( + MISSED, + MISSED_NOTIFICATION_PROFILE, + DECLINED, + NOT_ACCEPTED + ) + override fun serialize(data: Event): Int = data.code override fun deserialize(data: Int): Event { 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 3e3cc862d7..485e488a6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -828,7 +828,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat timestamp: Long, eraId: String, joinedUuids: Collection, - isCallFull: Boolean + isCallFull: Boolean, + isIncomingGroupCallRingingOnLocalDevice: Boolean ): MessageId { val recipient = Recipient.resolved(groupRecipientId) val threadId = threads.getOrCreateThreadIdFor(recipient) @@ -840,7 +841,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat startedCallUuid = Recipient.resolved(sender).requireServiceId().toString(), startedCallTimestamp = timestamp, inCallUuids = joinedUuids.map { it.toString() }, - isCallFull = isCallFull + isCallFull = isCallFull, + localUserJoined = joinedUuids.contains(Recipient.self().requireServiceId().rawUuid), + isRingingOnLocalDevice = isIncomingGroupCallRingingOnLocalDevice ).encode() val values = contentValuesOf( @@ -893,11 +896,46 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } + /** + * Clears the flag in GroupCallUpdateDetailsUtil that specifies that the call is ringing on the local device. + * Called when cleaning up the call ringing state (which can get out of sync in the case of an application crash) + */ + fun clearIsRingingOnLocalDeviceFlag(messageIds: Collection) { + writableDatabase.withinTransaction { db -> + val queries = SqlUtil.buildCollectionQuery(ID, messageIds) + + for (query in queries) { + val messageIdBodyPairs = db.select(ID, BODY) + .from(TABLE_NAME) + .where(query.where, query.whereArgs) + .run() + .readToList { cursor -> + cursor.requireLong(ID) to cursor.requireString(BODY) + } + + for ((messageId, body) in messageIdBodyPairs) { + val oldBody = GroupCallUpdateDetailsUtil.parse(body) + if (!oldBody.isRingingOnLocalDevice) { + continue + } + + val newBody = GroupCallUpdateDetailsUtil.createUpdatedBody(oldBody, oldBody.inCallUuids, oldBody.isCallFull, false) + + db.update(TABLE_NAME) + .values(BODY to newBody) + .where(ID_WHERE, messageId) + .run() + } + } + } + } + fun updateGroupCall( messageId: Long, eraId: String, joinedUuids: Collection, - isCallFull: Boolean + isCallFull: Boolean, + isRingingOnLocalDevice: Boolean ): MessageId { writableDatabase.withinTransaction { db -> val message = try { @@ -911,7 +949,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat 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) + BODY to GroupCallUpdateDetailsUtil.createUpdatedBody(updateDetail, inCallUuids, isCallFull, isRingingOnLocalDevice) ) if (sameEraId && containsSelf) { @@ -929,7 +967,13 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat return MessageId(messageId) } - fun updatePreviousGroupCall(threadId: Long, peekGroupCallEraId: String?, peekJoinedUuids: Collection, isCallFull: Boolean): Boolean { + fun updatePreviousGroupCall( + threadId: Long, + peekGroupCallEraId: String?, + peekJoinedUuids: Collection, + isCallFull: Boolean, + isRingingOnLocalDevice: Boolean + ): Boolean { return writableDatabase.withinTransaction { db -> val cursor = db .select(*MMS_PROJECTION) @@ -952,7 +996,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } val contentValues = contentValuesOf( - BODY to GroupCallUpdateDetailsUtil.createUpdatedBody(groupCallUpdateDetails, inCallUuids, isCallFull) + BODY to GroupCallUpdateDetailsUtil.createUpdatedBody(groupCallUpdateDetails, inCallUuids, isCallFull, isRingingOnLocalDevice) ) if (sameEraId && containsSelf) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java index 577e5a98b3..5ffa864c02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java @@ -136,24 +136,21 @@ public final class ThreadBodyUtil { boolean accepted = call.getEvent() == CallTable.Event.ACCEPTED; if (call.getDirection() == CallTable.Direction.OUTGOING) { if (call.getType() == CallTable.Type.AUDIO_CALL) { - return context.getString(accepted ? R.string.MessageRecord_outgoing_voice_call : R.string.MessageRecord_unanswered_voice_call); + return context.getString(R.string.MessageRecord_outgoing_voice_call); } else { - return context.getString(accepted ? R.string.MessageRecord_outgoing_video_call : R.string.MessageRecord_unanswered_video_call); + return context.getString(R.string.MessageRecord_outgoing_video_call); } } else { boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL; - boolean isMissed = call.getEvent().isMissedCall(); - if (accepted) { + if (accepted || !call.isDisplayedAsMissedCallInUi()) { return context.getString(isVideoCall ? R.string.MessageRecord_incoming_video_call : R.string.MessageRecord_incoming_voice_call); - } else if (isMissed) { + } else { if (call.getEvent() == CallTable.Event.MISSED_NOTIFICATION_PROFILE) { return isVideoCall ? context.getString(R.string.MessageRecord_missed_video_call_notification_profile) : context.getString(R.string.MessageRecord_missed_voice_call_notification_profile); } else { return isVideoCall ? context.getString(R.string.MessageRecord_missed_video_call) : context.getString(R.string.MessageRecord_missed_voice_call); } - } else { - return isVideoCall ? context.getString(R.string.MessageRecord_you_declined_a_video_call) : context.getString(R.string.MessageRecord_you_declined_a_voice_call); } } } else { 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 27a2903153..cc1d3741aa 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 @@ -82,6 +82,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V221_AddReadColumnT import org.thoughtcrime.securesms.database.helpers.migration.V222_DataHashRefactor import org.thoughtcrime.securesms.database.helpers.migration.V223_AddNicknameAndNoteFieldsToRecipientTable import org.thoughtcrime.securesms.database.helpers.migration.V224_AddAttachmentArchiveColumns +import org.thoughtcrime.securesms.database.helpers.migration.V225_AddLocalUserJoinedStateAndGroupCallActiveState /** * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. @@ -166,10 +167,11 @@ object SignalDatabaseMigrations { 221 to V221_AddReadColumnToCallEventsTable, 222 to V222_DataHashRefactor, 223 to V223_AddNicknameAndNoteFieldsToRecipientTable, - 224 to V224_AddAttachmentArchiveColumns + 224 to V224_AddAttachmentArchiveColumns, + 225 to V225_AddLocalUserJoinedStateAndGroupCallActiveState ) - const val DATABASE_VERSION = 224 + const val DATABASE_VERSION = 225 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V225_AddLocalUserJoinedStateAndGroupCallActiveState.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V225_AddLocalUserJoinedStateAndGroupCallActiveState.kt new file mode 100644 index 0000000000..ec1703e0da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V225_AddLocalUserJoinedStateAndGroupCallActiveState.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import net.zetetic.database.sqlcipher.SQLiteDatabase + +/** + * Adds local user joined state and group call active state to the + * call events table for proper representation of missed non-ringing + * group calls. + * + * Pre-migration call events will display as if the user joined the call. + */ +@Suppress("ClassName") +object V225_AddLocalUserJoinedStateAndGroupCallActiveState : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE call ADD COLUMN local_joined INTEGER DEFAULT 0") + db.execSQL("ALTER TABLE call ADD COLUMN group_call_active INTEGER DEFAULT 0") + + /** + * Assume for pre-migration calls that we've joined them all. This avoids + * erroneously marking calls as missed. + */ + db.execSQL("UPDATE call SET local_joined = 1") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateDetailsUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateDetailsUtil.java index d549cc2eb3..56c58e397f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateDetailsUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateDetailsUtil.java @@ -3,20 +3,53 @@ package org.thoughtcrime.securesms.database.model; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; import org.signal.core.util.Base64; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.backup.v2.proto.GroupCallChatUpdate; +import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.signalservice.api.push.ServiceId; import java.io.IOException; import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; public final class GroupCallUpdateDetailsUtil { private static final String TAG = Log.tag(GroupCallUpdateDetailsUtil.class); + private static final long CALL_RECENCY_TIMEOUT = TimeUnit.MINUTES.toMillis(5); + private GroupCallUpdateDetailsUtil() { } + /** + * Generates a group chat update message body from backup data + */ + public static @NonNull String createBodyFromBackup(@NonNull GroupCallChatUpdate groupCallChatUpdate) { + ServiceId.ACI startedCall = groupCallChatUpdate.startedCallAci != null ? ServiceId.ACI.parseOrNull(groupCallChatUpdate.startedCallAci) : null; + + GroupCallUpdateDetails details = new GroupCallUpdateDetails.Builder() + .startedCallUuid(Objects.toString(startedCall, null)) + .startedCallTimestamp(groupCallChatUpdate.startedCallTimestamp) + .endedCallTimestamp(groupCallChatUpdate.endedCallTimestamp) + .isCallFull(false) + .inCallUuids(groupCallChatUpdate.inCallAcis.stream() + .filter(Objects::nonNull) + .map(ServiceId.ACI::parseOrNull) + .filter(Objects::nonNull) + .map(ServiceId.ACI::toString) + .collect(Collectors.toList()) + ) + .isRingingOnLocalDevice(false) + .localUserJoined(groupCallChatUpdate.localUserJoined != GroupCallChatUpdate.LocalUserJoined.DID_NOT_JOIN) + .build(); + + return Base64.encodeWithPadding(details.encode()); + } + public static @NonNull GroupCallUpdateDetails parse(@Nullable String body) { GroupCallUpdateDetails groupCallUpdateDetails = new GroupCallUpdateDetails(); @@ -33,10 +66,38 @@ public final class GroupCallUpdateDetailsUtil { return groupCallUpdateDetails; } - public static @NonNull String createUpdatedBody(@NonNull GroupCallUpdateDetails groupCallUpdateDetails, @NonNull List inCallUuids, boolean isCallFull) { + public static boolean checkCallEndedRecently(@NonNull GroupCallUpdateDetails groupCallUpdateDetails) { + if (groupCallUpdateDetails.endedCallTimestamp == 0) { + return false; + } + + long now = System.currentTimeMillis(); + if (now > groupCallUpdateDetails.endedCallTimestamp) { + return false; + } + + return now - groupCallUpdateDetails.endedCallTimestamp < CALL_RECENCY_TIMEOUT; + } + + public static @NonNull String createUpdatedBody(@NonNull GroupCallUpdateDetails groupCallUpdateDetails, @NonNull List inCallUuids, boolean isCallFull, boolean isRingingOnLocalDevice) + { + boolean localUserJoined = groupCallUpdateDetails.localUserJoined || inCallUuids.contains(Recipient.self().requireServiceId().getRawUuid().toString()); + long endedTimestamp = groupCallUpdateDetails.endedCallTimestamp; + boolean callBecameEmpty = !groupCallUpdateDetails.inCallUuids.isEmpty() && inCallUuids.isEmpty() && !isRingingOnLocalDevice; + boolean ringTerminatedWithNoUsers = groupCallUpdateDetails.isRingingOnLocalDevice && !isRingingOnLocalDevice && inCallUuids.isEmpty(); + + if (callBecameEmpty || ringTerminatedWithNoUsers) { + endedTimestamp = System.currentTimeMillis(); + } else if (!inCallUuids.isEmpty()) { + endedTimestamp = 0; + } + GroupCallUpdateDetails.Builder builder = groupCallUpdateDetails.newBuilder() .isCallFull(isCallFull) - .inCallUuids(inCallUuids); + .inCallUuids(inCallUuids) + .localUserJoined(localUserJoined) + .endedCallTimestamp(endedTimestamp) + .isRingingOnLocalDevice(isRingingOnLocalDevice); return Base64.encodeWithPadding(builder.build().encode()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateMessageFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateMessageFactory.java index 2e268d4acb..9c1fbb0cd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateMessageFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupCallUpdateMessageFactory.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.concurrent.TimeUnit; /** * Create a group call update message based on time and joined members. @@ -53,45 +54,61 @@ public class GroupCallUpdateMessageFactory implements UpdateDescription.Spannabl } private @NonNull String createString() { - String time = DateUtils.getTimeString(context, Locale.getDefault(), groupCallUpdateDetails.startedCallTimestamp); + long endedTimestamp = groupCallUpdateDetails.endedCallTimestamp; + boolean isWithinTimeout = GroupCallUpdateDetailsUtil.checkCallEndedRecently(groupCallUpdateDetails); + String time = DateUtils.getTimeString(context, Locale.getDefault(), groupCallUpdateDetails.startedCallTimestamp); + boolean isOutgoing = Objects.equals(selfAci.toString(), groupCallUpdateDetails.startedCallUuid); switch (joinedMembers.size()) { case 0: - return withTime ? context.getString(R.string.MessageRecord_group_call_s, time) - : context.getString(R.string.MessageRecord_group_call); + if (isWithinTimeout) { + return withTime ? context.getString(R.string.MessageRecord__the_video_call_has_ended_s, time) + : context.getString(R.string.MessageRecord__the_video_call_has_ended); + } else if (endedTimestamp == 0 || groupCallUpdateDetails.localUserJoined) { + if (isOutgoing) { + return withTime ? context.getString(R.string.MessageRecord__outgoing_video_call_s, time) + : context.getString(R.string.MessageRecord__outgoing_video_call); + } else { + return withTime ? context.getString(R.string.MessageRecord__incoming_video_call_s, time) + : context.getString(R.string.MessageRecord__incoming_video_call); + } + } else { + return withTime ? context.getString(R.string.MessageRecord__missed_video_call_s, time) + : context.getString(R.string.MessageRecord__missed_video_call); + } case 1: if (joinedMembers.get(0).toString().equals(groupCallUpdateDetails.startedCallUuid)) { if (Objects.equals(joinedMembers.get(0), selfAci)) { - return withTime ? context.getString(R.string.MessageRecord_you_started_a_group_call_s, time) - : context.getString(R.string.MessageRecord_you_started_a_group_call); + return withTime ? context.getString(R.string.MessageRecord__you_started_a_video_call_s, time) + : context.getString(R.string.MessageRecord__you_started_a_video_call); } else { - return withTime ? context.getString(R.string.MessageRecord_s_started_a_group_call_s, describe(joinedMembers.get(0)), time) - : context.getString(R.string.MessageRecord_s_started_a_group_call, describe(joinedMembers.get(0))); + return withTime ? context.getString(R.string.MessageRecord__s_started_a_video_call_s, describe(joinedMembers.get(0)), time) + : context.getString(R.string.MessageRecord__s_started_a_video_call, describe(joinedMembers.get(0))); } } else if (Objects.equals(joinedMembers.get(0), selfAci)) { - return withTime ? context.getString(R.string.MessageRecord_you_are_in_the_group_call_s1, time) - : context.getString(R.string.MessageRecord_you_are_in_the_group_call); + return withTime ? context.getString(R.string.MessageRecord_you_are_in_the_call_s1, time) + : context.getString(R.string.MessageRecord_you_are_in_the_call); } else { - return withTime ? context.getString(R.string.MessageRecord_s_is_in_the_group_call_s, describe(joinedMembers.get(0)), time) - : context.getString(R.string.MessageRecord_s_is_in_the_group_call, describe(joinedMembers.get(0))); + return withTime ? context.getString(R.string.MessageRecord_s_is_in_the_call_s, describe(joinedMembers.get(0)), time) + : context.getString(R.string.MessageRecord_s_is_in_the_call, describe(joinedMembers.get(0))); } case 2: - return withTime ? context.getString(R.string.MessageRecord_s_and_s_are_in_the_group_call_s1, + return withTime ? context.getString(R.string.MessageRecord_s_and_s_are_in_the_call_s1, describe(joinedMembers.get(0)), describe(joinedMembers.get(1)), time) - : context.getString(R.string.MessageRecord_s_and_s_are_in_the_group_call, + : context.getString(R.string.MessageRecord_s_and_s_are_in_the_call, describe(joinedMembers.get(0)), describe(joinedMembers.get(1))); default: int others = joinedMembers.size() - 2; - return withTime ? context.getResources().getQuantityString(R.plurals.MessageRecord_s_s_and_d_others_are_in_the_group_call_s, + return withTime ? context.getResources().getQuantityString(R.plurals.MessageRecord_s_s_and_d_others_are_in_the_call_s, others, describe(joinedMembers.get(0)), describe(joinedMembers.get(1)), others, time) - : context.getResources().getQuantityString(R.plurals.MessageRecord_s_s_and_d_others_are_in_the_group_call, + : context.getResources().getQuantityString(R.plurals.MessageRecord_s_s_and_d_others_are_in_the_call, others, describe(joinedMembers.get(0)), describe(joinedMembers.get(1)), diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index febd0af229..ff41ee8e08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -232,21 +232,20 @@ public class MmsMessageRecord extends MessageRecord { if (call.getDirection() == CallTable.Direction.OUTGOING) { if (call.getType() == CallTable.Type.AUDIO_CALL) { - int updateString = accepted ? R.string.MessageRecord_outgoing_voice_call : R.string.MessageRecord_unanswered_voice_call; + int updateString = R.string.MessageRecord_outgoing_voice_call; return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), R.drawable.ic_update_audio_call_outgoing_16); } else { - int updateString = accepted ? R.string.MessageRecord_outgoing_video_call : R.string.MessageRecord_unanswered_video_call; + int updateString = R.string.MessageRecord_outgoing_video_call; return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), R.drawable.ic_update_video_call_outgoing_16); } } else { boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL; - boolean isMissed = call.getEvent().isMissedCall(); - if (accepted) { + if (accepted || !call.isDisplayedAsMissedCallInUi()) { int updateString = isVideoCall ? R.string.MessageRecord_incoming_video_call : R.string.MessageRecord_incoming_voice_call; int icon = isVideoCall ? R.drawable.ic_update_video_call_incoming_16 : R.drawable.ic_update_audio_call_incoming_16; return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), icon); - } else if (isMissed) { + } else { int icon = isVideoCall ? R.drawable.ic_update_video_call_missed_16 : R.drawable.ic_update_audio_call_missed_16; int message; if (call.getEvent() == CallTable.Event.MISSED_NOTIFICATION_PROFILE) { @@ -261,9 +260,6 @@ public class MmsMessageRecord extends MessageRecord { icon, ContextCompat.getColor(context, R.color.core_red_shade), ContextCompat.getColor(context, R.color.core_red)); - } else { - return isVideoCall ? staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(R.string.MessageRecord_you_declined_a_video_call), callDateString), R.drawable.ic_update_video_call_incoming_16) - : staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(R.string.MessageRecord_you_declined_a_voice_call), callDateString), R.drawable.ic_update_audio_call_incoming_16); } } } diff --git a/app/src/main/protowire/Backup.proto b/app/src/main/protowire/Backup.proto index ffa49fdc59..aa3208a861 100644 --- a/app/src/main/protowire/Backup.proto +++ b/app/src/main/protowire/Backup.proto @@ -516,9 +516,17 @@ message IndividualCallChatUpdate { } message GroupCallChatUpdate { + enum LocalUserJoined { + UNKNOWN = 0; + JOINED = 1; + DID_NOT_JOIN = 2; + } + optional bytes startedCallAci = 1; uint64 startedCallTimestamp = 2; repeated bytes inCallAcis = 3; + uint64 endedCallTimestamp = 4; // 0 indicates we do not know + LocalUserJoined localUserJoined = 5; } message SimpleChatUpdate { diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index f5ffc95987..c5800aeb4f 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -116,11 +116,14 @@ message CryptoValue { } message GroupCallUpdateDetails { - string eraId = 1; - string startedCallUuid = 2; - int64 startedCallTimestamp = 3; - repeated string inCallUuids = 4; - bool isCallFull = 5; + string eraId = 1; + string startedCallUuid = 2; + int64 startedCallTimestamp = 3; + repeated string inCallUuids = 4; + bool isCallFull = 5; + bool localUserJoined = 6; + int64 endedCallTimestamp = 7; + bool isRingingOnLocalDevice = 8; } message ExpiringProfileKeyCredentialColumnData { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 864f8c561e..db213e0b24 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1335,17 +1335,13 @@ You have left the group. You updated the group. The group was updated. - + Outgoing voice call - + Outgoing video call - - Unanswered voice call - - Unanswered video call - + Incoming voice call - + Incoming video call Missed voice call @@ -1355,10 +1351,6 @@ Missed voice call while notification profile on Missed video call while notification profile on - - You declined a voice call - - You declined a video call %1$s · %2$s %s updated the group. @@ -1567,28 +1559,53 @@ %s can now accept Payments - %1$s started a group call · %2$s - You started a group call · %1$s - %1$s is in the group call · %2$s - You are in the group call · %1$s - %1$s and %2$s are in the group call · %3$s - Group call · %1$s - - %1$s started a group call - You started a group call - %1$s is in the group call - You are in the group call - %1$s and %2$s are in the group call - Group call + + %1$s is in the call · %2$s + + You are in the call · %1$s + + %1$s and %2$s are in the call · %3$s + + %1$s is in the call + + You are in the call + + %1$s and %2$s are in the call + + The video call has ended + + The video call has ended · %1$s + + Missed video call + + Missed video call · %1$s + + Incoming video call + + Incoming video call · %1$s + + Outgoing video call + + Outgoing video call · %1$s + + You started a video call + + You started a video call · %1$s + + %1$s started a video call + + %1$s started a video call · %2$s You - + + %1$s, %2$s, and %3$d other are in the group call · %4$s %1$s, %2$s, and %3$d others are in the group call · %4$s - + + %1$s, %2$s, and %3$d other are in the group call %1$s, %2$s, and %3$d others are in the group call @@ -2712,6 +2729,8 @@ Loading Learn more Join call + + Call back Return to call Call is full Invite friends