mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user