789 lines
33 KiB
Swift
789 lines
33 KiB
Swift
//
|
||
// Copyright 2024 Signal Messenger, LLC
|
||
// SPDX-License-Identifier: AGPL-3.0-only
|
||
//
|
||
|
||
import SignalServiceKit
|
||
|
||
extension CallsListViewController {
|
||
/// Responsible for loading ``CallViewModel``s from disk and persisting them
|
||
/// in-memory.
|
||
///
|
||
/// This type will maintain an ordered list of references to every
|
||
/// ``CallViewModel`` it has ever loaded, as well as a bounded in-memory
|
||
/// cache of full view models. It exposes methods for paging forwards and
|
||
/// backwards in time, which results in updates to the currently-cached view
|
||
/// models as well as additions to the loaded view model references as
|
||
/// appropriate.
|
||
///
|
||
/// Callers should inspect ``loadedViewModelReferences`` for a comprehensive
|
||
/// list of all view models this instance is aware of, and use
|
||
/// ``getCachedViewModel(loadedViewModelReferenceIndex:)`` to request a full
|
||
/// view model for the reference at the given index in
|
||
/// ``loadedViewModelReferences``.
|
||
///
|
||
/// Note that in the event of a "cache miss" when requesting a full view
|
||
/// model from this type, it is the callers responsibility to instruct this
|
||
/// type to load models to refill the cache such that the next request
|
||
/// results in a cache hit.
|
||
struct ViewModelLoader {
|
||
typealias CreateCallViewModelBlock = (
|
||
_ primaryCallRecord: CallRecord,
|
||
_ coalescedCallRecords: [CallRecord],
|
||
_ tx: DBReadTransaction
|
||
) -> CallViewModel
|
||
|
||
typealias FetchCallRecordBlock = (
|
||
_ callRecordId: CallRecord.ID,
|
||
_ tx: DBReadTransaction
|
||
) -> CallRecord?
|
||
|
||
enum LoadDirection {
|
||
case older
|
||
case newer
|
||
}
|
||
|
||
private var callRecordLoader: CallRecordLoader
|
||
private let createCallViewModelBlock: CreateCallViewModelBlock
|
||
private let fetchCallRecordBlock: FetchCallRecordBlock
|
||
private let viewModelPageSize: UInt
|
||
private let maxCachedViewModelCount: Int
|
||
private let maxCoalescedCallsInOneViewModel: UInt
|
||
|
||
init(
|
||
callRecordLoader: CallRecordLoader,
|
||
createCallViewModelBlock: @escaping CreateCallViewModelBlock,
|
||
fetchCallRecordBlock: @escaping FetchCallRecordBlock,
|
||
viewModelPageSize: UInt = 50,
|
||
maxCachedViewModelCount: Int = 150,
|
||
maxCoalescedCallsInOneViewModel: UInt = 50
|
||
) {
|
||
owsAssert(
|
||
maxCachedViewModelCount >= viewModelPageSize,
|
||
"Must be able to cache at least one page of view models!"
|
||
)
|
||
|
||
self.callRecordLoader = callRecordLoader
|
||
self.createCallViewModelBlock = createCallViewModelBlock
|
||
self.fetchCallRecordBlock = fetchCallRecordBlock
|
||
self.viewModelPageSize = viewModelPageSize
|
||
self.maxCachedViewModelCount = maxCachedViewModelCount
|
||
self.maxCoalescedCallsInOneViewModel = maxCoalescedCallsInOneViewModel
|
||
|
||
self.loadedViewModelReferences = IdentifierIndexedArray(elements: [])
|
||
self.cachedViewModels = IdentifierIndexedArray(elements: [])
|
||
}
|
||
|
||
// MARK: - Loaded view model references
|
||
|
||
/// All the view model references we've loaded thus far.
|
||
private(set) var loadedViewModelReferences: IdentifierIndexedArray<CallViewModel.Reference>
|
||
|
||
/// Returns a view model reference matching the given one, with any of
|
||
/// the given call record IDs removed. If the resulting reference would
|
||
/// contain no call record IDs, `nil` is returned.
|
||
private func dropCallRecordIds(
|
||
_ idsToDrop: Set<CallRecord.ID>,
|
||
fromViewModelReference viewModelReference: CallViewModel.Reference
|
||
) -> CallViewModel.Reference? {
|
||
var containedCallRecordIds: [CallRecord.ID] = viewModelReference.containedIds
|
||
|
||
let countBefore = containedCallRecordIds.count
|
||
containedCallRecordIds.removeAll { idsToDrop.contains($0) }
|
||
|
||
guard countBefore != containedCallRecordIds.count else {
|
||
// Nothing was dropped, keep the same view model reference.
|
||
return viewModelReference
|
||
}
|
||
|
||
if containedCallRecordIds.isEmpty {
|
||
return nil
|
||
} else if containedCallRecordIds.count == 1 {
|
||
return .singleCall(containedCallRecordIds.first!)
|
||
} else {
|
||
return .coalescedCalls(
|
||
primary: containedCallRecordIds.first!,
|
||
coalesced: Array(containedCallRecordIds.dropFirst())
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - Cached view models
|
||
|
||
/// All the hydrated view models we currently have loaded.
|
||
private var cachedViewModels: IdentifierIndexedArray<CallViewModel>
|
||
|
||
/// Returns the cached view model corresponding to the view model
|
||
/// reference at the given index into ``loadedViewModelReferences``, if
|
||
/// one is cached.
|
||
///
|
||
/// - SeeAlso ``loadUntilCached(loadedViewModelReferenceIndex:tx:)``
|
||
func getCachedViewModel(loadedViewModelReferenceIndex index: Int) -> CallViewModel? {
|
||
guard let viewModelReference = loadedViewModelReferences[safe: index] else {
|
||
return nil
|
||
}
|
||
|
||
return getCachedViewModel(reference: viewModelReference)
|
||
}
|
||
|
||
/// Returns whether a view model is cached corresponding to the view
|
||
/// model reference at the given index into
|
||
/// ``loadedViewModelReferences``.
|
||
func hasCachedViewModel(loadedViewModelReferenceIndex index: Int) -> Bool {
|
||
return getCachedViewModel(loadedViewModelReferenceIndex: index) != nil
|
||
}
|
||
|
||
private func getCachedViewModel(reference: CallViewModel.Reference) -> CallViewModel? {
|
||
return cachedViewModels[id: reference.primaryId]
|
||
}
|
||
|
||
/// Merges the given view models into the view model cache in the given
|
||
/// direction, dropping models from the other end of the cache if
|
||
/// necessary.
|
||
private mutating func mergeIntoCachedViewModels(
|
||
newViewModels: [CallViewModel],
|
||
direction: LoadDirection
|
||
) {
|
||
if newViewModels.isEmpty { return }
|
||
|
||
let mergedCachedViewModels: [CallViewModel] = {
|
||
let combinedViewModels: [CallViewModel] = {
|
||
switch direction {
|
||
case .older: return cachedViewModels.allElements + newViewModels
|
||
case .newer: return newViewModels + cachedViewModels.allElements
|
||
}
|
||
}()
|
||
|
||
if combinedViewModels.count <= maxCachedViewModelCount {
|
||
return combinedViewModels
|
||
} else {
|
||
switch direction {
|
||
case .older: return Array(combinedViewModels.suffix(maxCachedViewModelCount))
|
||
case .newer: return Array(combinedViewModels.prefix(maxCachedViewModelCount))
|
||
}
|
||
}
|
||
}()
|
||
|
||
cachedViewModels = IdentifierIndexedArray(elements: mergedCachedViewModels)
|
||
}
|
||
|
||
/// Returns a view model reference matching the given one, with any
|
||
/// should-be-dropped call record IDs removed. If the resulting
|
||
/// reference contains no call record IDs, `nil` is returned.
|
||
private func dropCallRecordIds(
|
||
_ idsToDrop: Set<CallRecord.ID>,
|
||
fromViewModel viewModel: CallViewModel,
|
||
tx: DBReadTransaction
|
||
) -> CallViewModel? {
|
||
var containedCallRecords = viewModel.allCallRecords
|
||
|
||
let countBefore = containedCallRecords.count
|
||
containedCallRecords.removeAll { idsToDrop.contains($0.id) }
|
||
|
||
guard countBefore != containedCallRecords.count else {
|
||
// Nothing was dropped, keep the same view model.
|
||
return viewModel
|
||
}
|
||
|
||
if containedCallRecords.isEmpty {
|
||
return nil
|
||
} else {
|
||
return createCallViewModelBlock(
|
||
containedCallRecords.first!,
|
||
Array(containedCallRecords.dropFirst()),
|
||
tx
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - Load more
|
||
|
||
/// Repeatedly loads pages of view models until a cached view model is
|
||
/// available for the given loaded view model reference index.
|
||
///
|
||
/// This method is safe to call for any valid loaded view model
|
||
/// reference index.
|
||
///
|
||
/// - Note
|
||
/// This may result in multiple synchronous page loads if necessary. For
|
||
/// example, if view models are cached for rows in range `(500, 600)`
|
||
/// and this method is called for row 10, all the rows between row 500
|
||
/// and row 10 will be loaded.
|
||
///
|
||
/// This behavior should be fine in practice, since loading a page is a
|
||
/// fast operation.
|
||
mutating func loadUntilCached(
|
||
loadedViewModelReferenceIndex index: Int,
|
||
tx: DBReadTransaction
|
||
) {
|
||
guard
|
||
!hasCachedViewModel(loadedViewModelReferenceIndex: index),
|
||
!cachedViewModels.isEmpty
|
||
else { return }
|
||
|
||
guard let cachedViewModelReferenceIndices = cachedViewModelReferenceIndices() else {
|
||
owsFail("Missing cached view model indices, but we checked above for empty cached view models!")
|
||
}
|
||
|
||
let loadDirection: LoadDirection = {
|
||
if index > cachedViewModelReferenceIndices.last {
|
||
return .older
|
||
} else if index < cachedViewModelReferenceIndices.first {
|
||
return .newer
|
||
}
|
||
|
||
owsFail("Row index is in the cached range, but somehow we didn't have a cached model. How did that happen?")
|
||
}()
|
||
|
||
while true {
|
||
if hasCachedViewModel(loadedViewModelReferenceIndex: index) {
|
||
break
|
||
}
|
||
|
||
_ = loadMore(direction: loadDirection, tx: tx)
|
||
}
|
||
}
|
||
|
||
/// Load a page of calls in the requested direction.
|
||
///
|
||
/// This method may load brand new view models; it may page view models
|
||
/// into this loader's cache for view model references it had loaded in
|
||
/// the past; or it may do a combination of both.
|
||
///
|
||
/// - Returns
|
||
/// Whether changes were made to ``loadedViewModelReferences`` as a
|
||
/// result of this load.
|
||
mutating func loadMore(
|
||
direction loadDirection: LoadDirection,
|
||
tx: DBReadTransaction
|
||
) -> Bool {
|
||
var viewModelsToLoadCount = viewModelPageSize
|
||
|
||
let rehydratedCount = rehydrateLoadedViewModelReferencesIntoCache(
|
||
maxCount: viewModelsToLoadCount,
|
||
direction: loadDirection,
|
||
tx: tx
|
||
)
|
||
|
||
owsAssert(rehydratedCount <= viewModelsToLoadCount)
|
||
viewModelsToLoadCount -= rehydratedCount
|
||
|
||
if viewModelsToLoadCount == 0 {
|
||
/// We've loaded all the calls we need to, simply by rehydrating
|
||
/// already-loaded view model references.
|
||
return false
|
||
}
|
||
|
||
switch (loadDirection, cachedViewModels.count) {
|
||
case (.older, _), (.newer, 0):
|
||
/// If we're asked to load newer calls, but we don't have any
|
||
/// calls loaded yet, do an "older" load since they're gonna be
|
||
/// equivalent.
|
||
|
||
let brandNewViewModels = loadBrandNewViewModelsOlder(
|
||
maxCount: viewModelsToLoadCount, tx: tx
|
||
)
|
||
|
||
if brandNewViewModels.isEmpty {
|
||
return false
|
||
}
|
||
|
||
loadedViewModelReferences.append(
|
||
newElements: brandNewViewModels.map { $0.reference }
|
||
)
|
||
mergeIntoCachedViewModels(
|
||
newViewModels: brandNewViewModels,
|
||
direction: .older
|
||
)
|
||
|
||
return true
|
||
case (.newer, _):
|
||
guard let newestCachedViewModel = cachedViewModels.first else {
|
||
owsFail("Non-zero cached view model count, but missing first view model!")
|
||
}
|
||
|
||
let loadResult = loadBrandNewViewModelsNewer(
|
||
maxCount: viewModelsToLoadCount,
|
||
currentNewestViewModel: newestCachedViewModel,
|
||
tx: tx
|
||
)
|
||
|
||
switch loadResult {
|
||
case .nothingLoaded:
|
||
return false
|
||
case .coalescedIntoCurrentNewest(let updatedNewestViewModel):
|
||
/// If we coalesced newly-loaded view models into the
|
||
/// existing most-recent call, replace that call
|
||
/// in-place and let callers know things changed.
|
||
|
||
loadedViewModelReferences.replace(
|
||
elementAtIndex: 0, with: updatedNewestViewModel.reference
|
||
)
|
||
cachedViewModels.replace(
|
||
elementAtIndex: 0, with: updatedNewestViewModel
|
||
)
|
||
|
||
return true
|
||
case .loaded(let newViewModels):
|
||
owsAssert(newViewModels.count > 0)
|
||
|
||
loadedViewModelReferences.prepend(
|
||
newElements: newViewModels.map { $0.reference }
|
||
)
|
||
mergeIntoCachedViewModels(
|
||
newViewModels: newViewModels,
|
||
direction: .newer
|
||
)
|
||
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Rehydration
|
||
|
||
/// "Rehydrate" already-loaded view model references that we know about
|
||
/// in the direction we want to load, and add the rehydrated view models
|
||
/// to our cache.
|
||
///
|
||
/// Rehydration will begin with the next already-loaded view model
|
||
/// reference without a corresponding view model in the cache, in the
|
||
/// given direction. For example, if we've loaded view model references
|
||
/// for rows `[0, 100]`, have cached view models for `[25, 75]`, the
|
||
/// first rehydrated view model will be at index 24 or 76.
|
||
///
|
||
/// - Parameter maxCount
|
||
/// The max number of view models to rehydrate.
|
||
/// - Returns
|
||
/// The actual number of view models rehydrated. This may be lower than
|
||
/// the max if while rehydrating we run out of already-loaded view model
|
||
/// references in the given direction.
|
||
private mutating func rehydrateLoadedViewModelReferencesIntoCache(
|
||
maxCount: UInt,
|
||
direction loadDirection: LoadDirection,
|
||
tx: DBReadTransaction
|
||
) -> UInt {
|
||
let cachedViewModelReferenceIndices: CachedViewModelReferenceIndices? = cachedViewModelReferenceIndices()
|
||
|
||
/// Collect any view model references we've already loaded, in the
|
||
/// given direction, for which we don't have a loaded view model.
|
||
let loadedViewModelReferencesToHydrate: [CallViewModel.Reference] = {
|
||
guard let cachedViewModelReferenceIndices else { return [] }
|
||
|
||
var referencesToHydrate = [CallViewModel.Reference]()
|
||
|
||
switch loadDirection {
|
||
case .older:
|
||
var indexToHydrate = cachedViewModelReferenceIndices.last + 1
|
||
while
|
||
indexToHydrate < loadedViewModelReferences.count,
|
||
referencesToHydrate.count < maxCount
|
||
{
|
||
referencesToHydrate.append(loadedViewModelReferences[index: indexToHydrate])
|
||
indexToHydrate += 1
|
||
}
|
||
case .newer:
|
||
var indexToHydrate = cachedViewModelReferenceIndices.first - 1
|
||
while
|
||
indexToHydrate >= 0,
|
||
referencesToHydrate.count < maxCount
|
||
{
|
||
referencesToHydrate.insert(loadedViewModelReferences[index: indexToHydrate], at: 0)
|
||
indexToHydrate -= 1
|
||
}
|
||
}
|
||
|
||
return referencesToHydrate
|
||
}()
|
||
|
||
let rehydratedViewModels = loadedViewModelReferencesToHydrate.map { viewModelReference in
|
||
hydrate(viewModelReference: viewModelReference, tx: tx)
|
||
}
|
||
|
||
mergeIntoCachedViewModels(
|
||
newViewModels: rehydratedViewModels,
|
||
direction: loadDirection
|
||
)
|
||
|
||
return UInt(rehydratedViewModels.count)
|
||
}
|
||
|
||
/// Hydrates a full view model from the given reference.
|
||
private func hydrate(
|
||
viewModelReference: CallViewModel.Reference,
|
||
tx: DBReadTransaction
|
||
) -> CallViewModel {
|
||
switch viewModelReference {
|
||
case .singleCall(let id):
|
||
guard let primaryCallRecord = fetchCallRecordBlock(id, tx) else {
|
||
owsFail("Missing call record for single-call view model!")
|
||
}
|
||
|
||
return createCallViewModelBlock(primaryCallRecord, [], tx)
|
||
case .coalescedCalls(let primaryId, let coalescedIds):
|
||
guard let primaryCallRecord = fetchCallRecordBlock(primaryId, tx) else {
|
||
owsFail("Missing primary call record for coalesced-call view model!")
|
||
}
|
||
|
||
let coalescedCallRecords = coalescedIds.map { coalescedId -> CallRecord in
|
||
guard let coalescedCallRecord = fetchCallRecordBlock(coalescedId, tx) else {
|
||
owsFail("Missing coalesced call record for coalesced-call view model!")
|
||
}
|
||
|
||
return coalescedCallRecord
|
||
}
|
||
|
||
return createCallViewModelBlock(primaryCallRecord, coalescedCallRecords, tx)
|
||
}
|
||
}
|
||
|
||
// MARK: - Brand new models, older
|
||
|
||
/// Loads brand new view models older than the last cached view model.
|
||
///
|
||
/// - Important
|
||
/// This method must only be called if our current view model cache
|
||
/// includes the oldest loaded view model reference. If this is not the
|
||
/// case, callers should first rehydrate older loaded view model
|
||
/// references until it is and then call this method.
|
||
///
|
||
/// - Returns
|
||
/// View models not yet loaded in this loader's lifetime. No more than
|
||
/// `maxCount` view models will be returned, although note that due to
|
||
/// coalescing this may correspond to more than `maxCount` call records
|
||
/// having been loaded.
|
||
private func loadBrandNewViewModelsOlder(
|
||
maxCount: UInt,
|
||
tx: DBReadTransaction
|
||
) -> [CallViewModel] {
|
||
owsAssert(
|
||
cachedViewModels.isEmpty || cachedViewModels.last!.reference == loadedViewModelReferences.last!,
|
||
"Unexpectedly loading brand new view models, but last loaded view model reference does not have a cached view model!"
|
||
)
|
||
|
||
let oldestLoadedCallTimestamp: UInt64? = cachedViewModels.last.map { oldestViewModel in
|
||
return oldestViewModel.oldestContainedTimestamp
|
||
}
|
||
|
||
let newCallRecordsCursor: CallRecordCursor = callRecordLoader.loadCallRecords(
|
||
loadDirection: .olderThan(
|
||
oldestCallTimestamp: oldestLoadedCallTimestamp
|
||
),
|
||
tx: tx
|
||
)
|
||
|
||
/// Collate groups of call records that will each correspond to a
|
||
/// single view model, with the first call in the group being the
|
||
/// primary and all subsequent calls being coalesced under the
|
||
/// primary.
|
||
var groupedCallRecordsForViewModels = [[CallRecord]]()
|
||
var callRecordsForNextViewModel = [CallRecord]()
|
||
while true {
|
||
if groupedCallRecordsForViewModels.count == maxCount {
|
||
/// If we've reached the max view model count simply bail,
|
||
/// even if we started a grouping for another view model.
|
||
break
|
||
}
|
||
|
||
guard let nextCallRecord = try? newCallRecordsCursor.next() else {
|
||
/// We may have run out of call records while assembling a
|
||
/// group for the next view model. If so, we want to commit
|
||
/// the grouping as it is.
|
||
if !callRecordsForNextViewModel.isEmpty {
|
||
groupedCallRecordsForViewModels.append(callRecordsForNextViewModel)
|
||
}
|
||
|
||
break
|
||
}
|
||
|
||
if callRecordsForNextViewModel.isEmpty {
|
||
/// Start the first view model grouping.
|
||
callRecordsForNextViewModel.append(nextCallRecord)
|
||
} else if
|
||
callRecordsForNextViewModel.first!.isValidCoalescingAnchor(for: nextCallRecord),
|
||
callRecordsForNextViewModel.count < maxCoalescedCallsInOneViewModel
|
||
{
|
||
/// Build on the current new view model grouping.
|
||
callRecordsForNextViewModel.append(nextCallRecord)
|
||
} else {
|
||
/// Finish the current view model grouping, and start the
|
||
/// next one.
|
||
groupedCallRecordsForViewModels.append(callRecordsForNextViewModel)
|
||
callRecordsForNextViewModel = [nextCallRecord]
|
||
}
|
||
}
|
||
|
||
owsAssert(
|
||
groupedCallRecordsForViewModels.allSatisfy { !$0.isEmpty },
|
||
"Had an empty grouping for a view model. How did this happen?"
|
||
)
|
||
|
||
return groupedCallRecordsForViewModels.map { callRecords -> CallViewModel in
|
||
return createCallViewModelBlock(
|
||
callRecords.first!,
|
||
Array(callRecords.dropFirst()),
|
||
tx
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - Brand new models, newer
|
||
|
||
private enum LoadBrandNewViewModelsNewerResult {
|
||
/// No new view models were loaded.
|
||
case nothingLoaded
|
||
|
||
/// New view models were loaded.
|
||
/// - Note
|
||
/// The contained `newViewModels` are ordered descending by
|
||
/// their primary call record's timestamp.
|
||
case loaded(newViewModels: [CallViewModel])
|
||
|
||
/// New calls were loaded, but were coalesced into the existing
|
||
/// newest (by timestamp) view model.
|
||
case coalescedIntoCurrentNewest(updatedNewestViewModel: CallViewModel)
|
||
}
|
||
|
||
/// Loads brand new view models newer than the first cached view model.
|
||
///
|
||
/// - Note
|
||
/// Unlike when loading brand new older view models, we do not generally
|
||
/// coalesce the newer calls loaded by this method.
|
||
///
|
||
/// In general usage, this type expects to start empty and monotonically
|
||
/// load older calls; finding a brand-new newer call implies the call
|
||
/// was inserted after our initial load. Finding a single brand-new
|
||
/// newer call is expected in the case where a new call starts, but it
|
||
/// is unexpected to find multiple brand-new newer calls at once.
|
||
///
|
||
/// Consequently, we will coalesce a single brand-new newer call into
|
||
/// the given `currentNewestViewModel`, so as to support the "new call"
|
||
/// scenario. However, if multiple brand-new newer calls are found, they
|
||
/// will each be transformed into their own view models.
|
||
///
|
||
/// Users are unlikely to encounter this limitation in practice, and the
|
||
/// effect is simply that calls that should have coalesced are not until
|
||
/// the next time we load-from-empty. In return, we are able to simplify
|
||
/// this code as a result.
|
||
///
|
||
/// - Important
|
||
/// This method must only be called if our current view model cache
|
||
/// includes the newest loaded view model reference. If this is not the
|
||
/// case, callers should first rehydrate newer loaded view model
|
||
/// references until it is and then call this method.
|
||
///
|
||
/// - Parameter currentNewestViewModel
|
||
/// The current newest view model from our cache. This must correspond
|
||
/// to the newest loaded view model reference – see above.
|
||
///
|
||
/// - Returns
|
||
/// The result of loading newer calls.
|
||
private func loadBrandNewViewModelsNewer(
|
||
maxCount: UInt,
|
||
currentNewestViewModel: CallViewModel,
|
||
tx: DBReadTransaction
|
||
) -> LoadBrandNewViewModelsNewerResult {
|
||
owsAssert(currentNewestViewModel.reference == loadedViewModelReferences.first)
|
||
|
||
let newCallRecordsCursor: CallRecordCursor = callRecordLoader.loadCallRecords(
|
||
loadDirection: .newerThan(
|
||
newestCallTimestamp: currentNewestViewModel.newestContainedTimestamp
|
||
),
|
||
tx: tx
|
||
)
|
||
|
||
guard let pageOfNewCallRecordsAsc = try? newCallRecordsCursor.drain(
|
||
maxResults: maxCount
|
||
) else {
|
||
return .nothingLoaded
|
||
}
|
||
|
||
/// The call records we get back from our cursor will be ordered
|
||
/// ascending, but we'll ultimately need them sorted descending to
|
||
/// merge into `cachedViewModels` and `loadedViewModelReferences`.
|
||
///
|
||
/// We'll reverse them here, and return them ordered descending, to
|
||
/// make things easier on our callers.
|
||
owsAssert(pageOfNewCallRecordsAsc.isSortedByTimestamp(.ascending))
|
||
let pageOfNewCallRecords = pageOfNewCallRecordsAsc.reversed()
|
||
|
||
if pageOfNewCallRecords.isEmpty {
|
||
return .nothingLoaded
|
||
} else if
|
||
pageOfNewCallRecords.count == 1,
|
||
let singleNewCallRecord = pageOfNewCallRecords.first,
|
||
singleNewCallRecord.isValidCoalescingAnchor(for: currentNewestViewModel.primaryCallRecord)
|
||
{
|
||
let updatedNewestViewModel = createCallViewModelBlock(
|
||
singleNewCallRecord,
|
||
[currentNewestViewModel.primaryCallRecord] + currentNewestViewModel.coalescedCallRecords,
|
||
tx
|
||
)
|
||
|
||
return .coalescedIntoCurrentNewest(updatedNewestViewModel: updatedNewestViewModel)
|
||
} else {
|
||
let uncoalescedViewModels: [CallViewModel] = pageOfNewCallRecords
|
||
.map { callRecord in createCallViewModelBlock(callRecord, [], tx) }
|
||
|
||
return .loaded(newViewModels: uncoalescedViewModels)
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
/// Represents the bounding indices of the currently-cached view models
|
||
/// within ``loadedViewModelReferences``.
|
||
private struct CachedViewModelReferenceIndices {
|
||
let first: Int
|
||
let last: Int
|
||
}
|
||
|
||
/// Computes the bounding indices of the currently-cached view models
|
||
/// within ``loadedViewModelReferences``.
|
||
private func cachedViewModelReferenceIndices() -> CachedViewModelReferenceIndices? {
|
||
func getRowIndex(cachedViewModel: CallViewModel?) -> Int? {
|
||
return cachedViewModel.flatMap {
|
||
return loadedViewModelReferences.index(forId: $0.reference.primaryId)
|
||
}
|
||
}
|
||
|
||
guard
|
||
let firstCachedViewModelRowIndex = getRowIndex(cachedViewModel: cachedViewModels.first),
|
||
let lastCachedViewModelRowIndex = getRowIndex(cachedViewModel: cachedViewModels.last)
|
||
else {
|
||
return nil
|
||
}
|
||
|
||
return CachedViewModelReferenceIndices(
|
||
first: firstCachedViewModelRowIndex,
|
||
last: lastCachedViewModelRowIndex
|
||
)
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
/// Drops any calls with the given IDs from the loaded view model
|
||
/// references and view model cache.
|
||
///
|
||
/// - Important
|
||
/// ``loadedViewModelReferences`` may have changed as a result of
|
||
/// calling this method.
|
||
mutating func dropCalls(
|
||
matching callRecordIdsToDrop: [CallRecord.ID],
|
||
tx: DBReadTransaction
|
||
) {
|
||
let callRecordIdsToDrop = Set(callRecordIdsToDrop)
|
||
|
||
let droppedViewModelReferences = loadedViewModelReferences.allElements
|
||
.compactMap { viewModelReference in
|
||
return dropCallRecordIds(callRecordIdsToDrop, fromViewModelReference: viewModelReference)
|
||
}
|
||
|
||
let droppedCachedViewModels = cachedViewModels.allElements
|
||
.compactMap { viewModel in
|
||
return dropCallRecordIds(callRecordIdsToDrop, fromViewModel: viewModel, tx: tx)
|
||
}
|
||
|
||
loadedViewModelReferences = IdentifierIndexedArray(elements: droppedViewModelReferences)
|
||
cachedViewModels = IdentifierIndexedArray(elements: droppedCachedViewModels)
|
||
}
|
||
|
||
/// Refreshes cached view models containing any of the given IDs. If no
|
||
/// cached view models contain a given ID, that ID is ignored.
|
||
///
|
||
/// - Returns
|
||
/// References for any view models that were refreshed. Note that this
|
||
/// will not include any IDs that were ignored.
|
||
mutating func refreshViewModels(
|
||
callRecordIds callRecordIdsForWhichToRefreshViewModels: [CallRecord.ID],
|
||
tx: DBReadTransaction
|
||
) -> [CallViewModel.Reference] {
|
||
func refreshIfPossible(_ callRecord: CallRecord) -> CallRecord {
|
||
return fetchCallRecordBlock(callRecord.id, tx) ?? callRecord
|
||
}
|
||
|
||
/// The given call record IDs may not have a cached view model, in
|
||
/// which case there's nothing to refresh. Multiple call record IDs
|
||
/// may also point to the same cached view model, which only needs
|
||
/// to be refreshed once since we'll re-fetch all the call records
|
||
/// when refreshing each view model anyway.
|
||
let cachedViewModelIndicesToRefresh: Set<Int> = Set(
|
||
callRecordIdsForWhichToRefreshViewModels.compactMap { callRecordId -> Int? in
|
||
return cachedViewModels.index(forId: callRecordId)
|
||
}
|
||
)
|
||
|
||
var refreshedViewModelReferences = [CallViewModel.Reference]()
|
||
for cachedViewModelIndex in cachedViewModelIndicesToRefresh {
|
||
/// For each cached view model containing a call record we need
|
||
/// to reload, we'll in fact reload all the call records
|
||
/// associated with that view model. It's a tiny cost, and makes
|
||
/// this code a lot simpler; the alternative being to dissect
|
||
/// each view model to reload specific contained call records.
|
||
let cachedViewModel = cachedViewModels[index: cachedViewModelIndex]
|
||
|
||
let newPrimaryViewModel = refreshIfPossible(cachedViewModel.primaryCallRecord)
|
||
let newCoalescedViewModels = cachedViewModel.coalescedCallRecords.map(refreshIfPossible(_:))
|
||
let newCallViewModel = createCallViewModelBlock(
|
||
newPrimaryViewModel,
|
||
newCoalescedViewModels,
|
||
tx
|
||
)
|
||
|
||
/// We haven't changed any of the actual call record IDs in the
|
||
/// view model, so we don't need to recompute indices.
|
||
cachedViewModels.replace(
|
||
elementAtIndex: cachedViewModelIndex,
|
||
with: newCallViewModel
|
||
)
|
||
refreshedViewModelReferences.append(newCallViewModel.reference)
|
||
}
|
||
|
||
return refreshedViewModelReferences
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: -
|
||
|
||
private extension CallRecord {
|
||
private enum Constants {
|
||
/// A time interval representing a window within which two call records
|
||
/// can be coalesced together.
|
||
static let coalescingTimeWindow: TimeInterval = 4 * kHourInterval
|
||
}
|
||
|
||
/// Whether the given call record can be coalesced under this call record.
|
||
func isValidCoalescingAnchor(for otherCallRecord: CallRecord) -> Bool {
|
||
guard
|
||
threadRowId == otherCallRecord.threadRowId,
|
||
callDirection == otherCallRecord.callDirection,
|
||
callStatus.isMissedCall == otherCallRecord.callStatus.isMissedCall,
|
||
callBeganDate.addingTimeInterval(-Constants.coalescingTimeWindow) < otherCallRecord.callBeganDate
|
||
else { return false }
|
||
|
||
return true
|
||
}
|
||
}
|
||
|
||
// MARK: - ContainsIdentifiers
|
||
|
||
extension CallsListViewController.CallViewModel.Reference: ContainsIdentifiers {
|
||
typealias ContainedIdType = CallRecord.ID
|
||
|
||
var containedIds: [CallRecord.ID] {
|
||
switch self {
|
||
case .singleCall(let callRecordId): return [callRecordId]
|
||
case .coalescedCalls(let primary, let coalesced): return [primary] + coalesced
|
||
}
|
||
}
|
||
}
|
||
|
||
extension CallsListViewController.CallViewModel: ContainsIdentifiers {
|
||
typealias ContainedIdType = CallRecord.ID
|
||
|
||
var containedIds: [CallRecord.ID] {
|
||
return reference.containedIds
|
||
}
|
||
}
|