Server information (Monal-style): - Add ServerInformation model and ServerInfoView reachable from Settings, showing server software (XEP-0092), disco#info identities and features, conference (MUC) services via disco#items, and STUN/TURN via XEP-0215. - Add a curated XEP capability list with per-XEP success/error status derived from server/account disco features and connection flags. - Detect PEP (0163) from account disco pubsub#* features, HTTP Upload (0363) from the main domain plus its components, and add Roster Versioning (0237), Pre-Authenticated Roster Subscription (0379), and SASL SCRAM Downgrade Protection (0474, neutral because Martin does not expose SSDP). Connection statistics: - Add LumaConnectionStatsModule, an XmppStanzaFilter registered before stream management, to count sent / acknowledged / received stanzas. - Track last login and SMACKS session timestamps, and read SASL mechanisms and Client State Indication from the raw stream features. MAM / OMEMO handling: - Prefer a plaintext <body> fallback when OMEMO decryption fails instead of showing "failed to decrypt". - Keep the optimistic text of our own outgoing message when its echo cannot be decrypted back, instead of replacing it with a placeholder. Emoji picker: - Add EmojiCatalog and a cross-platform EmojiPickerView. - Open the picker for reactions (replacing the static quick list) and from the composer's smiley button, inserting the chosen emoji into the draft.
3825 lines
154 KiB
Swift
3825 lines
154 KiB
Swift
import Combine
|
|
import Foundation
|
|
import Martin
|
|
import MartinOMEMO
|
|
import WebRTC
|
|
|
|
private struct BufferedArchiveStanza {
|
|
let queryID: String
|
|
let message: Message
|
|
let timestamp: Date
|
|
let archiveID: String
|
|
}
|
|
|
|
private struct BufferedArchivePage {
|
|
let stanzas: [BufferedArchiveStanza]
|
|
let overflowed: Bool
|
|
let rejectedSource: Bool
|
|
}
|
|
|
|
private enum BufferedLiveDelivery {
|
|
case direct(message: Message, timestamp: Date)
|
|
case group(message: Message, room: RoomProtocol)
|
|
}
|
|
|
|
/// Martin publishes every MAM result synchronously on its parser queue. Keep
|
|
/// that burst off the main queue and hand the completed page to XMPPService
|
|
/// only after the final IQ callback. Message instances are immutable after the
|
|
/// publisher returns; the lock provides the ownership hand-off.
|
|
private final class ArchiveStanzaInbox: @unchecked Sendable {
|
|
private struct State {
|
|
let allowedSources: Set<String>
|
|
var stanzas: [BufferedArchiveStanza] = []
|
|
var overflowed = false
|
|
var rejectedSource = false
|
|
}
|
|
|
|
private let lock = NSLock()
|
|
private let maximumCount: Int
|
|
// private var activeQueryID: String?
|
|
// private var allowedSources: Set<String> = []
|
|
// private var stanzas: [BufferedArchiveStanza] = []
|
|
// private var overflowed = false
|
|
// private var rejectedSource = false
|
|
private var states: [String: State] = [:]
|
|
|
|
init(maximumCount: Int) {
|
|
self.maximumCount = maximumCount
|
|
}
|
|
|
|
func begin(queryID: String, allowedSources: Set<String>) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
// activeQueryID = queryID
|
|
// self.allowedSources = Set(allowedSources.map { $0.lowercased() })
|
|
// stanzas.removeAll(keepingCapacity: true)
|
|
// overflowed = false
|
|
// rejectedSource = false
|
|
states[queryID] = State(
|
|
allowedSources: Set(allowedSources.map { $0.lowercased() })
|
|
)
|
|
}
|
|
|
|
func append(
|
|
queryID: String,
|
|
source: BareJID,
|
|
message: Message,
|
|
timestamp: Date,
|
|
archiveID: String
|
|
) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
// guard activeQueryID == queryID else { return }
|
|
// guard allowedSources.contains(source.stringValue.lowercased()) else {
|
|
// rejectedSource = true
|
|
guard var state = states[queryID] else { return }
|
|
guard state.allowedSources.contains(source.stringValue.lowercased()) else {
|
|
state.rejectedSource = true
|
|
states[queryID] = state
|
|
return
|
|
}
|
|
// guard stanzas.count < maximumCount else {
|
|
// overflowed = true
|
|
guard state.stanzas.count < maximumCount else {
|
|
state.overflowed = true
|
|
states[queryID] = state
|
|
return
|
|
}
|
|
// stanzas.append(
|
|
state.stanzas.append(
|
|
BufferedArchiveStanza(
|
|
queryID: queryID,
|
|
message: message,
|
|
timestamp: timestamp,
|
|
archiveID: archiveID
|
|
))
|
|
// print("MAM source: \(source), allowed: \(allowedSources)")
|
|
states[queryID] = state
|
|
}
|
|
|
|
func take(queryID: String) -> BufferedArchivePage {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
// guard activeQueryID == queryID else {
|
|
guard let state = states.removeValue(forKey: queryID) else {
|
|
return BufferedArchivePage(stanzas: [], overflowed: false, rejectedSource: false)
|
|
}
|
|
// let page = BufferedArchivePage(
|
|
// stanzas: stanzas,
|
|
// overflowed: overflowed,
|
|
// rejectedSource: rejectedSource
|
|
// )
|
|
// activeQueryID = nil
|
|
// allowedSources.removeAll(keepingCapacity: true)
|
|
// stanzas.removeAll(keepingCapacity: true)
|
|
// overflowed = false
|
|
// rejectedSource = false
|
|
// return page
|
|
return BufferedArchivePage(
|
|
stanzas: state.stanzas,
|
|
overflowed: state.overflowed,
|
|
rejectedSource: state.rejectedSource
|
|
)
|
|
}
|
|
|
|
func cancel(queryID: String? = nil) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
// if let queryID, activeQueryID != queryID { return }
|
|
// activeQueryID = nil
|
|
// allowedSources.removeAll(keepingCapacity: true)
|
|
// stanzas.removeAll(keepingCapacity: true)
|
|
// overflowed = false
|
|
// rejectedSource = false
|
|
if let queryID {
|
|
states.removeValue(forKey: queryID)
|
|
} else {
|
|
states.removeAll(keepingCapacity: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class XMPPService {
|
|
enum ConnectionStatus: Equatable, Sendable {
|
|
case disconnected(reason: String?)
|
|
case connecting
|
|
case connected
|
|
}
|
|
|
|
struct MessageEnvelope: Sendable {
|
|
let id: String
|
|
let peerJID: String
|
|
let senderJID: String
|
|
let body: String
|
|
let timestamp: Date
|
|
let isOutgoing: Bool
|
|
let security: ChatMessage.Security
|
|
let kind: ChatMessage.Kind
|
|
let remoteAttachmentURL: String?
|
|
let localFilename: String?
|
|
let mimeType: String?
|
|
let duration: TimeInterval?
|
|
let byteCount: Int?
|
|
let fingerprint: String?
|
|
let correctionTargetID: String?
|
|
let replyToID: String?
|
|
let replyToJID: String?
|
|
let replyPreview: String?
|
|
let originID: String?
|
|
let stanzaID: String?
|
|
let senderDisplayName: String?
|
|
let isGroupMessage: Bool
|
|
let isArchived: Bool
|
|
}
|
|
|
|
struct ReplyReference: Sendable {
|
|
let id: String
|
|
let authorJID: String?
|
|
let fallbackAuthor: String
|
|
let preview: String
|
|
}
|
|
|
|
struct RoomStateEnvelope: Sendable {
|
|
let roomJID: String
|
|
let nickname: String
|
|
let joined: Bool
|
|
let occupantCount: Int
|
|
}
|
|
|
|
struct RoomInvitationEnvelope: Sendable {
|
|
let roomJID: String
|
|
let inviterJID: String?
|
|
let reason: String?
|
|
let password: String?
|
|
}
|
|
|
|
struct RetractionEnvelope: Sendable {
|
|
let peerJID: String
|
|
let senderJID: String
|
|
let targetID: String
|
|
let retractionID: String
|
|
let timestamp: Date
|
|
let isOutgoing: Bool
|
|
}
|
|
|
|
struct ReactionEnvelope: Sendable {
|
|
let peerJID: String
|
|
let senderJID: String
|
|
let targetID: String
|
|
let emojis: [String]
|
|
let timestamp: Date
|
|
let isOutgoing: Bool
|
|
let isGroupMessage: Bool
|
|
}
|
|
|
|
struct ChatStateEnvelope: Sendable {
|
|
let peerJID: String
|
|
let senderJID: String
|
|
let senderDisplayName: String?
|
|
let state: ChatTypingState
|
|
let isGroupMessage: Bool
|
|
}
|
|
|
|
struct MediaSendResult: Sendable {
|
|
let remoteURL: String
|
|
let fingerprint: String?
|
|
}
|
|
|
|
enum ArchiveMutation: Sendable {
|
|
case message(MessageEnvelope)
|
|
case retraction(RetractionEnvelope)
|
|
case reaction(ReactionEnvelope)
|
|
}
|
|
|
|
enum Event: Sendable {
|
|
case connection(ConnectionStatus)
|
|
case rosterItem(jid: String, name: String?)
|
|
case rosterRemoved(jid: String)
|
|
case presence(jid: String, online: Bool)
|
|
case message(MessageEnvelope)
|
|
case retraction(RetractionEnvelope)
|
|
case reaction(ReactionEnvelope)
|
|
case chatState(ChatStateEnvelope)
|
|
case groupMessageEcho(
|
|
roomJID: String, messageID: String, stanzaID: String, senderJID: String)
|
|
case roomState(RoomStateEnvelope)
|
|
case roomInvitation(RoomInvitationEnvelope)
|
|
case avatar(jid: String, data: Data)
|
|
case delivered(messageID: String)
|
|
case omemo(ready: Bool, ownFingerprint: String?)
|
|
case archiveBatch([ArchiveMutation])
|
|
case archiveSyncing(Bool)
|
|
case archiveSyncCompleted(ArchiveSyncCheckpoint)
|
|
case mucArchiveSyncCompleted(archive: MAMArchiveKey, checkpoint: MAMArchiveCheckpoint)
|
|
case call(CallSnapshot?)
|
|
case callHistory(CallHistoryEntry)
|
|
case callError(String)
|
|
case recoverableError(String)
|
|
}
|
|
|
|
var eventHandler: ((Event) -> Void)?
|
|
|
|
private let callEngine: LumaCallEngine
|
|
private var client: XMPPClient?
|
|
private var omemoStorage: LumaOMEMOStore?
|
|
private var connectionStatsModule: LumaConnectionStatsModule?
|
|
private var lastLoginDate: Date?
|
|
private var smacksSessionEstablishedDate: Date?
|
|
private var cancellables: Set<AnyCancellable> = []
|
|
private var account: AccountConfiguration?
|
|
private var archiveSyncStarted = false
|
|
private var archiveSyncCompletedForConnection = false
|
|
private var archiveSyncCheckpoint: ArchiveSyncCheckpoint?
|
|
private var archiveSyncQueryStartedAt: Date?
|
|
private var archiveSyncIndicatorVisible = false
|
|
private var archivePagination = ArchiveSyncPagination()
|
|
private var archiveRetryTask: Task<Void, Never>?
|
|
private var archiveQueryTimeoutTask: Task<Void, Never>?
|
|
private var archiveQueryCompletionTask: Task<Void, Never>?
|
|
private var archiveSyncRetryTask: Task<Void, Never>?
|
|
private var archiveAutoRetryCount = 0
|
|
private var archiveActiveQueryID: String?
|
|
private var archiveResumeAfter: String?
|
|
private var archiveWorkBudget = ArchiveSyncWorkBudget()
|
|
private var archiveHighWatermark: Date?
|
|
private var archiveLastCompletedCursor: String?
|
|
private var archiveHasCompletedPage = false
|
|
private var archiveCursorFallbackUsed = false
|
|
private var archivePassMutations: [ArchiveMutation] = []
|
|
private var archiveIsBootstrapQuery = false
|
|
private var archiveRetrySuppressedUntilActivation = false
|
|
private var archiveSyncSuspended = false
|
|
private var archiveResumeAfterMediaWorkTask: Task<Void, Never>?
|
|
private var archiveStanzaBuffer: [BufferedArchiveStanza] = []
|
|
private var archiveBufferOverflowed = false
|
|
private var archiveRejectedSource = false
|
|
private var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:]
|
|
private var pendingMUCCatchups: [MAMArchiveKey] = []
|
|
private var activeMUCCatchup: MAMArchiveKey?
|
|
private var mucCatchupHighWatermark: Date?
|
|
private var mucCatchupLastCursor: String?
|
|
private var mucCursorFallbackArchives: Set<MAMArchiveKey> = []
|
|
private var delayedLiveByArchive: [MAMArchiveKey: [BufferedLiveDelivery]] = [:]
|
|
private var olderHistoryQueryID: String?
|
|
private var olderHistoryCompletion: ((Result<Bool, Error>) -> Void)?
|
|
private var olderHistoryTimeoutTask: Task<Void, Never>?
|
|
/// When non-nil, archived mutations produced while applying an interactive
|
|
/// backward-history page are collected here instead of the catch-up
|
|
/// accumulator, so the two never interleave their mutations.
|
|
private var interactiveHistoryMutations: [ArchiveMutation]?
|
|
private let olderHistoryQueryTimeoutNanoseconds: UInt64 = 15_000_000_000
|
|
/// Serial background queue for OMEMO decryption. `decode` performs the
|
|
/// expensive Signal work (session decrypt + AES-GCM) and previously ran on
|
|
/// the main actor for every MAM stanza, stalling the UI during large
|
|
/// archive catch-ups. All decodes stay serialized on this single queue so
|
|
/// Signal's per-session state is never mutated concurrently.
|
|
private let omemoDecodeQueue = DispatchQueue(
|
|
label: "app.luma.omemo.decode",
|
|
qos: .userInitiated
|
|
)
|
|
private let archiveStanzaInbox = ArchiveStanzaInbox(
|
|
maximumCount: ArchiveMessageBatchPolicy.maximumBufferedStanzas
|
|
)
|
|
private var avatarRequests: Set<String> = []
|
|
private var observedRoomJIDs: Set<String> = []
|
|
private var roomOccupantsByJID: [String: [MucOccupant]] = [:]
|
|
private var roomRealJIDByNickname: [String: [String: BareJID]] = [:]
|
|
private var omemoConfiguredRoomJIDs: Set<String> = []
|
|
private var chatStatesEnabled = true
|
|
private var knownChatStatePeers: Set<String> = []
|
|
private let archivePageSize = ArchiveMessageBatchPolicy.pageSize
|
|
private let archiveBootstrapMessageLimit = ArchiveMessageBatchPolicy.bootstrapMessageLimit
|
|
private let archiveApplyBatchSize = ArchiveMessageBatchPolicy.decodeSliceSize
|
|
private let archiveForegroundRefreshInterval: TimeInterval = 60
|
|
private static let replyNamespace = "urn:xmpp:reply:0"
|
|
private static let fallbackNamespace = "urn:xmpp:fallback:0"
|
|
private static let retractionNamespace = "urn:xmpp:message-retract:1"
|
|
private static let reactionsNamespace = "urn:xmpp:reactions:0"
|
|
private static let stanzaIDNamespace = "urn:xmpp:sid:0"
|
|
private static let extendedAddressingNamespace = "http://jabber.org/protocol/address"
|
|
|
|
init(callEngine: LumaCallEngine? = nil) {
|
|
let callEngine = callEngine ?? LumaCallEngine()
|
|
self.callEngine = callEngine
|
|
callEngine.snapshotHandler = { [weak self] snapshot in
|
|
self?.eventHandler?(.call(snapshot))
|
|
}
|
|
callEngine.historyHandler = { [weak self] entry in
|
|
self?.eventHandler?(.callHistory(entry))
|
|
}
|
|
callEngine.errorHandler = { [weak self] message in
|
|
self?.eventHandler?(.callError(message))
|
|
}
|
|
}
|
|
|
|
var connectionStatus: ConnectionStatus {
|
|
guard let client else { return .disconnected(reason: nil) }
|
|
switch client.state {
|
|
case .connecting, .disconnecting:
|
|
return .connecting
|
|
case .connected:
|
|
return .connected
|
|
case .disconnected(let reason):
|
|
return .disconnected(reason: Self.reasonText(reason))
|
|
}
|
|
}
|
|
|
|
func connect(
|
|
account: AccountConfiguration,
|
|
password: String,
|
|
archiveCheckpoint: ArchiveSyncCheckpoint? = nil,
|
|
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:]
|
|
) async throws {
|
|
await disconnect()
|
|
cancellables.removeAll()
|
|
archiveSyncStarted = false
|
|
archiveSyncCompletedForConnection = false
|
|
archiveSyncCheckpoint = archiveCheckpoint
|
|
self.mamCheckpoints = mamCheckpoints
|
|
archiveSyncQueryStartedAt = nil
|
|
archivePagination = ArchiveSyncPagination()
|
|
archiveRetryTask?.cancel()
|
|
archiveRetryTask = nil
|
|
archiveQueryTimeoutTask?.cancel()
|
|
archiveQueryTimeoutTask = nil
|
|
archiveQueryCompletionTask?.cancel()
|
|
archiveQueryCompletionTask = nil
|
|
archiveSyncRetryTask?.cancel()
|
|
archiveSyncRetryTask = nil
|
|
archiveAutoRetryCount = 0
|
|
archiveActiveQueryID = nil
|
|
archiveResumeAfter = nil
|
|
archiveWorkBudget = ArchiveSyncWorkBudget()
|
|
archiveHighWatermark = nil
|
|
archiveLastCompletedCursor = archiveCheckpoint?.cursor
|
|
archiveHasCompletedPage = false
|
|
archiveCursorFallbackUsed = false
|
|
archivePassMutations.removeAll(keepingCapacity: false)
|
|
archiveIsBootstrapQuery = false
|
|
archiveRetrySuppressedUntilActivation = false
|
|
archiveStanzaBuffer.removeAll(keepingCapacity: false)
|
|
archiveBufferOverflowed = false
|
|
archiveRejectedSource = false
|
|
archiveStanzaInbox.cancel()
|
|
pendingMUCCatchups.removeAll()
|
|
activeMUCCatchup = nil
|
|
mucCursorFallbackArchives.removeAll()
|
|
mucCatchupHighWatermark = nil
|
|
mucCatchupLastCursor = nil
|
|
setArchiveSyncIndicator(false)
|
|
avatarRequests.removeAll()
|
|
observedRoomJIDs.removeAll()
|
|
roomOccupantsByJID.removeAll()
|
|
roomRealJIDByNickname.removeAll()
|
|
omemoConfiguredRoomJIDs.removeAll()
|
|
knownChatStatePeers.removeAll()
|
|
|
|
let account = try account.validated()
|
|
self.account = account
|
|
|
|
let client = XMPPClient()
|
|
let omemoStorage = LumaOMEMOStore(accountJID: account.normalizedJID)
|
|
guard let signalContext = SignalContext(withStorage: omemoStorage) else {
|
|
throw LumaXMPPError.omemoInitializationFailed
|
|
}
|
|
// Clean up any invalid self-session left behind by a previous buggy
|
|
// build so it cannot poison encrypt-to-self ("Bad MAC" loops).
|
|
omemoStorage.removeSessionWithOwnDevice()
|
|
|
|
configureModules(client: client, signalContext: signalContext, omemoStorage: omemoStorage)
|
|
configureConnection(client: client, account: account, password: password)
|
|
subscribe(to: client, omemoStorage: omemoStorage)
|
|
|
|
self.client = client
|
|
self.omemoStorage = omemoStorage
|
|
callEngine.attach(client: client)
|
|
eventHandler?(.connection(.connecting))
|
|
|
|
do {
|
|
try await client.loginAndWait()
|
|
let now = Date()
|
|
lastLoginDate = now
|
|
let streamManagement = client.module(.streamManagement)
|
|
if streamManagement.ackEnabled || streamManagement.resumptionEnabled {
|
|
smacksSessionEstablishedDate = now
|
|
}
|
|
eventHandler?(.connection(.connected))
|
|
fetchAvatar(for: account.normalizedJID)
|
|
startArchiveSyncIfAvailable()
|
|
} catch {
|
|
callEngine.detach()
|
|
eventHandler?(.connection(.disconnected(reason: error.localizedDescription)))
|
|
throw LumaXMPPError.connection(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
func disconnect() async {
|
|
callEngine.detach()
|
|
finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
|
archiveRetryTask?.cancel()
|
|
archiveRetryTask = nil
|
|
archiveQueryTimeoutTask?.cancel()
|
|
archiveQueryTimeoutTask = nil
|
|
archiveQueryCompletionTask?.cancel()
|
|
archiveQueryCompletionTask = nil
|
|
archiveSyncRetryTask?.cancel()
|
|
archiveSyncRetryTask = nil
|
|
archiveAutoRetryCount = 0
|
|
archiveActiveQueryID = nil
|
|
archiveResumeAfter = nil
|
|
archiveWorkBudget = ArchiveSyncWorkBudget()
|
|
archiveHighWatermark = nil
|
|
archiveLastCompletedCursor = archiveSyncCheckpoint?.cursor
|
|
archiveHasCompletedPage = false
|
|
archiveCursorFallbackUsed = false
|
|
archivePassMutations.removeAll(keepingCapacity: false)
|
|
archiveIsBootstrapQuery = false
|
|
archiveRetrySuppressedUntilActivation = false
|
|
archiveStanzaBuffer.removeAll(keepingCapacity: false)
|
|
archiveBufferOverflowed = false
|
|
archiveRejectedSource = false
|
|
archiveStanzaInbox.cancel()
|
|
if archiveSyncStarted, let client {
|
|
client.module(.omemo).mamSyncFinished(for: nil)
|
|
}
|
|
setArchiveSyncIndicator(false)
|
|
archiveSyncStarted = false
|
|
archiveSyncCompletedForConnection = false
|
|
archiveSyncQueryStartedAt = nil
|
|
archivePagination = ArchiveSyncPagination()
|
|
guard let client else { return }
|
|
if client.state == .connected() || client.state == .connecting {
|
|
try? await client.disconnect(force: false)
|
|
}
|
|
self.client = nil
|
|
omemoStorage?.flushPendingPersistence()
|
|
omemoStorage = nil
|
|
cancellables.removeAll()
|
|
avatarRequests.removeAll()
|
|
observedRoomJIDs.removeAll()
|
|
roomOccupantsByJID.removeAll()
|
|
roomRealJIDByNickname.removeAll()
|
|
omemoConfiguredRoomJIDs.removeAll()
|
|
knownChatStatePeers.removeAll()
|
|
connectionStatsModule = nil
|
|
lastLoginDate = nil
|
|
smacksSessionEstablishedDate = nil
|
|
eventHandler?(.connection(.disconnected(reason: nil)))
|
|
}
|
|
|
|
func setChatStatesEnabled(_ enabled: Bool) {
|
|
chatStatesEnabled = enabled
|
|
}
|
|
|
|
func prepareDirectChat(with recipient: String) {
|
|
guard let client, client.state == .connected() else { return }
|
|
let peer = BareJID(recipient.lowercased())
|
|
let manager = client.module(.message).chatManager
|
|
if manager.chat(for: client, with: peer) == nil {
|
|
_ = manager.createChat(for: client, with: peer)
|
|
}
|
|
}
|
|
|
|
func setApplicationActive(_ active: Bool) {
|
|
guard let client, client.state == .connected() else { return }
|
|
_ = client.module(.csi).setState(active)
|
|
if active {
|
|
archiveRetrySuppressedUntilActivation = false
|
|
archiveAutoRetryCount = 0
|
|
archiveSyncRetryTask?.cancel()
|
|
archiveSyncRetryTask = nil
|
|
refreshArchiveIfNeeded(client: client)
|
|
} else {
|
|
// iOS may freeze network callbacks and timeout tasks while the
|
|
// scene is inactive. Close the visible MAM pass now so it cannot
|
|
// return with a stale spinner after the app becomes active again.
|
|
pauseArchiveSync(client: client)
|
|
}
|
|
}
|
|
|
|
/// Media staging, encryption and MAM decryption compete for main-thread and
|
|
/// I/O time. Keep MAM finite and paused until camera/gallery work and the
|
|
/// corresponding upload have released their resources.
|
|
func setArchiveSyncSuspendedForMediaWork(_ suspended: Bool) {
|
|
archiveResumeAfterMediaWorkTask?.cancel()
|
|
archiveResumeAfterMediaWorkTask = nil
|
|
|
|
if suspended {
|
|
guard !archiveSyncSuspended else { return }
|
|
archiveSyncSuspended = true
|
|
if let client, client.state == .connected() {
|
|
pauseArchiveSync(client: client)
|
|
}
|
|
return
|
|
}
|
|
|
|
guard archiveSyncSuspended else { return }
|
|
archiveResumeAfterMediaWorkTask = Task { @MainActor [weak self] in
|
|
try? await Task.sleep(
|
|
nanoseconds: ArchiveSyncRecoveryPolicy.resumeAfterCaptureDelayNanoseconds
|
|
)
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.archiveResumeAfterMediaWorkTask = nil
|
|
self.archiveSyncSuspended = false
|
|
guard let client = self.client,
|
|
client.state == .connected()
|
|
else { return }
|
|
self.startArchiveSyncIfAvailable()
|
|
}
|
|
}
|
|
|
|
func reconnectIfNeeded(password: String, archiveCheckpoint: ArchiveSyncCheckpoint? = nil)
|
|
async throws
|
|
{
|
|
guard connectionStatus != .connected, let account else { return }
|
|
if let archiveCheckpoint {
|
|
self.archiveSyncCheckpoint = archiveCheckpoint
|
|
}
|
|
try await connect(
|
|
account: account, password: password, archiveCheckpoint: archiveSyncCheckpoint)
|
|
}
|
|
|
|
func startCall(to peerJID: String, withVideo: Bool) async throws {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
callEngine.startCall(to: peerJID, withVideo: withVideo) { result in
|
|
continuation.resume(with: result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func answerCall() async throws {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
callEngine.answerCall { result in
|
|
continuation.resume(with: result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func rejectCall() {
|
|
callEngine.rejectCall()
|
|
}
|
|
|
|
func endCall() {
|
|
callEngine.endCall()
|
|
}
|
|
|
|
func setCallMuted(_ muted: Bool) {
|
|
callEngine.setMuted(muted)
|
|
}
|
|
|
|
func setCallCameraEnabled(_ enabled: Bool) {
|
|
callEngine.setCameraEnabled(enabled)
|
|
}
|
|
|
|
func setCallSpeakerEnabled(_ enabled: Bool) {
|
|
callEngine.setSpeakerEnabled(enabled)
|
|
}
|
|
|
|
func switchCallCamera() {
|
|
callEngine.switchCamera()
|
|
}
|
|
|
|
func localCallVideoTrack(for callID: UUID) -> RTCVideoTrack? {
|
|
callEngine.localVideoTrack(for: callID)
|
|
}
|
|
|
|
func remoteCallVideoTrack(for callID: UUID) -> RTCVideoTrack? {
|
|
callEngine.remoteVideoTrack(for: callID)
|
|
}
|
|
|
|
func joinRoom(
|
|
roomJID rawRoomJID: String,
|
|
nickname rawNickname: String,
|
|
password: String? = nil
|
|
) async throws {
|
|
guard let client, client.state == .connected() else {
|
|
throw LumaXMPPError.notConnected
|
|
}
|
|
let roomJID = rawRoomJID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let parts = roomJID.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false)
|
|
guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else {
|
|
throw LumaXMPPError.invalidRoomJID
|
|
}
|
|
let nickname = rawNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !nickname.isEmpty else { throw LumaXMPPError.emptyRoomNickname }
|
|
|
|
let muc = client.module(.muc)
|
|
let result: RoomJoinResult = try await withCheckedThrowingContinuation { continuation in
|
|
muc.join(
|
|
roomName: String(parts[0]),
|
|
mucServer: String(parts[1]),
|
|
nickname: nickname,
|
|
password: password
|
|
) { result in
|
|
switch result {
|
|
case .success(let value): continuation.resume(returning: value)
|
|
case .failure(let error): continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
let room: RoomProtocol
|
|
switch result {
|
|
case .created(let createdRoom):
|
|
room = createdRoom
|
|
do {
|
|
try await configureInstantRoom(createdRoom, using: muc)
|
|
} catch {
|
|
try await muc.leave(room: createdRoom)
|
|
throw error
|
|
}
|
|
case .joined(let joinedRoom):
|
|
room = joinedRoom
|
|
}
|
|
observe(room: room)
|
|
enqueueMUCCatchup(roomJID: room.jid)
|
|
|
|
eventHandler?(
|
|
.roomState(
|
|
RoomStateEnvelope(
|
|
roomJID: room.jid.stringValue,
|
|
nickname: room.nickname,
|
|
joined: room.state == .joined,
|
|
occupantCount: 0
|
|
)))
|
|
}
|
|
|
|
func leaveRoom(roomJID: String) {
|
|
guard let client else { return }
|
|
let normalized = roomJID.lowercased()
|
|
let muc = client.module(.muc)
|
|
guard let room = muc.roomManager.room(for: client, with: BareJID(normalized)) else {
|
|
return
|
|
}
|
|
muc.leave(room: room)
|
|
observedRoomJIDs.remove(normalized)
|
|
roomOccupantsByJID.removeValue(forKey: normalized)
|
|
roomRealJIDByNickname.removeValue(forKey: normalized)
|
|
omemoConfiguredRoomJIDs.remove(normalized)
|
|
eventHandler?(
|
|
.roomState(
|
|
RoomStateEnvelope(
|
|
roomJID: normalized,
|
|
nickname: room.nickname,
|
|
joined: false,
|
|
occupantCount: 0
|
|
)))
|
|
}
|
|
|
|
func inviteMembers(_ jids: [String], to roomJID: String) async throws {
|
|
guard let client, client.state == .connected() else {
|
|
throw LumaXMPPError.notConnected
|
|
}
|
|
let normalized = roomJID.lowercased()
|
|
let muc = client.module(.muc)
|
|
guard let room = muc.roomManager.room(for: client, with: BareJID(normalized)),
|
|
room.state == .joined
|
|
else {
|
|
throw LumaXMPPError.roomNotJoined
|
|
}
|
|
let invitees = jids.map {
|
|
JID($0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
|
|
}
|
|
let ownAffiliation = room.occupant(nickname: room.nickname)?.affiliation ?? room.affiliation
|
|
if ownAffiliation == .owner || ownAffiliation == .admin {
|
|
let owners = try? await roomAffiliations(
|
|
in: room,
|
|
affiliation: .owner,
|
|
using: muc
|
|
)
|
|
let admins = try? await roomAffiliations(
|
|
in: room,
|
|
affiliation: .admin,
|
|
using: muc
|
|
)
|
|
if let owners, let admins {
|
|
let privilegedJIDs = Set((owners + admins).map { $0.jid.bareJid })
|
|
let memberships =
|
|
invitees
|
|
.filter { !privilegedJIDs.contains($0.bareJid) }
|
|
.map {
|
|
MucModule.RoomAffiliation(jid: $0, affiliation: .member)
|
|
}
|
|
if !memberships.isEmpty {
|
|
try await withCheckedThrowingContinuation {
|
|
(continuation: CheckedContinuation<Void, Error>) in
|
|
muc.setRoomAffiliations(to: room, changedAffiliations: memberships) {
|
|
result in
|
|
continuation.resume(with: result)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for invitee in invitees {
|
|
try await muc.invite(
|
|
to: room,
|
|
invitee: invitee,
|
|
reason: "Приглашение в групповой чат Luma"
|
|
)
|
|
}
|
|
}
|
|
|
|
func sendText(
|
|
_ text: String,
|
|
to recipient: String,
|
|
messageID: String,
|
|
encrypted: Bool,
|
|
outOfBandURL: String? = nil,
|
|
replacingMessageID: String? = nil,
|
|
replyTo: ReplyReference? = nil,
|
|
isGroup: Bool = false
|
|
) async throws -> String? {
|
|
guard let client, client.state == .connected() else {
|
|
throw LumaXMPPError.notConnected
|
|
}
|
|
|
|
let wireBody: String
|
|
let replyFallback: (prefix: String, scalarCount: Int)?
|
|
if let replyTo {
|
|
let fallback = MessageReplyFallback.make(
|
|
author: replyTo.fallbackAuthor,
|
|
preview: replyTo.preview
|
|
)
|
|
wireBody = fallback.prefix + text
|
|
replyFallback = fallback
|
|
} else {
|
|
wireBody = text
|
|
replyFallback = nil
|
|
}
|
|
|
|
if isGroup {
|
|
let roomJID = BareJID(recipient.lowercased())
|
|
let muc = client.module(.muc)
|
|
guard let room = muc.roomManager.room(for: client, with: roomJID),
|
|
room.state == .joined
|
|
else {
|
|
throw LumaXMPPError.roomNotJoined
|
|
}
|
|
let message = room.createMessage(text: wireBody, id: messageID)
|
|
addOriginID(messageID, to: message)
|
|
message.lastMessageCorrectionId = replacingMessageID
|
|
if chatStatesEnabled { message.chatState = .active }
|
|
addReply(replyTo, fallback: replyFallback, to: message)
|
|
if !encrypted, let outOfBandURL { message.oob = outOfBandURL }
|
|
if encrypted {
|
|
guard let omemo = client.moduleOrNil(.omemo), omemo.isReady else {
|
|
throw LumaXMPPError.omemoNotReady
|
|
}
|
|
let recipients = try await groupOMEMORecipients(
|
|
in: room,
|
|
using: muc,
|
|
client: client
|
|
)
|
|
let encryptedMessage = try await encrypt(
|
|
message,
|
|
forGroupRecipients: recipients,
|
|
using: omemo
|
|
)
|
|
try await room.send(message: encryptedMessage.message)
|
|
return encryptedMessage.fingerprint
|
|
} else {
|
|
try await room.send(message: message)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
let peer = BareJID(recipient.lowercased())
|
|
let manager = client.module(.message).chatManager
|
|
guard
|
|
let chat = manager.chat(for: client, with: peer)
|
|
?? manager.createChat(for: client, with: peer)
|
|
else {
|
|
throw LumaXMPPError.cannotCreateChat
|
|
}
|
|
|
|
let message = chat.createMessage(text: wireBody, id: messageID)
|
|
addOriginID(messageID, to: message)
|
|
message.lastMessageCorrectionId = replacingMessageID
|
|
if chatStatesEnabled { message.chatState = .active }
|
|
addReply(replyTo, fallback: replyFallback, to: message)
|
|
if !encrypted, let outOfBandURL {
|
|
message.oob = outOfBandURL
|
|
}
|
|
if encrypted {
|
|
guard let omemo = client.moduleOrNil(.omemo), omemo.isReady else {
|
|
throw LumaXMPPError.omemoNotReady
|
|
}
|
|
let encryptedMessage = try await encrypt(message, using: omemo)
|
|
try await chat.send(message: encryptedMessage.message)
|
|
return encryptedMessage.fingerprint
|
|
} else {
|
|
try await chat.send(message: message)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Loads one older MAM page for a single conversation. `before` is the
|
|
/// oldest server/MAM id currently known by the UI. For direct chats the
|
|
/// query is scoped with XEP-0313 `with`; for MUC the IQ is addressed to the
|
|
/// room archive itself.
|
|
func loadOlderHistory(
|
|
conversationJID: String,
|
|
isGroup: Bool,
|
|
before: String?,
|
|
completion: @escaping (Result<Bool, Error>) -> Void
|
|
) {
|
|
guard let client, client.state == .connected() else {
|
|
completion(.failure(LumaXMPPError.notConnected))
|
|
return
|
|
}
|
|
// Interactive backward-history is fully independent from catch-up: it
|
|
// owns a query-ID-scoped inbox entry and its own mutation collector, so
|
|
// it must never wait for an account/MUC catch-up pass to settle.
|
|
performOlderHistoryRequest(
|
|
conversationJID: conversationJID,
|
|
isGroup: isGroup,
|
|
before: before,
|
|
completion: completion
|
|
)
|
|
}
|
|
|
|
private func performOlderHistoryRequest(
|
|
conversationJID: String,
|
|
isGroup: Bool,
|
|
before: String?,
|
|
completion: @escaping (Result<Bool, Error>) -> Void
|
|
) {
|
|
guard let client, client.state == .connected() else {
|
|
completion(.failure(LumaXMPPError.notConnected))
|
|
return
|
|
}
|
|
guard let version = client.module(.mam).availableVersions.first else {
|
|
completion(.failure(LumaXMPPError.connection("MAM недоступен")))
|
|
return
|
|
}
|
|
|
|
let archive = isGroup
|
|
? MAMArchiveKey.muc(conversationJID)
|
|
: MAMArchiveKey.account(client.userBareJid.stringValue)
|
|
// Only one interactive backward-history query is allowed at a time.
|
|
// This is independent from the normal account/MUC catch-up query.
|
|
if let previous = olderHistoryCompletion {
|
|
// AppModel normally serializes requests, but never leak the
|
|
// previous completion if a request does slip through.
|
|
previous(.failure(LumaXMPPError.connection("Запрос истории заменён более новым.")))
|
|
}
|
|
olderHistoryTimeoutTask?.cancel()
|
|
olderHistoryTimeoutTask = nil
|
|
interactiveHistoryMutations = nil
|
|
let queryID = UUID().uuidString
|
|
olderHistoryQueryID = queryID
|
|
olderHistoryCompletion = completion
|
|
archiveStanzaInbox.begin(
|
|
queryID: queryID,
|
|
allowedSources: allowedMAMSources(for: archive, client: client)
|
|
)
|
|
scheduleOlderHistoryTimeout(
|
|
client: client,
|
|
queryID: queryID,
|
|
conversationJID: conversationJID
|
|
)
|
|
|
|
// Martin 3.2.4's convenience overload accidentally clears `with`.
|
|
// Construct MAMQueryForm explicitly so direct-chat history is truly
|
|
// scoped to this peer.
|
|
let form = MAMQueryForm(version: version)
|
|
if !isGroup {
|
|
form.with = JID(conversationJID.lowercased())
|
|
}
|
|
let componentJID: JID? = isGroup ? JID(conversationJID.lowercased()) : nil
|
|
let rsm: RSM.Query = before.map {
|
|
RSM.Query(before: $0, max: archivePageSize)
|
|
} ?? RSM.Query(lastItems: archivePageSize)
|
|
|
|
client.module(.mam).queryItems(
|
|
version: version,
|
|
componentJid: componentJID,
|
|
query: form,
|
|
queryId: queryID,
|
|
rsm: rsm
|
|
) { [weak self, weak client] result in
|
|
DispatchQueue.main.async {
|
|
guard let self else {
|
|
completion(.failure(LumaXMPPError.notConnected))
|
|
return
|
|
}
|
|
guard let client, self.client === client else {
|
|
self.finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
|
return
|
|
}
|
|
guard self.olderHistoryQueryID == queryID else {
|
|
// A newer request, a timeout, or a disconnect already owns
|
|
// the slot and has released this completion.
|
|
return
|
|
}
|
|
self.olderHistoryTimeoutTask?.cancel()
|
|
self.olderHistoryTimeoutTask = nil
|
|
let page = self.archiveStanzaInbox.take(queryID: queryID)
|
|
guard !page.overflowed, !page.rejectedSource else {
|
|
self.finishOlderHistory(
|
|
result: .failure(LumaXMPPError.connection("Некорректная MAM history page"))
|
|
)
|
|
return
|
|
}
|
|
|
|
Task { @MainActor [weak self, weak client] in
|
|
guard let self else { return }
|
|
guard let client, self.client === client else {
|
|
self.finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
|
return
|
|
}
|
|
// Collect this page's mutations in a dedicated buffer so a
|
|
// concurrently-running catch-up pass never interleaves its
|
|
// mutations with the interactive page.
|
|
self.interactiveHistoryMutations = []
|
|
for stanza in page.stanzas {
|
|
if stanza.message.type == .groupchat,
|
|
let roomJID = stanza.message.from?.bareJid {
|
|
await self.handle(
|
|
groupMessage: stanza.message,
|
|
archivedRoomJID: roomJID,
|
|
archivedTimestamp: stanza.timestamp,
|
|
archiveID: stanza.archiveID,
|
|
isArchived: true
|
|
)
|
|
} else {
|
|
await self.handle(
|
|
message: stanza.message,
|
|
timestamp: stanza.timestamp,
|
|
archiveID: stanza.archiveID,
|
|
isArchived: true,
|
|
archivedPeerJID: isGroup ? nil : BareJID(conversationJID.lowercased())
|
|
)
|
|
}
|
|
}
|
|
let historyMutations = self.interactiveHistoryMutations ?? []
|
|
self.interactiveHistoryMutations = nil
|
|
if !historyMutations.isEmpty {
|
|
self.eventHandler?(.archiveBatch(historyMutations))
|
|
}
|
|
switch result {
|
|
case .success(let response):
|
|
self.finishOlderHistory(result: .success(!response.complete))
|
|
case .failure(let error):
|
|
// Do not leave the UI in the loading state. A failed
|
|
// interactive query is recoverable by the next scroll.
|
|
self.finishOlderHistory(result: .failure(error))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scheduleOlderHistoryTimeout(
|
|
client: XMPPClient,
|
|
queryID: String,
|
|
conversationJID: String
|
|
) {
|
|
olderHistoryTimeoutTask?.cancel()
|
|
olderHistoryTimeoutTask = Task { @MainActor [weak self, weak client] in
|
|
try? await Task.sleep(nanoseconds: self?.olderHistoryQueryTimeoutNanoseconds ?? 15_000_000_000)
|
|
guard !Task.isCancelled,
|
|
let self,
|
|
let client,
|
|
self.client === client,
|
|
self.olderHistoryQueryID == queryID
|
|
else {
|
|
return
|
|
}
|
|
|
|
self.archiveStanzaInbox.cancel(queryID: queryID)
|
|
self.eventHandler?(
|
|
.recoverableError(
|
|
"Не удалось загрузить более старую историю чата \(conversationJID): MAM не ответил за 15 секунд."
|
|
)
|
|
)
|
|
self.finishOlderHistory(
|
|
result: .failure(
|
|
LumaXMPPError.connection(
|
|
"MAM не ответил вовремя. Повторите прокрутку вверх."
|
|
)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Releases the in-flight interactive backward-history request exactly once.
|
|
/// Every terminal path (success, failure, timeout, disconnect, background)
|
|
/// funnels through here so the UI's "loading older history" spinner can
|
|
/// never be left stuck.
|
|
private func finishOlderHistory(result: Result<Bool, Error>) {
|
|
guard olderHistoryCompletion != nil else { return }
|
|
let completion = olderHistoryCompletion
|
|
olderHistoryCompletion = nil
|
|
olderHistoryQueryID = nil
|
|
olderHistoryTimeoutTask?.cancel()
|
|
olderHistoryTimeoutTask = nil
|
|
interactiveHistoryMutations = nil
|
|
completion?(result)
|
|
}
|
|
|
|
func sendRetraction(
|
|
to recipient: String,
|
|
targetID: String,
|
|
retractionID: String,
|
|
encrypted: Bool
|
|
) async throws {
|
|
guard let client, client.state == .connected() else {
|
|
throw LumaXMPPError.notConnected
|
|
}
|
|
|
|
let peer = BareJID(recipient.lowercased())
|
|
let manager = client.module(.message).chatManager
|
|
guard
|
|
let chat = manager.chat(for: client, with: peer)
|
|
?? manager.createChat(for: client, with: peer)
|
|
else {
|
|
throw LumaXMPPError.cannotCreateChat
|
|
}
|
|
|
|
let fallbackText =
|
|
"/me удалил(а) сообщение. Обновите клиент, если оно всё ещё отображается."
|
|
let message = chat.createMessage(text: fallbackText, id: retractionID)
|
|
let retract = Element(name: "retract", xmlns: Self.retractionNamespace)
|
|
retract.setAttribute("id", value: targetID)
|
|
message.addChild(retract)
|
|
|
|
let fallback = Element(name: "fallback", xmlns: Self.fallbackNamespace)
|
|
fallback.setAttribute("for", value: Self.retractionNamespace)
|
|
message.addChild(fallback)
|
|
message.hints = [.store]
|
|
|
|
if encrypted {
|
|
guard let omemo = client.moduleOrNil(.omemo), omemo.isReady else {
|
|
throw LumaXMPPError.omemoNotReady
|
|
}
|
|
let encryptedMessage = try await encrypt(message, using: omemo)
|
|
try await chat.send(message: encryptedMessage.message)
|
|
} else {
|
|
try await chat.send(message: message)
|
|
}
|
|
}
|
|
|
|
func sendReactions(
|
|
to recipient: String,
|
|
targetID: String,
|
|
emojis: [String],
|
|
reactionMessageID: String,
|
|
isGroup: Bool
|
|
) async throws {
|
|
guard let client, client.state == .connected() else {
|
|
throw LumaXMPPError.notConnected
|
|
}
|
|
let normalizedTarget = targetID.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !normalizedTarget.isEmpty else { return }
|
|
|
|
let reactions = Element(name: "reactions", xmlns: Self.reactionsNamespace)
|
|
reactions.setAttribute("id", value: normalizedTarget)
|
|
for emoji in MessageReactionPolicy.sanitized(emojis) {
|
|
reactions.addChild(Element(name: "reaction", cdata: emoji))
|
|
}
|
|
|
|
if isGroup {
|
|
let roomJID = BareJID(recipient.lowercased())
|
|
let muc = client.module(.muc)
|
|
guard let room = muc.roomManager.room(for: client, with: roomJID),
|
|
room.state == .joined
|
|
else {
|
|
throw LumaXMPPError.roomNotJoined
|
|
}
|
|
let message = room.createMessage(id: reactionMessageID, type: .groupchat)
|
|
message.addChild(reactions)
|
|
message.hints = [.store]
|
|
try await room.send(message: message)
|
|
return
|
|
}
|
|
|
|
let peer = BareJID(recipient.lowercased())
|
|
let manager = client.module(.message).chatManager
|
|
guard
|
|
let chat = manager.chat(for: client, with: peer)
|
|
?? manager.createChat(for: client, with: peer)
|
|
else {
|
|
throw LumaXMPPError.cannotCreateChat
|
|
}
|
|
let message = chat.createMessage(id: reactionMessageID, type: .chat)
|
|
message.addChild(reactions)
|
|
message.hints = [.store]
|
|
try await chat.send(message: message)
|
|
}
|
|
|
|
func sendChatState(
|
|
_ state: ChatTypingState,
|
|
to recipient: String,
|
|
isGroup: Bool
|
|
) async throws {
|
|
guard chatStatesEnabled,
|
|
let client,
|
|
client.state == .connected()
|
|
else { return }
|
|
|
|
let martinState = Self.martinChatState(state)
|
|
if isGroup {
|
|
let roomJID = BareJID(recipient.lowercased())
|
|
let muc = client.module(.muc)
|
|
guard let room = muc.roomManager.room(for: client, with: roomJID),
|
|
room.state == .joined
|
|
else { return }
|
|
let message = room.createMessage(id: UUID().uuidString, type: .groupchat)
|
|
message.chatState = martinState
|
|
try await room.send(message: message)
|
|
return
|
|
}
|
|
|
|
let peer = BareJID(recipient.lowercased())
|
|
let chatStateModule = client.module(.chatStateNotifications)
|
|
let hasSupport =
|
|
knownChatStatePeers.contains(peer.stringValue.lowercased())
|
|
|| chatStateModule.hasSupport(jid: peer)
|
|
guard hasSupport else { return }
|
|
|
|
let manager = client.module(.message).chatManager
|
|
guard
|
|
let chat = manager.chat(for: client, with: peer)
|
|
?? manager.createChat(for: client, with: peer)
|
|
else { return }
|
|
let message = chat.createMessage(id: UUID().uuidString, type: .chat)
|
|
message.chatState = martinState
|
|
try await chat.send(message: message)
|
|
}
|
|
|
|
func uploadAndSendMedia(
|
|
data: Data,
|
|
filename: String,
|
|
mimeType: String,
|
|
kind: ChatMessage.Kind,
|
|
duration: TimeInterval?,
|
|
to recipient: String,
|
|
messageID: String,
|
|
encrypted: Bool,
|
|
isGroup: Bool = false
|
|
) async throws -> MediaSendResult {
|
|
guard let client, client.state == .connected() else {
|
|
throw LumaXMPPError.notConnected
|
|
}
|
|
|
|
let uploadData: Data
|
|
let uploadFilename: String
|
|
let uploadMimeType: String
|
|
var decryptionFragment: String?
|
|
let transportFilename = MediaMetadata.transportFilename(
|
|
originalFilename: filename,
|
|
kind: kind,
|
|
duration: duration
|
|
)
|
|
if encrypted {
|
|
guard let omemo = client.moduleOrNil(.omemo), omemo.isReady else {
|
|
throw LumaXMPPError.omemoNotReady
|
|
}
|
|
switch omemo.encryptFile(data: data) {
|
|
case .success(let value):
|
|
uploadData = value.0
|
|
decryptionFragment = value.1
|
|
case .failure:
|
|
throw LumaXMPPError.fileEncryptionFailed
|
|
}
|
|
// XEP-0454 uses aesgcm as the URI scheme, not as a filename
|
|
// extension. Keeping the original extension lets Conversations,
|
|
// Monal and other clients recover the media type after decrypting.
|
|
uploadFilename = transportFilename
|
|
uploadMimeType = "application/octet-stream"
|
|
} else {
|
|
uploadData = data
|
|
uploadFilename = transportFilename
|
|
uploadMimeType = mimeType
|
|
}
|
|
|
|
let uploadModule = client.module(.httpFileUpload)
|
|
let components: [HttpFileUploadModule.UploadComponent]
|
|
do {
|
|
components = try await uploadModule.findHttpUploadComponents()
|
|
} catch {
|
|
throw LumaXMPPError.uploadDiscoveryFailed(error.localizedDescription)
|
|
}
|
|
guard
|
|
let component =
|
|
components
|
|
.filter({ $0.maxSize >= uploadData.count })
|
|
.max(by: { $0.maxSize < $1.maxSize })
|
|
else {
|
|
throw LumaXMPPError.uploadUnavailable(size: uploadData.count)
|
|
}
|
|
|
|
let slot: HttpFileUploadModule.Slot
|
|
do {
|
|
slot = try await uploadModule.requestUploadSlot(
|
|
componentJid: component.jid,
|
|
filename: uploadFilename,
|
|
size: uploadData.count,
|
|
contentType: uploadMimeType
|
|
)
|
|
} catch {
|
|
throw LumaXMPPError.uploadSlotFailed(error.localizedDescription)
|
|
}
|
|
guard slot.putUri.scheme?.lowercased() == "https",
|
|
slot.getUri.scheme?.lowercased() == "https"
|
|
else {
|
|
throw LumaXMPPError.insecureUploadURL
|
|
}
|
|
|
|
var request = URLRequest(url: slot.putUri)
|
|
request.httpMethod = "PUT"
|
|
request.timeoutInterval = 120
|
|
request.setValue(uploadMimeType, forHTTPHeaderField: "Content-Type")
|
|
for (name, value) in slot.putHeaders {
|
|
request.setValue(value, forHTTPHeaderField: name)
|
|
}
|
|
let response: URLResponse
|
|
let uploadConfiguration = URLSessionConfiguration.ephemeral
|
|
uploadConfiguration.timeoutIntervalForRequest = 120
|
|
uploadConfiguration.timeoutIntervalForResource = 300
|
|
uploadConfiguration.waitsForConnectivity = false
|
|
let uploadSession = URLSession(configuration: uploadConfiguration)
|
|
defer { uploadSession.finishTasksAndInvalidate() }
|
|
do {
|
|
let (_, receivedResponse) = try await uploadSession.upload(
|
|
for: request, from: uploadData)
|
|
response = receivedResponse
|
|
} catch {
|
|
throw LumaXMPPError.uploadTransportFailed(error.localizedDescription)
|
|
}
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw LumaXMPPError.uploadFailed(statusCode: nil)
|
|
}
|
|
guard (200..<300).contains(http.statusCode) else {
|
|
throw LumaXMPPError.uploadFailed(statusCode: http.statusCode)
|
|
}
|
|
|
|
guard var urlComponents = URLComponents(url: slot.getUri, resolvingAgainstBaseURL: false)
|
|
else {
|
|
throw LumaXMPPError.invalidUploadURL
|
|
}
|
|
if let decryptionFragment {
|
|
urlComponents.scheme = "aesgcm"
|
|
urlComponents.fragment = decryptionFragment
|
|
}
|
|
|
|
guard let remoteURL = urlComponents.string else {
|
|
throw LumaXMPPError.invalidUploadURL
|
|
}
|
|
let fingerprint = try await sendText(
|
|
remoteURL,
|
|
to: recipient,
|
|
messageID: messageID,
|
|
encrypted: encrypted,
|
|
outOfBandURL: encrypted ? nil : remoteURL,
|
|
isGroup: isGroup
|
|
)
|
|
return MediaSendResult(remoteURL: remoteURL, fingerprint: fingerprint)
|
|
}
|
|
|
|
func downloadAttachment(_ remoteURL: String) async throws -> Data {
|
|
guard var components = URLComponents(string: remoteURL) else {
|
|
throw LumaXMPPError.invalidUploadURL
|
|
}
|
|
let encrypted = components.scheme?.lowercased() == "aesgcm"
|
|
let fragment = components.fragment
|
|
if encrypted {
|
|
components.scheme = "https"
|
|
components.fragment = nil
|
|
} else if components.scheme?.lowercased() != "https" {
|
|
throw LumaXMPPError.invalidUploadURL
|
|
}
|
|
guard let url = components.url else {
|
|
throw LumaXMPPError.invalidUploadURL
|
|
}
|
|
|
|
let (downloadedData, response) = try await URLSession.shared.data(from: url)
|
|
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
|
throw LumaXMPPError.downloadFailed
|
|
}
|
|
guard encrypted else { return downloadedData }
|
|
guard let omemo = client?.moduleOrNil(.omemo), let fragment else {
|
|
throw LumaXMPPError.fileDecryptionFailed
|
|
}
|
|
switch omemo.decryptFile(data: downloadedData, fragment: fragment) {
|
|
case .success(let data):
|
|
return data
|
|
case .failure:
|
|
throw LumaXMPPError.fileDecryptionFailed
|
|
}
|
|
}
|
|
|
|
func devices(for jid: String) -> [OMEMODevice] {
|
|
omemoStorage?.devices(for: jid) ?? []
|
|
}
|
|
|
|
func setDeviceVerified(_ verified: Bool, jid: String, deviceID: Int32) {
|
|
guard omemoStorage?.setVerified(verified, jid: jid, deviceID: deviceID) == true else {
|
|
return
|
|
}
|
|
eventHandler?(
|
|
.omemo(
|
|
ready: client?.moduleOrNil(.omemo)?.isReady ?? false,
|
|
ownFingerprint: omemoStorage?.ownFingerprint))
|
|
}
|
|
|
|
/// Queries the server's capabilities to populate the "server information"
|
|
/// settings screen: XEP-0030 disco#info (server + account), XEP-0092
|
|
/// software version, conference (MUC) services via disco#items, and
|
|
/// STUN/TURN services via XEP-0215.
|
|
func fetchServerInformation() async throws -> ServerInformation {
|
|
guard let client, client.state == .connected(), let account else {
|
|
throw LumaXMPPError.notConnected
|
|
}
|
|
let disco = client.module(.disco)
|
|
let versionModule = client.module(.softwareVersion)
|
|
let serverDomain = account.domain ?? client.userBareJid.domain
|
|
|
|
// Discover features, software version, server components and external
|
|
// services in parallel. The version/components/services queries are
|
|
// best-effort: not every server implements those XEPs, so they degrade
|
|
// to nil instead of failing the whole screen.
|
|
async let serverInfo = disco.serverFeatures()
|
|
async let accountInfo = disco.accountFeatures()
|
|
async let software = try? versionModule.checkSoftwareVersion(
|
|
for: JID(serverDomain)
|
|
)
|
|
async let components = try? disco.serverComponents()
|
|
async let services = try? client.module(.externalServiceDiscovery)
|
|
.discover(from: nil, type: nil)
|
|
|
|
let server = try await serverInfo
|
|
let accountDisco = try await accountInfo
|
|
let version = await software
|
|
let componentItems = await components
|
|
let extServices = await services
|
|
|
|
// Resolve each server component to its disco identity. Keep conference
|
|
// (MUC) services, and also detect HTTP File Upload when it is announced
|
|
// on a component (e.g. upload.<domain>) rather than the main domain.
|
|
var conferenceServers: [ServerInformation.ConferenceServer] = []
|
|
var uploadOnComponent = false
|
|
if let componentItems {
|
|
for item in componentItems.items {
|
|
guard let info = try? await disco.info(for: item.jid) else { continue }
|
|
if info.features.contains("urn:xmpp:http:upload:0")
|
|
|| info.features.contains("http://jabber.org/protocol/httpupload")
|
|
{
|
|
uploadOnComponent = true
|
|
}
|
|
for identity in info.identities where identity.category == "conference" {
|
|
conferenceServers.append(
|
|
ServerInformation.ConferenceServer(
|
|
jid: item.jid.stringValue,
|
|
name: identity.name,
|
|
type: identity.type,
|
|
category: identity.category
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
let externalServices = (extServices ?? []).filter {
|
|
["stun", "turn", "stuns", "turns"].contains($0.type.lowercased())
|
|
}.map {
|
|
ServerInformation.ExternalService(
|
|
type: $0.type,
|
|
host: $0.host,
|
|
port: $0.port,
|
|
transport: $0.transport?.rawValue
|
|
)
|
|
}
|
|
|
|
// SASL mechanisms and Client State Indication are advertised in the raw
|
|
// stream <features>, not in disco#info, so read them from there.
|
|
let streamFeaturesElement = client.module(.streamFeatures).streamFeatures.element
|
|
let saslMechanisms = streamFeaturesElement?
|
|
.findChild(name: "mechanisms", xmlns: "urn:ietf:params:xml:ns:xmpp-sasl")?
|
|
.children
|
|
.filter { $0.name == "mechanism" }
|
|
.compactMap { $0.value } ?? []
|
|
let supportsCSI = streamFeaturesElement?
|
|
.findChild(name: "csi", xmlns: "urn:xmpp:csi:0") != nil
|
|
let supportsRosterVersioning = streamFeaturesElement?
|
|
.findChild(name: "ver", xmlns: "urn:xmpp:features:rosterver") != nil
|
|
let supportsRosterPreApproval = streamFeaturesElement?
|
|
.findChild(name: "sub", xmlns: "urn:xmpp:features:pre-approval") != nil
|
|
|
|
let uploadOnServer = server.features.contains("urn:xmpp:http:upload:0")
|
|
|| server.features.contains("http://jabber.org/protocol/httpupload")
|
|
let supportsHTTPUpload = uploadOnServer || uploadOnComponent
|
|
|
|
let stats = connectionStatsModule?.snapshot
|
|
?? LumaConnectionStatsModule.Snapshot(sent: 0, acknowledged: 0, received: 0)
|
|
|
|
return ServerInformation(
|
|
serverDomain: serverDomain,
|
|
software: ServerInformation.Software(
|
|
name: version?.name,
|
|
version: version?.version,
|
|
os: version?.os
|
|
),
|
|
identities: server.identities.map {
|
|
ServerInformation.Identity(
|
|
category: $0.category,
|
|
type: $0.type,
|
|
name: $0.name
|
|
)
|
|
},
|
|
serverFeatures: server.features,
|
|
accountFeatures: accountDisco.features,
|
|
conferenceServers: conferenceServers,
|
|
externalServices: externalServices,
|
|
connectionStats: ServerInformation.ConnectionStats(
|
|
lastLogin: lastLoginDate,
|
|
smacksSessionEstablished: smacksSessionEstablishedDate,
|
|
sent: stats.sent,
|
|
acknowledged: stats.acknowledged,
|
|
received: stats.received
|
|
),
|
|
saslMethods: saslMechanisms,
|
|
supportsStreamManagement: client.module(.streamManagement).isAvailable,
|
|
supportsCarbons: client.module(.messageCarbons).isAvailable,
|
|
supportsClientState: supportsCSI,
|
|
supportsHTTPUpload: supportsHTTPUpload,
|
|
supportsRosterVersioning: supportsRosterVersioning,
|
|
supportsRosterPreApproval: supportsRosterPreApproval
|
|
)
|
|
}
|
|
|
|
func addToRoster(jid: String, name: String?) {
|
|
guard let client, client.state == .connected() else { return }
|
|
client.module(.roster).addItem(
|
|
jid: JID(jid), name: name, groups: [], completionHandler: nil)
|
|
client.module(.presence).subscribe(to: JID(jid))
|
|
fetchAvatar(for: jid)
|
|
}
|
|
|
|
func fetchAvatar(for jid: String, force: Bool = false) {
|
|
guard let client, client.state == .connected() else { return }
|
|
let normalized = jid.lowercased()
|
|
if force {
|
|
avatarRequests.remove(normalized)
|
|
}
|
|
guard avatarRequests.insert(normalized).inserted else { return }
|
|
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
let data: Data?
|
|
if let pepData = await self.retrievePEPAvatar(for: normalized, client: client) {
|
|
data = pepData
|
|
} else {
|
|
data = await self.retrieveLegacyAvatar(for: normalized, client: client)
|
|
}
|
|
if let data {
|
|
self.eventHandler?(.avatar(jid: normalized, data: data))
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateOwnAvatar(pngData: Data) async throws {
|
|
guard let client, client.state == .connected(), let account else {
|
|
throw LumaXMPPError.notConnected
|
|
}
|
|
do {
|
|
let avatar = PEPUserAvatarModule.Avatar(data: pngData, mimeType: "image/png")
|
|
_ = try await client.module(.pepUserAvatar).publishAvatar(avatar: [avatar])
|
|
} catch {
|
|
throw LumaXMPPError.avatarUpdateFailed(error.localizedDescription)
|
|
}
|
|
|
|
eventHandler?(.avatar(jid: account.normalizedJID, data: pngData))
|
|
|
|
// XEP-0084 is authoritative. vCard-temp is updated as a best-effort
|
|
// compatibility bridge for older clients.
|
|
Task {
|
|
let vcardModule = client.module(.vcardTemp)
|
|
let vcard = (try? await vcardModule.retrieveVCard(from: nil as JID?)) ?? VCard()
|
|
vcard.photos = [VCard.Photo(type: "image/png", binval: pngData.base64EncodedString())]
|
|
_ = try? await vcardModule.publish(vcard: vcard, to: nil)
|
|
}
|
|
}
|
|
|
|
private func configureModules(
|
|
client: XMPPClient,
|
|
signalContext: SignalContext,
|
|
omemoStorage: LumaOMEMOStore
|
|
) {
|
|
_ = client.modulesManager.register(AuthModule())
|
|
_ = client.modulesManager.register(StreamFeaturesModule())
|
|
// Registered before StreamManagementModule so it can observe the
|
|
// server's `<a h='N'>` acknowledgements for the stanza statistics.
|
|
connectionStatsModule = client.modulesManager.register(
|
|
LumaConnectionStatsModule()
|
|
)
|
|
_ = client.modulesManager.register(StreamManagementModule())
|
|
_ = client.modulesManager.register(SaslModule())
|
|
_ = client.modulesManager.register(ResourceBinderModule())
|
|
_ = client.modulesManager.register(SessionEstablishmentModule())
|
|
_ = client.modulesManager.register(
|
|
DiscoveryModule(identity: .init(category: "client", type: "phone", name: "Luma"))
|
|
)
|
|
_ = client.modulesManager.register(
|
|
SoftwareVersionModule(
|
|
version: .init(name: "Luma", version: "0.10.6", os: Self.platformName))
|
|
)
|
|
_ = client.modulesManager.register(PingModule())
|
|
_ = client.modulesManager.register(ClientStateIndicationModule())
|
|
// _ = client.modulesManager.register(ClientStateIndicationModule())
|
|
_ = client.modulesManager.register(
|
|
RosterModule(rosterManager: RosterManagerBase(store: LumaRosterStore()))
|
|
)
|
|
_ = client.modulesManager.register(PresenceModule(store: DefaultPresenceStore()))
|
|
_ = client.modulesManager.register(
|
|
CapabilitiesModule(additionalFeatures: [
|
|
.lastMessageCorrection,
|
|
.init(rawValue: Self.replyNamespace),
|
|
.init(rawValue: Self.fallbackNamespace),
|
|
.init(rawValue: Self.retractionNamespace),
|
|
.init(rawValue: Self.reactionsNamespace),
|
|
.init(rawValue: ChatStateNotificationsModule.XMLNS),
|
|
.init(rawValue: Self.stanzaIDNamespace),
|
|
])
|
|
)
|
|
_ = client.modulesManager.register(PubSubModule())
|
|
_ = client.modulesManager.register(PEPUserAvatarModule())
|
|
_ = client.modulesManager.register(VCardTempModule())
|
|
_ = client.modulesManager.register(
|
|
MessageModule(chatManager: ChatManagerBase(store: DefaultChatStore()))
|
|
)
|
|
_ = client.modulesManager.register(ChatStateNotificationsModule())
|
|
_ = client.modulesManager.register(
|
|
MucModule(roomManager: RoomManagerBase(store: LumaRoomStore()))
|
|
)
|
|
_ = client.modulesManager.register(MessageCarbonsModule())
|
|
_ = client.modulesManager.register(MessageArchiveManagementModule())
|
|
let receipts = client.modulesManager.register(MessageDeliveryReceiptsModule())
|
|
receipts.sendReceived = true
|
|
_ = client.modulesManager.register(ChatMarkersModule())
|
|
_ = client.modulesManager.register(HttpFileUploadModule())
|
|
let jingle = client.modulesManager.register(JingleModule(sessionManager: callEngine))
|
|
jingle.register(
|
|
transport: Jingle.Transport.ICEUDPTransport.self,
|
|
features: [
|
|
Jingle.Transport.ICEUDPTransport.XMLNS,
|
|
"urn:xmpp:jingle:apps:dtls:0",
|
|
]
|
|
)
|
|
jingle.register(
|
|
description: Jingle.RTP.Description.self,
|
|
features: [
|
|
"urn:xmpp:jingle:apps:rtp:1",
|
|
"urn:xmpp:jingle:apps:rtp:audio",
|
|
"urn:xmpp:jingle:apps:rtp:video",
|
|
]
|
|
)
|
|
jingle.supportsMessageInitiation = true
|
|
_ = client.modulesManager.register(ExternalServiceDiscoveryModule())
|
|
_ = client.modulesManager.register(
|
|
OMEMOModule(
|
|
aesGCMEngine: CryptoKitAESGCMEngine(),
|
|
signalContext: signalContext,
|
|
signalStorage: omemoStorage
|
|
)
|
|
)
|
|
}
|
|
|
|
private func configureConnection(
|
|
client: XMPPClient,
|
|
account: AccountConfiguration,
|
|
password: String
|
|
) {
|
|
client.connectionConfiguration.userJid = BareJID(account.normalizedJID)
|
|
client.connectionConfiguration.resource = account.effectiveResource
|
|
client.connectionConfiguration.nickname =
|
|
account.displayName.isEmpty ? nil : account.displayName
|
|
client.connectionConfiguration.credentials = .password(
|
|
password: password,
|
|
authenticationName: nil,
|
|
cache: nil
|
|
)
|
|
let expectedDomain = account.domain ?? account.normalizedJID
|
|
client.connectionConfiguration.modifyConnectorOptions(type: SocketConnector.Options.self) {
|
|
options in
|
|
options.conntectionTimeout = 20
|
|
options.sslCertificateValidation = .customValidator { trust in
|
|
CertificateTrustEvaluator.evaluateServerTrust(
|
|
trust,
|
|
expectedDomain: expectedDomain
|
|
)
|
|
}
|
|
if let host = account.manualHost {
|
|
let defaultPort = account.usesDirectTLS ? 5223 : 5222
|
|
options.dnsResolver = StaticDNSSrvResolver(
|
|
host: host,
|
|
port: account.manualPort ?? defaultPort,
|
|
directTLS: account.usesDirectTLS
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func subscribe(to client: XMPPClient, omemoStorage: LumaOMEMOStore) {
|
|
client.$state
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] state in
|
|
self?.handle(state: state)
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
client.module(.roster).events
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] action in
|
|
switch action {
|
|
case .addedOrUpdated(let item):
|
|
let jid = item.jid.bareJid.stringValue
|
|
self?.eventHandler?(.rosterItem(jid: jid, name: item.name))
|
|
self?.fetchAvatar(for: jid)
|
|
case .removed(let jid):
|
|
self?.eventHandler?(.rosterRemoved(jid: jid.bareJid.stringValue))
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
client.module(.pepUserAvatar).avatarChangePublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] change in
|
|
self?.fetchAvatar(for: change.jid.bareJid.stringValue, force: true)
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
client.module(.presence).presencePublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] change in
|
|
self?.eventHandler?(
|
|
.presence(
|
|
jid: change.jid.bareJid.stringValue,
|
|
online: change.presence.type != .unavailable
|
|
))
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
client.module(.message).messagesPublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] incoming in
|
|
Task { @MainActor [weak self] in
|
|
await self?.deliverOrDelayDirect(incoming.message, timestamp: Date())
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
client.module(.muc).messagesPublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] incoming in
|
|
Task { @MainActor [weak self] in
|
|
await self?.deliverOrDelayGroup(incoming.message, room: incoming.room)
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
client.module(.muc).inivitationsPublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] invitation in
|
|
self?.eventHandler?(
|
|
.roomInvitation(
|
|
RoomInvitationEnvelope(
|
|
roomJID: invitation.roomJid.stringValue,
|
|
inviterJID: invitation.inviter?.stringValue,
|
|
reason: invitation.reason,
|
|
password: invitation.password
|
|
)))
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
client.module(.messageCarbons).carbonsPublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] carbon in
|
|
Task { @MainActor [weak self] in
|
|
await self?.deliverOrDelayDirect(carbon.message, timestamp: Date())
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
client.module(.messageCarbons).$isAvailable
|
|
.filter { $0 }
|
|
.prefix(1)
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak client] _ in
|
|
client?.module(.messageCarbons).enable()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
client.module(.messageDeliveryReceipts).receiptsPublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] receipt in
|
|
self?.eventHandler?(.delivered(messageID: receipt.messageId))
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
// Do not enqueue every archived stanza onto DispatchQueue.main. Martin
|
|
// publishes all results before the final IQ callback, so a locked inbox
|
|
// can receive the whole page on the parser queue and hand it to the
|
|
// main actor once. This mirrors Monal's delayed-stanza queue and avoids
|
|
// flooding SwiftUI's run loop on large histories.
|
|
let archiveStanzaInbox = self.archiveStanzaInbox
|
|
client.module(.mam).archivedMessagesPublisher
|
|
.sink { archived in
|
|
archiveStanzaInbox.append(
|
|
queryID: archived.query.id,
|
|
source: archived.source,
|
|
message: archived.message,
|
|
timestamp: archived.timestamp,
|
|
archiveID: archived.messageId
|
|
)
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
client.module(.mam).$availableVersions
|
|
.filter { !$0.isEmpty }
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
self?.startArchiveSyncIfAvailable()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
client.module(.omemo).$isReady
|
|
.removeDuplicates()
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] ready in
|
|
self?.eventHandler?(
|
|
.omemo(ready: ready, ownFingerprint: omemoStorage.ownFingerprint))
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
private func deliverOrDelayDirect(_ message: Message, timestamp: Date) async {
|
|
guard let client else { return }
|
|
let archive = MAMArchiveKey.account(client.userBareJid.stringValue)
|
|
if archiveSyncStarted {
|
|
delayedLiveByArchive[archive, default: []].append(
|
|
.direct(message: message, timestamp: timestamp)
|
|
)
|
|
return
|
|
}
|
|
await handle(message: message, timestamp: timestamp, archiveID: nil)
|
|
}
|
|
|
|
private func deliverOrDelayGroup(_ message: Message, room: RoomProtocol) async {
|
|
let archive = MAMArchiveKey.muc(room.jid.stringValue)
|
|
if activeMUCCatchup == archive {
|
|
delayedLiveByArchive[archive, default: []].append(
|
|
.group(message: message, room: room)
|
|
)
|
|
return
|
|
}
|
|
await handle(groupMessage: message, room: room)
|
|
}
|
|
|
|
private func replayDelayedLive(for archive: MAMArchiveKey) async {
|
|
let delayed = delayedLiveByArchive.removeValue(forKey: archive) ?? []
|
|
for item in delayed {
|
|
switch item {
|
|
case .direct(let message, let timestamp):
|
|
await handle(message: message, timestamp: timestamp, archiveID: nil)
|
|
case .group(let message, let room):
|
|
await handle(groupMessage: message, room: room)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runs OMEMO decryption off the main actor and resumes with the raw
|
|
/// result. Kept as a thin wrapper so `handle` keeps its existing switch
|
|
/// logic and only the expensive `decode` call leaves the main thread.
|
|
private func decodeOmemoOffMain(
|
|
_ message: Message,
|
|
from sender: BareJID,
|
|
serverMsgId: String?,
|
|
module: OMEMOModule
|
|
) async -> DecryptionResult<Message, SignalError> {
|
|
let queue = omemoDecodeQueue
|
|
return await withCheckedContinuation { continuation in
|
|
queue.async {
|
|
let result = module.decode(message: message, from: sender, serverMsgId: serverMsgId)
|
|
continuation.resume(returning: result)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handle(state: XMPPClient.State) {
|
|
switch state {
|
|
case .connecting, .disconnecting:
|
|
eventHandler?(.connection(.connecting))
|
|
case .connected:
|
|
eventHandler?(.connection(.connected))
|
|
startArchiveSyncIfAvailable()
|
|
case .disconnected(let reason):
|
|
suspendArchiveSyncForDisconnect()
|
|
eventHandler?(.connection(.disconnected(reason: Self.reasonText(reason))))
|
|
}
|
|
}
|
|
|
|
private func handle(
|
|
message: Message,
|
|
timestamp: Date,
|
|
archiveID: String?,
|
|
isArchived: Bool = false,
|
|
archivedPeerJID: BareJID? = nil
|
|
) async {
|
|
guard message.type != .error,
|
|
message.type != .groupchat,
|
|
let client,
|
|
let sender = message.from?.bareJid
|
|
else { return }
|
|
|
|
let ownJID = client.userBareJid
|
|
let outgoing = sender == ownJID
|
|
// guard let peer = outgoing ? message.to?.bareJid : message.from?.bareJid else { return }
|
|
// let peer: BareJID?
|
|
// if outgoing {
|
|
let peer: BareJID?
|
|
if isArchived, let archivedPeerJID {
|
|
peer = archivedPeerJID
|
|
} else if outgoing {
|
|
peer = message.to?.bareJid ?? message.from?.bareJid
|
|
} else {
|
|
peer = message.from?.bareJid
|
|
}
|
|
guard let peer else { return }
|
|
|
|
let security: ChatMessage.Security
|
|
let fingerprint: String?
|
|
let contentMessage: Message?
|
|
switch await decodeOmemoOffMain(
|
|
message,
|
|
from: sender,
|
|
serverMsgId: archiveID,
|
|
module: client.module(.omemo)
|
|
) {
|
|
case .successMessage(let decodedMessage, let value):
|
|
security = .omemo
|
|
fingerprint = value
|
|
contentMessage = decodedMessage
|
|
case .successTransportKey(_, _):
|
|
return
|
|
case .failure(let error):
|
|
switch error {
|
|
case .notEncrypted:
|
|
security = .plaintext
|
|
fingerprint = nil
|
|
contentMessage = message
|
|
default:
|
|
// Some clients include a plaintext <body> fallback alongside the
|
|
// OMEMO <encrypted> payload. When decryption fails, prefer that
|
|
// fallback so a readable message is never shown as undecryptable.
|
|
if message.body?.isEmpty == false {
|
|
security = .plaintext
|
|
fingerprint = nil
|
|
contentMessage = message
|
|
} else {
|
|
// Includes `.duplicateMessage` (our own message without an
|
|
// encrypt-to-self key, or a key already consumed by a
|
|
// previous session): the body can no longer be recovered
|
|
// here. Still emit the message (as an undecryptable bubble)
|
|
// instead of dropping it, so history does not silently
|
|
// stop. Genuine duplicates are filtered later in AppModel
|
|
// by stanza/origin ID.
|
|
security = .decryptionFailed
|
|
fingerprint = nil
|
|
contentMessage = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
let payload = contentMessage ?? message
|
|
if !isArchived,
|
|
let state = Self.chatTypingState(payload.chatState ?? message.chatState)
|
|
{
|
|
knownChatStatePeers.insert(peer.stringValue.lowercased())
|
|
if !outgoing {
|
|
eventHandler?(
|
|
.chatState(
|
|
ChatStateEnvelope(
|
|
peerJID: peer.stringValue,
|
|
senderJID: sender.stringValue,
|
|
senderDisplayName: nil,
|
|
state: state,
|
|
isGroupMessage: false
|
|
)))
|
|
}
|
|
}
|
|
|
|
if let reactions = payload.firstChild(name: "reactions", xmlns: Self.reactionsNamespace)
|
|
?? message.firstChild(name: "reactions", xmlns: Self.reactionsNamespace),
|
|
let targetID = reactions.getAttribute("id"),
|
|
!targetID.isEmpty
|
|
{
|
|
emitReaction(
|
|
ReactionEnvelope(
|
|
peerJID: peer.stringValue,
|
|
senderJID: sender.stringValue,
|
|
targetID: targetID,
|
|
emojis: reactions.children.compactMap { child in
|
|
guard child.name == "reaction" else { return nil }
|
|
return child.value
|
|
},
|
|
timestamp: timestamp,
|
|
isOutgoing: outgoing,
|
|
isGroupMessage: false
|
|
),
|
|
isArchived: isArchived
|
|
)
|
|
return
|
|
}
|
|
|
|
if let retract = payload.firstChild(name: "retract", xmlns: Self.retractionNamespace)
|
|
?? message.firstChild(name: "retract", xmlns: Self.retractionNamespace),
|
|
let targetID = retract.getAttribute("id"),
|
|
!targetID.isEmpty
|
|
{
|
|
emitRetraction(
|
|
peer: peer,
|
|
sender: sender,
|
|
targetID: targetID,
|
|
retractionID: payload.id ?? message.id ?? archiveID ?? UUID().uuidString,
|
|
timestamp: timestamp,
|
|
outgoing: outgoing,
|
|
isArchived: isArchived
|
|
)
|
|
return
|
|
}
|
|
|
|
if payload.firstChild(name: "retracted", xmlns: Self.retractionNamespace) != nil
|
|
|| message.firstChild(name: "retracted", xmlns: Self.retractionNamespace) != nil
|
|
{
|
|
guard let targetID = message.originId ?? message.id ?? archiveID else { return }
|
|
let tombstone =
|
|
payload.firstChild(name: "retracted", xmlns: Self.retractionNamespace)
|
|
?? message.firstChild(name: "retracted", xmlns: Self.retractionNamespace)
|
|
emitRetraction(
|
|
peer: peer,
|
|
sender: sender,
|
|
targetID: targetID,
|
|
retractionID: tombstone?.getAttribute("id") ?? UUID().uuidString,
|
|
timestamp: timestamp,
|
|
outgoing: outgoing,
|
|
isArchived: isArchived
|
|
)
|
|
return
|
|
}
|
|
|
|
let rawBody =
|
|
contentMessage?.body
|
|
?? (security == .decryptionFailed ? "Не удалось расшифровать сообщение" : nil)
|
|
guard let body = rawBody, !body.isEmpty else { return }
|
|
|
|
let reply =
|
|
payload.firstChild(name: "reply", xmlns: Self.replyNamespace)
|
|
?? message.firstChild(name: "reply", xmlns: Self.replyNamespace)
|
|
let replyFallback =
|
|
payload.children.first { child in
|
|
child.name == "fallback"
|
|
&& child.xmlns == Self.fallbackNamespace
|
|
&& child.getAttribute("for") == Self.replyNamespace
|
|
}
|
|
?? message.children.first { child in
|
|
child.name == "fallback"
|
|
&& child.xmlns == Self.fallbackNamespace
|
|
&& child.getAttribute("for") == Self.replyNamespace
|
|
}
|
|
let fallbackBody = replyFallback?.findChild(name: "body")
|
|
let parsedReply = MessageReplyFallback.parse(
|
|
body: body,
|
|
fallbackStart: fallbackBody?.getAttribute("start").flatMap(Int.init),
|
|
fallbackEnd: fallbackBody?.getAttribute("end").flatMap(Int.init)
|
|
)
|
|
let visibleBody = parsedReply.body
|
|
|
|
let isEncryptedAttachment =
|
|
visibleBody.hasPrefix("aesgcm://")
|
|
&& visibleBody.rangeOfCharacter(from: .whitespacesAndNewlines) == nil
|
|
&& URLComponents(string: visibleBody)?.scheme?.lowercased() == "aesgcm"
|
|
let oob = payload.oob ?? message.oob
|
|
let standardOOB = oob?.hasPrefix("https://") == true ? oob : nil
|
|
let attachmentURL = isEncryptedAttachment ? visibleBody : standardOOB
|
|
let transportFilename = attachmentURL.flatMap { value -> String? in
|
|
guard let url = URL(string: value) else { return nil }
|
|
guard var filename = url.lastPathComponent.removingPercentEncoding else { return nil }
|
|
if filename.hasSuffix(".aesgcm") {
|
|
filename = String(filename.dropLast(".aesgcm".count))
|
|
}
|
|
return filename
|
|
}
|
|
let inferred = transportFilename.map(MediaMetadata.infer(from:))
|
|
let kind = inferred?.kind ?? (GeoLocation(uri: visibleBody) == nil ? .text : .location)
|
|
let displayBody =
|
|
attachmentURL == nil
|
|
? visibleBody
|
|
: Self.displayBody(kind: kind, filename: transportFilename ?? "Вложение")
|
|
|
|
// Live 1:1 messages carry their server archive UID in a <stanza-id>
|
|
// element (by = own bare JID). Surface it so the interactive MAM
|
|
// "before" cursor and dedup match the IDs the archive later returns.
|
|
let stanzaID = archiveID ?? Self.accountStanzaID(in: message, accountJID: ownJID)
|
|
let id = message.originId ?? message.id ?? archiveID ?? UUID().uuidString
|
|
emitMessage(
|
|
MessageEnvelope(
|
|
id: id,
|
|
peerJID: peer.stringValue,
|
|
senderJID: sender.stringValue,
|
|
body: displayBody,
|
|
timestamp: timestamp,
|
|
isOutgoing: outgoing,
|
|
security: security,
|
|
kind: kind,
|
|
remoteAttachmentURL: attachmentURL,
|
|
localFilename: transportFilename,
|
|
mimeType: inferred?.mimeType,
|
|
duration: inferred?.duration,
|
|
byteCount: nil,
|
|
fingerprint: fingerprint,
|
|
correctionTargetID: payload.lastMessageCorrectionId
|
|
?? message.lastMessageCorrectionId,
|
|
replyToID: reply?.getAttribute("id"),
|
|
replyToJID: reply?.getAttribute("to"),
|
|
replyPreview: parsedReply.preview,
|
|
originID: message.originId,
|
|
stanzaID: stanzaID,
|
|
senderDisplayName: nil,
|
|
isGroupMessage: false,
|
|
isArchived: isArchived
|
|
), isArchived: isArchived)
|
|
}
|
|
|
|
private func handle(
|
|
groupMessage message: Message,
|
|
room: RoomProtocol? = nil,
|
|
archivedRoomJID: BareJID? = nil,
|
|
archivedTimestamp: Date? = nil,
|
|
archiveID: String? = nil,
|
|
isArchived: Bool = false
|
|
) async {
|
|
guard message.type != .error,
|
|
let client,
|
|
let from = message.from,
|
|
let roomBareJID = room?.jid ?? archivedRoomJID ?? message.from?.bareJid
|
|
else { return }
|
|
|
|
let nickname = from.resource ?? "Участник"
|
|
// let outgoing = nickname == room.nickname
|
|
// let roomJID = room.jid.stringValue.lowercased()
|
|
let disclosedSender = Self.originalSenderJID(in: message)
|
|
?? XMucUserElement.extract(from: message)?.jid?.bareJid
|
|
let outgoing = room.map { nickname == $0.nickname }
|
|
?? (disclosedSender == client.userBareJid)
|
|
let roomJID = roomBareJID.stringValue.lowercased()
|
|
// In a MUC the author address used by XEP-0461 is the full occupant
|
|
// JID (room@service/nickname), not the participant's disclosed real JID.
|
|
let senderJID = from.stringValue
|
|
// let stanzaID = Self.roomStanzaID(in: message, roomJID: room.jid)
|
|
let stanzaID = Self.roomStanzaID(in: message, roomJID: roomBareJID) ?? archiveID
|
|
let id =
|
|
outgoing
|
|
? (message.originId ?? message.id ?? stanzaID ?? UUID().uuidString)
|
|
: (stanzaID ?? message.originId ?? message.id ?? UUID().uuidString)
|
|
|
|
let encrypted = message.firstChild(name: "encrypted", xmlns: OMEMOModule.XMLNS) != nil
|
|
let realSender: BareJID?
|
|
if outgoing {
|
|
realSender = client.userBareJid
|
|
} else if let disclosed = Self.originalSenderJID(in: message) {
|
|
realSender = disclosed
|
|
roomRealJIDByNickname[roomJID, default: [:]][nickname] = disclosed
|
|
} else if let disclosed = XMucUserElement.extract(from: message)?.jid?.bareJid {
|
|
realSender = disclosed
|
|
roomRealJIDByNickname[roomJID, default: [:]][nickname] = disclosed
|
|
// } else if let disclosed = room.occupant(nickname: nickname)?.jid?.bareJid {
|
|
} else if let disclosed = room?.occupant(nickname: nickname)?.jid?.bareJid {
|
|
realSender = disclosed
|
|
roomRealJIDByNickname[roomJID, default: [:]][nickname] = disclosed
|
|
} else {
|
|
realSender = roomRealJIDByNickname[roomJID]?[nickname]
|
|
}
|
|
|
|
let security: ChatMessage.Security
|
|
let fingerprint: String?
|
|
let contentMessage: Message?
|
|
if let realSender {
|
|
switch await decodeOmemoOffMain(
|
|
message,
|
|
from: realSender,
|
|
serverMsgId: stanzaID,
|
|
module: client.module(.omemo)
|
|
) {
|
|
case .successMessage(let decodedMessage, let value):
|
|
security = .omemo
|
|
fingerprint = value
|
|
contentMessage = decodedMessage
|
|
case .successTransportKey(_, _):
|
|
return
|
|
case .failure(let error):
|
|
switch error {
|
|
case .notEncrypted:
|
|
security = .plaintext
|
|
fingerprint = nil
|
|
contentMessage = message
|
|
case .duplicateMessage:
|
|
emitGroupEchoIfPossible(
|
|
roomJID: roomJID,
|
|
messageID: id,
|
|
stanzaID: stanzaID,
|
|
senderJID: senderJID
|
|
)
|
|
return
|
|
default:
|
|
if outgoing {
|
|
emitGroupEchoIfPossible(
|
|
roomJID: roomJID,
|
|
messageID: id,
|
|
stanzaID: stanzaID,
|
|
senderJID: senderJID
|
|
)
|
|
return
|
|
}
|
|
if message.body?.isEmpty == false {
|
|
security = .plaintext
|
|
fingerprint = nil
|
|
contentMessage = message
|
|
} else {
|
|
security = .decryptionFailed
|
|
fingerprint = nil
|
|
contentMessage = nil
|
|
}
|
|
}
|
|
}
|
|
} else if encrypted {
|
|
security = .decryptionFailed
|
|
fingerprint = nil
|
|
contentMessage = nil
|
|
} else {
|
|
security = .plaintext
|
|
fingerprint = nil
|
|
contentMessage = message
|
|
}
|
|
|
|
let payload = contentMessage ?? message
|
|
// if let state = Self.chatTypingState(payload.chatState ?? message.chatState) {
|
|
if !isArchived, let state = Self.chatTypingState(payload.chatState ?? message.chatState) {
|
|
knownChatStatePeers.insert(roomJID)
|
|
if !outgoing {
|
|
eventHandler?(
|
|
.chatState(
|
|
ChatStateEnvelope(
|
|
peerJID: roomJID,
|
|
senderJID: senderJID,
|
|
senderDisplayName: nickname,
|
|
state: state,
|
|
isGroupMessage: true
|
|
)))
|
|
}
|
|
}
|
|
|
|
if let reactions = payload.firstChild(name: "reactions", xmlns: Self.reactionsNamespace)
|
|
?? message.firstChild(name: "reactions", xmlns: Self.reactionsNamespace),
|
|
let targetID = reactions.getAttribute("id"),
|
|
!targetID.isEmpty
|
|
{
|
|
let reactionSender =
|
|
outgoing
|
|
? client.userBareJid.stringValue
|
|
: (realSender?.stringValue ?? senderJID)
|
|
emitReaction(
|
|
ReactionEnvelope(
|
|
peerJID: roomJID,
|
|
senderJID: reactionSender,
|
|
targetID: targetID,
|
|
emojis: reactions.children.compactMap { child in
|
|
guard child.name == "reaction" else { return nil }
|
|
return child.value
|
|
},
|
|
// timestamp: message.delay?.stamp ?? Date(),
|
|
timestamp: archivedTimestamp ?? message.delay?.stamp ?? Date(),
|
|
isOutgoing: outgoing,
|
|
isGroupMessage: true
|
|
),
|
|
// isArchived: false
|
|
isArchived: isArchived
|
|
)
|
|
return
|
|
}
|
|
|
|
let rawBody =
|
|
contentMessage?.body
|
|
?? (security == .decryptionFailed ? "Не удалось расшифровать групповое сообщение" : nil)
|
|
guard let rawBody, !rawBody.isEmpty else { return }
|
|
let reply =
|
|
payload.firstChild(name: "reply", xmlns: Self.replyNamespace)
|
|
?? message.firstChild(name: "reply", xmlns: Self.replyNamespace)
|
|
let replyFallback =
|
|
payload.children.first { child in
|
|
child.name == "fallback"
|
|
&& child.xmlns == Self.fallbackNamespace
|
|
&& child.getAttribute("for") == Self.replyNamespace
|
|
}
|
|
?? message.children.first { child in
|
|
child.name == "fallback"
|
|
&& child.xmlns == Self.fallbackNamespace
|
|
&& child.getAttribute("for") == Self.replyNamespace
|
|
}
|
|
let fallbackBody = replyFallback?.findChild(name: "body")
|
|
let parsedReply = MessageReplyFallback.parse(
|
|
body: rawBody,
|
|
fallbackStart: fallbackBody?.getAttribute("start").flatMap(Int.init),
|
|
fallbackEnd: fallbackBody?.getAttribute("end").flatMap(Int.init)
|
|
)
|
|
let visibleBody = parsedReply.body
|
|
|
|
let isEncryptedAttachment =
|
|
visibleBody.hasPrefix("aesgcm://")
|
|
&& visibleBody.rangeOfCharacter(from: .whitespacesAndNewlines) == nil
|
|
&& URLComponents(string: visibleBody)?.scheme?.lowercased() == "aesgcm"
|
|
let oob = payload.oob ?? message.oob
|
|
let standardOOB = oob?.hasPrefix("https://") == true ? oob : nil
|
|
let attachmentURL = isEncryptedAttachment ? visibleBody : standardOOB
|
|
let transportFilename = attachmentURL.flatMap { value -> String? in
|
|
guard let url = URL(string: value) else { return nil }
|
|
guard var filename = url.lastPathComponent.removingPercentEncoding else { return nil }
|
|
if filename.hasSuffix(".aesgcm") {
|
|
filename = String(filename.dropLast(".aesgcm".count))
|
|
}
|
|
return filename
|
|
}
|
|
let inferred = transportFilename.map(MediaMetadata.infer(from:))
|
|
let kind = inferred?.kind ?? (GeoLocation(uri: visibleBody) == nil ? .text : .location)
|
|
let displayBody =
|
|
attachmentURL == nil
|
|
? visibleBody
|
|
: Self.displayBody(kind: kind, filename: transportFilename ?? "Вложение")
|
|
// eventHandler?(
|
|
emitMessage(
|
|
// .message(
|
|
MessageEnvelope(
|
|
id: id,
|
|
// peerJID: room.jid.stringValue,
|
|
peerJID: roomBareJID.stringValue,
|
|
senderJID: senderJID,
|
|
body: displayBody,
|
|
// timestamp: message.delay?.stamp ?? Date(),
|
|
timestamp: archivedTimestamp ?? message.delay?.stamp ?? Date(),
|
|
isOutgoing: outgoing,
|
|
security: security,
|
|
kind: kind,
|
|
remoteAttachmentURL: attachmentURL,
|
|
localFilename: transportFilename,
|
|
mimeType: inferred?.mimeType,
|
|
duration: inferred?.duration,
|
|
byteCount: nil,
|
|
fingerprint: fingerprint,
|
|
correctionTargetID: payload.lastMessageCorrectionId
|
|
?? message.lastMessageCorrectionId,
|
|
replyToID: reply?.getAttribute("id"),
|
|
replyToJID: reply?.getAttribute("to"),
|
|
replyPreview: parsedReply.preview,
|
|
originID: message.originId,
|
|
stanzaID: stanzaID,
|
|
senderDisplayName: nickname,
|
|
isGroupMessage: true,
|
|
// isArchived: false
|
|
// )))
|
|
isArchived: isArchived
|
|
), isArchived: isArchived)
|
|
}
|
|
|
|
private func observe(room: RoomProtocol) {
|
|
let roomJID = room.jid.stringValue.lowercased()
|
|
guard observedRoomJIDs.insert(roomJID).inserted else { return }
|
|
|
|
room.statePublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self, weak room] state in
|
|
guard let room else { return }
|
|
self?.eventHandler?(
|
|
.roomState(
|
|
RoomStateEnvelope(
|
|
roomJID: roomJID,
|
|
nickname: room.nickname,
|
|
joined: state == .joined,
|
|
occupantCount: 0
|
|
)))
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
room.occupantsPublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self, weak room] occupants in
|
|
guard let self, let room else { return }
|
|
self.roomOccupantsByJID[roomJID] = occupants
|
|
var knownAuthors = self.roomRealJIDByNickname[roomJID] ?? [:]
|
|
for occupant in occupants {
|
|
if let realJID = occupant.jid?.bareJid {
|
|
knownAuthors[occupant.nickname] = realJID
|
|
}
|
|
}
|
|
self.roomRealJIDByNickname[roomJID] = knownAuthors
|
|
self.eventHandler?(
|
|
.roomState(
|
|
RoomStateEnvelope(
|
|
roomJID: roomJID,
|
|
nickname: room.nickname,
|
|
joined: room.state == .joined,
|
|
occupantCount: occupants.count
|
|
)))
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
private func configureInstantRoom(_ room: RoomProtocol, using muc: MucModule) async throws {
|
|
_ = try await configureRoomForOMEMO(
|
|
room,
|
|
using: muc,
|
|
makeMembersOnly: true
|
|
)
|
|
}
|
|
|
|
@discardableResult
|
|
private func configureRoomForOMEMO(
|
|
_ room: RoomProtocol,
|
|
using muc: MucModule,
|
|
makeMembersOnly: Bool
|
|
) async throws -> Bool {
|
|
let roomJID = JID(room.jid)
|
|
let roomConfig: RoomConfig = try await withCheckedThrowingContinuation { continuation in
|
|
muc.roomConfiguration(roomJid: roomJID) { result in
|
|
continuation.resume(with: result)
|
|
}
|
|
}
|
|
|
|
var supportsNonAnonymousRooms = false
|
|
|
|
if roomConfig.hasField(for: "muc#roomconfig_whois") {
|
|
roomConfig.whois = .anyone
|
|
supportsNonAnonymousRooms = true
|
|
}
|
|
|
|
if roomConfig.hasField(for: "muc#roomconfig_getmemberlist") {
|
|
let current = roomConfig.getMemberList ?? []
|
|
roomConfig.getMemberList = Array(Set(current + ["moderator", "participant"])).sorted()
|
|
}
|
|
|
|
if makeMembersOnly, roomConfig.hasField(for: "muc#roomconfig_membersonly") {
|
|
roomConfig.membersOnly = true
|
|
}
|
|
|
|
if makeMembersOnly, roomConfig.hasField(for: "muc#roomconfig_persistentroom") {
|
|
roomConfig.persistentRoom = true
|
|
}
|
|
|
|
try await withCheckedThrowingContinuation {
|
|
(continuation: CheckedContinuation<Void, Error>) in
|
|
muc.setRoomConfiguration(roomJid: roomJID, configuration: roomConfig) { result in
|
|
continuation.resume(with: result)
|
|
}
|
|
}
|
|
|
|
if supportsNonAnonymousRooms {
|
|
omemoConfiguredRoomJIDs.insert(room.jid.stringValue.lowercased())
|
|
}
|
|
|
|
return supportsNonAnonymousRooms
|
|
}
|
|
|
|
private func groupOMEMORecipients(
|
|
in room: RoomProtocol,
|
|
using muc: MucModule,
|
|
client: XMPPClient
|
|
) async throws -> [BareJID] {
|
|
let roomJID = room.jid.stringValue.lowercased()
|
|
try await ensureRoomIsNonAnonymous(room, using: muc, client: client)
|
|
|
|
var occupantsByNickname = Dictionary(
|
|
uniqueKeysWithValues: (roomOccupantsByJID[roomJID] ?? []).map {
|
|
($0.nickname, $0)
|
|
}
|
|
)
|
|
// `receive(on:)` delivers the cached publisher value on the next main
|
|
// queue turn. Read RoomBase synchronously as well so a send immediately
|
|
// after joining cannot miss already-present open-room occupants.
|
|
if let currentOccupants = (room as? RoomBase)?.occupants {
|
|
for occupant in currentOccupants {
|
|
occupantsByNickname[occupant.nickname] = occupant
|
|
}
|
|
}
|
|
let occupants = Array(occupantsByNickname.values)
|
|
let hiddenOccupants = occupants.filter {
|
|
$0.nickname != room.nickname && $0.role != .none && $0.jid == nil
|
|
}
|
|
guard hiddenOccupants.isEmpty else {
|
|
throw LumaXMPPError.groupEncryptionParticipantsHidden(
|
|
hiddenOccupants.map(\.nickname).sorted()
|
|
)
|
|
}
|
|
|
|
var recipients = Set(occupants.compactMap { $0.jid?.bareJid })
|
|
var knownAuthors = roomRealJIDByNickname[roomJID] ?? [:]
|
|
for affiliation in [MucAffiliation.member, .admin, .owner] {
|
|
let values: [MucModule.RoomAffiliation]
|
|
do {
|
|
values = try await roomAffiliations(
|
|
in: room,
|
|
affiliation: affiliation,
|
|
using: muc
|
|
)
|
|
} catch {
|
|
throw LumaXMPPError.groupEncryptionMemberListUnavailable(
|
|
error.localizedDescription
|
|
)
|
|
}
|
|
for value in values {
|
|
recipients.insert(value.jid.bareJid)
|
|
if let nickname = value.nickname, !nickname.isEmpty {
|
|
knownAuthors[nickname] = value.jid.bareJid
|
|
}
|
|
}
|
|
}
|
|
roomRealJIDByNickname[roomJID] = knownAuthors
|
|
|
|
// MartinOMEMO automatically adds the sender's other devices in
|
|
// _encode(..., forSelf: true). Remove the sender from the occupant
|
|
// set: its published device list also contains the active device and
|
|
// would incorrectly require a Signal session with itself.
|
|
recipients.remove(client.userBareJid)
|
|
recipients.remove(room.jid)
|
|
return recipients.sorted { $0.stringValue < $1.stringValue }
|
|
}
|
|
|
|
private func ensureRoomIsNonAnonymous(
|
|
_ room: RoomProtocol,
|
|
using muc: MucModule,
|
|
client: XMPPClient
|
|
) async throws {
|
|
let roomJID = room.jid.stringValue.lowercased()
|
|
if omemoConfiguredRoomJIDs.contains(roomJID) { return }
|
|
|
|
let info = try? await client.module(.disco).info(for: JID(room.jid))
|
|
if info?.features.contains("muc_nonanonymous") == true {
|
|
omemoConfiguredRoomJIDs.insert(roomJID)
|
|
return
|
|
}
|
|
|
|
let ownAffiliation = room.occupant(nickname: room.nickname)?.affiliation ?? room.affiliation
|
|
if ownAffiliation == .owner {
|
|
let configured = try await configureRoomForOMEMO(
|
|
room,
|
|
using: muc,
|
|
makeMembersOnly: false
|
|
)
|
|
guard configured else {
|
|
throw LumaXMPPError.groupEncryptionConfigurationUnsupported
|
|
}
|
|
eventHandler?(
|
|
.recoverableError(
|
|
"Комната переведена в неанонимный режим, необходимый для группового OMEMO."
|
|
))
|
|
return
|
|
}
|
|
|
|
throw LumaXMPPError.groupEncryptionRequiresNonAnonymousRoom
|
|
}
|
|
|
|
private func roomAffiliations(
|
|
in room: RoomProtocol,
|
|
affiliation: MucAffiliation,
|
|
using muc: MucModule
|
|
) async throws -> [MucModule.RoomAffiliation] {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
muc.getRoomAffiliations(from: room, with: affiliation) { result in
|
|
continuation.resume(with: result)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func addReply(
|
|
_ replyTo: ReplyReference?,
|
|
fallback replyFallback: (prefix: String, scalarCount: Int)?,
|
|
to message: Message
|
|
) {
|
|
guard let replyTo, let replyFallback else { return }
|
|
let reply = Element(name: "reply", xmlns: Self.replyNamespace)
|
|
reply.setAttribute("id", value: replyTo.id)
|
|
if let authorJID = replyTo.authorJID {
|
|
reply.setAttribute("to", value: authorJID)
|
|
}
|
|
message.addChild(reply)
|
|
|
|
let fallback = Element(name: "fallback", xmlns: Self.fallbackNamespace)
|
|
fallback.setAttribute("for", value: Self.replyNamespace)
|
|
let bodyRange = Element(name: "body")
|
|
bodyRange.setAttribute("start", value: "0")
|
|
bodyRange.setAttribute("end", value: String(replyFallback.scalarCount))
|
|
fallback.addChild(bodyRange)
|
|
message.addChild(fallback)
|
|
}
|
|
|
|
private func addOriginID(_ id: String, to message: Message) {
|
|
guard message.originId == nil else { return }
|
|
let origin = Element(name: "origin-id", xmlns: Self.stanzaIDNamespace)
|
|
origin.setAttribute("id", value: id)
|
|
message.addChild(origin)
|
|
}
|
|
|
|
private static func roomStanzaID(in message: Message, roomJID: BareJID) -> String? {
|
|
message.children.first { child in
|
|
guard child.name == "stanza-id", child.xmlns == Self.stanzaIDNamespace else {
|
|
return false
|
|
}
|
|
guard let by = child.getAttribute("by")?.lowercased() else { return false }
|
|
return by == roomJID.stringValue.lowercased()
|
|
}?.getAttribute("id")
|
|
}
|
|
|
|
private static func accountStanzaID(in message: Message, accountJID: BareJID) -> String? {
|
|
message.children.first { child in
|
|
guard child.name == "stanza-id", child.xmlns == Self.stanzaIDNamespace else {
|
|
return false
|
|
}
|
|
guard let by = child.getAttribute("by")?.lowercased() else { return false }
|
|
return by == accountJID.stringValue.lowercased()
|
|
}?.getAttribute("id")
|
|
}
|
|
|
|
private static func originalSenderJID(in message: Message) -> BareJID? {
|
|
guard
|
|
let address = message.firstChild(
|
|
name: "addresses",
|
|
xmlns: Self.extendedAddressingNamespace
|
|
)?.children.first(where: { child in
|
|
child.name == "address"
|
|
&& child.getAttribute("type") == "ofrom"
|
|
&& child.getAttribute("jid") != nil
|
|
}),
|
|
let value = address.getAttribute("jid")
|
|
else { return nil }
|
|
return JID(value).bareJid
|
|
}
|
|
|
|
private static func martinChatState(_ state: ChatTypingState) -> ChatState {
|
|
switch state {
|
|
case .active: return .active
|
|
case .composing: return .composing
|
|
case .paused: return .paused
|
|
case .inactive: return .inactive
|
|
case .gone: return .gone
|
|
}
|
|
}
|
|
|
|
private static func chatTypingState(_ state: ChatState?) -> ChatTypingState? {
|
|
guard let state else { return nil }
|
|
switch state {
|
|
case .active: return .active
|
|
case .composing: return .composing
|
|
case .paused: return .paused
|
|
case .inactive: return .inactive
|
|
case .gone: return .gone
|
|
}
|
|
}
|
|
|
|
private func emitGroupEchoIfPossible(
|
|
roomJID: String,
|
|
messageID: String,
|
|
stanzaID: String?,
|
|
senderJID: String
|
|
) {
|
|
guard let stanzaID, !stanzaID.isEmpty else { return }
|
|
eventHandler?(
|
|
.groupMessageEcho(
|
|
roomJID: roomJID,
|
|
messageID: messageID,
|
|
stanzaID: stanzaID,
|
|
senderJID: senderJID
|
|
))
|
|
}
|
|
|
|
private func emitRetraction(
|
|
peer: BareJID,
|
|
sender: BareJID,
|
|
targetID: String,
|
|
retractionID: String,
|
|
timestamp: Date,
|
|
outgoing: Bool,
|
|
isArchived: Bool
|
|
) {
|
|
let envelope = RetractionEnvelope(
|
|
peerJID: peer.stringValue,
|
|
senderJID: sender.stringValue,
|
|
targetID: targetID,
|
|
retractionID: retractionID,
|
|
timestamp: timestamp,
|
|
isOutgoing: outgoing
|
|
)
|
|
if isArchived {
|
|
appendArchiveMutation(.retraction(envelope))
|
|
} else {
|
|
eventHandler?(.retraction(envelope))
|
|
}
|
|
}
|
|
|
|
private func emitReaction(
|
|
_ envelope: ReactionEnvelope,
|
|
isArchived: Bool
|
|
) {
|
|
if isArchived {
|
|
appendArchiveMutation(.reaction(envelope))
|
|
} else {
|
|
eventHandler?(.reaction(envelope))
|
|
}
|
|
}
|
|
|
|
private func emitMessage(_ envelope: MessageEnvelope, isArchived: Bool) {
|
|
if isArchived {
|
|
appendArchiveMutation(.message(envelope))
|
|
} else {
|
|
eventHandler?(.message(envelope))
|
|
}
|
|
}
|
|
|
|
/// Archived mutations normally belong to the active account/MUC catch-up
|
|
/// pass. While an interactive backward-history page is being applied, its
|
|
/// mutations are collected in a dedicated buffer so the two flows never
|
|
/// interleave and never steal each other's mutations.
|
|
private func appendArchiveMutation(_ mutation: ArchiveMutation) {
|
|
if interactiveHistoryMutations != nil {
|
|
interactiveHistoryMutations?.append(mutation)
|
|
} else {
|
|
archivePassMutations.append(mutation)
|
|
}
|
|
}
|
|
|
|
private func retrievePEPAvatar(for jid: String, client: XMPPClient) async -> Data? {
|
|
do {
|
|
let avatarModule = client.module(.pepUserAvatar)
|
|
let info = try await avatarModule.retrieveAvatarMetadata(
|
|
from: BareJID(jid),
|
|
fireEvents: false
|
|
)
|
|
guard info.size > 0, info.size <= 2 * 1_024 * 1_024 else { return nil }
|
|
let (_, data) = try await avatarModule.retrieveAvatar(
|
|
from: BareJID(jid), itemId: info.id)
|
|
return data.count <= 2 * 1_024 * 1_024 ? data : nil
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func retrieveLegacyAvatar(for jid: String, client: XMPPClient) async -> Data? {
|
|
do {
|
|
let card = try await client.module(.vcardTemp).retrieveVCard(from: JID(jid))
|
|
guard let encoded = card.photos.first(where: { $0.binval != nil })?.binval,
|
|
let data = Data(base64Encoded: encoded),
|
|
data.count <= 2 * 1_024 * 1_024
|
|
else { return nil }
|
|
return data
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func displayBody(kind: ChatMessage.Kind, filename: String) -> String {
|
|
switch kind {
|
|
case .attachment:
|
|
return filename
|
|
case .photo:
|
|
return "Фото"
|
|
case .video:
|
|
return "Видео"
|
|
case .audio:
|
|
return filename
|
|
case .voice:
|
|
return "Голосовое сообщение"
|
|
case .videoNote:
|
|
return "Видеосообщение"
|
|
case .location, .text, .system:
|
|
return filename
|
|
}
|
|
}
|
|
|
|
private func encrypt(_ message: Message, using omemo: OMEMOModule) async throws -> (
|
|
message: Message, fingerprint: String?
|
|
) {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
omemo.encode(message: message, withStoreHint: true) { result in
|
|
switch result {
|
|
case .successMessage(let message, let fingerprint):
|
|
continuation.resume(returning: (message, fingerprint))
|
|
case .failure(let error):
|
|
continuation.resume(
|
|
throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func encrypt(
|
|
_ message: Message,
|
|
forGroupRecipients recipients: [BareJID],
|
|
using omemo: OMEMOModule
|
|
) async throws -> (message: Message, fingerprint: String?) {
|
|
let fetchedAddresses: [SignalAddress] = try await withCheckedThrowingContinuation {
|
|
continuation in
|
|
omemo.addresses(for: recipients) { result in
|
|
switch result {
|
|
case .success(let addresses):
|
|
continuation.resume(returning: addresses)
|
|
case .failure(let error):
|
|
continuation.resume(
|
|
throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue))
|
|
}
|
|
}
|
|
}
|
|
let addresses = Array(Set(fetchedAddresses))
|
|
|
|
let availableJIDs = Set(addresses.map { $0.name.lowercased() })
|
|
let missingJIDs =
|
|
recipients
|
|
.map(\.stringValue)
|
|
.filter { !availableJIDs.contains($0.lowercased()) }
|
|
guard missingJIDs.isEmpty else {
|
|
throw LumaXMPPError.groupOMEMODevicesUnavailable(missingJIDs.sorted())
|
|
}
|
|
|
|
let addressesWithoutSessions = addresses.filter {
|
|
omemoStorage?.hasSession(for: $0) != true
|
|
}
|
|
guard addressesWithoutSessions.isEmpty else {
|
|
throw LumaXMPPError.groupOMEMOSessionsUnavailable(
|
|
addressesWithoutSessions
|
|
.map { "\($0.name)/\($0.deviceId)" }
|
|
.sorted()
|
|
)
|
|
}
|
|
|
|
let encryptedMessage: (message: Message, fingerprint: String?) =
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
omemo.encode(message: message, forAddresses: addresses, withStoreHint: true) {
|
|
result in
|
|
switch result {
|
|
case .successMessage(let message, let fingerprint):
|
|
continuation.resume(returning: (message, fingerprint))
|
|
case .failure(let error):
|
|
continuation.resume(
|
|
throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue))
|
|
}
|
|
}
|
|
}
|
|
|
|
var encryptedKeyCounts: [Int32: Int] = [:]
|
|
encryptedMessage.message
|
|
.firstChild(name: "encrypted", xmlns: OMEMOModule.XMLNS)?
|
|
.findChild(name: "header")?
|
|
.children
|
|
.filter { $0.name == "key" }
|
|
.compactMap { $0.getAttribute("rid").flatMap(Int32.init) }
|
|
.forEach { encryptedKeyCounts[$0, default: 0] += 1 }
|
|
|
|
var unavailableAddresses: [String] = []
|
|
for address in addresses {
|
|
let availableCount = encryptedKeyCounts[address.deviceId, default: 0]
|
|
if availableCount > 0 {
|
|
encryptedKeyCounts[address.deviceId] = availableCount - 1
|
|
} else {
|
|
unavailableAddresses.append("\(address.name)/\(address.deviceId)")
|
|
}
|
|
}
|
|
guard unavailableAddresses.isEmpty else {
|
|
throw LumaXMPPError.groupOMEMOSessionsUnavailable(unavailableAddresses.sorted())
|
|
}
|
|
return encryptedMessage
|
|
}
|
|
|
|
/// Single Martin-specific MAM entry point. Martin 3.2.4 supports
|
|
/// `componentJid:` and writes it to IQ `to`, which is required for MUC MAM.
|
|
private func queryMAM(
|
|
client: XMPPClient,
|
|
archive: MAMArchiveKey,
|
|
start: Date?,
|
|
queryID: String,
|
|
rsm: RSM.Query?,
|
|
completion: @escaping (Result<MessageArchiveManagementModule.QueryResult, XMPPError>) -> Void
|
|
) {
|
|
let componentJID: JID?
|
|
switch archive.kind {
|
|
case .account:
|
|
componentJID = nil
|
|
case .muc:
|
|
componentJID = JID(archive.jid)
|
|
}
|
|
|
|
client.module(.mam).queryItems(
|
|
componentJid: componentJID,
|
|
start: start,
|
|
queryId: queryID,
|
|
rsm: rsm,
|
|
completionHandler: completion
|
|
)
|
|
}
|
|
|
|
private func allowedMAMSources(
|
|
for archive: MAMArchiveKey,
|
|
client: XMPPClient
|
|
) -> Set<String> {
|
|
switch archive.kind {
|
|
case .account:
|
|
return [
|
|
client.userBareJid.stringValue.lowercased(),
|
|
client.userBareJid.domain.lowercased(),
|
|
]
|
|
case .muc:
|
|
return [archive.jid.lowercased()]
|
|
}
|
|
}
|
|
|
|
private func startArchiveSyncIfAvailable() {
|
|
guard let client,
|
|
client.state == .connected(),
|
|
!archiveSyncSuspended,
|
|
!archiveRetrySuppressedUntilActivation,
|
|
!client.module(.mam).availableVersions.isEmpty
|
|
else { return }
|
|
startArchiveSync(client: client)
|
|
}
|
|
|
|
private func startArchiveSync(client: XMPPClient) {
|
|
guard !archiveSyncStarted,
|
|
!archiveSyncCompletedForConnection,
|
|
!archiveSyncSuspended,
|
|
!archiveRetrySuppressedUntilActivation,
|
|
self.client === client
|
|
else { return }
|
|
archiveSyncStarted = true
|
|
if archiveSyncQueryStartedAt == nil {
|
|
archiveSyncQueryStartedAt = Date()
|
|
}
|
|
archiveIsBootstrapQuery = archiveSyncCheckpoint == nil
|
|
archiveWorkBudget = ArchiveSyncWorkBudget()
|
|
archiveHighWatermark = nil
|
|
archiveLastCompletedCursor = archiveSyncCheckpoint?.cursor
|
|
archiveHasCompletedPage = false
|
|
archiveCursorFallbackUsed = false
|
|
archivePassMutations.removeAll(keepingCapacity: true)
|
|
archivePagination = ArchiveSyncPagination()
|
|
archiveRetryTask?.cancel()
|
|
archiveRetryTask = nil
|
|
archiveQueryTimeoutTask?.cancel()
|
|
archiveQueryTimeoutTask = nil
|
|
archiveQueryCompletionTask?.cancel()
|
|
archiveQueryCompletionTask = nil
|
|
archiveSyncRetryTask?.cancel()
|
|
archiveSyncRetryTask = nil
|
|
archiveActiveQueryID = nil
|
|
archiveStanzaBuffer.removeAll(keepingCapacity: true)
|
|
archiveBufferOverflowed = false
|
|
archiveRejectedSource = false
|
|
// Do NOT blanket-cancel the inbox here: an interactive backward-history
|
|
// query may be in flight and must keep its own query-ID-scoped buffer.
|
|
// Show the visible "Синхронизация истории…" banner only for the
|
|
// one-page bootstrap window. Incremental catch-up continues silently so
|
|
// a large backlog never leaves the spinner visible for the whole pass.
|
|
if archiveIsBootstrapQuery {
|
|
setArchiveSyncIndicator(true)
|
|
}
|
|
client.module(.omemo).mamSyncStarted(for: nil)
|
|
let initialPosition = ArchiveSyncCursorPolicy.requestPosition(
|
|
checkpoint: archiveSyncCheckpoint,
|
|
resumeAfter: archiveResumeAfter,
|
|
overlap: ArchiveSyncRecoveryPolicy.incrementalOverlap
|
|
)
|
|
queryArchive(
|
|
client: client,
|
|
after: archiveIsBootstrapQuery ? nil : initialPosition.after,
|
|
retry: 0
|
|
)
|
|
}
|
|
|
|
private func enqueueMUCCatchup(roomJID: BareJID) {
|
|
let archive = MAMArchiveKey.muc(roomJID.stringValue)
|
|
guard activeMUCCatchup != archive,
|
|
!pendingMUCCatchups.contains(archive) else { return }
|
|
pendingMUCCatchups.append(archive)
|
|
startNextMUCCatchupIfPossible()
|
|
}
|
|
|
|
/// Keep this first version serialized with the account pass. It prevents
|
|
/// archive mutations from different namespaces sharing the legacy batch.
|
|
/// Patch 0007 makes live delivery archive-aware while catch-up runs.
|
|
private func startNextMUCCatchupIfPossible() {
|
|
guard activeMUCCatchup == nil,
|
|
!archiveSyncStarted,
|
|
let client,
|
|
client.state == .connected(),
|
|
!client.module(.mam).availableVersions.isEmpty,
|
|
!pendingMUCCatchups.isEmpty else { return }
|
|
|
|
let archive = pendingMUCCatchups.removeFirst()
|
|
activeMUCCatchup = archive
|
|
mucCatchupHighWatermark = nil
|
|
mucCatchupLastCursor = mamCheckpoints[archive]?.cursor
|
|
queryMUCArchivePage(
|
|
archive: archive,
|
|
after: mamCheckpoints[archive]?.cursor,
|
|
client: client
|
|
)
|
|
}
|
|
|
|
private func queryMUCArchivePage(
|
|
archive: MAMArchiveKey,
|
|
after: String?,
|
|
client: XMPPClient
|
|
) {
|
|
guard activeMUCCatchup == archive else { return }
|
|
let queryID = UUID().uuidString
|
|
archiveStanzaInbox.begin(
|
|
queryID: queryID,
|
|
allowedSources: allowedMAMSources(for: archive, client: client)
|
|
)
|
|
|
|
let checkpoint = mamCheckpoints[archive]
|
|
let start = after == nil
|
|
? checkpoint?.timestamp.addingTimeInterval(-ArchiveSyncRecoveryPolicy.incrementalOverlap)
|
|
: nil
|
|
let rsm = checkpoint == nil
|
|
? RSM.Query(lastItems: archiveBootstrapMessageLimit)
|
|
: RSM.Query(after: after, max: archivePageSize)
|
|
|
|
queryMAM(
|
|
client: client,
|
|
archive: archive,
|
|
start: start,
|
|
queryID: queryID,
|
|
rsm: rsm
|
|
) { [weak self, weak client] result in
|
|
DispatchQueue.main.async {
|
|
guard let self, let client,
|
|
self.activeMUCCatchup == archive else { return }
|
|
let page = self.archiveStanzaInbox.take(queryID: queryID)
|
|
guard !page.overflowed, !page.rejectedSource else {
|
|
self.finishMUCCatchup(archive: archive, succeeded: false)
|
|
return
|
|
}
|
|
Task { @MainActor [weak self, weak client] in
|
|
guard let self, let client,
|
|
self.activeMUCCatchup == archive else { return }
|
|
for stanza in page.stanzas {
|
|
self.mucCatchupHighWatermark = max(
|
|
self.mucCatchupHighWatermark ?? .distantPast,
|
|
stanza.timestamp
|
|
)
|
|
guard stanza.message.type == .groupchat,
|
|
let roomJID = stanza.message.from?.bareJid else { continue }
|
|
await self.handle(
|
|
groupMessage: stanza.message,
|
|
archivedRoomJID: roomJID,
|
|
archivedTimestamp: stanza.timestamp,
|
|
archiveID: stanza.archiveID,
|
|
isArchived: true
|
|
)
|
|
}
|
|
|
|
switch result {
|
|
case .failure:
|
|
// Same recovery principle as account MAM: a retained
|
|
// local UID may have expired on the server. Retry once
|
|
// from the durable timestamp overlap, never loop
|
|
// cursor<->time.
|
|
if self.mamCheckpoints[archive]?.cursor != nil,
|
|
self.mucCursorFallbackArchives.insert(archive).inserted,
|
|
let old = self.mamCheckpoints[archive] {
|
|
self.mamCheckpoints[archive] = MAMArchiveCheckpoint(
|
|
timestamp: old.timestamp,
|
|
cursor: nil
|
|
)
|
|
self.queryMUCArchivePage(
|
|
archive: archive,
|
|
after: nil,
|
|
client: client
|
|
)
|
|
} else {
|
|
self.finishMUCCatchup(archive: archive, succeeded: false)
|
|
}
|
|
case .success(let response):
|
|
self.mucCatchupLastCursor =
|
|
response.rsm?.last ?? self.mucCatchupLastCursor
|
|
if !response.complete, let next = response.rsm?.last, next != after {
|
|
self.queryMUCArchivePage(archive: archive, after: next, client: client)
|
|
} else {
|
|
self.finishMUCCatchup(archive: archive, succeeded: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func finishMUCCatchup(archive: MAMArchiveKey, succeeded: Bool) {
|
|
defer {
|
|
activeMUCCatchup = nil
|
|
mucCatchupHighWatermark = nil
|
|
mucCatchupLastCursor = nil
|
|
Task { @MainActor [weak self] in
|
|
await self?.replayDelayedLive(for: archive)
|
|
}
|
|
startNextMUCCatchupIfPossible()
|
|
}
|
|
// MUC catch-up decodes into archivePassMutations just like the account
|
|
// pass. Publish them once the room catch-up settles, otherwise group
|
|
// history fetched via MAM is decoded and then silently dropped.
|
|
if succeeded, !archivePassMutations.isEmpty {
|
|
let mutations = archivePassMutations
|
|
archivePassMutations.removeAll(keepingCapacity: true)
|
|
eventHandler?(.archiveBatch(mutations))
|
|
} else {
|
|
archivePassMutations.removeAll(keepingCapacity: true)
|
|
}
|
|
guard succeeded else { return }
|
|
let checkpoint = MAMArchiveCheckpoint(
|
|
timestamp: max(mucCatchupHighWatermark ?? .distantPast, Date()),
|
|
cursor: mucCatchupLastCursor
|
|
)
|
|
mamCheckpoints[archive] = checkpoint
|
|
mucCursorFallbackArchives.remove(archive)
|
|
eventHandler?(.mucArchiveSyncCompleted(archive: archive, checkpoint: checkpoint))
|
|
}
|
|
|
|
private func queryArchive(client: XMPPClient, after: String?, retry: Int) {
|
|
guard archiveSyncStarted,
|
|
self.client === client,
|
|
client.state == .connected()
|
|
else {
|
|
if archiveSyncStarted, self.client === client {
|
|
finishArchiveSync(client: client, succeeded: false)
|
|
} else {
|
|
setArchiveSyncIndicator(false)
|
|
}
|
|
return
|
|
}
|
|
let queryID = UUID().uuidString
|
|
archiveActiveQueryID = queryID
|
|
archiveStanzaBuffer.removeAll(keepingCapacity: true)
|
|
archiveBufferOverflowed = false
|
|
archiveRejectedSource = false
|
|
let accountArchive = MAMArchiveKey.account(client.userBareJid.stringValue)
|
|
archiveStanzaInbox.begin(
|
|
queryID: queryID,
|
|
// allowedSources: [
|
|
// client.userBareJid.stringValue,
|
|
// client.userBareJid.domain,
|
|
// ]
|
|
allowedSources: allowedMAMSources(for: accountArchive, client: client)
|
|
)
|
|
scheduleArchiveQueryTimeout(client: client, queryID: queryID)
|
|
let start =
|
|
archiveIsBootstrapQuery
|
|
? nil
|
|
: (after == nil
|
|
? archiveSyncCheckpoint?.timestamp.addingTimeInterval(
|
|
-ArchiveSyncRecoveryPolicy.incrementalOverlap
|
|
) : nil)
|
|
let resultSet =
|
|
archiveIsBootstrapQuery
|
|
? RSM.Query(lastItems: archiveBootstrapMessageLimit)
|
|
: RSM.Query(after: after, max: archivePageSize)
|
|
// client.module(.mam).queryItems(
|
|
queryMAM(
|
|
client: client,
|
|
archive: accountArchive,
|
|
start: start,
|
|
queryID: queryID,
|
|
rsm: resultSet
|
|
) { [weak self, weak client] result in
|
|
// XEP-0313 guarantees that the final IQ follows every result. The
|
|
// parser-queue inbox is therefore complete before this single main
|
|
// actor hand-off runs.
|
|
DispatchQueue.main.async {
|
|
guard let self,
|
|
let client,
|
|
self.archiveSyncStarted,
|
|
self.archiveActiveQueryID == queryID,
|
|
self.client === client
|
|
else { return }
|
|
self.archiveQueryTimeoutTask?.cancel()
|
|
self.archiveQueryTimeoutTask = nil
|
|
let page = self.archiveStanzaInbox.take(queryID: queryID)
|
|
self.archiveStanzaBuffer = page.stanzas
|
|
self.archiveBufferOverflowed = page.overflowed
|
|
self.archiveRejectedSource = page.rejectedSource
|
|
|
|
guard case .success = result,
|
|
!page.overflowed,
|
|
!page.rejectedSource
|
|
else {
|
|
self.archiveActiveQueryID = nil
|
|
self.completeArchivePage(
|
|
result,
|
|
client: client,
|
|
requestedAfter: after,
|
|
retry: retry
|
|
)
|
|
return
|
|
}
|
|
|
|
self.scheduleArchiveApplyTimeout(client: client, queryID: queryID)
|
|
self.archiveQueryCompletionTask?.cancel()
|
|
self.archiveQueryCompletionTask = Task { @MainActor [weak self, weak client] in
|
|
guard let self, let client else { return }
|
|
let applied = await self.drainArchiveStanzas(queryID: queryID)
|
|
guard !Task.isCancelled,
|
|
applied,
|
|
self.archiveSyncStarted,
|
|
self.archiveActiveQueryID == queryID,
|
|
self.client === client
|
|
else { return }
|
|
self.archiveQueryTimeoutTask?.cancel()
|
|
self.archiveQueryTimeoutTask = nil
|
|
self.archiveQueryCompletionTask = nil
|
|
self.archiveActiveQueryID = nil
|
|
self.completeArchivePage(
|
|
result,
|
|
client: client,
|
|
requestedAfter: after,
|
|
retry: retry
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func drainArchiveStanzas(queryID: String) async -> Bool {
|
|
while archiveSyncStarted,
|
|
archiveActiveQueryID == queryID,
|
|
!Task.isCancelled
|
|
{
|
|
guard !archiveStanzaBuffer.isEmpty else { return true }
|
|
let count = min(archiveApplyBatchSize, archiveStanzaBuffer.count)
|
|
let batch = Array(archiveStanzaBuffer.prefix(count))
|
|
archiveStanzaBuffer.removeFirst(count)
|
|
|
|
for stanza in batch where stanza.queryID == queryID {
|
|
if let current = archiveHighWatermark {
|
|
archiveHighWatermark = max(current, stanza.timestamp)
|
|
} else {
|
|
archiveHighWatermark = stanza.timestamp
|
|
}
|
|
// handle(
|
|
// message: stanza.message,
|
|
// timestamp: stanza.timestamp,
|
|
// archiveID: stanza.archiveID,
|
|
// isArchived: true
|
|
// )
|
|
if stanza.message.type == .groupchat,
|
|
// let roomJID = stanza.message.from?.bareJid,
|
|
// let room = client?.module(.muc).roomManager.room(for: client!, with: roomJID)
|
|
let roomJID = stanza.message.from?.bareJid
|
|
{
|
|
// Some servers include MUC messages in the account MAM.
|
|
// Route them through the same group handler instead of
|
|
// dropping them in the direct-message handler.
|
|
// handle(groupMessage: stanza.message, room: room)
|
|
await handle(
|
|
groupMessage: stanza.message,
|
|
archivedRoomJID: roomJID,
|
|
archivedTimestamp: stanza.timestamp,
|
|
archiveID: stanza.archiveID,
|
|
isArchived: true
|
|
)
|
|
} else {
|
|
await handle(
|
|
message: stanza.message,
|
|
timestamp: stanza.timestamp,
|
|
archiveID: stanza.archiveID,
|
|
isArchived: true
|
|
)
|
|
}
|
|
}
|
|
|
|
// Give SwiftUI, UIScrollView and AVFoundation delegate tasks a
|
|
// chance to run after every decryption.
|
|
await Task.yield()
|
|
try? await Task.sleep(
|
|
nanoseconds: ArchiveMessageBatchPolicy.interSliceDelayNanoseconds
|
|
)
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func completeArchivePage(
|
|
_ result: Result<MessageArchiveManagementModule.QueryResult, XMPPError>,
|
|
client: XMPPClient,
|
|
requestedAfter after: String?,
|
|
retry: Int
|
|
) {
|
|
if archiveBufferOverflowed {
|
|
archiveBufferOverflowed = false
|
|
archiveStanzaBuffer.removeAll(keepingCapacity: true)
|
|
archivePassMutations.removeAll(keepingCapacity: true)
|
|
eventHandler?(
|
|
.recoverableError(
|
|
"Сервер вернул слишком большую страницу MAM; синхронизация остановлена без потери контрольной точки."
|
|
))
|
|
finishArchiveSync(client: client, succeeded: false)
|
|
return
|
|
}
|
|
if archiveRejectedSource {
|
|
archiveRejectedSource = false
|
|
archiveStanzaBuffer.removeAll(keepingCapacity: true)
|
|
archivePassMutations.removeAll(keepingCapacity: true)
|
|
eventHandler?(
|
|
.recoverableError(
|
|
"MAM вернул результат от неожиданного отправителя; страница отклонена."
|
|
))
|
|
finishArchiveSync(client: client, succeeded: false)
|
|
return
|
|
}
|
|
|
|
switch result {
|
|
case .success(let response):
|
|
archiveHasCompletedPage = true
|
|
let workBudgetReached = archiveWorkBudget.recordCompletedPage()
|
|
if archiveIsBootstrapQuery {
|
|
// Like Monal, start from a small recent window backed by local
|
|
// storage instead of replaying the whole account archive.
|
|
let checkpoint = resolvedArchiveCheckpoint(
|
|
cursor: response.rsm?.last,
|
|
caughtUp: true
|
|
)
|
|
archiveLastCompletedCursor = checkpoint.cursor
|
|
finishArchiveSync(
|
|
client: client,
|
|
succeeded: true,
|
|
checkpoint: checkpoint
|
|
)
|
|
return
|
|
}
|
|
switch archivePagination.decision(
|
|
complete: response.complete,
|
|
lastCursor: response.rsm?.last,
|
|
requestedAfter: after
|
|
) {
|
|
case .finished:
|
|
let checkpoint = resolvedArchiveCheckpoint(
|
|
cursor: response.rsm?.last ?? after,
|
|
caughtUp: true
|
|
)
|
|
archiveLastCompletedCursor = checkpoint.cursor
|
|
finishArchiveSync(
|
|
client: client,
|
|
succeeded: true,
|
|
checkpoint: checkpoint
|
|
)
|
|
case .next(let cursor):
|
|
archiveResumeAfter = cursor
|
|
let checkpoint = resolvedArchiveCheckpoint(
|
|
cursor: cursor,
|
|
caughtUp: false
|
|
)
|
|
archiveLastCompletedCursor = checkpoint.cursor
|
|
if workBudgetReached {
|
|
guard checkpoint.advances(over: archiveSyncCheckpoint) else {
|
|
archivePassMutations.removeAll(keepingCapacity: true)
|
|
eventHandler?(
|
|
.recoverableError(
|
|
"MAM не продвинул контрольную точку; синхронизация приостановлена."
|
|
))
|
|
finishArchiveSync(client: client, succeeded: false)
|
|
return
|
|
}
|
|
|
|
// Flush the accumulated mutations so the UI advances
|
|
// incrementally, then keep catching up in the background.
|
|
// The visible sync banner is only shown for the bootstrap
|
|
// window, so a large backlog never leaves a spinner hanging.
|
|
if !archivePassMutations.isEmpty {
|
|
let mutations = archivePassMutations
|
|
archivePassMutations.removeAll(keepingCapacity: true)
|
|
eventHandler?(.archiveBatch(mutations))
|
|
}
|
|
archiveSyncCheckpoint = checkpoint
|
|
archiveWorkBudget = ArchiveSyncWorkBudget()
|
|
scheduleArchiveNextPage(client: client, after: cursor)
|
|
} else {
|
|
scheduleArchiveNextPage(client: client, after: cursor)
|
|
}
|
|
case .invalidCursor:
|
|
// An incomplete page without a new UID cannot be committed
|
|
// atomically. Drop its decoded mutations so the next attempt
|
|
// can safely request the same page again.
|
|
archivePassMutations.removeAll(keepingCapacity: true)
|
|
eventHandler?(
|
|
.recoverableError(
|
|
"MAM вернул некорректный курсор; страница отклонена без изменения истории."
|
|
))
|
|
finishArchiveSync(client: client, succeeded: false)
|
|
}
|
|
case .failure(let error):
|
|
if retry < ArchiveSyncRecoveryPolicy.pageRetryLimit {
|
|
scheduleArchivePageRetry(
|
|
client: client,
|
|
after: after,
|
|
retry: retry + 1
|
|
)
|
|
} else if ArchiveSyncCursorPolicy.shouldFallbackFromStoredCursor(
|
|
requestedAfter: after,
|
|
checkpoint: archiveSyncCheckpoint,
|
|
hasCompletedPage: archiveHasCompletedPage,
|
|
fallbackAlreadyUsed: archiveCursorFallbackUsed
|
|
) {
|
|
// The server may have expired the UID because of archive
|
|
// retention. Fall back once to the legacy timestamp overlap;
|
|
// never loop between the two strategies automatically.
|
|
archiveCursorFallbackUsed = true
|
|
archivePagination = ArchiveSyncPagination()
|
|
archiveResumeAfter = nil
|
|
archiveHighWatermark = nil
|
|
archivePassMutations.removeAll(keepingCapacity: true)
|
|
queryArchive(client: client, after: nil, retry: 0)
|
|
} else {
|
|
eventHandler?(
|
|
.recoverableError(
|
|
"MAM: \(error.localizedDescription). Продолжим после следующего открытия приложения."
|
|
))
|
|
finishArchiveSync(client: client, succeeded: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func resolvedArchiveCheckpoint(
|
|
cursor: String?,
|
|
caughtUp: Bool
|
|
) -> ArchiveSyncCheckpoint {
|
|
ArchiveSyncCursorPolicy.resolvedCheckpoint(
|
|
previous: archiveSyncCheckpoint,
|
|
cursor: cursor ?? archiveLastCompletedCursor,
|
|
highWatermark: archiveHighWatermark,
|
|
queryStartedAt: archiveSyncQueryStartedAt ?? Date(),
|
|
caughtUp: caughtUp
|
|
)
|
|
}
|
|
|
|
private func scheduleArchivePageRetry(
|
|
client: XMPPClient,
|
|
after: String?,
|
|
retry: Int
|
|
) {
|
|
archiveRetryTask?.cancel()
|
|
archiveRetryTask = Task { @MainActor [weak self, weak client] in
|
|
let delaySeconds = min(8, 1 << max(0, retry - 1))
|
|
try? await Task.sleep(nanoseconds: UInt64(delaySeconds) * 1_000_000_000)
|
|
guard !Task.isCancelled,
|
|
let self,
|
|
let client,
|
|
self.archiveSyncStarted,
|
|
self.client === client
|
|
else { return }
|
|
self.queryArchive(client: client, after: after, retry: retry)
|
|
}
|
|
}
|
|
|
|
private func scheduleArchiveNextPage(client: XMPPClient, after: String) {
|
|
archiveRetryTask?.cancel()
|
|
archiveRetryTask = Task { @MainActor [weak self, weak client] in
|
|
// A short hand-off between MAM pages lets SwiftUI render and lets
|
|
// the archive consumer apply the previous batch. Without it a
|
|
// large server archive can monopolize the main run loop.
|
|
try? await Task.sleep(
|
|
nanoseconds: ArchiveSyncRecoveryPolicy.interPageDelayNanoseconds
|
|
)
|
|
guard !Task.isCancelled,
|
|
let self,
|
|
let client,
|
|
self.archiveSyncStarted,
|
|
self.client === client
|
|
else { return }
|
|
self.queryArchive(client: client, after: after, retry: 0)
|
|
}
|
|
}
|
|
|
|
private func scheduleArchiveQueryTimeout(client: XMPPClient, queryID: String) {
|
|
archiveQueryTimeoutTask?.cancel()
|
|
archiveQueryTimeoutTask = Task { @MainActor [weak self, weak client] in
|
|
try? await Task.sleep(
|
|
nanoseconds: ArchiveSyncRecoveryPolicy.queryTimeoutNanoseconds
|
|
)
|
|
guard !Task.isCancelled,
|
|
let self,
|
|
let client,
|
|
self.archiveSyncStarted,
|
|
self.archiveActiveQueryID == queryID,
|
|
self.client === client
|
|
else { return }
|
|
self.archiveQueryCompletionTask?.cancel()
|
|
self.archiveQueryCompletionTask = nil
|
|
self.archiveStanzaInbox.cancel(queryID: queryID)
|
|
self.archiveActiveQueryID = nil
|
|
self.archiveResumeAfter = self.archiveSyncCheckpoint?.cursor
|
|
self.archiveStanzaBuffer.removeAll(keepingCapacity: true)
|
|
self.archiveBufferOverflowed = false
|
|
self.archiveRejectedSource = false
|
|
self.archivePassMutations.removeAll(keepingCapacity: true)
|
|
self.eventHandler?(
|
|
.recoverableError(
|
|
"MAM не ответил вовремя; синхронизация остановлена до следующего открытия приложения."
|
|
))
|
|
self.finishArchiveSync(client: client, succeeded: false)
|
|
}
|
|
}
|
|
|
|
private func scheduleArchiveApplyTimeout(client: XMPPClient, queryID: String) {
|
|
archiveQueryTimeoutTask?.cancel()
|
|
archiveQueryTimeoutTask = Task { @MainActor [weak self, weak client] in
|
|
try? await Task.sleep(
|
|
nanoseconds: ArchiveSyncRecoveryPolicy.pageApplyTimeoutNanoseconds
|
|
)
|
|
guard !Task.isCancelled,
|
|
let self,
|
|
let client,
|
|
self.archiveSyncStarted,
|
|
self.archiveActiveQueryID == queryID,
|
|
self.client === client
|
|
else { return }
|
|
self.archiveQueryCompletionTask?.cancel()
|
|
self.archiveQueryCompletionTask = nil
|
|
self.archiveStanzaInbox.cancel(queryID: queryID)
|
|
self.archiveActiveQueryID = nil
|
|
self.archiveResumeAfter = self.archiveSyncCheckpoint?.cursor
|
|
self.archiveStanzaBuffer.removeAll(keepingCapacity: true)
|
|
self.archiveBufferOverflowed = false
|
|
self.archiveRejectedSource = false
|
|
self.archivePassMutations.removeAll(keepingCapacity: true)
|
|
self.eventHandler?(
|
|
.recoverableError(
|
|
"Обработка MAM заняла слишком много времени; синхронизация приостановлена."
|
|
))
|
|
self.finishArchiveSync(client: client, succeeded: false)
|
|
}
|
|
}
|
|
|
|
private func finishArchiveSync(
|
|
client: XMPPClient,
|
|
succeeded: Bool,
|
|
checkpoint: ArchiveSyncCheckpoint? = nil
|
|
) {
|
|
guard archiveSyncStarted, self.client === client else {
|
|
if !archiveSyncStarted {
|
|
setArchiveSyncIndicator(false)
|
|
}
|
|
return
|
|
}
|
|
archiveRetryTask?.cancel()
|
|
archiveRetryTask = nil
|
|
archiveQueryTimeoutTask?.cancel()
|
|
archiveQueryTimeoutTask = nil
|
|
archiveQueryCompletionTask?.cancel()
|
|
archiveQueryCompletionTask = nil
|
|
// Do NOT blanket-cancel the inbox here: an interactive backward-history
|
|
// query may be in flight and must keep its own query-ID-scoped buffer.
|
|
archiveActiveQueryID = nil
|
|
archiveStanzaBuffer.removeAll(keepingCapacity: true)
|
|
archiveBufferOverflowed = false
|
|
archiveRejectedSource = false
|
|
client.module(.omemo).mamSyncFinished(for: nil)
|
|
|
|
if succeeded, !archivePassMutations.isEmpty {
|
|
let mutations = archivePassMutations
|
|
archivePassMutations.removeAll(keepingCapacity: true)
|
|
eventHandler?(.archiveBatch(mutations))
|
|
} else {
|
|
archivePassMutations.removeAll(keepingCapacity: true)
|
|
}
|
|
setArchiveSyncIndicator(false)
|
|
archiveSyncStarted = false
|
|
archiveIsBootstrapQuery = false
|
|
archiveHighWatermark = nil
|
|
archiveHasCompletedPage = false
|
|
archiveCursorFallbackUsed = false
|
|
|
|
if succeeded {
|
|
let resolvedCheckpoint =
|
|
checkpoint
|
|
?? ArchiveSyncCheckpoint(
|
|
timestamp: archiveSyncQueryStartedAt ?? Date(),
|
|
cursor: archiveLastCompletedCursor ?? archiveSyncCheckpoint?.cursor
|
|
)
|
|
archiveSyncCheckpoint = resolvedCheckpoint
|
|
archiveLastCompletedCursor = resolvedCheckpoint.cursor
|
|
archiveSyncQueryStartedAt = nil
|
|
archiveSyncCompletedForConnection = true
|
|
archiveResumeAfter = nil
|
|
archiveRetrySuppressedUntilActivation = false
|
|
archiveAutoRetryCount = 0
|
|
eventHandler?(.archiveSyncCompleted(resolvedCheckpoint))
|
|
Task { @MainActor [weak self, weak client] in
|
|
guard let self, let client else { return }
|
|
await self.replayDelayedLive(for: .account(client.userBareJid.stringValue))
|
|
}
|
|
startNextMUCCatchupIfPossible()
|
|
return
|
|
}
|
|
|
|
archiveSyncQueryStartedAt = nil
|
|
archiveSyncCompletedForConnection = false
|
|
archiveResumeAfter = archiveSyncCheckpoint?.cursor
|
|
archiveLastCompletedCursor = archiveSyncCheckpoint?.cursor
|
|
if archiveAutoRetryCount < ArchiveSyncRecoveryPolicy.maximumAutomaticRetries {
|
|
// A single slow/timed-out page must not leave history unloaded
|
|
// until the next app activation. Retry automatically a bounded
|
|
// number of times while still connected and foregrounded.
|
|
archiveAutoRetryCount += 1
|
|
archiveRetrySuppressedUntilActivation = false
|
|
scheduleArchiveSyncRetry(client: client)
|
|
} else {
|
|
archiveAutoRetryCount = 0
|
|
archiveRetrySuppressedUntilActivation = true
|
|
eventHandler?(
|
|
.recoverableError(
|
|
"Синхронизация истории приостановлена. Luma продолжит после возврата в приложение."
|
|
))
|
|
}
|
|
}
|
|
|
|
private func scheduleArchiveSyncRetry(client: XMPPClient) {
|
|
archiveSyncRetryTask?.cancel()
|
|
archiveSyncRetryTask = Task { @MainActor [weak self, weak client] in
|
|
try? await Task.sleep(
|
|
nanoseconds: ArchiveSyncRecoveryPolicy.retryAfterFailureDelayNanoseconds
|
|
)
|
|
guard !Task.isCancelled,
|
|
let self,
|
|
let client,
|
|
self.client === client,
|
|
client.state == .connected(),
|
|
!self.archiveSyncSuspended
|
|
else { return }
|
|
self.archiveSyncRetryTask = nil
|
|
self.refreshArchiveIfNeeded(client: client)
|
|
}
|
|
}
|
|
|
|
private func refreshArchiveIfNeeded(client: XMPPClient) {
|
|
guard !archiveSyncStarted,
|
|
!archiveSyncSuspended,
|
|
self.client === client
|
|
else { return }
|
|
if !archiveSyncCompletedForConnection {
|
|
archiveRetrySuppressedUntilActivation = false
|
|
startArchiveSync(client: client)
|
|
return
|
|
}
|
|
let lastCheckpoint = archiveSyncCheckpoint?.timestamp ?? .distantPast
|
|
guard Date().timeIntervalSince(lastCheckpoint) >= archiveForegroundRefreshInterval else {
|
|
return
|
|
}
|
|
archiveSyncCompletedForConnection = false
|
|
archiveResumeAfter = nil
|
|
archiveSyncQueryStartedAt = nil
|
|
startArchiveSync(client: client)
|
|
}
|
|
|
|
private func suspendArchiveSyncForDisconnect() {
|
|
setArchiveSyncIndicator(false)
|
|
finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
|
archiveStanzaInbox.cancel()
|
|
guard let client else {
|
|
archiveSyncStarted = false
|
|
// archiveStanzaInbox.cancel()
|
|
archiveActiveQueryID = nil
|
|
archiveStanzaBuffer.removeAll(keepingCapacity: false)
|
|
archiveBufferOverflowed = false
|
|
archiveRejectedSource = false
|
|
archivePassMutations.removeAll(keepingCapacity: false)
|
|
return
|
|
}
|
|
archiveRetryTask?.cancel()
|
|
archiveRetryTask = nil
|
|
archiveQueryTimeoutTask?.cancel()
|
|
archiveQueryTimeoutTask = nil
|
|
archiveQueryCompletionTask?.cancel()
|
|
archiveQueryCompletionTask = nil
|
|
archiveSyncRetryTask?.cancel()
|
|
archiveSyncRetryTask = nil
|
|
if archiveSyncStarted {
|
|
archiveResumeAfter = archiveSyncCheckpoint?.cursor
|
|
client.module(.omemo).mamSyncFinished(for: nil)
|
|
}
|
|
archiveStanzaInbox.cancel()
|
|
archiveActiveQueryID = nil
|
|
archiveSyncStarted = false
|
|
archiveSyncCompletedForConnection = false
|
|
archiveRetrySuppressedUntilActivation = false
|
|
archiveSyncQueryStartedAt = nil
|
|
archiveWorkBudget = ArchiveSyncWorkBudget()
|
|
archiveHighWatermark = nil
|
|
archiveLastCompletedCursor = archiveSyncCheckpoint?.cursor
|
|
archiveHasCompletedPage = false
|
|
archiveCursorFallbackUsed = false
|
|
archivePassMutations.removeAll(keepingCapacity: false)
|
|
archiveIsBootstrapQuery = false
|
|
archiveStanzaBuffer.removeAll(keepingCapacity: false)
|
|
archiveBufferOverflowed = false
|
|
archiveRejectedSource = false
|
|
}
|
|
|
|
private func pauseArchiveSync(client: XMPPClient) {
|
|
setArchiveSyncIndicator(false)
|
|
// An in-flight interactive history request must not survive into the
|
|
// background with a dangling spinner; finish it before the catch-up
|
|
// guard below, which may early-return when no catch-up is active.
|
|
finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
|
guard archiveSyncStarted, self.client === client else { return }
|
|
archiveRetryTask?.cancel()
|
|
archiveRetryTask = nil
|
|
archiveQueryTimeoutTask?.cancel()
|
|
archiveQueryTimeoutTask = nil
|
|
archiveQueryCompletionTask?.cancel()
|
|
archiveQueryCompletionTask = nil
|
|
archiveSyncRetryTask?.cancel()
|
|
archiveSyncRetryTask = nil
|
|
archiveStanzaInbox.cancel()
|
|
archiveResumeAfter = archiveSyncCheckpoint?.cursor
|
|
archiveActiveQueryID = nil
|
|
archiveSyncStarted = false
|
|
archiveSyncCompletedForConnection = false
|
|
archiveSyncQueryStartedAt = nil
|
|
archivePagination = ArchiveSyncPagination()
|
|
archiveWorkBudget = ArchiveSyncWorkBudget()
|
|
archiveHighWatermark = nil
|
|
archiveLastCompletedCursor = archiveSyncCheckpoint?.cursor
|
|
archiveHasCompletedPage = false
|
|
archiveCursorFallbackUsed = false
|
|
archivePassMutations.removeAll(keepingCapacity: true)
|
|
archiveIsBootstrapQuery = false
|
|
archiveStanzaBuffer.removeAll(keepingCapacity: true)
|
|
archiveBufferOverflowed = false
|
|
archiveRejectedSource = false
|
|
client.module(.omemo).mamSyncFinished(for: nil)
|
|
}
|
|
|
|
private func setArchiveSyncIndicator(_ visible: Bool) {
|
|
guard archiveSyncIndicatorVisible != visible else { return }
|
|
archiveSyncIndicatorVisible = visible
|
|
eventHandler?(.archiveSyncing(visible))
|
|
}
|
|
|
|
private static var platformName: String {
|
|
#if os(macOS)
|
|
return "macOS"
|
|
#else
|
|
return "iOS/iPadOS"
|
|
#endif
|
|
}
|
|
|
|
private static func reasonText(_ reason: XMPPClient.State.DisconnectionReason) -> String? {
|
|
if case .none = reason {
|
|
return nil
|
|
}
|
|
return reason.localizedDescription
|
|
}
|
|
}
|
|
|
|
enum LumaXMPPError: LocalizedError {
|
|
case notConnected
|
|
case connection(String)
|
|
case omemoInitializationFailed
|
|
case omemoNotReady
|
|
case omemoEncryptionFailed(Int)
|
|
case cannotCreateChat
|
|
case invalidRoomJID
|
|
case emptyRoomNickname
|
|
case roomNotJoined
|
|
case groupEncryptionRequiresNonAnonymousRoom
|
|
case groupEncryptionConfigurationUnsupported
|
|
case groupEncryptionParticipantsHidden([String])
|
|
case groupEncryptionMemberListUnavailable(String)
|
|
case groupOMEMODevicesUnavailable([String])
|
|
case groupOMEMOSessionsUnavailable([String])
|
|
case fileEncryptionFailed
|
|
case fileDecryptionFailed
|
|
case uploadDiscoveryFailed(String)
|
|
case uploadUnavailable(size: Int)
|
|
case uploadSlotFailed(String)
|
|
case uploadTransportFailed(String)
|
|
case uploadFailed(statusCode: Int?)
|
|
case downloadFailed
|
|
case invalidUploadURL
|
|
case insecureUploadURL
|
|
case avatarUpdateFailed(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notConnected:
|
|
return "Нет соединения с XMPP-сервером."
|
|
case .connection(let message):
|
|
return "Не удалось подключиться: \(message)"
|
|
case .omemoInitializationFailed:
|
|
return "Не удалось инициализировать хранилище OMEMO."
|
|
case .omemoNotReady:
|
|
return "OMEMO ещё не готов. Подождите публикации ключей на сервере."
|
|
case .omemoEncryptionFailed(let code):
|
|
return code == SignalError.noSession.rawValue
|
|
? "У контакта нет доступных OMEMO-устройств."
|
|
: "Не удалось зашифровать сообщение OMEMO (код \(code))."
|
|
case .cannotCreateChat:
|
|
return "Не удалось открыть чат с этим JID."
|
|
case .invalidRoomJID:
|
|
return "Введите адрес комнаты в формате room@conference.example.org."
|
|
case .emptyRoomNickname:
|
|
return "Введите псевдоним для группового чата."
|
|
case .roomNotJoined:
|
|
return "Сначала подключитесь к групповой комнате."
|
|
case .groupEncryptionRequiresNonAnonymousRoom:
|
|
return
|
|
"Групповое OMEMO требует неанонимную MUC-комнату. Попросите владельца включить показ реальных JID (muc#roomconfig_whois = anyone)."
|
|
case .groupEncryptionConfigurationUnsupported:
|
|
return
|
|
"Сервер не предлагает настройку неанонимной MUC-комнаты, необходимую для группового OMEMO."
|
|
case .groupEncryptionParticipantsHidden(let nicknames):
|
|
return
|
|
"Нельзя безопасно зашифровать сообщение: комната скрывает JID участников \(nicknames.joined(separator: ", ")). Переподключитесь после включения неанонимного режима."
|
|
case .groupEncryptionMemberListUnavailable(let message):
|
|
return
|
|
"Сообщение не отправлено: сервер не отдал полный список участников комнаты для OMEMO (\(message))."
|
|
case .groupOMEMODevicesUnavailable(let jids):
|
|
return
|
|
"Сообщение не отправлено: OMEMO-устройства отсутствуют у \(jids.joined(separator: ", "))."
|
|
case .groupOMEMOSessionsUnavailable(let devices):
|
|
return
|
|
"Сообщение не отправлено: не удалось создать OMEMO-сессию для \(devices.joined(separator: ", "))."
|
|
case .fileEncryptionFailed:
|
|
return "Не удалось зашифровать вложение."
|
|
case .fileDecryptionFailed:
|
|
return "Не удалось расшифровать вложение."
|
|
case .uploadDiscoveryFailed(let message):
|
|
return "Не удалось найти XEP-0363 HTTP Upload на сервере: \(message)"
|
|
case .uploadUnavailable(let size):
|
|
return
|
|
"Сервер не предлагает HTTP Upload для файла размером \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))."
|
|
case .uploadSlotFailed(let message):
|
|
return "Prosody не выдал слот для загрузки: \(message)"
|
|
case .uploadTransportFailed(let message):
|
|
return "Не удалось отправить файл в HTTP Upload: \(message)"
|
|
case .uploadFailed(let statusCode):
|
|
if let statusCode {
|
|
return "HTTP Upload отклонил файл (HTTP \(statusCode))."
|
|
}
|
|
return "HTTP Upload вернул некорректный ответ."
|
|
case .downloadFailed:
|
|
return "Не удалось скачать вложение."
|
|
case .invalidUploadURL:
|
|
return "Получена некорректная ссылка на вложение."
|
|
case .insecureUploadURL:
|
|
return "Сервер предложил небезопасный HTTP Upload URL. Требуется HTTPS."
|
|
case .avatarUpdateFailed(let message):
|
|
return "Не удалось обновить аватар: \(message)"
|
|
}
|
|
}
|
|
}
|