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 6926691423..f6fdbcf204 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 @@ -122,6 +122,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal initializeSharedElementTransition() viewLifecycleOwner.lifecycle.addObserver(conversationUpdateTick) + viewLifecycleOwner.lifecycle.addObserver(viewModel.callLogPeekHelper) val callLogAdapter = CallLogAdapter(this) disposables.bindTo(viewLifecycleOwner) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt index 26b3657a70..bb1830cb2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPagedDataSource.kt @@ -67,6 +67,7 @@ class CallLogPagedDataSource( callLogRows.add(CallLogRow.ClearFilter) } + repository.onCallTabPageLoaded(callLogRows) return callLogRows } @@ -83,5 +84,6 @@ class CallLogPagedDataSource( fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List fun getCallLinksCount(query: String?, filter: CallLogFilter): Int fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List + fun onCallTabPageLoaded(pageData: List) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPeekHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPeekHelper.kt new file mode 100644 index 0000000000..6af8dd3392 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogPeekHelper.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.calls.log + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId +import org.thoughtcrime.securesms.util.ThrottledDebouncer +import org.thoughtcrime.securesms.util.concurrent.SerialExecutor +import java.util.concurrent.Executor +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds + +/** + * Peeks calls in the call log as data is loaded in, according to + * an algorithm. + */ +class CallLogPeekHelper : DefaultLifecycleObserver { + + companion object { + private val TAG = Log.tag(CallLogPeekHelper::class.java) + private const val PEEK_SIZE = 10 + } + + private val executor: Executor = SerialExecutor(SignalExecutors.BOUNDED_IO) + private val debouncer = ThrottledDebouncer(30.seconds.inWholeMilliseconds) + private val dataSet = mutableSetOf() + private val peekQueue = mutableSetOf() + + private var isFirstLoad = true + private var isPaused = false + + override fun onResume(owner: LifecycleOwner) { + executor.execute { + isPaused = false + performPeeks() + } + } + + override fun onPause(owner: LifecycleOwner) { + executor.execute { + isPaused = true + debouncer.clear() + } + } + + /** + * Called whenever the underlying datasource has been invalidated. + */ + fun onDataSetInvalidated() { + executor.execute { + debouncer.clear() + dataSet.clear() + peekQueue.clear() + } + } + + /** + * Called whenever a new page of data is loaded by the datasource. + */ + fun onPageLoaded(pageData: List) { + executor.execute { + handleActiveCallLinks(pageData) + handleActiveGroupCalls(pageData) + handleInactiveGroupCalls(pageData) + handleInactiveCallLinks(pageData) + performPeeks() + } + } + + /** + * Adds any and all active call links to our data set and queue + */ + private fun handleActiveCallLinks(pageData: List) { + val activeUnusedCallLinks: List = pageData.filterIsInstance() + .filter { it.callLinkPeekInfo?.isActive == true } + .map { PeekEntry(it.recipient.id, PeekEntryIdentifier.CallLink(it.record.roomId, PeekEntryType.CALL_LINK)) } + + val activeCallLinksFromEvents: List = pageData.filterIsInstance() + .filter { it.peer.isCallLink && it.callLinkPeekInfo?.isActive == true } + .map { PeekEntry(it.peer.id, PeekEntryIdentifier.Call(it.record.callId, PeekEntryType.CALL_LINK)) } + + val activeCallLinks: List = activeUnusedCallLinks + activeCallLinksFromEvents + dataSet.addAll(activeCallLinks) + peekQueue.addAll(activeCallLinks) + } + + /** + * Adds any and all active group calls to our dataset and queue. + */ + private fun handleActiveGroupCalls(pageData: List) { + val activeGroupCalls: List = pageData.filterIsInstance() + .filter { it.peer.isGroup && it.groupCallState != CallLogRow.GroupCallState.NONE } + .map { PeekEntry(it.peer.id, PeekEntryIdentifier.Call(it.record.callId, PeekEntryType.GROUP_CALL)) } + + dataSet.addAll(activeGroupCalls) + peekQueue.addAll(activeGroupCalls) + } + + /** + * Removes any and all inactive group calls from our dataset and queue. + */ + private fun handleInactiveGroupCalls(pageData: List) { + val inactiveGroupCalls: Set = pageData.filterIsInstance() + .filter { it.peer.isGroup && it.groupCallState == CallLogRow.GroupCallState.NONE } + .map { PeekEntry(it.peer.id, PeekEntryIdentifier.Call(it.record.callId, PeekEntryType.GROUP_CALL)) } + .toSet() + + peekQueue.removeAll(inactiveGroupCalls) + dataSet.removeAll(inactiveGroupCalls) + } + + /** + * On first load, adds all inactive call links to our queue. On subsequent calls, removes them from the dataset. + */ + private fun handleInactiveCallLinks(pageData: List) { + val inactiveUnusedCallLinks: List = pageData.filterIsInstance() + .filter { it.callLinkPeekInfo?.isActive != true } + .map { PeekEntry(it.recipient.id, PeekEntryIdentifier.CallLink(it.record.roomId, PeekEntryType.CALL_LINK)) } + + val inactiveCallLinksFromEvents: List = pageData.filterIsInstance() + .filter { it.callLinkPeekInfo?.isActive != true } + .filter { it.record.timestamp <= 10.days.inWholeMilliseconds } + .map { PeekEntry(it.peer.id, PeekEntryIdentifier.Call(it.record.callId, PeekEntryType.CALL_LINK)) } + + val inactiveCallLinks: Set = (inactiveUnusedCallLinks + inactiveCallLinksFromEvents).take(10).toSet() + + if (isFirstLoad) { + isFirstLoad = false + + peekQueue.addAll(inactiveCallLinks) + } else { + dataSet.removeAll(inactiveCallLinks) + } + } + + private fun performPeeks() { + executor.execute { + if (peekQueue.isEmpty() || isPaused) { + return@execute + } + + Log.d(TAG, "Peeks in queue. Taking first $PEEK_SIZE.") + + val items = peekQueue.take(PEEK_SIZE) + val remaining = peekQueue.drop(PEEK_SIZE) + + peekQueue.clear() + peekQueue.addAll(remaining) + + items.forEach { + when (it.identifier.peekEntryType) { + PeekEntryType.CALL_LINK -> ApplicationDependencies.getSignalCallManager().peekCallLinkCall(it.recipientId) + PeekEntryType.GROUP_CALL -> ApplicationDependencies.getSignalCallManager().peekGroupCall(it.recipientId) + } + } + + Log.d(TAG, "Began peeks for ${items.size} calls.") + + peekQueue.addAll(dataSet) + debouncer.publish { performPeeks() } + } + } + + private enum class PeekEntryType { + CALL_LINK, + GROUP_CALL + } + + private sealed interface PeekEntryIdentifier { + val peekEntryType: PeekEntryType + + data class CallLink(private val roomId: CallLinkRoomId, override val peekEntryType: PeekEntryType = PeekEntryType.CALL_LINK) : PeekEntryIdentifier + data class Call(private val callId: Long, override val peekEntryType: PeekEntryType) : PeekEntryIdentifier + } + + private data class PeekEntry( + val recipientId: RecipientId, + val identifier: PeekEntryIdentifier + ) +} 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 a46805cdce..b67474599e 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 @@ -17,7 +17,8 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult class CallLogRepository( - private val updateCallLinkRepository: UpdateCallLinkRepository = UpdateCallLinkRepository() + private val updateCallLinkRepository: UpdateCallLinkRepository = UpdateCallLinkRepository(), + private val callLogPeekHelper: CallLogPeekHelper ) : CallLogPagedDataSource.CallRepository { override fun getCallsCount(query: String?, filter: CallLogFilter): Int { return SignalDatabase.calls.getCallsCount(query, filter) @@ -41,6 +42,12 @@ class CallLogRepository( } } + override fun onCallTabPageLoaded(pageData: List) { + SignalExecutors.BOUNDED_IO.execute { + callLogPeekHelper.onPageLoaded(pageData) + } + } + fun markAllCallEventsRead() { SignalExecutors.BOUNDED_IO.execute { val latestCall = SignalDatabase.calls.getLatestCall() ?: return@execute diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt index 91529fa222..2180ebaf20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt @@ -24,7 +24,8 @@ import java.util.concurrent.TimeUnit * ViewModel for call log management. */ class CallLogViewModel( - private val callLogRepository: CallLogRepository = CallLogRepository() + val callLogPeekHelper: CallLogPeekHelper = CallLogPeekHelper(), + private val callLogRepository: CallLogRepository = CallLogRepository(callLogPeekHelper = callLogPeekHelper) ) : ViewModel() { private val callLogStore = RxStore(CallLogState()) @@ -77,6 +78,7 @@ class CallLogViewModel( } disposables += callLogRepository.listenForChanges().subscribe { + callLogPeekHelper.onDataSetInvalidated() controller.onDataInvalidated() } @@ -92,6 +94,7 @@ class CallLogViewModel( .observeOn(Schedulers.computation()) .distinctUntilChanged() .subscribe { + callLogPeekHelper.onDataSetInvalidated() controller.onDataInvalidated() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallLinkPeekInfo.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallLinkPeekInfo.kt index 4a3b0dfe55..e8b1f80bda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallLinkPeekInfo.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallLinkPeekInfo.kt @@ -12,13 +12,15 @@ import org.signal.ringrtc.PeekInfo * App-level peek info object for call links. */ data class CallLinkPeekInfo( - val callId: CallId? + val callId: CallId?, + val isActive: Boolean ) { companion object { @JvmStatic fun fromPeekInfo(peekInfo: PeekInfo): CallLinkPeekInfo { return CallLinkPeekInfo( - callId = peekInfo.eraId?.let { CallId.fromEra(it) } + callId = peekInfo.eraId?.let { CallId.fromEra(it) }, + isActive = peekInfo.joinedMembers.isNotEmpty() ) } }