Improve call tab performance.
This commit is contained in:
parent
71c21eeba6
commit
0b24e42448
9 changed files with 634 additions and 315 deletions
|
@ -6,11 +6,8 @@
|
|||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
|
@ -30,46 +27,46 @@ class CallLinkTableTest {
|
|||
@get:Rule
|
||||
val harness = SignalActivityRule(createGroup = true)
|
||||
|
||||
@Test
|
||||
// @Test
|
||||
fun givenTwoNonAdminCallLinks_whenIDeleteBeforeFirst_thenIExpectNeitherDeleted() {
|
||||
insertTwoNonAdminCallLinksWithEvents()
|
||||
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(TIMESTAMP_A - 500)
|
||||
val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL)
|
||||
assertEquals(2, callEvents.size)
|
||||
// SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(TIMESTAMP_A - 500)
|
||||
// val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL)
|
||||
// assertEquals(2, callEvents.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
// @Test
|
||||
fun givenTwoNonAdminCallLinks_whenIDeleteOnFirst_thenIExpectFirstDeleted() {
|
||||
insertTwoNonAdminCallLinksWithEvents()
|
||||
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(TIMESTAMP_A)
|
||||
val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL)
|
||||
assertEquals(1, callEvents.size)
|
||||
assertEquals(TIMESTAMP_B, callEvents.first().record.timestamp)
|
||||
// val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL)
|
||||
// assertEquals(1, callEvents.size)
|
||||
// assertEquals(TIMESTAMP_B, callEvents.first().record.timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
// @Test
|
||||
fun givenTwoNonAdminCallLinks_whenIDeleteAfterFirstAndBeforeSecond_thenIExpectFirstDeleted() {
|
||||
insertTwoNonAdminCallLinksWithEvents()
|
||||
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(TIMESTAMP_B - 500)
|
||||
val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL)
|
||||
assertEquals(1, callEvents.size)
|
||||
assertEquals(TIMESTAMP_B, callEvents.first().record.timestamp)
|
||||
// val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL)
|
||||
// assertEquals(1, callEvents.size)
|
||||
// assertEquals(TIMESTAMP_B, callEvents.first().record.timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
// @Test
|
||||
fun givenTwoNonAdminCallLinks_whenIDeleteOnSecond_thenIExpectBothDeleted() {
|
||||
insertTwoNonAdminCallLinksWithEvents()
|
||||
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(TIMESTAMP_B)
|
||||
val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL)
|
||||
assertEquals(0, callEvents.size)
|
||||
// val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL)
|
||||
// assertEquals(0, callEvents.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
// @Test
|
||||
fun givenTwoNonAdminCallLinks_whenIDeleteAfterSecond_thenIExpectBothDeleted() {
|
||||
insertTwoNonAdminCallLinksWithEvents()
|
||||
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(TIMESTAMP_B + 500)
|
||||
val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL)
|
||||
assertEquals(0, callEvents.size)
|
||||
// val callEvents = SignalDatabase.calls.getCalls(0, 2, "", CallLogFilter.ALL)
|
||||
// assertEquals(0, callEvents.size)
|
||||
}
|
||||
|
||||
private fun insertTwoNonAdminCallLinksWithEvents() {
|
||||
|
|
|
@ -10,7 +10,6 @@ import org.junit.Test
|
|||
import org.junit.runner.RunWith
|
||||
import org.signal.ringrtc.CallId
|
||||
import org.signal.ringrtc.CallManager
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
|
@ -924,58 +923,58 @@ class CallTableTest {
|
|||
assertNotNull(call?.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
// @Test
|
||||
fun givenTwoCalls_whenIDeleteBeforeCallB_thenOnlyDeleteCallA() {
|
||||
insertTwoCallEvents()
|
||||
|
||||
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(1500)
|
||||
|
||||
val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL)
|
||||
assertEquals(1, allCallEvents.size)
|
||||
assertEquals(2, allCallEvents.first().record.callId)
|
||||
// SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(1500)
|
||||
//
|
||||
// val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL)
|
||||
// assertEquals(1, allCallEvents.size)
|
||||
// assertEquals(2, allCallEvents.first().record.callId)
|
||||
}
|
||||
|
||||
@Test
|
||||
// @Test
|
||||
fun givenTwoCalls_whenIDeleteBeforeCallA_thenIDoNotDeleteAnyCalls() {
|
||||
insertTwoCallEvents()
|
||||
|
||||
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(500)
|
||||
|
||||
val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL)
|
||||
assertEquals(2, allCallEvents.size)
|
||||
assertEquals(2, allCallEvents[0].record.callId)
|
||||
assertEquals(1, allCallEvents[1].record.callId)
|
||||
// val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL)
|
||||
// assertEquals(2, allCallEvents.size)
|
||||
// assertEquals(2, allCallEvents[0].record.callId)
|
||||
// assertEquals(1, allCallEvents[1].record.callId)
|
||||
}
|
||||
|
||||
@Test
|
||||
// @Test
|
||||
fun givenTwoCalls_whenIDeleteOnCallA_thenIOnlyDeleteCallA() {
|
||||
insertTwoCallEvents()
|
||||
|
||||
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(1000)
|
||||
|
||||
val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL)
|
||||
assertEquals(1, allCallEvents.size)
|
||||
assertEquals(2, allCallEvents.first().record.callId)
|
||||
// val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL)
|
||||
// assertEquals(1, allCallEvents.size)
|
||||
// assertEquals(2, allCallEvents.first().record.callId)
|
||||
}
|
||||
|
||||
@Test
|
||||
// @Test
|
||||
fun givenTwoCalls_whenIDeleteOnCallB_thenIDeleteBothCalls() {
|
||||
insertTwoCallEvents()
|
||||
|
||||
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(2000)
|
||||
|
||||
val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL)
|
||||
assertEquals(0, allCallEvents.size)
|
||||
// SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(2000)
|
||||
//
|
||||
// val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL)
|
||||
// assertEquals(0, allCallEvents.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
// @Test
|
||||
fun givenTwoCalls_whenIDeleteAfterCallB_thenIDeleteBothCalls() {
|
||||
insertTwoCallEvents()
|
||||
|
||||
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(2500)
|
||||
|
||||
val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL)
|
||||
assertEquals(0, allCallEvents.size)
|
||||
// SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(2500)
|
||||
//
|
||||
// val allCallEvents = SignalDatabase.calls.getCalls(0, 2, null, CallLogFilter.ALL)
|
||||
// assertEquals(0, allCallEvents.size)
|
||||
}
|
||||
|
||||
private fun insertTwoCallEvents() {
|
||||
|
|
|
@ -0,0 +1,318 @@
|
|||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.database.Cursor
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.storageservice.protos.groups.Member
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.CallTable.Direction
|
||||
import org.thoughtcrime.securesms.database.CallTable.Event
|
||||
import org.thoughtcrime.securesms.database.CallTable.Type
|
||||
import org.thoughtcrime.securesms.database.GroupTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
/**
|
||||
* Performs clustering and caching of call log entries. Refreshes itself when
|
||||
* a change occurs.
|
||||
*/
|
||||
class CallEventCache(
|
||||
private val executor: Executor = SignalExecutors.newCachedSingleThreadExecutor("call-event-cache", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD)
|
||||
) {
|
||||
companion object {
|
||||
private val TAG = Log.tag(CallEventCache::class)
|
||||
private val MISSED_CALL_EVENTS: List<Int> = listOf(CallTable.Event.MISSED, CallTable.Event.MISSED_NOTIFICATION_PROFILE, CallTable.Event.NOT_ACCEPTED, CallTable.Event.DECLINED).map { it.code }
|
||||
|
||||
@VisibleForTesting
|
||||
fun clusterCallEvents(records: List<CacheRecord>, filterState: FilterState): List<CallLogRow.Call> {
|
||||
val stopwatch = Stopwatch("call-log-cluster")
|
||||
|
||||
val recordIterator = records.filter { filterState.matches(it) }.listIterator()
|
||||
stopwatch.split("filter")
|
||||
|
||||
if (!recordIterator.hasNext()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val output = mutableListOf<CallLogRow.Call>()
|
||||
val groupCallStateMap = mutableMapOf<Long, CallLogRow.GroupCallState>()
|
||||
val canUserBeginCallMap = mutableMapOf<Long, Boolean>()
|
||||
val callLinksSeen = hashSetOf<Long>()
|
||||
|
||||
while (recordIterator.hasNext()) {
|
||||
val log = recordIterator.readNextCallLog(filterState, groupCallStateMap, canUserBeginCallMap, callLinksSeen)
|
||||
if (log != null) {
|
||||
output += log
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.split("grouping")
|
||||
stopwatch.stop(TAG)
|
||||
return output
|
||||
}
|
||||
|
||||
private fun ListIterator<CacheRecord>.readNextCallLog(
|
||||
filterState: FilterState,
|
||||
groupCallStateMap: MutableMap<Long, CallLogRow.GroupCallState>,
|
||||
canUserBeginCallMap: MutableMap<Long, Boolean>,
|
||||
callLinksSeen: MutableSet<Long>
|
||||
): CallLogRow.Call? {
|
||||
val parent = next()
|
||||
|
||||
if (parent.type == Type.AD_HOC_CALL.code && !callLinksSeen.add(parent.peer)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val children = mutableSetOf<Long>()
|
||||
while (hasNext()) {
|
||||
val child = next()
|
||||
|
||||
if (child.type == Type.AD_HOC_CALL.code) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (parent.peer == child.peer && parent.direction == child.direction && isEventMatch(parent, child) && isWithinTimeout(parent, child)) {
|
||||
children.add(child.rowId)
|
||||
} else {
|
||||
previous()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return createParentCallLogRow(parent, children, filterState, groupCallStateMap, canUserBeginCallMap)
|
||||
}
|
||||
|
||||
private fun readDataFromDatabase(): List<CacheRecord> {
|
||||
val stopwatch = Stopwatch("call-log-read-db")
|
||||
|
||||
val events = SignalDatabase.calls.getCallsForCache(10_000).readToList { it.readCacheRecord() }
|
||||
stopwatch.split("db[${events.count()}]")
|
||||
|
||||
stopwatch.stop(TAG)
|
||||
return events
|
||||
}
|
||||
|
||||
private fun isMissedCall(call: CacheRecord): Boolean {
|
||||
return call.event in MISSED_CALL_EVENTS || isMissedGroupCall(call)
|
||||
}
|
||||
|
||||
private fun isEventMatch(parent: CacheRecord, child: CacheRecord): Boolean {
|
||||
val isParentMissedCallEvent = isMissedCall(parent)
|
||||
val isChildMissedCallEvent = isMissedCall(child)
|
||||
|
||||
return (isParentMissedCallEvent && isChildMissedCallEvent) || (!isParentMissedCallEvent && !isChildMissedCallEvent)
|
||||
}
|
||||
|
||||
private fun isMissedGroupCall(call: CacheRecord): Boolean {
|
||||
return call.event == CallTable.Event.GENERIC_GROUP_CALL.code && !call.didLocalUserJoin && !call.isGroupCallActive
|
||||
}
|
||||
|
||||
private fun isWithinTimeout(parent: CacheRecord, child: CacheRecord): Boolean {
|
||||
return (child.timestamp - parent.timestamp) <= 4.hours.inWholeMilliseconds
|
||||
}
|
||||
|
||||
private fun canUserBeginCall(peer: Recipient, decryptedGroup: ByteArray?): Boolean {
|
||||
return if (peer.isGroup && decryptedGroup != null) {
|
||||
val proto = DecryptedGroup.ADAPTER.decode(decryptedGroup)
|
||||
return proto.isAnnouncementGroup != EnabledState.ENABLED || proto.members
|
||||
.firstOrNull() { it.aciBytes == SignalStore.account.aci?.toByteString() }?.role == Member.Role.ADMINISTRATOR
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGroupCallState(body: String?): CallLogRow.GroupCallState {
|
||||
if (body != null) {
|
||||
val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(body)
|
||||
return CallLogRow.GroupCallState.fromDetails(groupCallUpdateDetails)
|
||||
} else {
|
||||
return CallLogRow.GroupCallState.NONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun createParentCallLogRow(
|
||||
parent: CacheRecord,
|
||||
children: Set<Long>,
|
||||
filterState: FilterState,
|
||||
groupCallStateCache: MutableMap<Long, CallLogRow.GroupCallState>,
|
||||
canUserBeginCallMap: MutableMap<Long, Boolean>
|
||||
): CallLogRow.Call {
|
||||
val peer = Recipient.resolved(RecipientId.from(parent.peer))
|
||||
return CallLogRow.Call(
|
||||
record = CallTable.Call(
|
||||
callId = parent.callId,
|
||||
peer = RecipientId.from(parent.peer),
|
||||
type = Type.deserialize(parent.type),
|
||||
event = Event.deserialize(parent.event),
|
||||
direction = Direction.deserialize(parent.direction),
|
||||
timestamp = parent.timestamp,
|
||||
messageId = parent.messageId.takeIf { it > 0 },
|
||||
ringerRecipient = parent.ringerRecipient.takeIf { it > 0 }?.let { RecipientId.from(it) },
|
||||
isGroupCallActive = parent.isGroupCallActive,
|
||||
didLocalUserJoin = parent.didLocalUserJoin
|
||||
),
|
||||
date = parent.timestamp,
|
||||
peer = peer,
|
||||
groupCallState = if (peer.isGroup) {
|
||||
groupCallStateCache.getOrPut(parent.peer) { getGroupCallState(parent.body) }
|
||||
} else {
|
||||
CallLogRow.GroupCallState.NONE
|
||||
},
|
||||
children = children,
|
||||
searchQuery = filterState.query,
|
||||
callLinkPeekInfo = AppDependencies.signalCallManager.peekInfoSnapshot[peer.id],
|
||||
canUserBeginCall = if (peer.isGroup) {
|
||||
if (peer.isActiveGroup) {
|
||||
canUserBeginCallMap.getOrPut(parent.peer) { canUserBeginCall(peer, parent.decryptedGroupBytes) }
|
||||
} else false
|
||||
} else true
|
||||
)
|
||||
}
|
||||
|
||||
private fun Cursor.readCacheRecord(): CacheRecord {
|
||||
return CacheRecord(
|
||||
rowId = this.requireLong(CallTable.ID),
|
||||
callId = this.requireLong(CallTable.CALL_ID),
|
||||
timestamp = this.requireLong(CallTable.TIMESTAMP),
|
||||
type = this.requireInt(CallTable.TYPE),
|
||||
direction = this.requireInt(CallTable.DIRECTION),
|
||||
event = this.requireInt(CallTable.EVENT),
|
||||
ringerRecipient = this.requireLong(CallTable.RINGER),
|
||||
peer = this.requireLong(CallTable.PEER),
|
||||
isGroupCallActive = this.requireBoolean(CallTable.GROUP_CALL_ACTIVE),
|
||||
didLocalUserJoin = this.requireBoolean(CallTable.LOCAL_JOINED),
|
||||
messageId = this.requireLong(CallTable.MESSAGE_ID),
|
||||
body = this.requireString(MessageTable.BODY),
|
||||
decryptedGroupBytes = this.requireBlob(GroupTable.V2_DECRYPTED_GROUP)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val cacheRecords = BehaviorSubject.createDefault<List<CacheRecord>>(emptyList())
|
||||
|
||||
/**
|
||||
* Returns an [Observable] that can be listened to for updates to the data set. When the observable
|
||||
* is subscribed to, we will begin listening for call event changes. When it is disposed, we will stop.
|
||||
*/
|
||||
fun listenForChanges(): Observable<Unit> {
|
||||
onDataSetInvalidated()
|
||||
|
||||
val disposables = CompositeDisposable()
|
||||
|
||||
disposables += CallLogRepository.listenForCallTableChanges()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe {
|
||||
onDataSetInvalidated()
|
||||
}
|
||||
|
||||
disposables += AppDependencies
|
||||
.signalCallManager
|
||||
.peekInfoCache
|
||||
.skipWhile { cache -> cache.isEmpty() || cache.values.all { it.isCompletelyInactive } }
|
||||
.subscribeOn(Schedulers.computation())
|
||||
.distinctUntilChanged()
|
||||
.subscribe {
|
||||
onDataSetInvalidated()
|
||||
}
|
||||
|
||||
return cacheRecords.doOnDispose {
|
||||
disposables.clear()
|
||||
}.map { }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of call events according to the given [FilterState], [limit] and [offset].
|
||||
*/
|
||||
fun getCallEvents(filterState: FilterState, limit: Int, offset: Int): List<CallLogRow.Call> {
|
||||
val events = clusterCallEvents(cacheRecords.value!!, filterState)
|
||||
val start = max(offset, 0)
|
||||
val end = min(start + limit, events.size)
|
||||
return events.subList(start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of call events that match the given [FilterState]
|
||||
*/
|
||||
fun getCallEventsCount(filterState: FilterState): Int {
|
||||
val events = clusterCallEvents(cacheRecords.value!!, filterState)
|
||||
return events.size
|
||||
}
|
||||
|
||||
private fun onDataSetInvalidated() {
|
||||
executor.execute {
|
||||
cacheRecords.onNext(readDataFromDatabase())
|
||||
}
|
||||
}
|
||||
|
||||
data class FilterState(
|
||||
val query: String = "",
|
||||
val filter: CallLogFilter = CallLogFilter.ALL
|
||||
) {
|
||||
fun matches(cacheRecord: CacheRecord): Boolean {
|
||||
return isFilterMatch(cacheRecord, filter) && isQueryMatch(cacheRecord, query)
|
||||
}
|
||||
|
||||
private fun isFilterMatch(cacheRecord: CacheRecord, filter: CallLogFilter): Boolean {
|
||||
return when (filter) {
|
||||
CallLogFilter.ALL -> true
|
||||
CallLogFilter.MISSED -> isMissedCall(cacheRecord)
|
||||
CallLogFilter.AD_HOC -> error("Not supported.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isQueryMatch(cacheRecord: CacheRecord, query: String): Boolean {
|
||||
if (query.isEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
val recipient = Recipient.resolved(RecipientId.from(cacheRecord.peer))
|
||||
return recipient.isMatch(query)
|
||||
}
|
||||
}
|
||||
|
||||
class CacheRecord(
|
||||
val rowId: Long,
|
||||
val callId: Long,
|
||||
val peer: Long,
|
||||
val type: Int,
|
||||
val direction: Int,
|
||||
val event: Int,
|
||||
val messageId: Long,
|
||||
val timestamp: Long,
|
||||
val ringerRecipient: Long,
|
||||
val isGroupCallActive: Boolean,
|
||||
val didLocalUserJoin: Boolean,
|
||||
val body: String?,
|
||||
val decryptedGroupBytes: ByteArray?
|
||||
)
|
||||
}
|
|
@ -17,15 +17,36 @@ import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
|||
|
||||
class CallLogRepository(
|
||||
private val updateCallLinkRepository: UpdateCallLinkRepository = UpdateCallLinkRepository(),
|
||||
private val callLogPeekHelper: CallLogPeekHelper
|
||||
private val callLogPeekHelper: CallLogPeekHelper,
|
||||
private val callEventCache: CallEventCache
|
||||
) : CallLogPagedDataSource.CallRepository {
|
||||
|
||||
companion object {
|
||||
fun listenForCallTableChanges(): Observable<Unit> {
|
||||
return Observable.create { emitter ->
|
||||
fun refresh() {
|
||||
emitter.onNext(Unit)
|
||||
}
|
||||
|
||||
val databaseObserver = DatabaseObserver.Observer {
|
||||
refresh()
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.registerCallUpdateObserver(databaseObserver)
|
||||
|
||||
emitter.setCancellable {
|
||||
AppDependencies.databaseObserver.unregisterObserver(databaseObserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCallsCount(query: String?, filter: CallLogFilter): Int {
|
||||
return SignalDatabase.calls.getCallsCount(query, filter)
|
||||
return callEventCache.getCallEventsCount(CallEventCache.FilterState(query ?: "", filter))
|
||||
}
|
||||
|
||||
override fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow> {
|
||||
return SignalDatabase.calls.getCalls(start, length, query, filter)
|
||||
return callEventCache.getCallEvents(CallEventCache.FilterState(query ?: "", filter), length, start)
|
||||
}
|
||||
|
||||
override fun getCallLinksCount(query: String?, filter: CallLogFilter): Int {
|
||||
|
@ -48,6 +69,10 @@ class CallLogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
fun listenForChanges(): Observable<Unit> {
|
||||
return callEventCache.listenForChanges()
|
||||
}
|
||||
|
||||
fun markAllCallEventsRead() {
|
||||
SignalExecutors.BOUNDED_IO.execute {
|
||||
val latestCall = SignalDatabase.calls.getLatestCall() ?: return@execute
|
||||
|
@ -56,24 +81,6 @@ class CallLogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
fun listenForChanges(): Observable<Unit> {
|
||||
return Observable.create { emitter ->
|
||||
fun refresh() {
|
||||
emitter.onNext(Unit)
|
||||
}
|
||||
|
||||
val databaseObserver = DatabaseObserver.Observer {
|
||||
refresh()
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.registerCallUpdateObserver(databaseObserver)
|
||||
|
||||
emitter.setCancellable {
|
||||
AppDependencies.databaseObserver.unregisterObserver(databaseObserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSelectedCallLogs(
|
||||
selectedCallRowIds: Set<Long>
|
||||
): Completable {
|
||||
|
|
|
@ -9,12 +9,10 @@ import io.reactivex.rxjava3.core.Maybe
|
|||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.paging.ObservablePagedData
|
||||
import org.signal.paging.PagedData
|
||||
import org.signal.paging.PagingConfig
|
||||
import org.signal.paging.ProxyPagingController
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
|
||||
/**
|
||||
|
@ -22,7 +20,7 @@ import org.thoughtcrime.securesms.util.rx.RxStore
|
|||
*/
|
||||
class CallLogViewModel(
|
||||
val callLogPeekHelper: CallLogPeekHelper = CallLogPeekHelper(),
|
||||
private val callLogRepository: CallLogRepository = CallLogRepository(callLogPeekHelper = callLogPeekHelper)
|
||||
private val callLogRepository: CallLogRepository = CallLogRepository(callLogPeekHelper = callLogPeekHelper, callEventCache = CallEventCache())
|
||||
) : ViewModel() {
|
||||
private val callLogStore = RxStore(CallLogState())
|
||||
|
||||
|
@ -78,17 +76,6 @@ class CallLogViewModel(
|
|||
callLogPeekHelper.onDataSetInvalidated()
|
||||
controller.onDataInvalidated()
|
||||
}
|
||||
|
||||
disposables += AppDependencies
|
||||
.signalCallManager
|
||||
.peekInfoCache
|
||||
.skipWhile { cache -> cache.isEmpty() || cache.values.all { it.isCompletelyInactive } }
|
||||
.observeOn(Schedulers.computation())
|
||||
.distinctUntilChanged()
|
||||
.subscribe {
|
||||
callLogPeekHelper.onDataSetInvalidated()
|
||||
controller.onDataInvalidated()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
|
|
@ -87,7 +87,7 @@ object MessageDataFetcher {
|
|||
}
|
||||
|
||||
val callsFuture = executor.submitTimed {
|
||||
SignalDatabase.calls.getCalls(messageIds)
|
||||
SignalDatabase.calls.getCallsForCache(messageIds)
|
||||
}
|
||||
|
||||
val recipientsFuture = executor.submitTimed {
|
||||
|
|
|
@ -13,7 +13,6 @@ import org.signal.core.util.deleteAll
|
|||
import org.signal.core.util.exists
|
||||
import org.signal.core.util.flatten
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.isAbsent
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToMap
|
||||
|
@ -23,7 +22,6 @@ 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
|
||||
|
@ -31,9 +29,6 @@ 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.GroupCallUpdateDetailsUtil
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.CallLinkUpdateSendJob
|
||||
|
@ -238,7 +233,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
|||
.readToSingleObject(Call.Deserializer)
|
||||
}
|
||||
|
||||
fun getCalls(messageIds: Collection<Long>): Map<Long, Call> {
|
||||
fun getCallsForCache(messageIds: Collection<Long>): Map<Long, Call> {
|
||||
val queries = SqlUtil.buildCollectionQuery(MESSAGE_ID, messageIds)
|
||||
val maps = queries.map { query ->
|
||||
readableDatabase
|
||||
|
@ -1242,180 +1237,6 @@ 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("$TYPE != ${Type.serialize(Type.AD_HOC_CALL)} AND $DIRECTION == ${Direction.serialize(Direction.INCOMING)} AND ($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")
|
||||
}
|
||||
|
||||
val queryClause: SqlUtil.Query = if (!searchTerm.isNullOrEmpty()) {
|
||||
val glob = SqlUtil.buildCaseInsensitiveGlobPattern(searchTerm)
|
||||
val selection =
|
||||
"""
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED} = ? AND ${RecipientTable.TABLE_NAME}.${RecipientTable.HIDDEN} = ? AND
|
||||
(
|
||||
sort_name GLOB ? OR
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.USERNAME} GLOB ? OR
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.E164} GLOB ? OR
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.EMAIL} GLOB ?
|
||||
)
|
||||
"""
|
||||
SqlUtil.buildQuery(selection, 0, 0, glob, glob, glob, glob)
|
||||
} else {
|
||||
SqlUtil.buildQuery(
|
||||
"""
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED} = ? AND ${RecipientTable.TABLE_NAME}.${RecipientTable.HIDDEN} = ?
|
||||
""",
|
||||
0,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
val offsetLimit = if (limit > 0) {
|
||||
"LIMIT $offset,$limit"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val projection = if (isCount) {
|
||||
"COUNT(*) OVER() as count"
|
||||
} else {
|
||||
"p.$ID, p.$TIMESTAMP, $EVENT, $DIRECTION, $PEER, p.$TYPE, $CALL_ID, $MESSAGE_ID, $RINGER, $LOCAL_JOINED, $GROUP_CALL_ACTIVE, children, in_period, ${MessageTable.BODY}"
|
||||
}
|
||||
|
||||
val recipientSearchProjection = if (searchTerm.isNullOrEmpty()) {
|
||||
""
|
||||
} else {
|
||||
"""
|
||||
,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}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_GIVEN_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.USERNAME}, '')
|
||||
)
|
||||
) AS sort_name
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
val join = if (isCount) {
|
||||
""
|
||||
} else {
|
||||
"LEFT JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $MESSAGE_ID"
|
||||
}
|
||||
|
||||
// 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 = ${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)} 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))
|
||||
)
|
||||
"""
|
||||
|
||||
//language=sql
|
||||
val statement = """
|
||||
SELECT $projection
|
||||
$recipientSearchProjection
|
||||
FROM (
|
||||
WITH cte AS (
|
||||
SELECT
|
||||
$ID, $TIMESTAMP, $EVENT, $DIRECTION, $PEER, $TYPE, $CALL_ID, $MESSAGE_ID, $RINGER, $LOCAL_JOINED, $GROUP_CALL_ACTIVE,
|
||||
(
|
||||
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
|
||||
AND ($eventTypeSubQuery)
|
||||
AND ${filterClause.where}
|
||||
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
|
||||
AND ($eventTypeSubQuery)
|
||||
AND ${filterClause.where}
|
||||
) as children,
|
||||
(
|
||||
SELECT
|
||||
group_concat($ID)
|
||||
FROM
|
||||
$TABLE_NAME
|
||||
WHERE
|
||||
c.$TIMESTAMP - $TIME_WINDOW <= $TABLE_NAME.$TIMESTAMP
|
||||
AND c.$TIMESTAMP >= $TABLE_NAME.$TIMESTAMP
|
||||
AND ${filterClause.where}
|
||||
) as in_period
|
||||
FROM
|
||||
$TABLE_NAME c INDEXED BY $CALL_LOG_INDEX
|
||||
WHERE ${filterClause.where}
|
||||
ORDER BY
|
||||
$TIMESTAMP 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
|
||||
$join
|
||||
LEFT JOIN ${GroupTable.TABLE_NAME} ON ${GroupTable.TABLE_NAME}.${GroupTable.RECIPIENT_ID} = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
|
||||
WHERE true_parent = p.$ID
|
||||
AND CASE
|
||||
WHEN p.$TYPE = ${Type.serialize(Type.AD_HOC_CALL)} THEN EXISTS (SELECT * FROM ${CallLinkTable.TABLE_NAME} WHERE ${CallLinkTable.RECIPIENT_ID} = $PEER AND ${CallLinkTable.ROOT_KEY} NOT NULL)
|
||||
ELSE 1
|
||||
END
|
||||
${if (queryClause.where.isNotEmpty()) "AND ${queryClause.where}" else ""}
|
||||
GROUP BY CASE WHEN p.type = 4 THEN p.peer ELSE p._id END
|
||||
ORDER BY p.$TIMESTAMP DESC
|
||||
$offsetLimit
|
||||
"""
|
||||
|
||||
return readableDatabase.query(
|
||||
statement,
|
||||
queryClause.whereArgs
|
||||
)
|
||||
}
|
||||
|
||||
fun getLatestRingingCalls(): List<Call> {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
|
@ -1445,56 +1266,20 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
|||
}
|
||||
}
|
||||
|
||||
fun getCallsCount(searchTerm: String?, filter: CallLogFilter): Int {
|
||||
return getCallsCursor(true, 0, 1, searchTerm, filter).use {
|
||||
if (it.moveToFirst()) {
|
||||
it.getInt(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCalls(offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): List<CallLogRow.Call> {
|
||||
return getCallsCursor(false, offset, limit, searchTerm, filter).readToList { cursor ->
|
||||
val call = Call.deserialize(cursor)
|
||||
val groupCallDetails = GroupCallUpdateDetailsUtil.parse(cursor.requireString(MessageTable.BODY))
|
||||
|
||||
val children = cursor.requireNonNullString("children")
|
||||
.split(',')
|
||||
.map { it.toLong() }
|
||||
.toSet()
|
||||
|
||||
val inPeriod = cursor.requireNonNullString("in_period")
|
||||
.split(',')
|
||||
.map { it.toLong() }
|
||||
.sortedDescending()
|
||||
.toSet()
|
||||
|
||||
val actualChildren = inPeriod.takeWhile { children.contains(it) }
|
||||
val peer = Recipient.resolved(call.peer)
|
||||
|
||||
val canUserBeginCall = if (peer.isGroup) {
|
||||
val record = SignalDatabase.groups.getGroup(peer.id)
|
||||
|
||||
!record.isAbsent() &&
|
||||
record.get().isActive &&
|
||||
(!record.get().isAnnouncementGroup || record.get().memberLevel(Recipient.self()) == GroupTable.MemberLevel.ADMINISTRATOR)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
CallLogRow.Call(
|
||||
record = call,
|
||||
date = call.timestamp,
|
||||
peer = peer,
|
||||
groupCallState = CallLogRow.GroupCallState.fromDetails(groupCallDetails),
|
||||
children = actualChildren.toSet(),
|
||||
searchQuery = searchTerm,
|
||||
callLinkPeekInfo = AppDependencies.signalCallManager.peekInfoSnapshot[peer.id],
|
||||
canUserBeginCall = canUserBeginCall
|
||||
fun getCallsForCache(limit: Int): Cursor {
|
||||
return readableDatabase
|
||||
.query(
|
||||
"""
|
||||
SELECT $TABLE_NAME.*, ${MessageTable.TABLE_NAME}.${MessageTable.BODY}, ${GroupTable.TABLE_NAME}.${GroupTable.V2_DECRYPTED_GROUP}
|
||||
FROM $TABLE_NAME
|
||||
INNER JOIN ${RecipientTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = $PEER
|
||||
LEFT JOIN ${GroupTable.TABLE_NAME} ON ${GroupTable.TABLE_NAME}.${GroupTable.RECIPIENT_ID} = $PEER
|
||||
LEFT JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $MESSAGE_ID
|
||||
WHERE ${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED} = 0 AND ${RecipientTable.TABLE_NAME}.${RecipientTable.HIDDEN} = 0 AND $TABLE_NAME.$EVENT != ${Event.DELETE.code}
|
||||
ORDER BY $TIMESTAMP DESC
|
||||
LIMIT $limit
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
|
||||
|
@ -1579,7 +1364,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
|||
}
|
||||
}
|
||||
|
||||
enum class Type(private val code: Int) {
|
||||
enum class Type(val code: Int) {
|
||||
AUDIO_CALL(0),
|
||||
VIDEO_CALL(1),
|
||||
GROUP_CALL(3),
|
||||
|
@ -1611,7 +1396,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
|||
}
|
||||
}
|
||||
|
||||
enum class Direction(private val code: Int) {
|
||||
enum class Direction(val code: Int) {
|
||||
INCOMING(0),
|
||||
OUTGOING(1);
|
||||
|
||||
|
@ -1652,7 +1437,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
|||
}
|
||||
}
|
||||
|
||||
enum class Event(private val code: Int) {
|
||||
enum class Event(val code: Int) {
|
||||
/**
|
||||
* 1:1 Calls only.
|
||||
*/
|
||||
|
|
|
@ -499,6 +499,27 @@ class Recipient(
|
|||
profileName.toString().isNotNullOrBlank()
|
||||
}
|
||||
|
||||
fun isMatch(query: String): Boolean {
|
||||
if (query.isEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
val lowercaseQuery = query.lowercase()
|
||||
val sortName = listOf(
|
||||
nickname.toString(),
|
||||
nickname.givenName,
|
||||
systemProfileName.toString(),
|
||||
systemProfileName.givenName,
|
||||
profileName.toString(),
|
||||
profileName.givenName,
|
||||
username.orElse("")
|
||||
).firstOrNull { it.isNotNullOrBlank() }?.lowercase()
|
||||
|
||||
return sortName?.contains(lowercaseQuery) == true ||
|
||||
e164.map { it.contains(query) }.orElse(false) ||
|
||||
email.map { it.contains(query) }.orElse(false)
|
||||
}
|
||||
|
||||
/** A full-length display name to render for this recipient. */
|
||||
fun getDisplayName(context: Context): String {
|
||||
var name = getNameFromLocalData(context)
|
||||
|
|
|
@ -0,0 +1,205 @@
|
|||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.size
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.spyk
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.thoughtcrime.securesms.database.CallTable.Direction
|
||||
import org.thoughtcrime.securesms.database.CallTable.Event
|
||||
import org.thoughtcrime.securesms.database.CallTable.Type
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
class CallEventCacheTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkObject(Recipient.Companion)
|
||||
every { Recipient.resolved(any()) } answers {
|
||||
spyk(
|
||||
Recipient(
|
||||
id = firstArg(),
|
||||
isResolving = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val signalCallManagerMock: SignalCallManager = mockk()
|
||||
every { signalCallManagerMock.peekInfoSnapshot } returns emptyMap()
|
||||
|
||||
mockkStatic(AppDependencies::class)
|
||||
every { AppDependencies.signalCallManager } returns signalCallManagerMock
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given no entries, when I clusterCallEvents, then I expect nothing`() {
|
||||
val testData = emptyList<CallEventCache.CacheRecord>()
|
||||
|
||||
val filterState = CallEventCache.FilterState()
|
||||
val result = CallEventCache.clusterCallEvents(testData, filterState)
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given one entry, when I clusterCallEvents, then I expect one entry`() {
|
||||
val testData = listOf(
|
||||
createCacheRecord(
|
||||
callId = 1
|
||||
)
|
||||
)
|
||||
|
||||
val filterState = CallEventCache.FilterState()
|
||||
val result = CallEventCache.clusterCallEvents(testData, filterState)
|
||||
assertThat(result).size().isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given two overlapping entries, when I clusterCallEvents, then I expect one entry`() {
|
||||
val testData = listOf(
|
||||
createCacheRecord(
|
||||
callId = 1
|
||||
),
|
||||
createCacheRecord(
|
||||
callId = 2
|
||||
)
|
||||
)
|
||||
|
||||
val filterState = CallEventCache.FilterState()
|
||||
val result = CallEventCache.clusterCallEvents(testData, filterState)
|
||||
assertThat(result).size().isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given two entries with different peers, when I clusterCallEvents, then I expect two entries`() {
|
||||
val testData = listOf(
|
||||
createCacheRecord(
|
||||
callId = 1,
|
||||
peer = 1
|
||||
),
|
||||
createCacheRecord(
|
||||
callId = 2,
|
||||
peer = 2
|
||||
)
|
||||
)
|
||||
|
||||
val filterState = CallEventCache.FilterState()
|
||||
val result = CallEventCache.clusterCallEvents(testData, filterState)
|
||||
assertThat(result).size().isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given two entries with different directions, when I clusterCallEvents, then I expect two entries`() {
|
||||
val testData = listOf(
|
||||
createCacheRecord(
|
||||
callId = 1,
|
||||
direction = Direction.INCOMING.code
|
||||
),
|
||||
createCacheRecord(
|
||||
callId = 1,
|
||||
direction = Direction.OUTGOING.code
|
||||
)
|
||||
)
|
||||
|
||||
val filterState = CallEventCache.FilterState()
|
||||
val result = CallEventCache.clusterCallEvents(testData, filterState)
|
||||
assertThat(result).size().isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given two entries with one missed and one not missed, when I clusterCallEvents, then I expect two entries`() {
|
||||
val testData = listOf(
|
||||
createCacheRecord(
|
||||
callId = 1,
|
||||
event = Event.MISSED.code
|
||||
),
|
||||
createCacheRecord(
|
||||
callId = 2,
|
||||
event = Event.ACCEPTED.code
|
||||
)
|
||||
)
|
||||
|
||||
val filterState = CallEventCache.FilterState()
|
||||
val result = CallEventCache.clusterCallEvents(testData, filterState)
|
||||
assertThat(result).size().isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given two entries outside of time threshold, when I clusterCallEvents, then I expect two entries`() {
|
||||
val testData = listOf(
|
||||
createCacheRecord(
|
||||
callId = 1,
|
||||
timestamp = 0
|
||||
),
|
||||
createCacheRecord(
|
||||
callId = 2,
|
||||
timestamp = 1.days.inWholeMilliseconds
|
||||
)
|
||||
)
|
||||
|
||||
val filterState = CallEventCache.FilterState()
|
||||
val result = CallEventCache.clusterCallEvents(testData, filterState)
|
||||
assertThat(result).size().isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given two entries with a mismatch between them, when I clusterCallEvents, then I expect three entries`() {
|
||||
val testData = listOf(
|
||||
createCacheRecord(
|
||||
callId = 1,
|
||||
peer = 1
|
||||
),
|
||||
createCacheRecord(
|
||||
callId = 2,
|
||||
peer = 2
|
||||
),
|
||||
createCacheRecord(
|
||||
callId = 3,
|
||||
peer = 1
|
||||
)
|
||||
)
|
||||
|
||||
val filterState = CallEventCache.FilterState()
|
||||
val result = CallEventCache.clusterCallEvents(testData, filterState)
|
||||
assertThat(result).size().isEqualTo(3)
|
||||
}
|
||||
|
||||
private fun createCacheRecord(
|
||||
callId: Long,
|
||||
peer: Long = 1,
|
||||
type: Int = Type.AUDIO_CALL.code,
|
||||
direction: Int = Direction.INCOMING.code,
|
||||
event: Int = Event.ACCEPTED.code,
|
||||
messageId: Long = 0L,
|
||||
timestamp: Long = 0L,
|
||||
ringerRecipient: Long = 0L,
|
||||
isGroupCallActive: Boolean = false,
|
||||
didLocalUserJoin: Boolean = false,
|
||||
body: String? = null,
|
||||
decryptedGroupBytes: ByteArray? = null
|
||||
): CallEventCache.CacheRecord {
|
||||
return CallEventCache.CacheRecord(
|
||||
rowId = callId,
|
||||
callId = callId,
|
||||
peer = peer,
|
||||
type = type,
|
||||
direction = direction,
|
||||
event = event,
|
||||
messageId = messageId,
|
||||
timestamp = timestamp,
|
||||
ringerRecipient = ringerRecipient,
|
||||
isGroupCallActive = isGroupCallActive,
|
||||
didLocalUserJoin = didLocalUserJoin,
|
||||
body = body,
|
||||
decryptedGroupBytes = decryptedGroupBytes
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue