diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index a74b6882..4e4ad3f3 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -2,36 +2,129 @@ import BitFoundation import BitLogger import Foundation +/// The narrow surface `ChatLifecycleCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatLifecycleCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatLifecycleContext: AnyObject { + // MARK: Chat & receipt state + var messages: [BitchatMessage] { get } + var privateChats: [PeerID: [BitchatMessage]] { get set } + var unreadPrivateMessages: Set { get set } + var selectedPrivateChatPeer: PeerID? { get } + var sentReadReceipts: Set { get } + var nickname: String { get } + var myPeerID: PeerID { get } + var activeChannel: ChannelID { get } + var nostrKeyMapping: [PeerID: String] { get } + /// Records that a read receipt is being sent for `messageID`. + /// Returns `false` when one was already recorded — the caller must skip sending. + @discardableResult + func markReadReceiptSent(_ messageID: String) -> Bool + /// The owner-level read pass (chat manager + receipts); used for the + /// delayed re-run after the app becomes active. + func markPrivateMessagesAsRead(from peerID: PeerID) + /// Marks the chat read in the private chat manager (sends pending mesh READ acks). + func markChatAsRead(from peerID: PeerID) + func synchronizePrivateConversationStore() + func addSystemMessage(_ content: String) + + // MARK: Peers & sessions + func peerNickname(for peerID: PeerID) -> String? + /// The peer's current entry in the unified peer service, if known. + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? + func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState + func stopMeshServices() + /// Re-reads the transport's current Bluetooth state and updates the alert UI. + func refreshBluetoothState() + + // MARK: Routing & receipts + func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) + func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) + func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) + + // MARK: Nostr & geohash + var isTeleported: Bool { get } + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity + func recordGeoParticipant(pubkeyHex: String) + + // MARK: Identity persistence + /// Forces the identity manager to persist its state now. + func forceSaveIdentity() + /// Confirms the Noise identity key is still present in the keychain. + @discardableResult + func verifyIdentityKeyExists() -> Bool +} + +extension ChatViewModel: ChatLifecycleContext { + // `messages`, `privateChats`, `unreadPrivateMessages`, + // `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`, + // `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`, + // `markPrivateMessagesAsRead(from:)`, + // `synchronizePrivateConversationStore()`, `addSystemMessage(_:)`, + // `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`, + // the routing/ack members, `isTeleported`, + // `deriveNostrIdentity(forGeohash:)`, and `recordGeoParticipant(pubkeyHex:)` + // are shared requirements with the other contexts or satisfied by + // existing `ChatViewModel` members. The members below flatten nested + // service accesses into intent-named calls. + + func markChatAsRead(from peerID: PeerID) { + privateChatManager.markAsRead(from: peerID) + } + + func stopMeshServices() { + meshService.stopServices() + } + + func refreshBluetoothState() { + if let bleService = meshService as? BLEService { + updateBluetoothState(bleService.getCurrentBluetoothState()) + } + } + + func forceSaveIdentity() { + identityManager.forceSave() + } + + @discardableResult + func verifyIdentityKeyExists() -> Bool { + keychain.verifyIdentityKeyExists() + } +} + @MainActor final class ChatLifecycleCoordinator { - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatLifecycleContext - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatLifecycleContext) { + self.context = context } func handleDidBecomeActive() { - if let bleService = viewModel.meshService as? BLEService { - let currentState = bleService.getCurrentBluetoothState() - viewModel.updateBluetoothState(currentState) - } + context.refreshBluetoothState() - guard let peerID = viewModel.selectedPrivateChatPeer else { return } + guard let peerID = context.selectedPrivateChatPeer else { return } markPrivateMessagesAsRead(from: peerID) - let viewModel = self.viewModel - DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak viewModel] in + let context = self.context + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak context] in Task { @MainActor in - viewModel?.markPrivateMessagesAsRead(from: peerID) + context?.markPrivateMessagesAsRead(from: peerID) } } } func handleScreenshotCaptured() { - let screenshotMessage = "* \(viewModel.nickname) took a screenshot *" + let screenshotMessage = "* \(context.nickname) took a screenshot *" - if let peerID = viewModel.selectedPrivateChatPeer { + if let peerID = context.selectedPrivateChatPeer { sendPrivateScreenshotNotificationIfPossible( screenshotMessage, to: peerID @@ -40,9 +133,9 @@ final class ChatLifecycleCoordinator { return } - switch viewModel.activeChannel { + switch context.activeChannel { case .mesh: - viewModel.meshService.sendMessage( + context.sendMeshMessage( screenshotMessage, mentions: [], messageID: UUID().uuidString, @@ -56,43 +149,41 @@ final class ChatLifecycleCoordinator { ) } - viewModel.addSystemMessage("you took a screenshot") + context.addSystemMessage("you took a screenshot") } func saveIdentityState() { - viewModel.identityManager.forceSave() - _ = viewModel.keychain.verifyIdentityKeyExists() + context.forceSaveIdentity() + context.verifyIdentityKeyExists() } func applicationWillTerminate() { - viewModel.meshService.stopServices() + context.stopMeshServices() saveIdentityState() } func markPrivateMessagesAsRead(from peerID: PeerID) { - viewModel.privateChatManager.markAsRead(from: peerID) - viewModel.synchronizePrivateConversationStore() + context.markChatAsRead(from: peerID) + context.synchronizePrivateConversationStore() if peerID.isGeoDM, - let recipientHex = viewModel.nostrKeyMapping[peerID], - case .location(let channel) = viewModel.activeChannel, - let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { - let messages = viewModel.privateChats[peerID] ?? [] + let recipientHex = context.nostrKeyMapping[peerID], + case .location(let channel) = context.activeChannel, + let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { + let messages = context.privateChats[peerID] ?? [] for message in messages where message.senderPeerID == peerID && !message.isRelay { - guard !viewModel.sentReadReceipts.contains(message.id) else { continue } + guard !context.sentReadReceipts.contains(message.id) else { continue } SecureLogger.debug( "GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…", category: .session ) - let nostrTransport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) - nostrTransport.senderPeerID = viewModel.meshService.myPeerID - nostrTransport.sendReadReceiptGeohash( + context.sendGeohashReadReceipt( message.id, toRecipientHex: recipientHex, from: identity ) - viewModel.sentReadReceipts.insert(message.id) + context.markReadReceiptSent(message.id) } return } @@ -104,13 +195,13 @@ final class ChatLifecycleCoordinator { let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { noiseKeyHex = peerID peerNostrPubkey = favoriteStatus.peerNostrPublicKey - } else if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + } else if let peer = context.unifiedPeer(for: peerID) { noiseKeyHex = PeerID(hexData: peer.noisePublicKey) let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey) peerNostrPubkey = favoriteStatus?.peerNostrPublicKey - if let noiseKeyHex, viewModel.unreadPrivateMessages.contains(noiseKeyHex) { - viewModel.unreadPrivateMessages.remove(noiseKeyHex) + if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) { + context.unreadPrivateMessages.remove(noiseKeyHex) } } @@ -121,37 +212,37 @@ final class ChatLifecycleCoordinator { continue } - guard !viewModel.sentReadReceipts.contains(message.id) else { continue } + guard !context.sentReadReceipts.contains(message.id) else { continue } let receipt = ReadReceipt( originalMessageID: message.id, - readerID: viewModel.meshService.myPeerID, - readerNickname: viewModel.nickname + readerID: context.myPeerID, + readerNickname: context.nickname ) let recipientPeerID = peerID.isHex ? peerID - : (viewModel.unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID) + : (context.unifiedPeer(for: peerID)?.peerID ?? peerID) - viewModel.messageRouter.sendReadReceipt(receipt, to: recipientPeerID) - viewModel.sentReadReceipts.insert(message.id) + context.routeReadReceipt(receipt, to: recipientPeerID) + context.markReadReceiptSent(message.id) } } func getMessages(for peerID: PeerID?) -> [BitchatMessage] { - guard let peerID else { return viewModel.messages } + guard let peerID else { return context.messages } return getPrivateChatMessages(for: peerID) } func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { var combined: [BitchatMessage] = [] - if let ephemeralMessages = viewModel.privateChats[peerID] { + if let ephemeralMessages = context.privateChats[peerID] { combined.append(contentsOf: ephemeralMessages) } - if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + if let peer = context.unifiedPeer(for: peerID) { let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - if noiseKeyHex != peerID, let stableMessages = viewModel.privateChats[noiseKeyHex] { + if noiseKeyHex != peerID, let stableMessages = context.privateChats[noiseKeyHex] { combined.append(contentsOf: stableMessages) } } @@ -175,12 +266,12 @@ final class ChatLifecycleCoordinator { private extension ChatLifecycleCoordinator { func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) { - guard let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) else { return } + guard let peerNickname = context.peerNickname(for: peerID) else { return } - let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID) + let sessionState = context.noiseSessionState(for: peerID) switch sessionState { case .established: - viewModel.messageRouter.sendPrivate( + context.routePrivateMessage( message, to: peerID, recipientNickname: peerNickname, @@ -203,30 +294,30 @@ private extension ChatLifecycleCoordinator { isRelay: false, originalSender: nil, isPrivate: true, - recipientNickname: viewModel.meshService.peerNickname(peerID: peerID), - senderPeerID: viewModel.meshService.myPeerID + recipientNickname: context.peerNickname(for: peerID), + senderPeerID: context.myPeerID ) - var chats = viewModel.privateChats + var chats = context.privateChats if chats[peerID] == nil { chats[peerID] = [] } chats[peerID]?.append(notice) - viewModel.privateChats = chats + context.privateChats = chats } func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) { - Task { @MainActor [weak viewModel] in - guard let viewModel else { return } + Task { @MainActor [weak context = self.context] in + guard let context else { return } do { - let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) + let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) let event = try NostrProtocol.createEphemeralGeohashEvent( content: message, geohash: channel.geohash, senderIdentity: identity, - nickname: viewModel.nickname, - teleported: viewModel.locationManager.teleported + nickname: context.nickname, + teleported: context.isTeleported ) let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) @@ -236,10 +327,10 @@ private extension ChatLifecycleCoordinator { NostrRelayManager.shared.sendEvent(event, to: targetRelays) } - viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) + context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) } catch { SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session) - viewModel.addSystemMessage( + context.addSystemMessage( String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") ) } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index 2ddd574f..fbabf1fb 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -6,26 +6,90 @@ import Foundation import UIKit #endif +/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatMediaTransferCoordinatorContextTests`) and makes its +/// true dependencies explicit. +@MainActor +protocol ChatMediaTransferContext: AnyObject { + // MARK: Composition state + var canSendMediaInCurrentContext: Bool { get } + var selectedPrivateChatPeer: PeerID? { get } + var nickname: String { get } + var myPeerID: PeerID { get } + var activeChannel: ChannelID { get } + func nicknameForPeer(_ peerID: PeerID) -> String + func currentPublicSender() -> (name: String, peerID: PeerID) + + // MARK: Message state + var privateChats: [PeerID: [BitchatMessage]] { get set } + func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) + func refreshVisibleMessages(from channel: ChannelID?) + func trimMessagesIfNeeded() + func removeMessage(withID messageID: String, cleanupFile: Bool) + func addSystemMessage(_ content: String) + /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). + func notifyUIChanged() + + // MARK: Delivery status & dedup + func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) + func normalizedContentKey(_ content: String) -> String + func recordContentKey(_ key: String, timestamp: Date) + + // MARK: Mesh file transfer + func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) + func cancelTransfer(_ transferId: String) +} + +extension ChatViewModel: ChatMediaTransferContext { + // `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`, + // `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`, + // `currentPublicSender()`, `privateChats`, + // `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`, + // `trimMessagesIfNeeded()`, `removeMessage(withID:cleanupFile:)`, + // `addSystemMessage(_:)`, `notifyUIChanged()`, + // `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`, + // and `recordContentKey(_:timestamp:)` are shared requirements with the + // other contexts or satisfied by existing `ChatViewModel` members. The + // members below flatten mesh service accesses. + + func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { + meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) + } + + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { + meshService.sendFileBroadcast(packet, transferId: transferId) + } + + func cancelTransfer(_ transferId: String) { + meshService.cancelTransfer(transferId) + } +} + @MainActor final class ChatMediaTransferCoordinator { - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatMediaTransferContext private(set) var transferIdToMessageIDs: [String: [String]] = [:] private(set) var messageIDToTransferId: [String: String] = [:] - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatMediaTransferContext) { + self.context = context } func sendVoiceNote(at url: URL) { - guard viewModel.canSendMediaInCurrentContext else { + guard context.canSendMediaInCurrentContext else { SecureLogger.info("Voice note blocked outside mesh/private context", category: .session) try? FileManager.default.removeItem(at: url) - viewModel.addSystemMessage("Voice notes are only available in mesh chats.") + context.addSystemMessage("Voice notes are only available in mesh chats.") return } - let targetPeer = viewModel.selectedPrivateChatPeer + let targetPeer = context.selectedPrivateChatPeer let message = enqueueMediaMessage( content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer @@ -41,9 +105,9 @@ final class ChatMediaTransferCoordinator { guard let self else { return } self.registerTransfer(transferId: transferId, messageID: messageID) if let peerID = targetPeer { - self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) + self.context.sendFilePrivate(packet, to: peerID, transferId: transferId) } else { - self.viewModel.meshService.sendFileBroadcast(packet, transferId: transferId) + self.context.sendFileBroadcast(packet, transferId: transferId) } } } catch ChatMediaPreparationError.voiceNoteTooLarge(let size) { @@ -96,20 +160,20 @@ final class ChatMediaTransferCoordinator { #endif func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) { - guard viewModel.canSendMediaInCurrentContext else { + guard context.canSendMediaInCurrentContext else { SecureLogger.info("Image send blocked outside mesh/private context", category: .session) cleanup?() - viewModel.addSystemMessage("Images are only available in mesh chats.") + context.addSystemMessage("Images are only available in mesh chats.") return } - let targetPeer = viewModel.selectedPrivateChatPeer + let targetPeer = context.selectedPrivateChatPeer do { try ImageUtils.validateImageSource(at: sourceURL) } catch { SecureLogger.error("Image send preparation failed: \(error)", category: .session) - viewModel.addSystemMessage("Failed to prepare image for sending.") + context.addSystemMessage("Failed to prepare image for sending.") return } @@ -127,22 +191,22 @@ final class ChatMediaTransferCoordinator { let transferId = self.makeTransferID(messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID) if let peerID = targetPeer { - self.viewModel.meshService.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId) + self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId) } else { - self.viewModel.meshService.sendFileBroadcast(prepared.packet, transferId: transferId) + self.context.sendFileBroadcast(prepared.packet, transferId: transferId) } } } catch ChatMediaPreparationError.imageTooLarge(let size) { SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session) await MainActor.run { [weak self] in guard let self else { return } - self.viewModel.addSystemMessage("Image is too large to send.") + self.context.addSystemMessage("Image is too large to send.") } } catch { SecureLogger.error("Image send preparation failed: \(error)", category: .session) await MainActor.run { [weak self] in guard let self else { return } - self.viewModel.addSystemMessage("Failed to prepare image for sending.") + self.context.addSystemMessage("Failed to prepare image for sending.") } } } @@ -154,22 +218,22 @@ final class ChatMediaTransferCoordinator { if let peerID = targetPeer { message = BitchatMessage( - sender: viewModel.nickname, + sender: context.nickname, content: content, timestamp: timestamp, isRelay: false, originalSender: nil, isPrivate: true, - recipientNickname: viewModel.nicknameForPeer(peerID), - senderPeerID: viewModel.meshService.myPeerID, + recipientNickname: context.nicknameForPeer(peerID), + senderPeerID: context.myPeerID, deliveryStatus: .sending ) - var chats = viewModel.privateChats + var chats = context.privateChats chats[peerID, default: []].append(message) - viewModel.privateChats = chats - viewModel.trimMessagesIfNeeded() + context.privateChats = chats + context.trimMessagesIfNeeded() } else { - let (displayName, senderPeerID) = viewModel.currentPublicSender() + let (displayName, senderPeerID) = context.currentPublicSender() message = BitchatMessage( sender: displayName, content: content, @@ -181,14 +245,14 @@ final class ChatMediaTransferCoordinator { senderPeerID: senderPeerID, deliveryStatus: .sending ) - viewModel.timelineStore.append(message, to: viewModel.activeChannel) - viewModel.refreshVisibleMessages(from: viewModel.activeChannel) - viewModel.trimMessagesIfNeeded() + context.appendTimelineMessage(message, to: context.activeChannel) + context.refreshVisibleMessages(from: context.activeChannel) + context.trimMessagesIfNeeded() } - let key = viewModel.deduplicationService.normalizedContentKey(message.content) - viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp) - viewModel.objectWillChange.send() + let key = context.normalizedContentKey(message.content) + context.recordContentKey(key, timestamp: timestamp) + context.notifyUIChanged() return message } @@ -217,7 +281,7 @@ final class ChatMediaTransferCoordinator { } func handleMediaSendFailure(messageID: String, reason: String) { - viewModel.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason)) + context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason)) clearTransferMapping(for: messageID) } @@ -225,18 +289,18 @@ final class ChatMediaTransferCoordinator { switch event { case .started(let id, let total): guard let messageID = transferIdToMessageIDs[id]?.first else { return } - viewModel.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total)) + context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total)) case .updated(let id, let sent, let total): guard let messageID = transferIdToMessageIDs[id]?.first else { return } - viewModel.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total)) + context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total)) case .completed(let id, _): guard let messageID = transferIdToMessageIDs[id]?.first else { return } - viewModel.updateMessageDeliveryStatus(messageID, status: .sent) + context.updateMessageDeliveryStatus(messageID, status: .sent) clearTransferMapping(for: messageID) case .cancelled(let id, _, _): guard let messageID = transferIdToMessageIDs[id]?.first else { return } clearTransferMapping(for: messageID) - viewModel.removeMessage(withID: messageID, cleanupFile: true) + context.removeMessage(withID: messageID, cleanupFile: true) } } @@ -270,15 +334,15 @@ final class ChatMediaTransferCoordinator { if let transferId = messageIDToTransferId[messageID], let active = transferIdToMessageIDs[transferId]?.first, active == messageID { - viewModel.meshService.cancelTransfer(transferId) + context.cancelTransfer(transferId) } clearTransferMapping(for: messageID) - viewModel.removeMessage(withID: messageID, cleanupFile: true) + context.removeMessage(withID: messageID, cleanupFile: true) } func deleteMediaMessage(messageID: String) { clearTransferMapping(for: messageID) - viewModel.removeMessage(withID: messageID, cleanupFile: true) + context.removeMessage(withID: messageID, cleanupFile: true) } } diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 372c7956..320f7caf 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -3,24 +3,206 @@ import BitLogger import CoreBluetooth import Foundation -final class ChatPeerIdentityCoordinator { - private unowned let viewModel: ChatViewModel +/// The narrow surface `ChatPeerIdentityCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatPeerIdentityCoordinatorContextTests`) and makes its true +/// dependencies explicit. Several members are flattened service accesses — +/// this coordinator implements the `ChatViewModel`-level peer-identity API, so +/// its context members deliberately sit one level below those wrappers +/// (`unifiedIsBlocked(_:)` vs `isPeerBlocked(_:)`, `unifiedFingerprint(for:)` +/// vs `getFingerprint(for:)`, …) to avoid call cycles. +@MainActor +protocol ChatPeerIdentityContext: AnyObject { + // MARK: Conversation state + var privateChats: [PeerID: [BitchatMessage]] { get set } + var unreadPrivateMessages: Set { get set } + var selectedPrivateChatPeer: PeerID? { get set } + var selectedPrivateChatFingerprint: String? { get set } + var nickname: String { get } + var myPeerID: PeerID { get } + var activeChannel: ChannelID { get } + /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). + func notifyUIChanged() + func addSystemMessage(_ content: String) - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + // MARK: Private chat session lifecycle + /// Merges messages stored under alternate peer-ID representations into `peerID`'s chat. + /// Returns `true` when unread messages were discovered during consolidation. + @discardableResult + func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool + /// Marks read receipts as sent for own messages already delivered/read in + /// `peerID`'s chat. (Single mutation path into the owner's + /// `sentReadReceipts`; this coordinator never touches the raw set.) + func syncReadReceiptsForSentMessages(for peerID: PeerID) + /// Re-targets the private chat session in the chat manager (no store-sync side effects). + func beginPrivateChatSession(with peerID: PeerID) + func synchronizePrivateConversationStore() + func synchronizeConversationSelectionStore() + func markPrivateMessagesAsRead(from peerID: PeerID) + + // MARK: Unified peer service + var connectedPeers: Set { get } + /// The peer's current entry in the unified peer service, if known. + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? + func unifiedIsBlocked(_ peerID: PeerID) -> Bool + func unifiedToggleFavorite(_ peerID: PeerID) + func unifiedFingerprint(for peerID: PeerID) -> String? + func unifiedPeerID(forNickname nickname: String) -> PeerID? + /// Resolves the ephemeral (short) peer ID for a known Noise public key, if connected. + func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? + + // MARK: Mesh & Noise sessions + func peerNickname(for peerID: PeerID) -> String? + func meshPeerNicknames() -> [PeerID: String] + func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState + func triggerHandshake(with peerID: PeerID) + func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool + func hasNoiseSession(with peerID: PeerID) -> Bool + /// Our own Noise identity fingerprint. + func noiseIdentityFingerprint() -> String + + // MARK: Identity store (fingerprints & encryption status) + func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) + /// Moves the stored fingerprint mapping from `oldPeerID` to `newPeerID`, + /// falling back to `fallback` when none was stored. Returns the migrated fingerprint. + func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? + func isVerifiedFingerprint(_ fingerprint: String) -> Bool + func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) + func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? + func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) + func invalidateStoredEncryptionCache(for peerID: PeerID?) + func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? + + // MARK: Geohash & Nostr + var geoNicknames: [String: String] { get } + func visibleGeohashPeople() -> [GeoPerson] + /// Records the Nostr pubkey behind a (possibly virtual) peer ID. + func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) + func bridgedNostrPublicKey(for noiseKey: Data) -> String? + func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) +} + +extension ChatViewModel: ChatPeerIdentityContext { + // `privateChats`, `unreadPrivateMessages`, `selectedPrivateChatPeer`, + // `selectedPrivateChatFingerprint`, `nickname`, `myPeerID`, + // `activeChannel`, `connectedPeers`, `geoNicknames`, `notifyUIChanged()`, + // `addSystemMessage(_:)`, `peerNickname(for:)`, `meshPeerNicknames()`, + // `ephemeralPeerID(forNoiseKey:)`, `unifiedPeer(for:)`, + // `registerNostrKeyMapping(_:for:)`, `visibleGeohashPeople()`, + // `markPrivateMessagesAsRead(from:)`, `sendFavoriteNotificationViaNostr`, + // and the conversation-store sync methods are shared requirements with + // the other contexts or satisfied by existing `ChatViewModel` members. + // The single-writer intent op `syncReadReceiptsForSentMessages(for:)` + // lives next to its backing state in `ChatViewModel`. The members below + // flatten nested service accesses into intent-named calls. + + @discardableResult + func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool { + privateChatManager.consolidateMessages( + for: peerID, + peerNickname: peerNickname, + persistedReadReceipts: sentReadReceipts + ) + } + + func beginPrivateChatSession(with peerID: PeerID) { + privateChatManager.startChat(with: peerID) + } + + func unifiedIsBlocked(_ peerID: PeerID) -> Bool { + unifiedPeerService.isBlocked(peerID) + } + + func unifiedToggleFavorite(_ peerID: PeerID) { + unifiedPeerService.toggleFavorite(peerID) + } + + func unifiedFingerprint(for peerID: PeerID) -> String? { + unifiedPeerService.getFingerprint(for: peerID) + } + + func unifiedPeerID(forNickname nickname: String) -> PeerID? { + unifiedPeerService.getPeerID(for: nickname) + } + + func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState { + meshService.getNoiseSessionState(for: peerID) + } + + func triggerHandshake(with peerID: PeerID) { + meshService.triggerHandshake(with: peerID) + } + + func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { + meshService.getNoiseService().hasEstablishedSession(with: peerID) + } + + func hasNoiseSession(with peerID: PeerID) -> Bool { + meshService.getNoiseService().hasSession(with: peerID) + } + + func noiseIdentityFingerprint() -> String { + meshService.getNoiseService().getIdentityFingerprint() + } + + func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) { + peerIdentityStore.setFingerprint(fingerprint, for: peerID) + } + + func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? { + peerIdentityStore.migrateFingerprintMapping(from: oldPeerID, to: newPeerID, fallback: fallback) + } + + func isVerifiedFingerprint(_ fingerprint: String) -> Bool { + peerIdentityStore.isVerified(fingerprint) + } + + func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { + peerIdentityStore.setEncryptionStatus(status, for: peerID) + } + + func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? { + peerIdentityStore.cachedEncryptionStatus(for: peerID) + } + + func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) { + peerIdentityStore.setCachedEncryptionStatus(status, for: peerID) + } + + func invalidateStoredEncryptionCache(for peerID: PeerID?) { + peerIdentityStore.invalidateEncryptionCache(for: peerID) + } + + func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? { + identityManager.getSocialIdentity(for: fingerprint) + } + + func bridgedNostrPublicKey(for noiseKey: Data) -> String? { + idBridge.getNostrPublicKey(for: noiseKey) + } +} + +final class ChatPeerIdentityCoordinator { + private unowned let context: any ChatPeerIdentityContext + + init(context: any ChatPeerIdentityContext) { + self.context = context } @MainActor func openMostRelevantPrivateChat() { - let unreadSorted = viewModel.unreadPrivateMessages - .map { ($0, viewModel.privateChats[$0]?.last?.timestamp ?? Date.distantPast) } + let unreadSorted = context.unreadPrivateMessages + .map { ($0, context.privateChats[$0]?.last?.timestamp ?? Date.distantPast) } .sorted { $0.1 > $1.1 } if let target = unreadSorted.first?.0 { startPrivateChat(with: target) return } - let recent = viewModel.privateChats + let recent = context.privateChats .map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) } .sorted { $0.ts > $1.ts } if let target = recent.first?.id { @@ -30,7 +212,7 @@ final class ChatPeerIdentityCoordinator { @MainActor func isPeerBlocked(_ peerID: PeerID) -> Bool { - viewModel.unifiedPeerService.isBlocked(peerID) + context.unifiedIsBlocked(peerID) } @MainActor @@ -38,24 +220,24 @@ final class ChatPeerIdentityCoordinator { var noiseKeyPeerID: PeerID? var nostrPeerID: PeerID? - if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + if let peer = context.unifiedPeer(for: peerID) { noiseKeyPeerID = PeerID(hexData: peer.noisePublicKey) if let nostrHex = peer.nostrPublicKey { nostrPeerID = PeerID(nostr_: nostrHex) } } - let context = ChatUnreadPeerContext( + let unreadContext = ChatUnreadPeerContext( peerID: peerID, noiseKeyPeerID: noiseKeyPeerID, nostrPeerID: nostrPeerID, - nickname: viewModel.meshService.peerNickname(peerID: peerID) + nickname: context.peerNickname(for: peerID) ) return ChatUnreadStateResolver.hasUnreadMessages( - for: context, - unreadPrivateMessages: viewModel.unreadPrivateMessages, - privateChats: viewModel.privateChats + for: unreadContext, + unreadPrivateMessages: context.unreadPrivateMessages, + privateChats: context.privateChats ) } @@ -66,8 +248,8 @@ final class ChatPeerIdentityCoordinator { return } - viewModel.unifiedPeerService.toggleFavorite(peerID) - viewModel.objectWillChange.send() + context.unifiedToggleFavorite(peerID) + context.notifyUIChanged() } @MainActor @@ -76,36 +258,36 @@ final class ChatPeerIdentityCoordinator { return FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)?.isFavorite ?? false } - return viewModel.unifiedPeerService.getPeer(by: peerID)?.isFavorite ?? false + return context.unifiedPeer(for: peerID)?.isFavorite ?? false } @MainActor func updatePrivateChatPeerIfNeeded() { - guard let chatFingerprint = viewModel.selectedPrivateChatFingerprint, + guard let chatFingerprint = context.selectedPrivateChatFingerprint, let currentPeerID = currentPeerID(forFingerprint: chatFingerprint) else { return } - if let oldPeerID = viewModel.selectedPrivateChatPeer, oldPeerID != currentPeerID { + if let oldPeerID = context.selectedPrivateChatPeer, oldPeerID != currentPeerID { migrateChatState(from: oldPeerID, to: currentPeerID) - viewModel.selectedPrivateChatPeer = currentPeerID - } else if viewModel.selectedPrivateChatPeer == nil { - viewModel.selectedPrivateChatPeer = currentPeerID + context.selectedPrivateChatPeer = currentPeerID + } else if context.selectedPrivateChatPeer == nil { + context.selectedPrivateChatPeer = currentPeerID } - var unread = viewModel.unreadPrivateMessages + var unread = context.unreadPrivateMessages unread.remove(currentPeerID) - viewModel.unreadPrivateMessages = unread + context.unreadPrivateMessages = unread } @MainActor func startPrivateChat(with peerID: PeerID) { - guard peerID != viewModel.meshService.myPeerID else { return } + guard peerID != context.myPeerID else { return } - let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "unknown" + let peerNickname = context.peerNickname(for: peerID) ?? "unknown" - if viewModel.unifiedPeerService.isBlocked(peerID) { - viewModel.addSystemMessage( + if context.unifiedIsBlocked(peerID) { + context.addSystemMessage( String( format: String( localized: "system.chat.blocked", @@ -118,9 +300,9 @@ final class ChatPeerIdentityCoordinator { return } - if let peer = viewModel.unifiedPeerService.getPeer(by: peerID), + if let peer = context.unifiedPeer(for: peerID), peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { - viewModel.addSystemMessage( + context.addSystemMessage( String( format: String( localized: "system.chat.requires_favorite", @@ -133,16 +315,12 @@ final class ChatPeerIdentityCoordinator { return } - _ = viewModel.privateChatManager.consolidateMessages( - for: peerID, - peerNickname: peerNickname, - persistedReadReceipts: viewModel.sentReadReceipts - ) + _ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname) if !peerID.isGeoDM && !peerID.isGeoChat { - switch viewModel.meshService.getNoiseSessionState(for: peerID) { + switch context.noiseSessionState(for: peerID) { case .none, .failed: - viewModel.meshService.triggerHandshake(with: peerID) + context.triggerHandshake(with: peerID) case .handshakeQueued, .handshaking, .established: break } @@ -150,28 +328,24 @@ final class ChatPeerIdentityCoordinator { SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session) } - viewModel.privateChatManager.syncReadReceiptsForSentMessages( - peerID: peerID, - nickname: viewModel.nickname, - externalReceipts: &viewModel.sentReadReceipts - ) + context.syncReadReceiptsForSentMessages(for: peerID) if let fingerprint = getFingerprint(for: peerID) { - viewModel.peerIdentityStore.setFingerprint(fingerprint, for: peerID) - viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint) + context.setStoredFingerprint(fingerprint, for: peerID) + context.selectedPrivateChatFingerprint = fingerprint } else { - viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil) + context.selectedPrivateChatFingerprint = nil } - viewModel.privateChatManager.startChat(with: peerID) - viewModel.synchronizePrivateConversationStore() - viewModel.synchronizeConversationSelectionStore() - viewModel.markPrivateMessagesAsRead(from: peerID) + context.beginPrivateChatSession(with: peerID) + context.synchronizePrivateConversationStore() + context.synchronizeConversationSelectionStore() + context.markPrivateMessagesAsRead(from: peerID) } @MainActor func endPrivateChat() { - viewModel.selectedPrivateChatPeer = nil - viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil) + context.selectedPrivateChatPeer = nil + context.selectedPrivateChatFingerprint = nil } @MainActor @@ -182,8 +356,8 @@ final class ChatPeerIdentityCoordinator { func handleFavoriteStatusChanged(_ notification: Notification) { guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return } - Task { @MainActor [weak viewModel] in - guard let viewModel else { return } + Task { @MainActor [weak context = self.context] in + guard let context else { return } if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool, isKeyUpdate, @@ -200,28 +374,26 @@ final class ChatPeerIdentityCoordinator { let peerID = PeerID(hexData: peerPublicKey) let action = isFavorite ? "favorited" : "unfavorited" let peerNickname = favoriteNotificationNickname(for: peerID, peerPublicKey: peerPublicKey) - viewModel.addSystemMessage("\(peerNickname) \(action) you") + context.addSystemMessage("\(peerNickname) \(action) you") } } } @MainActor func updateEncryptionStatusForPeers() { - for peerID in viewModel.connectedPeers { + for peerID in context.connectedPeers { updateEncryptionStatus(for: peerID) } } @MainActor func updateEncryptionStatus(for peerID: PeerID) { - let noiseService = viewModel.meshService.getNoiseService() - - if noiseService.hasEstablishedSession(with: peerID) { - viewModel.peerIdentityStore.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID) - } else if noiseService.hasSession(with: peerID) { - viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID) + if context.hasEstablishedNoiseSession(with: peerID) { + context.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID) + } else if context.hasNoiseSession(with: peerID) { + context.setEncryptionStatus(.noiseHandshaking, for: peerID) } else { - viewModel.peerIdentityStore.setEncryptionStatus(nil, for: peerID) + context.setEncryptionStatus(nil, for: peerID) } invalidateEncryptionCache(for: peerID) @@ -229,12 +401,12 @@ final class ChatPeerIdentityCoordinator { @MainActor func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { - if let cachedStatus = viewModel.peerIdentityStore.cachedEncryptionStatus(for: peerID) { + if let cachedStatus = context.cachedEncryptionStatus(for: peerID) { return cachedStatus } let hasEverEstablishedSession = getFingerprint(for: peerID) != nil - let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID) + let sessionState = context.noiseSessionState(for: peerID) let status: EncryptionStatus switch sessionState { @@ -248,18 +420,18 @@ final class ChatPeerIdentityCoordinator { status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none } - viewModel.peerIdentityStore.setCachedEncryptionStatus(status, for: peerID) + context.setCachedEncryptionStatus(status, for: peerID) return status } @MainActor func invalidateEncryptionCache(for peerID: PeerID? = nil) { - viewModel.peerIdentityStore.invalidateEncryptionCache(for: peerID) + context.invalidateStoredEncryptionCache(for: peerID) } @MainActor func getFingerprint(for peerID: PeerID) -> String? { - viewModel.unifiedPeerService.getFingerprint(for: peerID) + context.unifiedFingerprint(for: peerID) } @MainActor @@ -270,12 +442,12 @@ final class ChatPeerIdentityCoordinator { return peerID.id } - if let nickname = viewModel.meshService.getPeerNicknames()[peerID] { + if let nickname = context.meshPeerNicknames()[peerID] { return nickname } if let fingerprint = getFingerprint(for: peerID), - let identity = viewModel.identityManager.getSocialIdentity(for: fingerprint) { + let identity = context.socialIdentity(forFingerprint: fingerprint) { if let petname = identity.localPetname { return petname } @@ -289,19 +461,18 @@ final class ChatPeerIdentityCoordinator { @MainActor func getMyFingerprint() -> String { - viewModel.meshService.getNoiseService().getIdentityFingerprint() + context.noiseIdentityFingerprint() } @MainActor func getPeerIDForNickname(_ nickname: String) -> PeerID? { - switch viewModel.activeChannel { + switch context.activeChannel { case .location: if nickname.contains("#"), - let person = viewModel.publicConversationCoordinator - .visibleGeohashPeople() + let person = context.visibleGeohashPeople() .first(where: { $0.displayName == nickname }) { let conversationKey = PeerID(nostr_: person.id) - viewModel.registerNostrKeyMapping(person.id, for: conversationKey) + context.registerNostrKeyMapping(person.id, for: conversationKey) return conversationKey } @@ -310,9 +481,9 @@ final class ChatPeerIdentityCoordinator { .first .map(String.init)? .lowercased() ?? nickname.lowercased() - if let pubkey = viewModel.geoNicknames.first(where: { $0.value.lowercased() == base })?.key { + if let pubkey = context.geoNicknames.first(where: { $0.value.lowercased() == base })?.key { let conversationKey = PeerID(nostr_: pubkey) - viewModel.registerNostrKeyMapping(pubkey, for: conversationKey) + context.registerNostrKeyMapping(pubkey, for: conversationKey) return conversationKey } @@ -320,12 +491,12 @@ final class ChatPeerIdentityCoordinator { break } - return viewModel.unifiedPeerService.getPeerID(for: nickname) + return context.unifiedPeerID(forNickname: nickname) } @MainActor func nicknameForPeer(_ peerID: PeerID) -> String { - if let name = viewModel.meshService.peerNickname(peerID: peerID) { + if let name = context.peerNickname(for: peerID) { return name } if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), @@ -344,7 +515,7 @@ final class ChatPeerIdentityCoordinator { private extension ChatPeerIdentityCoordinator { @MainActor func currentPeerID(forFingerprint fingerprint: String) -> PeerID? { - for peerID in viewModel.connectedPeers where getFingerprint(for: peerID) == fingerprint { + for peerID in context.connectedPeers where getFingerprint(for: peerID) == fingerprint { return peerID } return nil @@ -352,8 +523,8 @@ private extension ChatPeerIdentityCoordinator { @MainActor func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) { - if let oldMessages = viewModel.privateChats[oldPeerID] { - var chats = viewModel.privateChats + if let oldMessages = context.privateChats[oldPeerID] { + var chats = context.privateChats chats[newPeerID, default: []].append(contentsOf: oldMessages) chats[newPeerID]?.sort { $0.timestamp < $1.timestamp } @@ -367,45 +538,45 @@ private extension ChatPeerIdentityCoordinator { } chats.removeValue(forKey: oldPeerID) - viewModel.privateChats = chats + context.privateChats = chats } - var unread = viewModel.unreadPrivateMessages + var unread = context.unreadPrivateMessages if unread.contains(oldPeerID) { unread.remove(oldPeerID) unread.insert(newPeerID) - viewModel.unreadPrivateMessages = unread + context.unreadPrivateMessages = unread } } @MainActor func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) { - if viewModel.selectedPrivateChatPeer == oldPeerID { + if context.selectedPrivateChatPeer == oldPeerID { SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session) - } else if viewModel.privateChats[oldPeerID] != nil { + } else if context.privateChats[oldPeerID] != nil { SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session) } migrateChatState(from: oldPeerID, to: newPeerID) - if viewModel.selectedPrivateChatPeer == oldPeerID { - viewModel.selectedPrivateChatPeer = newPeerID + if context.selectedPrivateChatPeer == oldPeerID { + context.selectedPrivateChatPeer = newPeerID } - if let fingerprint = viewModel.peerIdentityStore.migrateFingerprintMapping( + if let fingerprint = context.migrateFingerprintMapping( from: oldPeerID, to: newPeerID, fallback: getFingerprint(for: newPeerID) ) { - if viewModel.selectedPrivateChatPeer == newPeerID { - viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint) + if context.selectedPrivateChatPeer == newPeerID { + context.selectedPrivateChatFingerprint = fingerprint } } } @MainActor func favoriteNotificationNickname(for peerID: PeerID, peerPublicKey: Data) -> String { - if let nickname = viewModel.meshService.peerNickname(peerID: peerID) { + if let nickname = context.peerNickname(for: peerID) { return nickname } if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) { @@ -417,7 +588,7 @@ private extension ChatPeerIdentityCoordinator { @MainActor func verifiedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { if let fingerprint = getFingerprint(for: peerID), - viewModel.peerIdentityStore.isVerified(fingerprint) { + context.isVerifiedFingerprint(fingerprint) { return .noiseVerified } return .noiseSecured @@ -425,18 +596,18 @@ private extension ChatPeerIdentityCoordinator { @MainActor func toggleFavoriteForNoiseKey(_ noisePublicKey: Data, peerID: PeerID) { - if let ephemeralID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == noisePublicKey })?.peerID { - viewModel.unifiedPeerService.toggleFavorite(ephemeralID) - viewModel.objectWillChange.send() + if let ephemeralID = context.ephemeralPeerID(forNoiseKey: noisePublicKey) { + context.unifiedToggleFavorite(ephemeralID) + context.notifyUIChanged() return } let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) - let fallbackNickname = viewModel.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender + let fallbackNickname = context.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender let plan = ChatFavoriteTogglePolicy.plan( currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init), fallbackNickname: fallbackNickname, - bridgedNostrKey: viewModel.idBridge.getNostrPublicKey(for: noisePublicKey) + bridgedNostrKey: context.bridgedNostrPublicKey(for: noisePublicKey) ) switch plan.persistenceAction { @@ -451,10 +622,10 @@ private extension ChatPeerIdentityCoordinator { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) } - viewModel.objectWillChange.send() + context.notifyUIChanged() if case .send(let isFavorite) = plan.notification { - viewModel.sendFavoriteNotificationViaNostr( + context.sendFavoriteNotificationViaNostr( noisePublicKey: noisePublicKey, isFavorite: isFavorite ) diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index a671f7b3..ee19a527 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -2,26 +2,139 @@ import BitFoundation import BitLogger import Foundation -final class ChatTransportEventCoordinator { - private unowned let viewModel: ChatViewModel +/// The narrow surface `ChatTransportEventCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatTransportEventCoordinatorContextTests`) and makes its +/// true dependencies explicit. +@MainActor +protocol ChatTransportEventContext: AnyObject { + // MARK: Connection & chat state + var isConnected: Bool { get set } + var nickname: String { get } + var myPeerID: PeerID { get } + var privateChats: [PeerID: [BitchatMessage]] { get set } + var unreadPrivateMessages: Set { get set } + var selectedPrivateChatPeer: PeerID? { get set } + /// Forgets that read receipts were sent for `ids` so READ acks can be + /// re-sent after the peer reconnects. (Single mutation path for the + /// owner's `sentReadReceipts`; this coordinator never reads the raw set.) + func unmarkReadReceiptsSent(_ ids: [String]) + /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). + func notifyUIChanged() - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + // MARK: Inbound message handling + func isMessageBlocked(_ message: BitchatMessage) -> Bool + func handlePrivateMessage(_ message: BitchatMessage) + func handlePublicMessage(_ message: BitchatMessage) + func checkForMentions(_ message: BitchatMessage) + func sendHapticFeedback(for message: BitchatMessage) + func parseMentions(from content: String) -> [String] + + // MARK: Peer identity & sessions + func isPeerBlocked(_ peerID: PeerID) -> Bool + /// The peer's current entry in the unified peer service, if known. + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? + func resolveNickname(for peerID: PeerID) -> String + func registerEphemeralSession(peerID: PeerID) + func removeEphemeralSession(peerID: PeerID) + /// Resolves the peer's Noise static key from the active Noise session, if any. + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? + func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) + func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? + + // MARK: Routing & acknowledgements + func flushRouterOutbox(for peerID: PeerID) + func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) + + // MARK: Delivery status + /// Applies the status to every known location of the message. + /// Returns `false` when no message with that ID was updated. + @discardableResult + func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool + func deliveryStatus(for messageID: String) -> DeliveryStatus? + + // MARK: Verification payloads + func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) + func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) +} + +extension ChatViewModel: ChatTransportEventContext { + // `isConnected`, `nickname`, `myPeerID`, `privateChats`, + // `unreadPrivateMessages`, `selectedPrivateChatPeer`, `notifyUIChanged()`, + // the inbound message handlers, `isPeerBlocked(_:)`, + // `parseMentions(from:)`, `resolveNickname(for:)`, + // `cacheStablePeerID(_:for:)`, and `cachedStablePeerID(for:)` are shared + // requirements with the other contexts or satisfied by existing + // `ChatViewModel` members. The single-writer intent op + // `unmarkReadReceiptsSent(_:)` lives next to its backing state in + // `ChatViewModel`. The members below flatten nested service accesses into + // intent-named calls. + + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { + unifiedPeerService.getPeer(by: peerID) + } + + func registerEphemeralSession(peerID: PeerID) { + identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) + } + + func removeEphemeralSession(peerID: PeerID) { + identityManager.removeEphemeralSession(peerID: peerID) + } + + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { + meshService.getNoiseService().getPeerPublicKeyData(peerID) + } + + func flushRouterOutbox(for peerID: PeerID) { + messageRouter.flushOutbox(for: peerID) + } + + func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) { + meshService.sendDeliveryAck(for: messageID, to: peerID) + } + + @discardableResult + func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { + deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: status) + } + + func deliveryStatus(for messageID: String) -> DeliveryStatus? { + deliveryCoordinator.deliveryStatus(for: messageID) + } + + func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) { + verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload) + } + + func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { + verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) + } +} + +final class ChatTransportEventCoordinator { + private unowned let context: any ChatTransportEventContext + + init(context: any ChatTransportEventContext) { + self.context = context } func didReceiveMessage(_ message: BitchatMessage) { - runOnMain { viewModel in - guard !viewModel.isMessageBlocked(message) else { return } + runOnMain { context in + guard !context.isMessageBlocked(message) else { return } guard !message.content.trimmed.isEmpty || message.isPrivate else { return } if message.isPrivate { - viewModel.handlePrivateMessage(message) + context.handlePrivateMessage(message) } else { - viewModel.handlePublicMessage(message) + context.handlePublicMessage(message) } - viewModel.checkForMentions(message) - viewModel.sendHapticFeedback(for: message) + context.checkForMentions(message) + context.sendHapticFeedback(for: message) } } @@ -32,9 +145,9 @@ final class ChatTransportEventCoordinator { timestamp: Date, messageID: String? ) { - runOnMain { viewModel in + runOnMain { context in let normalized = content.trimmed - let mentions = viewModel.parseMentions(from: normalized) + let mentions = context.parseMentions(from: normalized) let message = BitchatMessage( id: messageID, sender: nickname, @@ -48,9 +161,9 @@ final class ChatTransportEventCoordinator { mentions: mentions.isEmpty ? nil : mentions ) - viewModel.handlePublicMessage(message) - viewModel.checkForMentions(message) - viewModel.sendHapticFeedback(for: message) + context.handlePublicMessage(message) + context.checkForMentions(message) + context.sendHapticFeedback(for: message) } } @@ -60,13 +173,13 @@ final class ChatTransportEventCoordinator { payload: Data, timestamp: Date ) { - runOnMain { [self] viewModel in + runOnMain { [self] context in handleNoisePayload( from: peerID, type: type, payload: payload, timestamp: timestamp, - in: viewModel + in: context ) } } @@ -74,60 +187,61 @@ final class ChatTransportEventCoordinator { func didConnectToPeer(_ peerID: PeerID) { SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session) - runOnMain { viewModel in - viewModel.isConnected = true - viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) - viewModel.objectWillChange.send() + runOnMain { context in + context.isConnected = true + context.registerEphemeralSession(peerID: peerID) + context.notifyUIChanged() - if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + if let peer = context.unifiedPeer(for: peerID) { let stablePeerID = PeerID(hexData: peer.noisePublicKey) - viewModel.cacheStablePeerID(stablePeerID, for: peerID) + context.cacheStablePeerID(stablePeerID, for: peerID) } - viewModel.messageRouter.flushOutbox(for: peerID) + context.flushRouterOutbox(for: peerID) } } func didDisconnectFromPeer(_ peerID: PeerID) { SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session) - runOnMain { viewModel in - viewModel.identityManager.removeEphemeralSession(peerID: peerID) + runOnMain { context in + context.removeEphemeralSession(peerID: peerID) - var stablePeerID = viewModel.cachedStablePeerID(for: peerID) + var stablePeerID = context.cachedStablePeerID(for: peerID) if stablePeerID == nil, - let key = viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) { + let key = context.noiseSessionPublicKeyData(for: peerID) { let derivedPeerID = PeerID(hexData: key) - viewModel.cacheStablePeerID(derivedPeerID, for: peerID) + context.cacheStablePeerID(derivedPeerID, for: peerID) stablePeerID = derivedPeerID } - if let currentPeerID = viewModel.selectedPrivateChatPeer, + if let currentPeerID = context.selectedPrivateChatPeer, currentPeerID == peerID, let stablePeerID { self.migrateSelectedConversationIfNeeded( from: peerID, to: stablePeerID, - in: viewModel + in: context ) } - if let messages = viewModel.privateChats[peerID] { - for message in messages where message.senderPeerID == peerID { - viewModel.sentReadReceipts.remove(message.id) - } + if let messages = context.privateChats[peerID] { + let receiptIDs = messages + .filter { $0.senderPeerID == peerID } + .map(\.id) + context.unmarkReadReceiptsSent(receiptIDs) } - viewModel.objectWillChange.send() + context.notifyUIChanged() } } } private extension ChatTransportEventCoordinator { - func runOnMain(_ action: @escaping @MainActor (ChatViewModel) -> Void) { - Task { @MainActor [weak viewModel = self.viewModel] in - guard let viewModel else { return } - action(viewModel) + func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) { + Task { @MainActor [weak context = self.context] in + guard let context else { return } + action(context) } } @@ -135,14 +249,14 @@ private extension ChatTransportEventCoordinator { func migrateSelectedConversationIfNeeded( from shortPeerID: PeerID, to stablePeerID: PeerID, - in viewModel: ChatViewModel + in context: any ChatTransportEventContext ) { - if let messages = viewModel.privateChats[shortPeerID] { - if viewModel.privateChats[stablePeerID] == nil { - viewModel.privateChats[stablePeerID] = [] + if let messages = context.privateChats[shortPeerID] { + if context.privateChats[stablePeerID] == nil { + context.privateChats[stablePeerID] = [] } - let existingIDs = Set(viewModel.privateChats[stablePeerID]?.map(\.id) ?? []) + let existingIDs = Set(context.privateChats[stablePeerID]?.map(\.id) ?? []) for message in messages where !existingIDs.contains(message.id) { let migrated = BitchatMessage( id: message.id, @@ -153,25 +267,25 @@ private extension ChatTransportEventCoordinator { originalSender: message.originalSender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID == viewModel.meshService.myPeerID - ? viewModel.meshService.myPeerID + senderPeerID: message.senderPeerID == context.myPeerID + ? context.myPeerID : stablePeerID, mentions: message.mentions, deliveryStatus: message.deliveryStatus ) - viewModel.privateChats[stablePeerID]?.append(migrated) + context.privateChats[stablePeerID]?.append(migrated) } - viewModel.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp } - viewModel.privateChats.removeValue(forKey: shortPeerID) + context.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp } + context.privateChats.removeValue(forKey: shortPeerID) } - if viewModel.unreadPrivateMessages.contains(shortPeerID) { - viewModel.unreadPrivateMessages.remove(shortPeerID) - viewModel.unreadPrivateMessages.insert(stablePeerID) + if context.unreadPrivateMessages.contains(shortPeerID) { + context.unreadPrivateMessages.remove(shortPeerID) + context.unreadPrivateMessages.insert(stablePeerID) } - viewModel.selectedPrivateChatPeer = stablePeerID + context.selectedPrivateChatPeer = stablePeerID } @MainActor @@ -180,19 +294,19 @@ private extension ChatTransportEventCoordinator { type: NoisePayloadType, payload: Data, timestamp: Date, - in viewModel: ChatViewModel + in context: any ChatTransportEventContext ) { switch type { case .privateMessage: guard let packet = PrivateMessagePacket.decode(from: payload) else { return } - guard !viewModel.isPeerBlocked(peerID) else { + guard !context.isPeerBlocked(peerID) else { SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security) return } - let senderName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown" - let mentions = viewModel.parseMentions(from: packet.content) + let senderName = context.unifiedPeer(for: peerID)?.nickname ?? "Unknown" + let mentions = context.parseMentions(from: packet.content) let message = BitchatMessage( id: packet.messageID, sender: senderName, @@ -201,24 +315,24 @@ private extension ChatTransportEventCoordinator { isRelay: false, originalSender: nil, isPrivate: true, - recipientNickname: viewModel.nickname, + recipientNickname: context.nickname, senderPeerID: peerID, mentions: mentions.isEmpty ? nil : mentions ) - viewModel.handlePrivateMessage(message) - viewModel.meshService.sendDeliveryAck(for: packet.messageID, to: peerID) + context.handlePrivateMessage(message) + context.sendMeshDeliveryAck(for: packet.messageID, to: peerID) case .delivered: guard let messageID = String(data: payload, encoding: .utf8) else { return } - let name = deliveryStatusName(for: peerID, in: viewModel) - let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus( + let name = deliveryStatusName(for: peerID, in: context) + let didUpdate = context.applyMessageDeliveryStatus( messageID, status: .delivered(to: name, at: Date()) ) if !didUpdate { - if case .read? = viewModel.deliveryCoordinator.deliveryStatus(for: messageID) { + if case .read? = context.deliveryStatus(for: messageID) { SecureLogger.debug("📬 Ignored stale delivered ACK for already-read message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session) } else { SecureLogger.debug("📬 Delivered ACK for unknown message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))…", category: .session) @@ -228,8 +342,8 @@ private extension ChatTransportEventCoordinator { case .readReceipt: guard let messageID = String(data: payload, encoding: .utf8) else { return } - let name = deliveryStatusName(for: peerID, in: viewModel) - let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus( + let name = deliveryStatusName(for: peerID, in: context) + let didUpdate = context.applyMessageDeliveryStatus( messageID, status: .read(by: name, at: Date()) ) @@ -239,15 +353,15 @@ private extension ChatTransportEventCoordinator { } case .verifyChallenge: - viewModel.verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload) + context.handleVerifyChallengePayload(from: peerID, payload: payload) case .verifyResponse: - viewModel.verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) + context.handleVerifyResponsePayload(from: peerID, payload: payload) } } @MainActor - func deliveryStatusName(for peerID: PeerID, in viewModel: ChatViewModel) -> String { - viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? viewModel.resolveNickname(for: peerID) + func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String { + context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID) } } diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index 8ef7f3d1..b61aec2f 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -3,6 +3,115 @@ import BitLogger import Foundation import Security +/// The narrow surface `ChatVerificationCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatVerificationCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatVerificationContext: AnyObject { + // MARK: Fingerprints & verification state + func getFingerprint(for peerID: PeerID) -> String? + /// The UI-facing verified-fingerprint set (peer identity store backed). + var verifiedFingerprints: Set { get set } + /// The persisted verified-fingerprint set from the identity manager. + func persistedVerifiedFingerprints() -> Set + /// Persists the verified flag in the identity manager. + func setIdentityVerified(fingerprint: String, verified: Bool) + /// Updates the UI-facing verified flag in the peer identity store. + func setStoredVerified(_ fingerprint: String, verified: Bool) + func isVerifiedFingerprint(_ fingerprint: String) -> Bool + func saveIdentityState() + + // MARK: Encryption status + func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) + func updateEncryptionStatus(for peerID: PeerID) + func invalidateEncryptionCache(for peerID: PeerID?) + /// Signals that verification state changed so observers refresh (e.g. `objectWillChange.send()`). + func notifyUIChanged() + + // MARK: Peers + var unifiedPeers: [BitchatPeer] { get } + var unifiedFavorites: [BitchatPeer] { get } + /// The peer's current entry in the unified peer service, if known. + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? + func unifiedFingerprint(for peerID: PeerID) -> String? + func resolveNickname(for peerID: PeerID) -> String + func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? + func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) + + // MARK: Noise sessions & verification transport + /// Installs the Noise service's session callbacks (single registration point). + func installNoiseSessionCallbacks( + onPeerAuthenticated: @escaping (PeerID, String) -> Void, + onHandshakeRequired: @escaping (PeerID) -> Void + ) + /// Resolves the peer's Noise static key from the active Noise session, if any. + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? + /// Our own Noise static public key. + func noiseStaticPublicKeyData() -> Data + func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool + func triggerHandshake(with peerID: PeerID) + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) +} + +extension ChatViewModel: ChatVerificationContext { + // `getFingerprint(for:)`, `verifiedFingerprints`, `saveIdentityState()`, + // `updateEncryptionStatus(for:)`, `invalidateEncryptionCache(for:)`, + // `notifyUIChanged()`, `unifiedPeer(for:)`, `unifiedFingerprint(for:)`, + // `isVerifiedFingerprint(_:)`, `setEncryptionStatus(_:for:)`, + // `resolveNickname(for:)`, `cachedStablePeerID(for:)`, + // `cacheStablePeerID(_:for:)`, `noiseSessionPublicKeyData(for:)`, + // `hasEstablishedNoiseSession(with:)`, and `triggerHandshake(with:)` are + // shared requirements with the other contexts or satisfied by existing + // `ChatViewModel` members. The members below flatten nested service + // accesses into intent-named calls. + + func persistedVerifiedFingerprints() -> Set { + identityManager.getVerifiedFingerprints() + } + + func setIdentityVerified(fingerprint: String, verified: Bool) { + identityManager.setVerified(fingerprint: fingerprint, verified: verified) + } + + func setStoredVerified(_ fingerprint: String, verified: Bool) { + peerIdentityStore.setVerified(fingerprint, verified: verified) + } + + var unifiedPeers: [BitchatPeer] { + unifiedPeerService.peers + } + + var unifiedFavorites: [BitchatPeer] { + unifiedPeerService.favorites + } + + func installNoiseSessionCallbacks( + onPeerAuthenticated: @escaping (PeerID, String) -> Void, + onHandshakeRequired: @escaping (PeerID) -> Void + ) { + let noiseService = meshService.getNoiseService() + noiseService.onPeerAuthenticated = onPeerAuthenticated + noiseService.onHandshakeRequired = onHandshakeRequired + } + + func noiseStaticPublicKeyData() -> Data { + meshService.getNoiseService().getStaticPublicKeyData() + } + + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { + meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) + } + + func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { + meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) + } +} + @MainActor final class ChatVerificationCoordinator { struct PendingVerification { @@ -13,44 +122,44 @@ final class ChatVerificationCoordinator { var sent: Bool } - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatVerificationContext private var pendingQRVerifications: [PeerID: PendingVerification] = [:] private var lastVerifyNonceByPeer: [PeerID: Data] = [:] private var lastInboundVerifyChallengeAt: [String: Date] = [:] private var lastMutualToastAt: [String: Date] = [:] - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatVerificationContext) { + self.context = context } func verifyFingerprint(for peerID: PeerID) { - guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return } + guard let fingerprint = context.getFingerprint(for: peerID) else { return } - viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true) - viewModel.saveIdentityState() - viewModel.peerIdentityStore.setVerified(fingerprint, verified: true) - viewModel.updateEncryptionStatus(for: peerID) + context.setIdentityVerified(fingerprint: fingerprint, verified: true) + context.saveIdentityState() + context.setStoredVerified(fingerprint, verified: true) + context.updateEncryptionStatus(for: peerID) } func unverifyFingerprint(for peerID: PeerID) { - guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return } - viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: false) - viewModel.saveIdentityState() - viewModel.peerIdentityStore.setVerified(fingerprint, verified: false) - viewModel.updateEncryptionStatus(for: peerID) + guard let fingerprint = context.getFingerprint(for: peerID) else { return } + context.setIdentityVerified(fingerprint: fingerprint, verified: false) + context.saveIdentityState() + context.setStoredVerified(fingerprint, verified: false) + context.updateEncryptionStatus(for: peerID) } func loadVerifiedFingerprints() { - viewModel.peerIdentityStore.setVerifiedFingerprints(viewModel.identityManager.getVerifiedFingerprints()) - let sample = Array(viewModel.peerIdentityStore.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)) + context.verifiedFingerprints = context.persistedVerifiedFingerprints() + let sample = Array(context.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)) .map { $0.prefix(8) } .joined(separator: ", ") - SecureLogger.info("🔐 Verified loaded: \(viewModel.peerIdentityStore.verifiedFingerprints.count) [\(sample)]", category: .security) + SecureLogger.info("🔐 Verified loaded: \(context.verifiedFingerprints.count) [\(sample)]", category: .security) - let offlineFavorites = viewModel.unifiedPeerService.favorites.filter { !$0.isConnected } + let offlineFavorites = context.unifiedFavorites.filter { !$0.isConnected } for favorite in offlineFavorites { - let fingerprint = viewModel.unifiedPeerService.getFingerprint(for: favorite.peerID) - let isVerified = fingerprint.flatMap { viewModel.peerIdentityStore.isVerified($0) } ?? false + let fingerprint = context.unifiedFingerprint(for: favorite.peerID) + let isVerified = fingerprint.flatMap { context.isVerifiedFingerprint($0) } ?? false let shortFingerprint = fingerprint?.prefix(8) ?? "nil" SecureLogger.info( "⭐️ Favorite offline: \(favorite.nickname) fp=\(shortFingerprint) verified=\(isVerified)", @@ -58,62 +167,61 @@ final class ChatVerificationCoordinator { ) } - viewModel.invalidateEncryptionCache() - viewModel.objectWillChange.send() + context.invalidateEncryptionCache(for: nil) + context.notifyUIChanged() } func setupNoiseCallbacks() { - let noiseService = viewModel.meshService.getNoiseService() + context.installNoiseSessionCallbacks( + onPeerAuthenticated: { [weak self] peerID, fingerprint in + DispatchQueue.main.async { [weak self] in + guard let self else { return } - noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in - DispatchQueue.main.async { [weak self] in - guard let self else { return } + SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security) - SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security) + if self.context.isVerifiedFingerprint(fingerprint) { + self.context.setEncryptionStatus(.noiseVerified, for: peerID) + } else { + self.context.setEncryptionStatus(.noiseSecured, for: peerID) + } - if self.viewModel.peerIdentityStore.isVerified(fingerprint) { - self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseVerified, for: peerID) - } else { - self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseSecured, for: peerID) + self.context.invalidateEncryptionCache(for: peerID) + + if self.context.cachedStablePeerID(for: peerID) == nil, + let keyData = self.context.noiseSessionPublicKeyData(for: peerID) { + let stablePeerID = PeerID(hexData: keyData) + self.context.cacheStablePeerID(stablePeerID, for: peerID) + SecureLogger.debug( + "🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…", + category: .session + ) + } + + if var pending = self.pendingQRVerifications[peerID], pending.sent == false { + self.context.sendVerifyChallenge( + to: peerID, + noiseKeyHex: pending.noiseKeyHex, + nonceA: pending.nonceA + ) + pending.sent = true + self.pendingQRVerifications[peerID] = pending + SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security) + } } - - self.viewModel.invalidateEncryptionCache(for: peerID) - - if self.viewModel.cachedStablePeerID(for: peerID) == nil, - let keyData = self.viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) { - let stablePeerID = PeerID(hexData: keyData) - self.viewModel.cacheStablePeerID(stablePeerID, for: peerID) - SecureLogger.debug( - "🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…", - category: .session - ) - } - - if var pending = self.pendingQRVerifications[peerID], pending.sent == false { - self.viewModel.meshService.sendVerifyChallenge( - to: peerID, - noiseKeyHex: pending.noiseKeyHex, - nonceA: pending.nonceA - ) - pending.sent = true - self.pendingQRVerifications[peerID] = pending - SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security) + }, + onHandshakeRequired: { [weak self] peerID in + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.context.setEncryptionStatus(.noiseHandshaking, for: peerID) + self.context.invalidateEncryptionCache(for: peerID) } } - } - - noiseService.onHandshakeRequired = { [weak self] peerID in - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID) - self.viewModel.invalidateEncryptionCache(for: peerID) - } - } + ) } func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { let targetNoise = qr.noiseKeyHex.lowercased() - guard let peer = viewModel.unifiedPeerService.peers.first(where: { + guard let peer = context.unifiedPeers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else { return false @@ -135,13 +243,12 @@ final class ChatVerificationCoordinator { ) pendingQRVerifications[peerID] = pending - let noise = viewModel.meshService.getNoiseService() - if noise.hasEstablishedSession(with: peerID) { - viewModel.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) + if context.hasEstablishedNoiseSession(with: peerID) { + context.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) pending.sent = true pendingQRVerifications[peerID] = pending } else { - viewModel.meshService.triggerHandshake(with: peerID) + context.triggerHandshake(with: peerID) } return true @@ -150,9 +257,7 @@ final class ChatVerificationCoordinator { func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) { guard let challenge = VerificationService.shared.parseVerifyChallenge(payload) else { return } - let myNoiseHex = viewModel.meshService - .getNoiseService() - .getStaticPublicKeyData() + let myNoiseHex = context.noiseStaticPublicKeyData() .hexEncodedString() .lowercased() guard challenge.noiseKeyHex.lowercased() == myNoiseHex else { return } @@ -160,22 +265,22 @@ final class ChatVerificationCoordinator { lastVerifyNonceByPeer[peerID] = challenge.nonceA - if let fingerprint = viewModel.getFingerprint(for: peerID) { + if let fingerprint = context.getFingerprint(for: peerID) { lastInboundVerifyChallengeAt[fingerprint] = Date() - if viewModel.peerIdentityStore.isVerified(fingerprint) { + if context.isVerifiedFingerprint(fingerprint) { maybeSendMutualVerificationNotification( fingerprint: fingerprint, peerID: peerID, title: "Mutual verification", - bodyName: viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname - ?? viewModel.resolveNickname(for: peerID), + bodyName: context.unifiedPeer(for: peerID)?.nickname + ?? context.resolveNickname(for: peerID), notificationPrefix: "verify-mutual" ) } } - viewModel.meshService.sendVerifyResponse( + context.sendVerifyResponse( to: peerID, noiseKeyHex: challenge.noiseKeyHex, nonceA: challenge.nonceA @@ -198,16 +303,16 @@ final class ChatVerificationCoordinator { pendingQRVerifications.removeValue(forKey: peerID) - guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return } + guard let fingerprint = context.getFingerprint(for: peerID) else { return } let shortFingerprint = fingerprint.prefix(8) SecureLogger.info("🔐 Marking verified fingerprint: \(shortFingerprint)", category: .security) - viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true) - viewModel.saveIdentityState() - viewModel.peerIdentityStore.setVerified(fingerprint, verified: true) + context.setIdentityVerified(fingerprint: fingerprint, verified: true) + context.saveIdentityState() + context.setStoredVerified(fingerprint, verified: true) - let peerName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname - ?? viewModel.resolveNickname(for: peerID) + let peerName = context.unifiedPeer(for: peerID)?.nickname + ?? context.resolveNickname(for: peerID) NotificationService.shared.sendLocalNotification( title: "Verified", body: "You verified \(peerName)", @@ -225,7 +330,7 @@ final class ChatVerificationCoordinator { ) } - viewModel.updateEncryptionStatus(for: peerID) + context.updateEncryptionStatus(for: peerID) } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 23667420..e5e3baa6 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -147,18 +147,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele let autocompleteService: AutocompleteService let deduplicationService: MessageDeduplicationService // internal for test access private lazy var outgoingCoordinator = ChatOutgoingCoordinator(viewModel: self) - private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(viewModel: self) - private lazy var transportEventCoordinator = ChatTransportEventCoordinator(viewModel: self) + private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(context: self) + private lazy var transportEventCoordinator = ChatTransportEventCoordinator(context: self) private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self) private lazy var messageFormatter = ChatMessageFormatter(viewModel: self) - lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(viewModel: self) + lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(context: self) lazy var deliveryCoordinator = ChatDeliveryCoordinator(context: self) lazy var composerCoordinator = ChatComposerCoordinator(viewModel: self) lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self) lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self) lazy var nostrCoordinator = ChatNostrCoordinator(context: self) - lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(viewModel: self) - lazy var verificationCoordinator = ChatVerificationCoordinator(viewModel: self) + lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self) + lazy var verificationCoordinator = ChatVerificationCoordinator(context: self) // Computed properties for compatibility @MainActor @@ -453,6 +453,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele sentGeoDeliveryAcks.insert(messageID).inserted } + /// Forgets that read receipts were sent for `ids` so READ acks can be + /// re-sent after the peer reconnects. + @MainActor + func unmarkReadReceiptsSent(_ ids: [String]) { + sentReadReceipts.subtract(ids) + } + + /// Marks read receipts as sent for own messages already delivered/read in + /// `peerID`'s chat, syncing the chat manager's tracking with the persisted + /// set. (Wraps the manager's `inout` sync so the raw set never leaks.) + @MainActor + func syncReadReceiptsForSentMessages(for peerID: PeerID) { + privateChatManager.syncReadReceiptsForSentMessages( + peerID: peerID, + nickname: nickname, + externalReceipts: &sentReadReceipts + ) + } + /// Drops every recorded read receipt whose message ID is no longer valid. /// Returns the number of receipts removed. @MainActor diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift new file mode 100644 index 00000000..12693601 --- /dev/null +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -0,0 +1,283 @@ +// +// ChatLifecycleCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatLifecycleCoordinator` against a mock `ChatLifecycleContext` +// — proving the coordinator works without a `ChatViewModel`, following the +// `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// Scope note: the mesh/Nostr read-receipt branch of +// `markPrivateMessagesAsRead` consults `FavoritesPersistenceService.shared` +// and the geohash-screenshot branch publishes via `NostrRelayManager.shared` / +// `GeoRelayDirectory.shared`; those stay covered by the full view-model tests. +// The GeoDM read pass, message merging, screenshot notices, and lifecycle +// persistence flows are covered here. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatLifecycleContext` proving that +/// `ChatLifecycleCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatLifecycleContext: ChatLifecycleContext { + // Chat & receipt state + var messages: [BitchatMessage] = [] + var privateChats: [PeerID: [BitchatMessage]] = [:] + var unreadPrivateMessages: Set = [] + var selectedPrivateChatPeer: PeerID? + var sentReadReceipts: Set = [] + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var activeChannel: ChannelID = .mesh + var nostrKeyMapping: [PeerID: String] = [:] + private(set) var ownerLevelReadPasses: [PeerID] = [] + private(set) var managerReadMarks: [PeerID] = [] + private(set) var privateStoreSyncCount = 0 + private(set) var systemMessages: [String] = [] + + @discardableResult + func markReadReceiptSent(_ messageID: String) -> Bool { + sentReadReceipts.insert(messageID).inserted + } + + func markPrivateMessagesAsRead(from peerID: PeerID) { + ownerLevelReadPasses.append(peerID) + } + + func markChatAsRead(from peerID: PeerID) { + managerReadMarks.append(peerID) + } + + func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 } + func addSystemMessage(_ content: String) { systemMessages.append(content) } + + // Peers & sessions + var nicknamesByPeerID: [PeerID: String] = [:] + var peersByID: [PeerID: BitchatPeer] = [:] + var noiseSessionStates: [PeerID: LazyHandshakeState] = [:] + private(set) var stopMeshServicesCount = 0 + private(set) var refreshBluetoothStateCount = 0 + + func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] } + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] } + func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState { + noiseSessionStates[peerID] ?? .none + } + func stopMeshServices() { stopMeshServicesCount += 1 } + func refreshBluetoothState() { refreshBluetoothStateCount += 1 } + + // Routing & receipts + private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String)] = [] + private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = [] + private(set) var meshBroadcasts: [String] = [] + private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] + + func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { + routedPrivateMessages.append((content, peerID, recipientNickname)) + } + + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + routedReadReceipts.append((receipt.originalMessageID, peerID)) + } + + func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { + meshBroadcasts.append(content) + } + + func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { + geoReadReceipts.append((messageID, recipientHex)) + } + + // Nostr & geohash + var isTeleported = false + private(set) var recordedGeoParticipants: [String] = [] + + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity } + func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) } + + // Identity persistence + private(set) var forceSaveIdentityCount = 0 + private(set) var verifyIdentityKeyExistsCount = 0 + + func forceSaveIdentity() { forceSaveIdentityCount += 1 } + + @discardableResult + func verifyIdentityKeyExists() -> Bool { + verifyIdentityKeyExistsCount += 1 + return true + } + + static let dummyIdentity = NostrIdentity( + privateKey: Data(repeating: 0x11, count: 32), + publicKey: Data(repeating: 0x22, count: 32), + npub: "npub1mock", + createdAt: Date(timeIntervalSince1970: 0) + ) +} + +// MARK: - Helpers + +@MainActor +private func makePrivateMessage( + id: String, + sender: String = "alice", + timestamp: Date = Date(), + senderPeerID: PeerID? = nil, + isRelay: Bool = false, + deliveryStatus: DeliveryStatus? = nil +) -> BitchatMessage { + BitchatMessage( + id: id, + sender: sender, + content: "hello", + timestamp: timestamp, + isRelay: isRelay, + isPrivate: true, + recipientNickname: "me", + senderPeerID: senderPeerID, + deliveryStatus: deliveryStatus + ) +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatLifecycleCoordinator` against `MockChatLifecycleContext` +/// with no `ChatViewModel`. +struct ChatLifecycleCoordinatorContextTests { + + @Test @MainActor + func getPrivateChatMessages_mergesEphemeralAndStableKeepingBestStatus() async { + let context = MockChatLifecycleContext() + let coordinator = ChatLifecycleCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0xAB, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice") + + let t1 = Date(timeIntervalSince1970: 1) + let t2 = Date(timeIntervalSince1970: 2) + // Same message under both keys: the read copy must win over sent. + context.privateChats[peerID] = [ + makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent), + makePrivateMessage(id: "m2", timestamp: t2), + ] + context.privateChats[stablePeerID] = [ + makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)), + ] + + let merged = coordinator.getPrivateChatMessages(for: peerID) + #expect(merged.map(\.id) == ["m1", "m2"]) + if case .read? = merged.first?.deliveryStatus { + } else { + Issue.record("expected the .read copy of m1 to win the merge") + } + + // getMessages(for: nil) falls back to the public timeline. + context.messages = [makePrivateMessage(id: "pub")] + #expect(coordinator.getMessages(for: nil).map(\.id) == ["pub"]) + } + + @Test @MainActor + func markPrivateMessagesAsRead_geoDM_sendsReadReceiptsOnce() async { + let context = MockChatLifecycleContext() + let coordinator = ChatLifecycleCoordinator(context: context) + let convKey = PeerID(nostr_: "feedface00112233") + let recipientHex = "feedface00112233" + context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy")) + context.nostrKeyMapping[convKey] = recipientHex + context.sentReadReceipts = ["already-acked"] + context.privateChats[convKey] = [ + makePrivateMessage(id: "m1", senderPeerID: convKey), + makePrivateMessage(id: "already-acked", senderPeerID: convKey), + makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true), + makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID), + ] + + coordinator.markPrivateMessagesAsRead(from: convKey) + + #expect(context.managerReadMarks == [convKey]) + #expect(context.privateStoreSyncCount == 1) + // Only the peer's own un-acked, non-relay message gets a READ. + #expect(context.geoReadReceipts.map(\.messageID) == ["m1"]) + #expect(context.geoReadReceipts.first?.recipientHex == recipientHex) + #expect(context.sentReadReceipts.contains("m1")) + + // Second pass: nothing new to send. + coordinator.markPrivateMessagesAsRead(from: convKey) + #expect(context.geoReadReceipts.count == 1) + } + + @Test @MainActor + func handleScreenshotCaptured_privateChat_appendsNoticeAndRoutesWhenEstablished() async { + let context = MockChatLifecycleContext() + let coordinator = ChatLifecycleCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.nicknamesByPeerID[peerID] = "alice" + + // No established session: local notice only, no network send. + coordinator.handleScreenshotCaptured() + #expect(context.routedPrivateMessages.isEmpty) + #expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"]) + #expect(context.privateChats[peerID]?.first?.sender == "system") + + // Established session: the peer is notified too. + context.noiseSessionStates[peerID] = .established + coordinator.handleScreenshotCaptured() + #expect(context.routedPrivateMessages.count == 1) + #expect(context.routedPrivateMessages.first?.content == "* me took a screenshot *") + #expect(context.routedPrivateMessages.first?.recipientNickname == "alice") + #expect(context.privateChats[peerID]?.count == 2) + // The public-channel system message is not used for private chats. + #expect(context.systemMessages.isEmpty) + #expect(context.meshBroadcasts.isEmpty) + } + + @Test @MainActor + func handleScreenshotCaptured_meshChannel_broadcastsAndConfirmsLocally() async { + let context = MockChatLifecycleContext() + let coordinator = ChatLifecycleCoordinator(context: context) + + coordinator.handleScreenshotCaptured() + + #expect(context.meshBroadcasts == ["* me took a screenshot *"]) + #expect(context.systemMessages == ["you took a screenshot"]) + #expect(context.privateChats.isEmpty) + } + + @Test @MainActor + func lifecycleEvents_persistIdentityAndScheduleReadPasses() async { + let context = MockChatLifecycleContext() + let coordinator = ChatLifecycleCoordinator(context: context) + + coordinator.applicationWillTerminate() + #expect(context.stopMeshServicesCount == 1) + #expect(context.forceSaveIdentityCount == 1) + #expect(context.verifyIdentityKeyExistsCount == 1) + + // Becoming active with no open chat only refreshes Bluetooth state. + coordinator.handleDidBecomeActive() + #expect(context.refreshBluetoothStateCount == 1) + #expect(context.managerReadMarks.isEmpty) + + // With an open chat the read pass runs immediately (manager-level) and + // a delayed owner-level pass is scheduled. + let peerID = PeerID(nostr_: "feedface00112233") + context.selectedPrivateChatPeer = peerID + coordinator.handleDidBecomeActive() + #expect(context.refreshBluetoothStateCount == 2) + #expect(context.managerReadMarks == [peerID]) + + let deadline = Date().addingTimeInterval(2) + while context.ownerLevelReadPasses.isEmpty && Date() < deadline { + try? await Task.sleep(nanoseconds: 20_000_000) + } + #expect(context.ownerLevelReadPasses == [peerID]) + } +} diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift new file mode 100644 index 00000000..12e639a1 --- /dev/null +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -0,0 +1,206 @@ +// +// ChatMediaTransferCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatMediaTransferCoordinator` against a mock +// `ChatMediaTransferContext` — proving the coordinator works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// Scope note: the async media-preparation pipelines (`ImageUtils`, +// `ChatMediaPreparation`) run real file/codec work and remain covered by +// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer +// bookkeeping, and the blocked-context guards. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatMediaTransferContext` proving that +/// `ChatMediaTransferCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatMediaTransferContext: ChatMediaTransferContext { + // Composition state + var canSendMediaInCurrentContext = true + var selectedPrivateChatPeer: PeerID? + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var activeChannel: ChannelID = .mesh + var nicknamesByPeerID: [PeerID: String] = [:] + + func nicknameForPeer(_ peerID: PeerID) -> String { + nicknamesByPeerID[peerID] ?? "user" + } + + func currentPublicSender() -> (name: String, peerID: PeerID) { + (nickname, myPeerID) + } + + // Message state + var privateChats: [PeerID: [BitchatMessage]] = [:] + var meshTimeline: [BitchatMessage] = [] + private(set) var refreshedChannels: [ChannelID?] = [] + private(set) var trimCount = 0 + private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = [] + private(set) var systemMessages: [String] = [] + private(set) var notifyUIChangedCount = 0 + + func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) { + meshTimeline.append(message) + } + + func refreshVisibleMessages(from channel: ChannelID?) { + refreshedChannels.append(channel) + } + + func trimMessagesIfNeeded() { trimCount += 1 } + + func removeMessage(withID messageID: String, cleanupFile: Bool) { + removedMessages.append((messageID, cleanupFile)) + } + + func addSystemMessage(_ content: String) { systemMessages.append(content) } + func notifyUIChanged() { notifyUIChangedCount += 1 } + + // Delivery status & dedup + private(set) var deliveryStatusUpdates: [(messageID: String, status: DeliveryStatus)] = [] + private(set) var recordedContentKeys: [String] = [] + + func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { + deliveryStatusUpdates.append((messageID, status)) + } + + func normalizedContentKey(_ content: String) -> String { content.lowercased() } + + func recordContentKey(_ key: String, timestamp: Date) { + recordedContentKeys.append(key) + } + + // Mesh file transfer + private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = [] + private(set) var broadcastFileSends: [String] = [] + private(set) var cancelledTransfers: [String] = [] + + func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { + privateFileSends.append((peerID, transferId)) + } + + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { + broadcastFileSends.append(transferId) + } + + func cancelTransfer(_ transferId: String) { + cancelledTransfers.append(transferId) + } +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatMediaTransferCoordinator` against +/// `MockChatMediaTransferContext` with no `ChatViewModel`. +struct ChatMediaTransferCoordinatorContextTests { + + @Test @MainActor + func enqueueMediaMessage_privateChatAppendsAndRecordsDedupKey() async { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.nicknamesByPeerID[peerID] = "alice" + + let message = coordinator.enqueueMediaMessage(content: "[voice] note.m4a", targetPeer: peerID) + + #expect(context.privateChats[peerID]?.map(\.id) == [message.id]) + #expect(message.isPrivate) + #expect(message.recipientNickname == "alice") + #expect(message.senderPeerID == context.myPeerID) + #expect(message.deliveryStatus == .sending) + #expect(context.recordedContentKeys == ["[voice] note.m4a"]) + #expect(context.trimCount == 1) + #expect(context.notifyUIChangedCount == 1) + #expect(context.meshTimeline.isEmpty) + } + + @Test @MainActor + func enqueueMediaMessage_publicAppendsToTimelineAndRefreshes() async { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + + let message = coordinator.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: nil) + + #expect(context.meshTimeline.map(\.id) == [message.id]) + #expect(!message.isPrivate) + #expect(message.sender == "me") + #expect(context.refreshedChannels.count == 1) + #expect(context.privateChats.isEmpty) + #expect(context.notifyUIChangedCount == 1) + } + + @Test @MainActor + func transferEvents_driveDeliveryStatusAndMappingCleanup() async { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + coordinator.registerTransfer(transferId: "t1", messageID: "m1") + + coordinator.handleTransferEvent(.started(id: "t1", totalFragments: 10)) + coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 4, totalFragments: 10)) + coordinator.handleTransferEvent(.completed(id: "t1", totalFragments: 10)) + // After completion the mapping is gone: further events are ignored. + coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 9, totalFragments: 10)) + + #expect(context.deliveryStatusUpdates.count == 3) + #expect(context.deliveryStatusUpdates[0].status == .partiallyDelivered(reached: 0, total: 10)) + #expect(context.deliveryStatusUpdates[1].status == .partiallyDelivered(reached: 4, total: 10)) + #expect(context.deliveryStatusUpdates[2].status == .sent) + #expect(coordinator.messageIDToTransferId.isEmpty) + + // A cancelled transfer removes the message (with file cleanup). + coordinator.registerTransfer(transferId: "t2", messageID: "m2") + coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5)) + #expect(context.removedMessages.count == 1) + #expect(context.removedMessages.first?.messageID == "m2") + #expect(context.removedMessages.first?.cleanupFile == true) + } + + @Test @MainActor + func cancelMediaSend_cancelsOnlyActiveTransferAndRemovesMessage() async { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + // Two messages share a transfer queue; only the active head cancels + // the underlying transfer. + coordinator.registerTransfer(transferId: "t1", messageID: "m1") + coordinator.registerTransfer(transferId: "t1", messageID: "m2") + + coordinator.cancelMediaSend(messageID: "m2") + #expect(context.cancelledTransfers.isEmpty) + #expect(context.removedMessages.map(\.messageID) == ["m2"]) + + coordinator.cancelMediaSend(messageID: "m1") + #expect(context.cancelledTransfers == ["t1"]) + #expect(context.removedMessages.map(\.messageID) == ["m2", "m1"]) + #expect(coordinator.transferIdToMessageIDs.isEmpty) + #expect(coordinator.messageIDToTransferId.isEmpty) + } + + @Test @MainActor + func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + context.canSendMediaInCurrentContext = false + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("voice-note-test-\(UUID().uuidString).m4a") + try Data([0x01, 0x02]).write(to: url) + + coordinator.sendVoiceNote(at: url) + + #expect(!FileManager.default.fileExists(atPath: url.path)) + #expect(context.systemMessages == ["Voice notes are only available in mesh chats."]) + #expect(context.privateChats.isEmpty) + #expect(context.meshTimeline.isEmpty) + #expect(coordinator.transferIdToMessageIDs.isEmpty) + } +} diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift new file mode 100644 index 00000000..bf0cbddb --- /dev/null +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -0,0 +1,358 @@ +// +// ChatPeerIdentityCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatPeerIdentityCoordinator` against a mock +// `ChatPeerIdentityContext` — proving the coordinator works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// Scope note: flows that hit the `FavoritesPersistenceService.shared` +// singleton (`isFavorite` / `toggleFavoriteForNoiseKey` / favorite +// notifications / `nicknameForPeer` fallbacks) remain covered by the full +// view-model tests; the session, migration, encryption-status, and nickname +// resolution flows are covered here. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatPeerIdentityContext` proving that +/// `ChatPeerIdentityCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { + // Conversation state + var privateChats: [PeerID: [BitchatMessage]] = [:] + var unreadPrivateMessages: Set = [] + var selectedPrivateChatPeer: PeerID? + var selectedPrivateChatFingerprint: String? + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var activeChannel: ChannelID = .mesh + private(set) var notifyUIChangedCount = 0 + private(set) var systemMessages: [String] = [] + + func notifyUIChanged() { notifyUIChangedCount += 1 } + func addSystemMessage(_ content: String) { systemMessages.append(content) } + + // Private chat session lifecycle + private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = [] + private(set) var syncedReadReceiptPeers: [PeerID] = [] + private(set) var begunChatSessions: [PeerID] = [] + private(set) var privateStoreSyncCount = 0 + private(set) var selectionStoreSyncCount = 0 + private(set) var markedReadPeers: [PeerID] = [] + + @discardableResult + func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool { + consolidatedPeers.append((peerID, peerNickname)) + return false + } + + func syncReadReceiptsForSentMessages(for peerID: PeerID) { + syncedReadReceiptPeers.append(peerID) + } + + func beginPrivateChatSession(with peerID: PeerID) { + begunChatSessions.append(peerID) + } + + func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 } + func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 } + func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) } + + // Unified peer service + var connectedPeers: Set = [] + var peersByID: [PeerID: BitchatPeer] = [:] + var blockedPeers: Set = [] + var fingerprintsByPeerID: [PeerID: String] = [:] + var peerIDsByNickname: [String: PeerID] = [:] + var ephemeralPeerIDsByNoiseKey: [Data: PeerID] = [:] + private(set) var toggledFavoritePeers: [PeerID] = [] + + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] } + func unifiedIsBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } + func unifiedToggleFavorite(_ peerID: PeerID) { toggledFavoritePeers.append(peerID) } + func unifiedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } + func unifiedPeerID(forNickname nickname: String) -> PeerID? { peerIDsByNickname[nickname] } + func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? { ephemeralPeerIDsByNoiseKey[noiseKey] } + + // Mesh & Noise sessions + var nicknamesByPeerID: [PeerID: String] = [:] + var noiseSessionStates: [PeerID: LazyHandshakeState] = [:] + var establishedNoiseSessions: Set = [] + var activeNoiseSessions: Set = [] + var myNoiseFingerprint = "my-fingerprint" + private(set) var triggeredHandshakes: [PeerID] = [] + + func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] } + func meshPeerNicknames() -> [PeerID: String] { nicknamesByPeerID } + func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState { + noiseSessionStates[peerID] ?? .none + } + func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) } + func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { + establishedNoiseSessions.contains(peerID) + } + func hasNoiseSession(with peerID: PeerID) -> Bool { activeNoiseSessions.contains(peerID) } + func noiseIdentityFingerprint() -> String { myNoiseFingerprint } + + // Identity store (fingerprints & encryption status) + var verifiedFingerprintSet: Set = [] + var socialIdentitiesByFingerprint: [String: SocialIdentity] = [:] + private(set) var storedFingerprints: [(fingerprint: String, peerID: PeerID)] = [] + private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:] + private(set) var cachedEncryptionStatuses: [PeerID: EncryptionStatus] = [:] + private(set) var invalidatedEncryptionCachePeers: [PeerID?] = [] + + func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) { + storedFingerprints.append((fingerprint, peerID)) + fingerprintsByPeerID[peerID] = fingerprint + } + + func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? { + let fingerprint = fingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback + if let fingerprint { + fingerprintsByPeerID[newPeerID] = fingerprint + } + return fingerprint + } + + func isVerifiedFingerprint(_ fingerprint: String) -> Bool { + verifiedFingerprintSet.contains(fingerprint) + } + + func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { + encryptionStatuses[peerID] = status + } + + func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? { + cachedEncryptionStatuses[peerID] + } + + func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) { + cachedEncryptionStatuses[peerID] = status + } + + func invalidateStoredEncryptionCache(for peerID: PeerID?) { + invalidatedEncryptionCachePeers.append(peerID) + if let peerID { + cachedEncryptionStatuses.removeValue(forKey: peerID) + } else { + cachedEncryptionStatuses.removeAll() + } + } + + func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? { + socialIdentitiesByFingerprint[fingerprint] + } + + // Geohash & Nostr + var geoNicknames: [String: String] = [:] + var geohashPeople: [GeoPerson] = [] + private(set) var registeredNostrKeyMappings: [(pubkey: String, peerID: PeerID)] = [] + private(set) var nostrFavoriteNotifications: [(noisePublicKey: Data, isFavorite: Bool)] = [] + var bridgedNostrKeysByNoiseKey: [Data: String] = [:] + + func visibleGeohashPeople() -> [GeoPerson] { geohashPeople } + + func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { + registeredNostrKeyMappings.append((pubkey, peerID)) + } + + func bridgedNostrPublicKey(for noiseKey: Data) -> String? { + bridgedNostrKeysByNoiseKey[noiseKey] + } + + func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { + nostrFavoriteNotifications.append((noisePublicKey, isFavorite)) + } +} + +// MARK: - Helpers + +@MainActor +private func makePrivateMessage( + id: String, + sender: String = "alice", + timestamp: Date = Date(), + senderPeerID: PeerID? = nil +) -> BitchatMessage { + BitchatMessage( + id: id, + sender: sender, + content: "hello", + timestamp: timestamp, + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: senderPeerID + ) +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatPeerIdentityCoordinator` against +/// `MockChatPeerIdentityContext` with no `ChatViewModel`. +struct ChatPeerIdentityCoordinatorContextTests { + + @Test @MainActor + func startPrivateChat_runsFullSessionSetupSequence() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.nicknamesByPeerID[peerID] = "alice" + context.fingerprintsByPeerID[peerID] = "fp-alice" + + // Chatting with ourselves is a no-op. + coordinator.startPrivateChat(with: context.myPeerID) + #expect(context.begunChatSessions.isEmpty) + + coordinator.startPrivateChat(with: peerID) + + #expect(context.consolidatedPeers.map(\.peerID) == [peerID]) + #expect(context.consolidatedPeers.first?.peerNickname == "alice") + // No Noise session yet -> handshake triggered. + #expect(context.triggeredHandshakes == [peerID]) + #expect(context.syncedReadReceiptPeers == [peerID]) + #expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"]) + #expect(context.selectedPrivateChatFingerprint == "fp-alice") + #expect(context.begunChatSessions == [peerID]) + #expect(context.privateStoreSyncCount == 1) + #expect(context.selectionStoreSyncCount == 1) + #expect(context.markedReadPeers == [peerID]) + + // Established session: no second handshake. + context.noiseSessionStates[peerID] = .established + coordinator.startPrivateChat(with: peerID) + #expect(context.triggeredHandshakes == [peerID]) + } + + @Test @MainActor + func startPrivateChat_blockedPeerOnlyGetsSystemMessage() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.blockedPeers = [peerID] + + coordinator.startPrivateChat(with: peerID) + + #expect(context.systemMessages.count == 1) + #expect(context.begunChatSessions.isEmpty) + #expect(context.consolidatedPeers.isEmpty) + #expect(context.markedReadPeers.isEmpty) + } + + @Test @MainActor + func updatePrivateChatPeerIfNeeded_migratesChatStateByFingerprint() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let oldPeerID = PeerID(str: "1111111111111111") + let newPeerID = PeerID(str: "2222222222222222") + + context.selectedPrivateChatFingerprint = "fp" + context.selectedPrivateChatPeer = oldPeerID + context.connectedPeers = [newPeerID] + context.fingerprintsByPeerID[newPeerID] = "fp" + let earlier = makePrivateMessage(id: "m1", timestamp: Date(timeIntervalSince1970: 1)) + let later = makePrivateMessage(id: "m2", timestamp: Date(timeIntervalSince1970: 2)) + context.privateChats[oldPeerID] = [later] + context.privateChats[newPeerID] = [earlier, later] // duplicate id "m2" + context.unreadPrivateMessages = [oldPeerID] + + coordinator.updatePrivateChatPeerIfNeeded() + + // Old chat is merged into the new peer's chat, deduplicated by id and + // sorted by timestamp; old keys are dropped. + #expect(context.privateChats[oldPeerID] == nil) + #expect(context.privateChats[newPeerID]?.map(\.id) == ["m1", "m2"]) + #expect(context.selectedPrivateChatPeer == newPeerID) + // Unread moved to the new peer, then cleared for the now-open chat. + #expect(context.unreadPrivateMessages.isEmpty) + } + + @Test @MainActor + func getEncryptionStatus_computesVerifiedStatusAndCachesIt() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + + // Unknown peer with no fingerprint or session: no handshake yet. + #expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake) + #expect(context.cachedEncryptionStatuses[peerID] == .noHandshake) + + // Cache hit short-circuits recomputation. + context.noiseSessionStates[peerID] = .established + #expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake) + + // After invalidation, an established session with a verified + // fingerprint resolves (and re-caches) as verified. + coordinator.invalidateEncryptionCache(for: peerID) + context.fingerprintsByPeerID[peerID] = "fp" + context.verifiedFingerprintSet = ["fp"] + #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified) + #expect(context.cachedEncryptionStatuses[peerID] == .noiseVerified) + + // updateEncryptionStatus publishes to the store and invalidates the cache. + context.establishedNoiseSessions = [peerID] + coordinator.updateEncryptionStatus(for: peerID) + #expect(context.encryptionStatuses[peerID] == .noiseVerified) + #expect(context.cachedEncryptionStatuses[peerID] == nil) + } + + @Test @MainActor + func resolveNickname_walksMeshIdentityAndAnonFallbacks() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let meshPeer = PeerID(str: "aabbccddeeff0011") + let identityPeer = PeerID(str: "1234567890abcdef") + let unknownPeer = PeerID(str: "feedfacefeedface") + + context.nicknamesByPeerID[meshPeer] = "alice" + #expect(coordinator.resolveNickname(for: meshPeer) == "alice") + + context.fingerprintsByPeerID[identityPeer] = "fp" + context.socialIdentitiesByFingerprint["fp"] = SocialIdentity( + fingerprint: "fp", + localPetname: "bob!", + claimedNickname: "bob", + trustLevel: .casual, + isFavorite: false, + isBlocked: false, + notes: nil + ) + #expect(coordinator.resolveNickname(for: identityPeer) == "bob!") + + #expect(coordinator.resolveNickname(for: unknownPeer) == "anonfeed") + #expect(coordinator.getMyFingerprint() == "my-fingerprint") + } + + @Test @MainActor + func getPeerIDForNickname_inGeohashChannel_registersNostrMapping() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let pubkey = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".lowercased() + context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy")) + context.geohashPeople = [GeoPerson(id: pubkey, displayName: "alice#6789", lastSeen: Date())] + context.geoNicknames[pubkey] = "alice" + + // Suffixed display-name match. + let bySuffix = coordinator.getPeerIDForNickname("alice#6789") + #expect(bySuffix == PeerID(nostr_: pubkey)) + // Base-nickname match via geoNicknames. + let byBase = coordinator.getPeerIDForNickname("ALICE") + #expect(byBase == PeerID(nostr_: pubkey)) + #expect(context.registeredNostrKeyMappings.count == 2) + #expect(context.registeredNostrKeyMappings.allSatisfy { $0.pubkey == pubkey }) + + // Mesh channel falls through to the unified peer service. + context.activeChannel = .mesh + let meshPeer = PeerID(str: "1122334455667788") + context.peerIDsByNickname["carol"] = meshPeer + #expect(coordinator.getPeerIDForNickname("carol") == meshPeer) + } +} diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift new file mode 100644 index 00000000..9b6ec7ce --- /dev/null +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -0,0 +1,331 @@ +// +// ChatTransportEventCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatTransportEventCoordinator` against a mock +// `ChatTransportEventContext` — proving the coordinator works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// Scope note: the coordinator hops every event onto the main actor via an +// internal `Task`; tests drain those tasks with `Task.yield()`. All flows are +// mockable — no singletons are involved at this layer. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatTransportEventContext` proving that +/// `ChatTransportEventCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatTransportEventContext: ChatTransportEventContext { + // Connection & chat state + var isConnected = false + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var privateChats: [PeerID: [BitchatMessage]] = [:] + var unreadPrivateMessages: Set = [] + var selectedPrivateChatPeer: PeerID? + private(set) var unmarkedReadReceiptBatches: [[String]] = [] + private(set) var notifyUIChangedCount = 0 + + func unmarkReadReceiptsSent(_ ids: [String]) { + unmarkedReadReceiptBatches.append(ids) + } + + func notifyUIChanged() { + notifyUIChangedCount += 1 + } + + // Inbound message handling + var blockedMessageIDs: Set = [] + private(set) var handledPrivateMessages: [BitchatMessage] = [] + private(set) var handledPublicMessages: [BitchatMessage] = [] + private(set) var mentionCheckedMessageIDs: [String] = [] + private(set) var hapticMessageIDs: [String] = [] + + func isMessageBlocked(_ message: BitchatMessage) -> Bool { + blockedMessageIDs.contains(message.id) + } + + func handlePrivateMessage(_ message: BitchatMessage) { + handledPrivateMessages.append(message) + } + + func handlePublicMessage(_ message: BitchatMessage) { + handledPublicMessages.append(message) + } + + func checkForMentions(_ message: BitchatMessage) { + mentionCheckedMessageIDs.append(message.id) + } + + func sendHapticFeedback(for message: BitchatMessage) { + hapticMessageIDs.append(message.id) + } + + func parseMentions(from content: String) -> [String] { + content.contains("@me") ? ["me"] : [] + } + + // Peer identity & sessions + var blockedPeers: Set = [] + var peersByID: [PeerID: BitchatPeer] = [:] + var noiseSessionKeysByPeerID: [PeerID: Data] = [:] + private(set) var stablePeerIDCache: [PeerID: PeerID] = [:] + private(set) var registeredEphemeralSessions: [PeerID] = [] + private(set) var removedEphemeralSessions: [PeerID] = [] + + func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] } + func resolveNickname(for peerID: PeerID) -> String { "anon\(peerID.id.prefix(4))" } + func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) } + func removeEphemeralSession(peerID: PeerID) { removedEphemeralSessions.append(peerID) } + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { noiseSessionKeysByPeerID[peerID] } + func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) { + stablePeerIDCache[shortPeerID] = stablePeerID + } + func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { stablePeerIDCache[shortPeerID] } + + // Routing & acknowledgements + private(set) var flushedOutboxPeerIDs: [PeerID] = [] + private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] + + func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) } + func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) { + meshDeliveryAcks.append((messageID, peerID)) + } + + // Delivery status + var applyMessageDeliveryStatusResult = true + var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:] + private(set) var appliedDeliveryStatuses: [(messageID: String, status: DeliveryStatus)] = [] + + @discardableResult + func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { + appliedDeliveryStatuses.append((messageID, status)) + return applyMessageDeliveryStatusResult + } + + func deliveryStatus(for messageID: String) -> DeliveryStatus? { + deliveryStatusesByMessageID[messageID] + } + + // Verification payloads + private(set) var verifyChallengePayloads: [(peerID: PeerID, payload: Data)] = [] + private(set) var verifyResponsePayloads: [(peerID: PeerID, payload: Data)] = [] + + func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) { + verifyChallengePayloads.append((peerID, payload)) + } + + func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { + verifyResponsePayloads.append((peerID, payload)) + } +} + +// MARK: - Helpers + +/// Lets the coordinator's internal `Task { @MainActor … }` hops run. +@MainActor +private func drainMainActorTasks() async { + for _ in 0..<10 { await Task.yield() } +} + +private func makeMessage( + id: String, + sender: String = "alice", + content: String = "hello", + isPrivate: Bool = false, + senderPeerID: PeerID? = nil +) -> BitchatMessage { + BitchatMessage( + id: id, + sender: sender, + content: content, + timestamp: Date(), + isRelay: false, + isPrivate: isPrivate, + recipientNickname: isPrivate ? "me" : nil, + senderPeerID: senderPeerID + ) +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatTransportEventCoordinator` against +/// `MockChatTransportEventContext` with no `ChatViewModel`. +struct ChatTransportEventCoordinatorContextTests { + + @Test @MainActor + func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + + // Blocked messages are dropped before any handling. + context.blockedMessageIDs = ["blocked"] + coordinator.didReceiveMessage(makeMessage(id: "blocked")) + // Empty public content is dropped too. + coordinator.didReceiveMessage(makeMessage(id: "empty", content: " ")) + await drainMainActorTasks() + #expect(context.handledPublicMessages.isEmpty) + #expect(context.handledPrivateMessages.isEmpty) + #expect(context.mentionCheckedMessageIDs.isEmpty) + + // Private goes to the private handler, public to the public handler; + // both get mention checks and haptics. + coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true)) + coordinator.didReceiveMessage(makeMessage(id: "pub")) + await drainMainActorTasks() + #expect(context.handledPrivateMessages.map(\.id) == ["pm"]) + #expect(context.handledPublicMessages.map(\.id) == ["pub"]) + #expect(context.mentionCheckedMessageIDs == ["pm", "pub"]) + #expect(context.hapticMessageIDs == ["pm", "pub"]) + } + + @Test @MainActor + func didReceivePublicMessage_trimsContentAndParsesMentions() async { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "aabbccdd00112233") + + coordinator.didReceivePublicMessage( + from: peerID, + nickname: "alice", + content: " hi @me ", + timestamp: Date(), + messageID: "m1" + ) + await drainMainActorTasks() + + #expect(context.handledPublicMessages.count == 1) + let message = context.handledPublicMessages[0] + #expect(message.content == "hi @me") + #expect(message.mentions == ["me"]) + #expect(message.senderPeerID == peerID) + #expect(context.hapticMessageIDs == ["m1"]) + } + + @Test @MainActor + func didConnectAndDisconnect_manageSessionsStableIDsAndReadReceipts() async { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0xAB, count: 32) + context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice") + + coordinator.didConnectToPeer(peerID) + await drainMainActorTasks() + #expect(context.isConnected) + #expect(context.registeredEphemeralSessions == [peerID]) + #expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey)) + #expect(context.flushedOutboxPeerIDs == [peerID]) + #expect(context.notifyUIChangedCount == 1) + + // Their messages' read receipts are un-marked on disconnect so READ + // acks can be re-sent after reconnect; our own messages are not. + context.privateChats[peerID] = [ + makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID), + makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID), + makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID), + ] + coordinator.didDisconnectFromPeer(peerID) + await drainMainActorTasks() + #expect(context.removedEphemeralSessions == [peerID]) + #expect(context.unmarkedReadReceiptBatches == [["theirs-1", "theirs-2"]]) + #expect(context.notifyUIChangedCount == 2) + } + + @Test @MainActor + func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0xCD, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + + // No cached stable ID: it must be derived from the Noise session key. + context.noiseSessionKeysByPeerID[peerID] = noiseKey + context.selectedPrivateChatPeer = peerID + context.unreadPrivateMessages = [peerID] + context.privateChats[peerID] = [ + makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID), + makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID), + ] + + coordinator.didDisconnectFromPeer(peerID) + await drainMainActorTasks() + + #expect(context.privateChats[peerID] == nil) + #expect(context.privateChats[stablePeerID]?.map(\.id) == ["m1", "mine"]) + // Sender IDs migrate to the stable peer ID, except our own. + #expect(context.privateChats[stablePeerID]?.first?.senderPeerID == stablePeerID) + #expect(context.privateChats[stablePeerID]?.last?.senderPeerID == context.myPeerID) + #expect(context.selectedPrivateChatPeer == stablePeerID) + #expect(context.unreadPrivateMessages == [stablePeerID]) + #expect(context.stablePeerIDCache[peerID] == stablePeerID) + } + + @Test @MainActor + func noisePayloads_driveDeliveryStatusAcksAndVerification() async { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "99aabbccddeeff00") + let noiseKey = Data(repeating: 0x44, count: 32) + context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice") + + // Inbound private message: decoded, handled, and delivery-acked. + let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there") + coordinator.didReceiveNoisePayload( + from: peerID, + type: .privateMessage, + payload: packet.encode() ?? Data(), + timestamp: Date() + ) + await drainMainActorTasks() + #expect(context.handledPrivateMessages.map(\.id) == ["pm-1"]) + #expect(context.handledPrivateMessages.first?.sender == "alice") + #expect(context.meshDeliveryAcks.count == 1) + #expect(context.meshDeliveryAcks.first?.messageID == "pm-1") + + // Delivered / read acks resolve the display name from the unified peer. + coordinator.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data("m-1".utf8), timestamp: Date()) + coordinator.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data("m-2".utf8), timestamp: Date()) + await drainMainActorTasks() + #expect(context.appliedDeliveryStatuses.count == 2) + #expect(context.appliedDeliveryStatuses[0].messageID == "m-1") + if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status { + #expect(to == "alice") + } else { + Issue.record("expected .delivered status") + } + if case .read(let by, _) = context.appliedDeliveryStatuses[1].status { + #expect(by == "alice") + } else { + Issue.record("expected .read status") + } + + // Verification payloads are forwarded untouched. + coordinator.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data([0x01]), timestamp: Date()) + coordinator.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data([0x02]), timestamp: Date()) + await drainMainActorTasks() + #expect(context.verifyChallengePayloads.count == 1) + #expect(context.verifyResponsePayloads.count == 1) + + // Blocked peers' private messages are dropped (no handling, no ack). + context.blockedPeers = [peerID] + coordinator.didReceiveNoisePayload( + from: peerID, + type: .privateMessage, + payload: packet.encode() ?? Data(), + timestamp: Date() + ) + await drainMainActorTasks() + #expect(context.handledPrivateMessages.count == 1) + #expect(context.meshDeliveryAcks.count == 1) + } +} diff --git a/bitchatTests/ChatVerificationCoordinatorContextTests.swift b/bitchatTests/ChatVerificationCoordinatorContextTests.swift new file mode 100644 index 00000000..8dff14c3 --- /dev/null +++ b/bitchatTests/ChatVerificationCoordinatorContextTests.swift @@ -0,0 +1,284 @@ +// +// ChatVerificationCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatVerificationCoordinator` against a mock +// `ChatVerificationContext` — proving the coordinator works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// Scope note: `handleVerifyResponsePayload` requires a real Ed25519 signature +// and posts via `NotificationService.shared`; it remains covered by the full +// view-model/integration tests. Challenge handling, QR kickoff, fingerprint +// verification, and verified-set loading are covered here +// (`VerificationService.shared` is only used for pure payload build/parse). +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatVerificationContext` proving that +/// `ChatVerificationCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatVerificationContext: ChatVerificationContext { + // Fingerprints & verification state + var fingerprintsByPeerID: [PeerID: String] = [:] + var verifiedFingerprints: Set = [] + var persistedFingerprints: Set = [] + private(set) var identityVerifiedCalls: [(fingerprint: String, verified: Bool)] = [] + private(set) var storedVerifiedCalls: [(fingerprint: String, verified: Bool)] = [] + private(set) var saveIdentityStateCount = 0 + + func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } + func persistedVerifiedFingerprints() -> Set { persistedFingerprints } + + func setIdentityVerified(fingerprint: String, verified: Bool) { + identityVerifiedCalls.append((fingerprint, verified)) + } + + func setStoredVerified(_ fingerprint: String, verified: Bool) { + storedVerifiedCalls.append((fingerprint, verified)) + } + + func isVerifiedFingerprint(_ fingerprint: String) -> Bool { + verifiedFingerprints.contains(fingerprint) + } + + func saveIdentityState() { saveIdentityStateCount += 1 } + + // Encryption status + private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:] + private(set) var updatedEncryptionStatusPeers: [PeerID] = [] + private(set) var invalidatedEncryptionCachePeers: [PeerID?] = [] + private(set) var notifyUIChangedCount = 0 + + func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { + encryptionStatuses[peerID] = status + } + + func updateEncryptionStatus(for peerID: PeerID) { + updatedEncryptionStatusPeers.append(peerID) + } + + func invalidateEncryptionCache(for peerID: PeerID?) { + invalidatedEncryptionCachePeers.append(peerID) + } + + func notifyUIChanged() { notifyUIChangedCount += 1 } + + // Peers + var unifiedPeers: [BitchatPeer] = [] + var unifiedFavorites: [BitchatPeer] = [] + private(set) var stablePeerIDCache: [PeerID: PeerID] = [:] + + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { + unifiedPeers.first { $0.peerID == peerID } + } + + func unifiedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } + func resolveNickname(for peerID: PeerID) -> String { "anon\(peerID.id.prefix(4))" } + func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { stablePeerIDCache[shortPeerID] } + + func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) { + stablePeerIDCache[shortPeerID] = stablePeerID + } + + // Noise sessions & verification transport + var myNoiseStaticKey = Data(repeating: 0x42, count: 32) + var establishedNoiseSessions: Set = [] + var noiseSessionKeysByPeerID: [PeerID: Data] = [:] + private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)? + private(set) var triggeredHandshakes: [PeerID] = [] + private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] + private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] + + func installNoiseSessionCallbacks( + onPeerAuthenticated: @escaping (PeerID, String) -> Void, + onHandshakeRequired: @escaping (PeerID) -> Void + ) { + installedCallbacks = (onPeerAuthenticated, onHandshakeRequired) + } + + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { noiseSessionKeysByPeerID[peerID] } + func noiseStaticPublicKeyData() -> Data { myNoiseStaticKey } + func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { + establishedNoiseSessions.contains(peerID) + } + func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) } + + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { + sentChallenges.append((peerID, noiseKeyHex, nonceA)) + } + + func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { + sentResponses.append((peerID, noiseKeyHex, nonceA)) + } +} + +// MARK: - Helpers + +/// Builds the raw verify-challenge TLV as it arrives at the coordinator +/// (i.e. with the `NoisePayload` type byte already stripped). +private func makeVerifyChallengeTLV(noiseKeyHex: String, nonceA: Data) -> Data { + var tlv = Data() + tlv.append(0x01) + tlv.append(UInt8(noiseKeyHex.count)) + tlv.append(Data(noiseKeyHex.utf8)) + tlv.append(0x02) + tlv.append(UInt8(nonceA.count)) + tlv.append(nonceA) + return tlv +} + +private func makeVerificationQR(noiseKeyHex: String) -> VerificationService.VerificationQR { + VerificationService.VerificationQR( + v: 1, + noiseKeyHex: noiseKeyHex, + signKeyHex: "00" + String(repeating: "ab", count: 31), + npub: nil, + nickname: "alice", + ts: 0, + nonceB64: "", + sigHex: "" + ) +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatVerificationCoordinator` against +/// `MockChatVerificationContext` with no `ChatViewModel`. +struct ChatVerificationCoordinatorContextTests { + + @Test @MainActor + func verifyAndUnverifyFingerprint_updateBothStoresAndStatus() async { + let context = MockChatVerificationContext() + let coordinator = ChatVerificationCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + + // Unknown fingerprint: nothing happens. + coordinator.verifyFingerprint(for: peerID) + #expect(context.identityVerifiedCalls.isEmpty) + + context.fingerprintsByPeerID[peerID] = "fp" + coordinator.verifyFingerprint(for: peerID) + coordinator.unverifyFingerprint(for: peerID) + + #expect(context.identityVerifiedCalls.map(\.fingerprint) == ["fp", "fp"]) + #expect(context.identityVerifiedCalls.map(\.verified) == [true, false]) + #expect(context.storedVerifiedCalls.map(\.verified) == [true, false]) + #expect(context.saveIdentityStateCount == 2) + #expect(context.updatedEncryptionStatusPeers == [peerID, peerID]) + } + + @Test @MainActor + func beginQRVerification_sendsChallengeOrTriggersHandshake() async { + let context = MockChatVerificationContext() + let coordinator = ChatVerificationCoordinator(context: context) + let noiseKey = Data(repeating: 0xCD, count: 32) + let peerID = PeerID(str: "1122334455667788") + let qr = makeVerificationQR(noiseKeyHex: noiseKey.hexEncodedString()) + + // No matching peer -> not started. + #expect(!coordinator.beginQRVerification(with: qr)) + + // Matching peer without an established session -> handshake first. + context.unifiedPeers = [BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")] + #expect(coordinator.beginQRVerification(with: qr)) + #expect(context.triggeredHandshakes == [peerID]) + #expect(context.sentChallenges.isEmpty) + + // Already pending -> short-circuits without re-triggering. + #expect(coordinator.beginQRVerification(with: qr)) + #expect(context.triggeredHandshakes == [peerID]) + + // Fresh coordinator with an established session -> immediate challenge. + let context2 = MockChatVerificationContext() + context2.unifiedPeers = [BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")] + context2.establishedNoiseSessions = [peerID] + let coordinator2 = ChatVerificationCoordinator(context: context2) + #expect(coordinator2.beginQRVerification(with: qr)) + #expect(context2.sentChallenges.count == 1) + #expect(context2.sentChallenges.first?.noiseKeyHex == qr.noiseKeyHex) + #expect(context2.triggeredHandshakes.isEmpty) + } + + @Test @MainActor + func handleVerifyChallengePayload_respondsOncePerNonceForOurKeyOnly() async { + let context = MockChatVerificationContext() + let coordinator = ChatVerificationCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let myHex = context.myNoiseStaticKey.hexEncodedString() + let nonce = Data(repeating: 0x07, count: 16) + let payload = makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: nonce) + + coordinator.handleVerifyChallengePayload(from: peerID, payload: payload) + #expect(context.sentResponses.count == 1) + #expect(context.sentResponses.first?.noiseKeyHex.lowercased() == myHex) + #expect(context.sentResponses.first?.nonceA == nonce) + + // Same nonce again: deduplicated, no second response. + coordinator.handleVerifyChallengePayload(from: peerID, payload: payload) + #expect(context.sentResponses.count == 1) + + // A challenge for someone else's key is ignored. + let otherHex = Data(repeating: 0x99, count: 32).hexEncodedString() + let otherPayload = makeVerifyChallengeTLV( + noiseKeyHex: otherHex, + nonceA: Data(repeating: 0x08, count: 16) + ) + coordinator.handleVerifyChallengePayload(from: peerID, payload: otherPayload) + #expect(context.sentResponses.count == 1) + } + + @Test @MainActor + func loadVerifiedFingerprints_syncsPersistedSetAndRefreshesUI() async { + let context = MockChatVerificationContext() + let coordinator = ChatVerificationCoordinator(context: context) + context.persistedFingerprints = ["fp1", "fp2"] + + coordinator.loadVerifiedFingerprints() + + #expect(context.verifiedFingerprints == ["fp1", "fp2"]) + #expect(context.invalidatedEncryptionCachePeers == [nil]) + #expect(context.notifyUIChangedCount == 1) + } + + @Test @MainActor + func installedNoiseCallbacks_publishStatusAndStableIDs() async { + let context = MockChatVerificationContext() + let coordinator = ChatVerificationCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0x33, count: 32) + context.noiseSessionKeysByPeerID[peerID] = noiseKey + context.verifiedFingerprints = ["fp-verified"] + + coordinator.setupNoiseCallbacks() + let callbacks = try? #require(context.installedCallbacks) + + // Authenticated with a verified fingerprint -> verified status and a + // cached stable peer ID derived from the session key. + callbacks?.onPeerAuthenticated(peerID, "fp-verified") + await waitForMainQueue() + #expect(context.encryptionStatuses[peerID] == .noiseVerified) + #expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey)) + #expect(context.invalidatedEncryptionCachePeers.contains(peerID)) + + // Handshake required -> handshaking status. + callbacks?.onHandshakeRequired(peerID) + await waitForMainQueue() + #expect(context.encryptionStatuses[peerID] == .noiseHandshaking) + } +} + +/// The installed callbacks hop through `DispatchQueue.main.async`; tests must +/// let that queue drain before asserting. +@MainActor +private func waitForMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } +}