TM-SGNL-iOS/Signal/Calls/CallRecordLoader.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

207 lines
7 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import GRDB
import SignalServiceKit
/// Describes a direction in which to load call records.
enum CallRecordLoaderLoadDirection {
case olderThan(oldestCallTimestamp: UInt64?)
case newerThan(newestCallTimestamp: UInt64)
}
/// A ``CallRecordLoader`` is a layer between making raw queries over call
/// records and a stream of call records that's consumable by the Calls Tab.
///
/// Specifically, a ``CallRecordLoaderImpl`` serves as an in-memory repository
/// for a query configuration, and consolidates the results of the potentially
/// multiple raw queries that a configuration requires into a single call record
/// cursor.
protocol CallRecordLoader {
typealias LoadDirection = CallRecordLoaderLoadDirection
/// Load call records in the given direction.
///
/// - Important
/// Recall that a ``CallRecordCursor`` is only valid within the transaction
/// in which it was created.
/// - SeeAlso: ``CallRecordCursor``
///
/// - Returns
/// A cursor over call records matching this loader's parameters and the
/// given load direction.
///
/// If the given direction is ``LoadDirection/older``, the returned cursor
/// will be ordered by ``CallRecordCursorOrdering/descending``. If the given
/// direction is ``LoadDirection/newer``, the returned cursor will be
/// ordered by ``CallRecordCursorOrdering/ascending``.
func loadCallRecords(
loadDirection: LoadDirection,
tx: DBReadTransaction
) -> CallRecordCursor
}
class CallRecordLoaderImpl: CallRecordLoader {
struct Configuration {
/// Whether the loader should only load missed calls.
let onlyLoadMissedCalls: Bool
/// If present, the loader will only load calls matching threads with
/// the given SQLite row IDs.
let onlyMatchThreadRowIds: [Int64]?
init(
onlyLoadMissedCalls: Bool,
onlyMatchThreadRowIds: [Int64]?
) {
self.onlyLoadMissedCalls = onlyLoadMissedCalls
self.onlyMatchThreadRowIds = onlyMatchThreadRowIds
}
}
private let callRecordQuerier: CallRecordQuerier
private let configuration: Configuration
init(
callRecordQuerier: CallRecordQuerier,
configuration: Configuration
) {
self.callRecordQuerier = callRecordQuerier
self.configuration = configuration
}
/// Loads a page of ``CallRecord``s in the given direction.
///
/// - Returns
/// The newly-loaded records. These records are always sorted descending;
/// i.e., the first record is the newest and the last record is the oldest.
func loadCallRecords(
loadDirection: LoadDirection,
tx: DBReadTransaction
) -> CallRecordCursor {
let fetchOrdering: CallRecordQuerier.FetchOrdering = {
switch loadDirection {
case .olderThan(nil):
return .descending
case .olderThan(.some(let oldestCallTimestamp)):
return .descendingBefore(timestamp: oldestCallTimestamp)
case .newerThan(let newestCallTimestamp):
return .ascendingAfter(timestamp: newestCallTimestamp)
}
}()
let callRecordCursors = callRecordCursors(
ordering: fetchOrdering,
tx: tx
)
if callRecordCursors.isEmpty {
return EmptyCallRecordCursor()
}
do {
return try InterleavingCallRecordCursor(
callRecordCursors: callRecordCursors.map {
InterleavableCallRecordCursor(callRecordCursor: $0)
},
nextElementComparator: { (lhs, rhs) in
switch fetchOrdering {
case .descending, .descendingBefore:
// When descending, we want the newest record next.
return lhs.callBeganTimestamp > rhs.callBeganTimestamp
case .ascendingAfter:
// When ascending, we want the oldest record next.
return lhs.callBeganTimestamp < rhs.callBeganTimestamp
}
}
)
} catch let error {
CallRecordLogger.shared.error("Failed to drain cursors! \(error)")
return EmptyCallRecordCursor()
}
}
private func callRecordCursors(
ordering: CallRecordQuerierFetchOrdering,
tx: DBReadTransaction
) -> [CallRecordCursor] {
if
let onlyMatchThreadRowIds = configuration.onlyMatchThreadRowIds,
configuration.onlyLoadMissedCalls
{
return onlyMatchThreadRowIds.flatMap { threadRowId -> [CallRecordCursor] in
return CallRecord.CallStatus.missedCalls.compactMap { callStatus -> CallRecordCursor? in
return callRecordQuerier.fetchCursor(
threadRowId: threadRowId,
callStatus: callStatus,
ordering: ordering,
tx: tx
)
}
}
} else if let onlyMatchThreadRowIds = configuration.onlyMatchThreadRowIds {
return onlyMatchThreadRowIds.compactMap { threadRowId -> CallRecordCursor? in
return callRecordQuerier.fetchCursor(
threadRowId: threadRowId,
ordering: ordering,
tx: tx
)
}
} else if configuration.onlyLoadMissedCalls {
return CallRecord.CallStatus.missedCalls.compactMap { callStatus -> CallRecordCursor? in
return callRecordQuerier.fetchCursor(
callStatus: callStatus,
ordering: ordering,
tx: tx
)
}
} else if let fetchCursor = callRecordQuerier.fetchCursor(
ordering: ordering,
tx: tx
) {
return [fetchCursor]
} else {
return []
}
}
}
// MARK: -
private struct InterleavableCallRecordCursor: InterleavableCursor {
private let callRecordCursor: CallRecordCursor
init(callRecordCursor: CallRecordCursor) {
self.callRecordCursor = callRecordCursor
}
// MARK: InterleavableCursor
typealias InterleavableElement = CallRecord
func nextInterleavableElement() throws -> InterleavableElement? {
return try callRecordCursor.next()
}
}
private class InterleavingCallRecordCursor: InterleavingCompositeCursor<InterleavableCallRecordCursor>, CallRecordCursor {
init(
callRecordCursors: [InterleavableCallRecordCursor],
nextElementComparator: @escaping ElementSortComparator
) throws {
try super.init(
interleaving: callRecordCursors,
nextElementComparator: nextElementComparator
)
}
}
// MARK: -
private struct EmptyCallRecordCursor: CallRecordCursor {
func next() throws -> CallRecord? {
return nil
}
}