Signal-iOS/Signal/CallsTab/CallRecordLoader.swift

290 lines
9.3 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import GRDB
import SignalServiceKit
class CallRecordLoader {
struct Configuration {
/// Whether the loader should only load missed calls.
let onlyLoadMissedCalls: Bool
/// If present, the loader will only load calls that match this term.
let searchTerm: String?
// [CallsTab] TODO: do we want special behavior if a load exceeds this amount?
// [CallsTab] TODO: what happens if the search results change under us between loads?
/// The max number of matches for ``searchTerm`` used for an invocation
/// of ``CallRecordLoader/loadCallRecords(loadDirection:onlyMissedCalls:matchingSearchTerm:)``.
let maxSearchResults: UInt
init(
onlyLoadMissedCalls: Bool = false,
searchTerm: String? = nil,
maxSearchResults: UInt = 100
) {
self.onlyLoadMissedCalls = onlyLoadMissedCalls
self.searchTerm = searchTerm
self.maxSearchResults = maxSearchResults
}
}
enum LoadDirection {
case older(oldestCallTimestamp: UInt64?)
case newer(newestCallTimestamp: UInt64)
}
private let callRecordQuerier: CallRecordQuerier
private let fullTextSearchFinder: Shims.FullTextSearchFinder
private let configuration: Configuration
/// The row IDs for the threads matching the search term in this loader's
/// ``Configuration``. Set during the first load with this configuration.
/// `nil` if this configuration does not contain a search term.
///
/// If each time we loaded a new page of records we performed a new search,
/// we may end up with records for a different set of threads for the new
/// page than in the existing pages, if something changed in the search
/// index between loads. That'd be very bad.
private var threadRowIdsMatchingSearchTerm: [Int64]?
init(
callRecordQuerier: CallRecordQuerier,
fullTextSearchFinder: Shims.FullTextSearchFinder,
configuration: Configuration
) {
self.callRecordQuerier = callRecordQuerier
self.fullTextSearchFinder = fullTextSearchFinder
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,
pageSize: UInt,
tx: DBReadTransaction
) -> [CallRecord] {
let fetchOrdering: CallRecordQuerierFetchOrdering = {
switch loadDirection {
case .older(nil):
return .descending
case .older(.some(let oldestCallTimestamp)):
return .descendingBefore(timestamp: oldestCallTimestamp)
case .newer(let newestCallTimestamp):
return .ascendingAfter(timestamp: newestCallTimestamp)
}
}()
let newCallRecords: [CallRecord] = {
let callRecordCursors = callRecordCursors(
ordering: fetchOrdering,
tx: tx
)
if callRecordCursors.isEmpty {
return []
}
do {
let interleavingCursor = try InterleavingCompositeCursor(
interleaving: 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
}
}
)
return try interleavingCursor.drain(maxResults: pageSize)
} catch let error {
CallRecordLogger.shared.error("Failed to drain cursors! \(error)")
return []
}
}()
switch fetchOrdering {
case .descending, .descendingBefore:
return newCallRecords
case .ascendingAfter:
// The new call records will be sorted ascending, which makes sense
// for the query but we want to return them descending.
return newCallRecords.reversed()
}
}
private func callRecordCursors(
ordering: CallRecordQuerierFetchOrdering,
tx: DBReadTransaction
) -> [CallRecordCursor] {
guard let searchTerm = configuration.searchTerm else {
if configuration.onlyLoadMissedCalls {
return CallRecord.CallStatus.missedCalls.compactMap { callStatus in
callRecordQuerier.fetchCursor(
callStatus: callStatus,
ordering: ordering,
tx: tx
)
}
} else if let singleCursor = callRecordQuerier.fetchCursor(
ordering: ordering,
tx: tx
) {
return [singleCursor]
}
return []
}
let threadRowIdsMatchingSearchTerm: [Int64] = {
if let threadsFromEarlierSearch = self.threadRowIdsMatchingSearchTerm {
return threadsFromEarlierSearch
}
let threadsMatchingSearch = fullTextSearchFinder.findThreadsMatching(
searchTerm: searchTerm,
maxSearchResults: configuration.maxSearchResults,
tx: tx
)
let threadRowIdsMatchingSearchTerm = threadsMatchingSearch.compactMap { thread -> Int64 in
guard let threadRowId = thread.sqliteRowId else {
owsFail("How did we match a thread in the FTS index that doesn't have a SQLite row ID?")
}
return threadRowId
}
self.threadRowIdsMatchingSearchTerm = threadRowIdsMatchingSearchTerm
return threadRowIdsMatchingSearchTerm
}()
return threadRowIdsMatchingSearchTerm.flatMap { threadRowId -> [CallRecordCursor] in
if configuration.onlyLoadMissedCalls {
return CallRecord.CallStatus.missedCalls.compactMap { callStatus in
return callRecordQuerier.fetchCursor(
threadRowId: threadRowId,
callStatus: callStatus,
ordering: ordering,
tx: tx
)
}
} else if let singleThreadCursor = callRecordQuerier.fetchCursor(
threadRowId: threadRowId,
ordering: ordering,
tx: tx
) {
return [singleThreadCursor]
}
return []
}
}
}
extension CallRecord.CallStatus {
static var missedCalls: [CallRecord.CallStatus] {
return [
.individual(.incomingMissed),
.group(.ringingMissed)
]
}
var isMissedCall: Bool {
return Self.missedCalls.contains(self)
}
}
// MARK: -
private extension CallRecordLoader {
struct InterleavableCallRecordCursor: InterleavableCursor {
private let callRecordCursor: CallRecordCursor
init(callRecordCursor: CallRecordCursor) {
self.callRecordCursor = callRecordCursor
}
// MARK: InterleavableCursor
typealias Element = CallRecord
func nextElement() throws -> Element? {
return try callRecordCursor.next()
}
}
}
// MARK: -
private extension InterleavingCompositeCursor {
func drain(maxResults: UInt) throws -> [CursorType.Element] {
var results = [CursorType.Element]()
while
let next = try next(),
results.count < maxResults
{
results.append(next)
}
return results
}
}
// MARK: - Shims
extension CallRecordLoader {
enum Shims {
typealias FullTextSearchFinder = CallRecordLoader_FullTextSearchFinder_Shim
}
enum Wrappers {
typealias FullTextSearchFinder = CallRecordLoader_FullTextSearchFinder_Wrapper
}
}
protocol CallRecordLoader_FullTextSearchFinder_Shim {
func findThreadsMatching(
searchTerm: String,
maxSearchResults: UInt,
tx: DBReadTransaction
) -> [TSThread]
}
struct CallRecordLoader_FullTextSearchFinder_Wrapper: CallRecordLoader_FullTextSearchFinder_Shim {
init() {}
func findThreadsMatching(
searchTerm: String,
maxSearchResults: UInt,
tx: DBReadTransaction
) -> [TSThread] {
var threads = [TSThread]()
FullTextSearchFinder.enumerateObjects(
searchText: searchTerm,
maxResults: maxSearchResults,
transaction: SDSDB.shimOnlyBridge(tx)
) { (thread: TSThread, _, _) in
threads.append(thread)
}
return threads
}
}