AzureCommunicationUI/sdk/AzureCommunicationUICalling/Sources/Presentation/Manager/CaptionsRttDataManager.swift (214 lines of code) (raw):

// // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. // import UIKit import Combine import SwiftUI class CaptionsRttDataManager: ObservableObject { // MARK: - Properties @Published var captionsRttData = [CaptionsRttRecord]() @Published var isAutoCommit = false var isTranslationEnabled = false private let callingSDKWrapper: CallingSDKWrapperProtocol private let store: Store<AppState, Action> private var subscriptions = Set<AnyCancellable>() private let maxDataCount = 50 private let finalizationDelay: TimeInterval = 5 // seconds private var hasInsertedRttInfo = false private var finalizedRttMap: [String: CaptionsRttRecord] = [:] // MARK: - Initialization init(store: Store<AppState, Action>, callingSDKWrapper: CallingSDKWrapperProtocol) { self.callingSDKWrapper = callingSDKWrapper self.store = store subscribeToEvents() } // MARK: - Subscription Setup private func subscribeToEvents() { subscribeToCaptions() subscribeToRtt() subscribeToStore() } private func subscribeToCaptions() { callingSDKWrapper.callingEventsHandler.captionsReceived .receive(on: DispatchQueue.main) .sink { [weak self] captions in let displayData = captions.toDisplayData() self?.handleNewData(displayData) } .store(in: &subscriptions) } private func subscribeToRtt() { callingSDKWrapper.callingEventsHandler.rttReceived .receive(on: DispatchQueue.main) .sink { [weak self] rtt in let displayData = rtt.toDisplayData() self?.handleNewData(displayData) } .store(in: &subscriptions) } private func subscribeToStore() { store.$state .receive(on: DispatchQueue.main) .sink { [weak self] state in self?.updateState(from: state) } .store(in: &subscriptions) } // MARK: - State Handling private func updateState(from state: AppState) { isTranslationEnabled = !(state.captionsState.captionLanguage?.isEmpty ?? true) let captionsEnabled = state.captionsState.isCaptionsOn if state.rttState.isRttOn && !hasInsertedRttInfo { insertRttInfoMessage() } filterCaptionsRttData(captionsEnabled: captionsEnabled) } private func filterCaptionsRttData(captionsEnabled: Bool) { if !captionsEnabled { // If captions are disabled, keep RTT and RTT info messages captionsRttData = captionsRttData.filter { $0.captionsRttType == .rtt || $0.captionsRttType == .rttInfo } } } // MARK: - Data Handling func handleNewData(_ newData: CaptionsRttRecord) { guard shouldAddData(newData) else { return } if newData.captionsRttType == .rtt && !store.state.rttState.isRttOn { store.dispatch(action: .rttAction(.turnOnRtt)) } if newData.captionsRttType == .rtt && newData.isFinal { finalizedRttMap[newData.displayRawId] = newData } manageAutoCommit(for: newData) processAndStore(newData) } private func shouldAddData(_ data: CaptionsRttRecord) -> Bool { if data.captionsRttType == .captions { return shouldAddCaption(data) } return true // Always add RTT data } private func shouldAddCaption(_ data: CaptionsRttRecord) -> Bool { if isTranslationEnabled { // Add only if translation is enabled and caption text is not empty. return !(data.captionsText?.isEmpty ?? true) } // Additional check: skip caption if identical RTT already exists if data.isFinal, store.state.callingState.transcriptionStatus == .on, let lastRtt = finalizedRttMap.first(where: { data.displayRawId.contains($0.key) })?.value, isContentEqual(rtt: lastRtt, caption: data) { return false } // Add caption regardless of text if translation is not enabled. return true } private func isContentEqual(rtt: CaptionsRttRecord, caption: CaptionsRttRecord) -> Bool { return rtt.isFinal && caption.isFinal && rtt.text == caption.captionsText } private func manageAutoCommit(for data: CaptionsRttRecord) { if data.isLocal && data.captionsRttType == .rtt && data.isFinal { isAutoCommit = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in self?.isAutoCommit = false } } } private func processAndStore(_ newData: CaptionsRttRecord) { if newData.captionsRttType == .rtt && newData.text.isEmpty { // Remove non-final RTT entries with the same displayRawId. captionsRttData.removeAll { $0.displayRawId == newData.displayRawId && !$0.isFinal } return } if newData.captionsRttType == .rtt && !hasInsertedRttInfo { insertRttInfoMessage() } if captionsRttData.isEmpty { captionsRttData.append(newData) return } // Update existing non-final entry if present. if let existingIndex = captionsRttData.firstIndex(where: { $0.displayRawId == newData.displayRawId && !$0.isFinal && $0.captionsRttType == newData.captionsRttType }) { captionsRttData[existingIndex] = newData } else { captionsRttData.append(newData) } // Finalize the previous message if the delay has passed. finalizePreviousMessageIfNeeded(with: newData) // Sort the data to maintain order. captionsRttData.sort(by: sortCaptions) // Enforce the maximum data count. enforceMaxDataCount() } private func finalizePreviousMessageIfNeeded(with newData: CaptionsRttRecord) { if let lastIndex = captionsRttData.lastIndex(where: { $0.displayRawId == newData.displayRawId && $0.captionsRttType == newData.captionsRttType && !$0.isFinal }), shouldFinalize(lastData: captionsRttData[lastIndex], newData: newData) { captionsRttData[lastIndex].isFinal = true captionsRttData = captionsRttData // Trigger a refresh by reassigning } } private func shouldFinalize(lastData: CaptionsRttRecord, newData: CaptionsRttRecord) -> Bool { let duration = newData.createdTimestamp.timeIntervalSince(lastData.createdTimestamp) return duration > finalizationDelay } private func sortCaptions(_ first: CaptionsRttRecord, _ second: CaptionsRttRecord) -> Bool { // Rule 0: RTT Info messages should always be before RTT messages if first.captionsRttType == .rttInfo { return true // Always keep RTT Info at the top } if second.captionsRttType == .rttInfo { return false // Ensure RTT Info is above RTT messages } // Rule 1: Local non-final messages always at the bottom. if first.isLocal && !first.isFinal { return false // `first` stays below `second`. } if second.isLocal && !second.isFinal { return true // `second` stays below `first`. } // Rule 2: Non-final RTT messages below finalized RTT messages. if first.captionsRttType == .rtt && !first.isFinal && second.captionsRttType == .rtt && second.isFinal { return false } if second.captionsRttType == .rtt && !second.isFinal && first.captionsRttType == .rtt && first.isFinal { return true } // Rule 3: For finalized RTT messages, use updatedTimestamp for sorting. if first.captionsRttType == .rtt && first.isFinal || second.captionsRttType == .rtt && second.isFinal { return first.updatedTimestamp < second.updatedTimestamp } // Default sorting based on updatedTimestamp. return first.createdTimestamp < second.createdTimestamp } // MARK: - RTT Info Message private func insertRttInfoMessage() { let rttInfo = CaptionsRttRecord( displayRawId: UUID().uuidString, // Unique ID displayName: "", text: "", spokenText: "", captionsText: "", spokenLanguage: "", captionsLanguage: "", captionsRttType: .rttInfo, createdTimestamp: Date(), updatedTimestamp: Date(), isFinal: true, isLocal: false ) DispatchQueue.main.async { // Find the first RTT message index if let firstRttIndex = self.captionsRttData.firstIndex(where: { $0.captionsRttType == .rtt }) { self.captionsRttData.insert(rttInfo, at: firstRttIndex) // Insert before the first RTT } else { self.captionsRttData.append(rttInfo) // If no RTT, append at the end } } hasInsertedRttInfo = true } // MARK: - Data Management private func enforceMaxDataCount() { if captionsRttData.count > maxDataCount { withAnimation { captionsRttData.removeFirst(captionsRttData.count - maxDataCount) } } } }