Migrate five more coordinators to narrow context protocols

ChatTransportEventCoordinator, ChatPeerIdentityCoordinator,
ChatMediaTransferCoordinator, ChatVerificationCoordinator, and
ChatLifecycleCoordinator drop their unowned ChatViewModel back-refs for
narrow @MainActor contexts (20-36 members each), reusing shared
witnesses across protocols. The two remaining raw writers of
sentReadReceipts now route through owner intent ops
(unmarkReadReceiptsSent, syncReadReceiptsForSentMessages), closing the
gaps noted in the previous commit. NoiseEncryptionService stays fully
out of the verification coordinator via installNoiseSessionCallbacks.
26 new mock-context tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-10 21:54:54 +02:00
co-authored by Claude Fable 5
parent 707b22878d
commit 82736c4991
11 changed files with 2376 additions and 350 deletions
+148 -57
View File
@@ -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<PeerID> { get set }
var selectedPrivateChatPeer: PeerID? { get }
var sentReadReceipts: Set<String> { 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")
)
}
@@ -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)
}
}
@@ -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<PeerID> { 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<PeerID> { 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
)
@@ -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<PeerID> { 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)
}
}
@@ -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<String> { get set }
/// The persisted verified-fingerprint set from the identity manager.
func persistedVerifiedFingerprints() -> Set<String>
/// 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<String> {
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)
}
}
+24 -5
View File
@@ -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
@@ -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<PeerID> = []
var selectedPrivateChatPeer: PeerID?
var sentReadReceipts: Set<String> = []
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])
}
}
@@ -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)
}
}
@@ -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<PeerID> = []
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<PeerID> = []
var peersByID: [PeerID: BitchatPeer] = [:]
var blockedPeers: Set<PeerID> = []
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<PeerID> = []
var activeNoiseSessions: Set<PeerID> = []
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<String> = []
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)
}
}
@@ -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<PeerID> = []
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<String> = []
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<PeerID> = []
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)
}
}
@@ -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<String> = []
var persistedFingerprints: Set<String> = []
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<String> { 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<PeerID> = []
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() }
}
}