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 b9b61e8831..62045dc467 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 @@ -155,24 +155,29 @@ class CallLogAdapter( binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), model.call.peer, true) binding.callRecipientBadge.setBadgeFromRecipient(model.call.peer) binding.callRecipientName.text = model.call.peer.getDisplayName(context) - presentCallInfo(model.call.call, model.call.date) + presentCallInfo(model.call, model.call.date) presentCallType(model) } - private fun presentCallInfo(call: CallTable.Call, date: Long) { + private fun presentCallInfo(call: CallLogRow.Call, date: Long) { + val callState = context.getString(getCallStateStringRes(call.record)) binding.callInfo.text = context.getString( R.string.CallLogAdapter__s_dot_s, - context.getString(getCallStateStringRes(call)), + if (call.children.size > 1) { + context.getString(R.string.CallLogAdapter__d_s, call.children.size, callState) + } else { + callState + }, DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), date) ) binding.callInfo.setRelativeDrawables( - start = getCallStateDrawableRes(call) + start = getCallStateDrawableRes(call.record) ) val color = ContextCompat.getColor( context, - if (call.event == CallTable.Event.MISSED) { + if (call.record.event == CallTable.Event.MISSED) { R.color.signal_colorError } else { R.color.signal_colorOnSurface @@ -188,7 +193,7 @@ class CallLogAdapter( } private fun presentCallType(model: CallModel) { - when (model.call.call.type) { + when (model.call.record.type) { CallTable.Type.AUDIO_CALL -> { binding.callType.setImageResource(R.drawable.symbol_phone_24) binding.callType.setOnClickListener { onStartAudioCallClicked(model.call.peer) } 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 49f1a2e76c..74a168e687 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.record.messageId!!)) fragment.startActivity(intent) } } @@ -87,7 +87,7 @@ class CallLogContextMenu( } private fun getDeleteActionItem(call: CallLogRow.Call): ActionItem? { - if (call.call.event == CallTable.Event.ONGOING) { + if (call.record.event == CallTable.Event.ONGOING) { return null } 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 685d529c80..51773a3c6b 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 @@ -277,7 +277,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, (callLogRow.id as CallLogRow.Id.Call).children.toLongArray()) 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 4e5453ac46..abd1814269 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 @@ -36,18 +36,18 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository { } fun deleteSelectedCallLogs( - selectedCallIds: Set + selectedCallRowIds: Set ): Completable { return Completable.fromAction { - SignalDatabase.calls.deleteCallEvents(selectedCallIds) + SignalDatabase.calls.deleteCallEvents(selectedCallRowIds) }.observeOn(Schedulers.io()) } fun deleteAllCallLogsExcept( - selectedCallIds: Set + selectedCallRowIds: Set ): Completable { return Completable.fromAction { - SignalDatabase.calls.deleteAllCallEventsExcept(selectedCallIds) + SignalDatabase.calls.deleteAllCallEventsExcept(selectedCallRowIds) }.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 bfb0f3ba61..b7a6c92d82 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 @@ -15,11 +15,12 @@ sealed class CallLogRow { * An incoming, outgoing, or missed call. */ data class Call( - val call: CallTable.Call, + val record: CallTable.Call, val peer: Recipient, val date: Long, val groupCallState: GroupCallState, - override val id: Id = Id.Call(call.callId) + val children: Set, + override val id: Id = Id.Call(children) ) : CallLogRow() /** @@ -34,7 +35,7 @@ sealed class CallLogRow { } sealed class Id { - data class Call(val callId: Long) : Id() + data class Call(val children: Set) : Id() object ClearFilter : Id() object CreateCallLink : 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 b710e851bb..1b0db1ebe1 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,16 @@ class CallLogStagedDeletion( } isCommitted = true - val callIds = stateSnapshot.selected() + val callRowIds = stateSnapshot.selected() .filterIsInstance() - .map { it.callId } + .map { it.children } + .flatten() .toSet() if (stateSnapshot.isExclusionary()) { - repository.deleteAllCallLogsExcept(callIds).subscribe() + repository.deleteAllCallLogsExcept(callRowIds).subscribe() } else { - repository.deleteSelectedCallLogs(callIds).subscribe() + repository.deleteSelectedCallLogs(callRowIds).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 178c64f927..3ba6ade5d3 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 @@ -40,13 +40,14 @@ class ConversationSettingsRepository( private val groupManagementRepository: GroupManagementRepository = GroupManagementRepository(context) ) { - fun getCallEvents(callMessageIds: LongArray): Single>> { - return if (callMessageIds.isEmpty()) { + fun getCallEvents(callRowIds: LongArray): Single>> { + return if (callRowIds.isEmpty()) { Single.just(emptyList()) } else { Single.fromCallable { - val callMap = SignalDatabase.calls.getCalls(callMessageIds.toList()) - SignalDatabase.messages.getMessages(callMessageIds.toList()).iterator().asSequence() + val callMap = SignalDatabase.calls.getCallsByRowIds(callRowIds.toList()) + val messageIds = callMap.values.mapNotNull { it.messageId } + SignalDatabase.messages.getMessages(messageIds).iterator().asSequence() .filter { callMap.containsKey(it.id) } .map { callMap[it.id]!! to it } .toList() 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 067f10cb95..ed273ec202 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt @@ -8,12 +8,15 @@ 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.flatten import org.signal.core.util.insertInto import org.signal.core.util.logging.Log 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.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 @@ -32,6 +35,7 @@ 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 +import java.util.concurrent.TimeUnit /** * Contains details for each 1:1 call. @@ -40,6 +44,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl companion object { private val TAG = Log.tag(CallTable::class.java) + private val TIME_WINDOW = TimeUnit.HOURS.toMillis(4) private const val TABLE_NAME = "call" private const val ID = "_id" @@ -152,19 +157,37 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl } fun getCalls(messageIds: Collection): Map { - val calls = mutableMapOf() val queries = SqlUtil.buildCollectionQuery(MESSAGE_ID, messageIds) - - queries.forEach { query -> - val cursor = readableDatabase + val maps = queries.map { query -> + readableDatabase .select() .from(TABLE_NAME) .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) }) + .readToMap { c -> c.requireLong(MESSAGE_ID) to Call.deserialize(c) } } - return calls + + return maps.flatten() + } + + /** + * @param callRowIds The CallTable.ID collection to query + * + * @return a map of raw MessageId -> Call + */ + fun getCallsByRowIds(callRowIds: Collection): Map { + val queries = SqlUtil.buildCollectionQuery(ID, callRowIds) + + val maps = queries.map { query -> + readableDatabase + .select() + .from(TABLE_NAME) + .where("$EVENT != ${Event.serialize(Event.DELETE)} AND ${query.where}", query.whereArgs) + .run() + .readToMap { c -> c.requireLong(MESSAGE_ID) to Call.deserialize(c) } + } + + return maps.flatten() } fun getOldestDeletionTimestamp(): Long { @@ -670,14 +693,14 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl .run() } - fun deleteCallEvents(callIds: Set) { - val messageIds = getMessageIds(callIds) + fun deleteCallEvents(callRowIds: Set) { + val messageIds = getMessageIds(callRowIds) SignalDatabase.messages.deleteCallUpdates(messageIds) updateCallEventDeletionTimestamps() } - fun deleteAllCallEventsExcept(callIds: Set) { - val messageIds = getMessageIds(callIds) + fun deleteAllCallEventsExcept(callRowIds: Set) { + val messageIds = getMessageIds(callRowIds) SignalDatabase.messages.deleteAllCallUpdatesExcept(messageIds) updateCallEventDeletionTimestamps() } @@ -697,10 +720,10 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl } } - private fun getMessageIds(callIds: Set): Set { + private fun getMessageIds(callRowIds: Set): Set { val queries = SqlUtil.buildCollectionQuery( - CALL_ID, - callIds, + ID, + callRowIds, "$MESSAGE_ID NOT NULL AND" ) @@ -718,12 +741,12 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl // endregion private fun getCallsCursor(isCount: Boolean, offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): Cursor { - val filterClause = when (filter) { + val filterClause: SqlUtil.Query = when (filter) { CallLogFilter.ALL -> SqlUtil.buildQuery("$EVENT != ${Event.serialize(Event.DELETE)}") CallLogFilter.MISSED -> SqlUtil.buildQuery("$EVENT == ${Event.serialize(Event.MISSED)}") } - val queryClause = if (!searchTerm.isNullOrEmpty()) { + val queryClause: SqlUtil.Query = if (!searchTerm.isNullOrEmpty()) { val glob = SqlUtil.buildCaseInsensitiveGlobPattern(searchTerm) val selection = """ @@ -740,41 +763,86 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl SqlUtil.buildQuery("") } - val whereClause = filterClause and queryClause - val where = if (whereClause.where.isNotEmpty()) { - "WHERE ${whereClause.where}" - } else { - "" - } - val offsetLimit = if (limit > 0) { "LIMIT $offset,$limit" } else { "" } + val projection = if (isCount) { + "COUNT(*)," + } else { + "p.$ID, $TIMESTAMP, $EVENT, $DIRECTION, $PEER, p.$TYPE, $CALL_ID, $MESSAGE_ID, $RINGER, children, ${MessageTable.DATE_RECEIVED}, ${MessageTable.BODY}," + } + //language=sql val statement = """ - SELECT - ${if (isCount) "COUNT(*)," else "$TABLE_NAME.*, ${MessageTable.DATE_RECEIVED}, ${MessageTable.BODY},"} - LOWER( - COALESCE( - NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_JOINED_NAME}, ''), - NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_GIVEN_NAME}, ''), - NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_JOINED_NAME}, ''), - NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_GIVEN_NAME}, ''), - NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.USERNAME}, '') + SELECT $projection + LOWER( + COALESCE( + NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_JOINED_NAME}, ''), + NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_GIVEN_NAME}, ''), + NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_JOINED_NAME}, ''), + NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_GIVEN_NAME}, ''), + NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.USERNAME}, '') + ) + ) AS sort_name + FROM ( + WITH cte AS ( + SELECT + $ID, $TIMESTAMP, $EVENT, $DIRECTION, $PEER, $TYPE, $CALL_ID, $MESSAGE_ID, $RINGER, + ( + SELECT + $ID + FROM + $TABLE_NAME + WHERE + $TABLE_NAME.$DIRECTION = c.$DIRECTION + AND $TABLE_NAME.$PEER = c.$PEER + AND $TABLE_NAME.$TIMESTAMP - $TIME_WINDOW <= c.$TIMESTAMP + AND $TABLE_NAME.$TIMESTAMP >= c.$TIMESTAMP + ORDER BY + $TIMESTAMP DESC + ) as parent, + ( + SELECT + group_concat($ID) + FROM + $TABLE_NAME + WHERE + $TABLE_NAME.$DIRECTION = c.$DIRECTION + AND $TABLE_NAME.$PEER = c.$PEER + AND c.$TIMESTAMP - $TIME_WINDOW <= $TABLE_NAME.$TIMESTAMP + AND c.$TIMESTAMP >= $TABLE_NAME.$TIMESTAMP + ) as children + FROM + $TABLE_NAME c + WHERE ${filterClause.where} + ORDER BY + $TIMESTAMP DESC ) - ) AS sort_name - FROM $TABLE_NAME - INNER JOIN ${RecipientTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = $TABLE_NAME.$PEER - INNER JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $TABLE_NAME.$MESSAGE_ID - $where - ORDER BY ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} DESC + SELECT + *, + CASE + WHEN LAG (parent, 1, 0) OVER ( + ORDER BY + $TIMESTAMP DESC + ) != parent THEN $ID + ELSE parent + END true_parent + FROM + cte + ) p + INNER JOIN ${RecipientTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = $PEER + INNER JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $MESSAGE_ID + WHERE true_parent = p.$ID ${if (queryClause.where.isNotEmpty()) "AND ${queryClause.where}" else ""} $offsetLimit """.trimIndent() - return readableDatabase.query(statement, whereClause.whereArgs) + return readableDatabase.query( + statement, + queryClause.whereArgs + ) } fun getCallsCount(searchTerm: String?, filter: CallLogFilter): Int { @@ -785,17 +853,21 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl } fun getCalls(offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): List { - return getCallsCursor(false, offset, limit, searchTerm, filter).readToList { - val call = Call.deserialize(it) + return getCallsCursor(false, offset, limit, searchTerm, filter).readToList { cursor -> + val call = Call.deserialize(cursor) val recipient = Recipient.resolved(call.peer) - val date = it.requireLong(MessageTable.DATE_RECEIVED) - val groupCallDetails = GroupCallUpdateDetailsUtil.parse(it.requireString(MessageTable.BODY)) + val date = cursor.requireLong(MessageTable.DATE_RECEIVED) + val groupCallDetails = GroupCallUpdateDetailsUtil.parse(cursor.requireString(MessageTable.BODY)) CallLogRow.Call( - call = call, + record = call, peer = recipient, date = date, - groupCallState = CallLogRow.GroupCallState.fromDetails(groupCallDetails) + groupCallState = CallLogRow.GroupCallState.fromDetails(groupCallDetails), + children = cursor.requireNonNullString("children") + .split(',') + .map { it.toLong() } + .toSet() ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c18f0bbd87..771fb4bb07 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5784,6 +5784,8 @@ Join Return + + (%1$d) %2$s diff --git a/core-util/src/main/java/org/signal/core/util/CollectionsExtensions.kt b/core-util/src/main/java/org/signal/core/util/CollectionsExtensions.kt new file mode 100644 index 0000000000..cd66da0c2c --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/CollectionsExtensions.kt @@ -0,0 +1,8 @@ +package org.signal.core.util + +/** + * Flattens a List of Map into a Map using the + operator. + * + * @return A Map containing all of the K, V pairings of the maps contained in the original list. + */ +fun List>.flatten(): Map = foldRight(emptyMap()) { a, b -> a + b } diff --git a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt index 9c142091bf..14774731f8 100644 --- a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt @@ -137,6 +137,11 @@ inline fun Cursor.readToList(predicate: (T) -> Boolean = { true }, mapper: ( return list } +@JvmOverloads +inline fun Cursor.readToMap(predicate: (Pair) -> Boolean = { true }, mapper: (Cursor) -> Pair): Map { + return readToList(predicate, mapper).associate { it } +} + inline fun Cursor.readToSet(predicate: (T) -> Boolean = { true }, mapper: (Cursor) -> T): Set { val set = mutableSetOf() use {