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

221 lines
8.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import LibSignalClient
import SignalRingRTC
import SignalServiceKit
/// Cleans up any group calls "stuck" in the ringing state.
///
/// After a group call rings (i.e., RingRTC gives the app a group ring update
/// indicating a new ring was requested), it is either accepted/declined by the
/// user or expires if unanswered for too long. Expiration is signaled by
/// another RingRTC ring update.
///
/// However, if app execution is interrupted while the call is ringing it's
/// possible that the app will never receive the "ring expired" ring update from
/// RingRTC. (This is due to RingRTC behavior "ring expired" messages are not
/// delivered if the app is offline for sufficiently long after the expiration.)
///
/// This manager is a backstop responsible for finding any calls stuck in that
/// state and moving them to the terminal "ringing missed" state.
///
/// It will also attempt to determine if the most recent calls in this state are
/// still ongoing, and if so will notify the user as if the call had just
/// started.
class GroupCallRecordRingingCleanupManager {
private enum Constants {
/// The max number of ringing calls to peek to determine if they are
/// still ongoing. Any calls beyond this limit will not be peeked, and
/// will be assumed to have ended.
static let maxRingingCallsToPeek: Int = 10
}
private let callRecordStore: CallRecordStore
private let callRecordQuerier: CallRecordQuerier
private let db: any DB
private let interactionStore: InteractionStore
private let groupCallPeekClient: GroupCallPeekClient
private let notificationPresenter: Shims.NotificationPresenter
private let threadStore: ThreadStore
init(
callRecordStore: CallRecordStore,
callRecordQuerier: CallRecordQuerier,
db: any DB,
interactionStore: InteractionStore,
groupCallPeekClient: GroupCallPeekClient,
notificationPresenter: Shims.NotificationPresenter,
threadStore: ThreadStore
) {
self.callRecordStore = callRecordStore
self.callRecordQuerier = callRecordQuerier
self.db = db
self.interactionStore = interactionStore
self.groupCallPeekClient = groupCallPeekClient
self.notificationPresenter = notificationPresenter
self.threadStore = threadStore
}
static func fromGlobals() -> GroupCallRecordRingingCleanupManager {
return GroupCallRecordRingingCleanupManager(
callRecordStore: DependenciesBridge.shared.callRecordStore,
callRecordQuerier: DependenciesBridge.shared.callRecordQuerier,
db: DependenciesBridge.shared.db,
interactionStore: DependenciesBridge.shared.interactionStore,
groupCallPeekClient: SSKEnvironment.shared.groupCallManagerRef.groupCallPeekClient,
notificationPresenter: Wrappers.NotificationPresenter(
notificationPresenter: SSKEnvironment.shared.notificationPresenterRef
),
threadStore: DependenciesBridge.shared.threadStore
)
}
func cleanupRingingCalls(tx: DBWriteTransaction) {
guard
let ringingGroupCallCursor = callRecordQuerier.fetchCursor(
callStatus: .group(.ringing),
ordering: .descending,
tx: tx
),
let ringingCallRecords = try? ringingGroupCallCursor.drain()
else { return }
guard !ringingCallRecords.isEmpty else {
// This should be the 99% case, since having a call in the "ringing"
// state on launch means something went wrong in a previous launch.
return
}
// We'll peek the group calls from the most recent ringing call records
// to see if the call is still ongoing.
let callRecordsToPeek = ringingCallRecords.prefix(Constants.maxRingingCallsToPeek)
for ringingCallRecord in ringingCallRecords {
callRecordStore.updateCallAndUnreadStatus(
callRecord: ringingCallRecord,
newCallStatus: .group(.ringingMissed),
tx: tx
)
}
/// A little chunky group by the group thread row ID, then map those
/// groupings to load the group thread for each row ID.
let callRecordsByGroupId: [(GroupIdentifier, [CallRecord])] = Dictionary(
grouping: callRecordsToPeek,
by: { $0.conversationId }
).compactMap { (conversationId, callRecords) -> (GroupIdentifier, [CallRecord])? in
switch conversationId {
case .thread(let threadRowId):
guard
let groupThread = threadStore.fetchThread(rowId: threadRowId, tx: tx) as? TSGroupThread,
let groupId = try? groupThread.groupIdentifier
else {
return nil
}
return (groupId, callRecords)
case .callLink(_):
return nil
}
}
for (groupId, callRecords) in callRecordsByGroupId {
Task {
try await peekGroupAndNotifyIfNecessary(
groupId: groupId,
callRecords: callRecords
)
}
}
}
/// Peeks the group thread and compares the current group call against the
/// ringing group call records in it. If the current call for a group
/// matches one of the records for the group (i.e., the call that created
/// the ringing record is still ongoing), posts a notification.
private func peekGroupAndNotifyIfNecessary(
groupId: GroupIdentifier,
callRecords: [CallRecord]
) async throws {
let peekInfo = try await self.groupCallPeekClient.fetchPeekInfo(groupId: groupId)
let callId = peekInfo.eraId.map({ callIdFromEra($0) })
await self.db.awaitableWrite { tx in
// Reload the group thread, since it may have changed.
guard let groupThread = self.threadStore.fetchGroupThread(groupId: groupId, tx: tx) else {
owsFail("Where did the thread go?")
}
let interactionRowIdsMatchingCurrentCall = callRecords.compactMap { callRecord -> Int64? in
switch callRecord.interactionReference {
case .thread(let threadRowId, let interactionRowId):
owsPrecondition(threadRowId == groupThread.sqliteRowId!)
guard callRecord.callId == callId else {
return nil
}
return interactionRowId
case .none:
owsFail("Must pass callRecords for groupThread.")
}
}
owsAssertDebug(interactionRowIdsMatchingCurrentCall.count <= 1)
for interactionRowId in interactionRowIdsMatchingCurrentCall {
let interaction = self.interactionStore.fetchInteraction(rowId: interactionRowId, tx: tx)
guard let groupCallInteraction = interaction as? OWSGroupCallMessage else {
continue
}
self.notificationPresenter.notifyUserGroupCallStarted(
groupCallInteraction: groupCallInteraction,
groupThread: groupThread,
tx: tx
)
}
}
}
}
// MARK: - Mocks
extension GroupCallRecordRingingCleanupManager {
enum Shims {
typealias NotificationPresenter = GroupCallRecordRingingCleanupManager_NotificationPresenter_Shim
}
enum Wrappers {
typealias NotificationPresenter = GroupCallRecordRingingCleanupManager_NotificationPresenter_Wrapper
}
}
protocol GroupCallRecordRingingCleanupManager_NotificationPresenter_Shim {
func notifyUserGroupCallStarted(
groupCallInteraction: OWSGroupCallMessage,
groupThread: TSGroupThread,
tx: DBWriteTransaction
)
}
class GroupCallRecordRingingCleanupManager_NotificationPresenter_Wrapper: GroupCallRecordRingingCleanupManager_NotificationPresenter_Shim {
private let notificationPresenter: any NotificationPresenter
init(notificationPresenter: any NotificationPresenter) {
self.notificationPresenter = notificationPresenter
}
func notifyUserGroupCallStarted(
groupCallInteraction: OWSGroupCallMessage,
groupThread: TSGroupThread,
tx: DBWriteTransaction
) {
notificationPresenter.notifyUser(
forPreviewableInteraction: groupCallInteraction,
thread: groupThread,
wantsSound: true,
transaction: SDSDB.shimOnlyBridge(tx)
)
}
}