PeerID 18/n: BitchatDelegate + Tests (#769)

This commit is contained in:
Islam
2025-10-07 16:01:53 +02:00
committed by GitHub
parent fbc15ea08f
commit 551a843691
9 changed files with 120 additions and 121 deletions
+7 -7
View File
@@ -162,9 +162,9 @@ enum DeliveryStatus: Codable, Equatable, Hashable {
protocol BitchatDelegate: AnyObject { protocol BitchatDelegate: AnyObject {
func didReceiveMessage(_ message: BitchatMessage) func didReceiveMessage(_ message: BitchatMessage)
func didConnectToPeer(_ peerID: String) func didConnectToPeer(_ peerID: PeerID)
func didDisconnectFromPeer(_ peerID: String) func didDisconnectFromPeer(_ peerID: PeerID)
func didUpdatePeerList(_ peers: [String]) func didUpdatePeerList(_ peers: [PeerID])
// Optional method to check if a fingerprint belongs to a favorite peer // Optional method to check if a fingerprint belongs to a favorite peer
func isFavorite(fingerprint: String) -> Bool func isFavorite(fingerprint: String) -> Bool
@@ -172,11 +172,11 @@ protocol BitchatDelegate: AnyObject {
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
// Low-level events for better separation of concerns // Low-level events for better separation of concerns
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
// Bluetooth state updates for user notifications // Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState) func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
} }
// Provide default implementation to make it effectively optional // Provide default implementation to make it effectively optional
@@ -189,11 +189,11 @@ extension BitchatDelegate {
// Default empty implementation // Default empty implementation
} }
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) { func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
// Default empty implementation // Default empty implementation
} }
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
// Default empty implementation // Default empty implementation
} }
} }
+19 -15
View File
@@ -60,6 +60,9 @@ final class BLEService: NSObject {
var lastSeen: Date var lastSeen: Date
} }
private var peers: [String: PeerInfo] = [:] private var peers: [String: PeerInfo] = [:]
private var currentPeerIDs: [PeerID] {
peers.keys.map { PeerID(str: $0) }
}
// 4. Efficient Message Deduplication // 4. Efficient Message Deduplication
private let messageDeduplicator = MessageDeduplicator() private let messageDeduplicator = MessageDeduplicator()
@@ -934,7 +937,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
guard let self = self else { return } guard let self = self else { return }
// Get current peer list (after removal) // Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
if let peerID = peerID { if let peerID = peerID {
self.notifyPeerDisconnectedDebounced(peerID.id) self.notifyPeerDisconnectedDebounced(peerID.id)
@@ -1355,7 +1358,7 @@ extension BLEService: CBPeripheralManagerDelegate {
guard let self = self else { return } guard let self = self else { return }
// Get current peer list (after removal) // Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
self.notifyPeerDisconnectedDebounced(peerID.id) self.notifyPeerDisconnectedDebounced(peerID.id)
// Publish snapshots so UnifiedPeerService can refresh icons promptly // Publish snapshots so UnifiedPeerService can refresh icons promptly
@@ -2549,11 +2552,11 @@ extension BLEService {
guard let self = self else { return } guard let self = self else { return }
// Get current peer list (after addition) // Get current peer list (after addition)
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
// Only notify of connection for new or reconnected peers when it is a direct announce // Only notify of connection for new or reconnected peers when it is a direct announce
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) { if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
self.delegate?.didConnectToPeer(peerID) self.delegate?.didConnectToPeer(PeerID(str: peerID))
// Schedule initial unicast sync to this peer // Schedule initial unicast sync to this peer
self.gossipSyncManager?.scheduleInitialSyncToPeer(PeerID(str: peerID), delaySeconds: 1.0) self.gossipSyncManager?.scheduleInitialSyncToPeer(PeerID(str: peerID), delaySeconds: 1.0)
} }
@@ -2700,7 +2703,7 @@ extension BLEService {
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in notifyUI { [weak self] in
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts) self?.delegate?.didReceivePublicMessage(from: PeerID(str: peerID), nickname: senderNickname, content: content, timestamp: ts)
} }
} }
@@ -2738,6 +2741,7 @@ extension BLEService {
} }
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: String) { private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: String) {
let peerID = PeerID(str: peerID)
SecureLogger.debug("🔐 handleNoiseEncrypted called for packet from \(peerID)") SecureLogger.debug("🔐 handleNoiseEncrypted called for packet from \(peerID)")
guard let recipientID = packet.recipientID else { guard let recipientID = packet.recipientID else {
@@ -2752,10 +2756,10 @@ extension BLEService {
} }
// Update lastSeen for the peer we received from (important for private messages) // Update lastSeen for the peer we received from (important for private messages)
updatePeerLastSeen(peerID) updatePeerLastSeen(peerID.id)
do { do {
let decrypted = try noiseService.decrypt(packet.payload, from: PeerID(str: peerID)) let decrypted = try noiseService.decrypt(packet.payload, from: peerID)
guard decrypted.count > 0 else { return } guard decrypted.count > 0 else { return }
// First byte indicates the payload type // First byte indicates the payload type
@@ -2795,8 +2799,8 @@ extension BLEService {
// We received an encrypted message before establishing a session with this peer. // We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted. // Trigger a handshake so future messages can be decrypted.
SecureLogger.debug("🔑 Encrypted message from \(peerID) without session; initiating handshake") SecureLogger.debug("🔑 Encrypted message from \(peerID) without session; initiating handshake")
if !noiseService.hasSession(with: PeerID(str: peerID)) { if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID) initiateNoiseHandshake(with: peerID.id)
} }
} catch { } catch {
SecureLogger.error("❌ Failed to decrypt message from \(peerID): \(error)") SecureLogger.error("❌ Failed to decrypt message from \(peerID): \(error)")
@@ -2815,9 +2819,9 @@ extension BLEService {
guard let self = self else { return } guard let self = self else { return }
// Get current peer list (after removal) // Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
self.delegate?.didDisconnectFromPeer(peerID) self.delegate?.didDisconnectFromPeer(PeerID(str: peerID))
self.delegate?.didUpdatePeerList(currentPeerIDs) self.delegate?.didUpdatePeerList(currentPeerIDs)
} }
} }
@@ -2937,7 +2941,7 @@ extension BLEService {
let now = Date() let now = Date()
let last = recentDisconnectNotifies[peerID] let last = recentDisconnectNotifies[peerID]
if last == nil || now.timeIntervalSince(last!) >= TransportConfig.bleDisconnectNotifyDebounceSeconds { if last == nil || now.timeIntervalSince(last!) >= TransportConfig.bleDisconnectNotifyDebounceSeconds {
delegate?.didDisconnectFromPeer(peerID) delegate?.didDisconnectFromPeer(PeerID(str: peerID))
recentDisconnectNotifies[peerID] = now recentDisconnectNotifies[peerID] = now
} else { } else {
// Suppressed duplicate disconnect notification // Suppressed duplicate disconnect notification
@@ -3078,13 +3082,13 @@ extension BLEService {
// Update UI if there were direct disconnections or offline removals // Update UI if there were direct disconnections or offline removals
if !disconnectedPeers.isEmpty || removedOfflineCount > 0 { if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
notifyUI { [weak self] in notifyUI { [weak self] in
guard let self = self else { return } guard let self else { return }
// Get current peer list (after removal) // Get current peer list (after removal)
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs }
for peerID in disconnectedPeers { for peerID in disconnectedPeers {
self.delegate?.didDisconnectFromPeer(peerID) self.delegate?.didDisconnectFromPeer(PeerID(str: peerID))
} }
// Publish snapshots so UnifiedPeerService updates connection/reachability icons // Publish snapshots so UnifiedPeerService updates connection/reachability icons
self.requestPeerDataPublish() self.requestPeerDataPublish()
+41 -43
View File
@@ -223,7 +223,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
@Published var isConnected = false @Published var isConnected = false
private var hasNotifiedNetworkAvailable = false private var hasNotifiedNetworkAvailable = false
private var recentlySeenPeers: Set<String> = [] private var recentlySeenPeers: Set<PeerID> = []
private var lastNetworkNotificationTime = Date.distantPast private var lastNetworkNotificationTime = Date.distantPast
private var networkResetTimer: Timer? = nil private var networkResetTimer: Timer? = nil
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
@@ -4449,12 +4449,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Low-level BLE events // Low-level BLE events
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) { func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
Task { @MainActor in Task { @MainActor in
switch type { switch type {
case .privateMessage: case .privateMessage:
guard let pm = PrivateMessagePacket.decode(from: payload) else { return } guard let pm = PrivateMessagePacket.decode(from: payload) else { return }
let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown" let senderName = unifiedPeerService.getPeer(by: peerID.id)?.nickname ?? "Unknown"
let pmMentions = parseMentions(from: pm.content) let pmMentions = parseMentions(from: pm.content)
let msg = BitchatMessage( let msg = BitchatMessage(
id: pm.messageID, id: pm.messageID,
@@ -4465,27 +4465,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil, originalSender: nil,
isPrivate: true, isPrivate: true,
recipientNickname: nickname, recipientNickname: nickname,
senderPeerID: PeerID(str: peerID), senderPeerID: peerID,
mentions: pmMentions.isEmpty ? nil : pmMentions mentions: pmMentions.isEmpty ? nil : pmMentions
) )
handlePrivateMessage(msg) handlePrivateMessage(msg)
// Send delivery ACK back over BLE // Send delivery ACK back over BLE
meshService.sendDeliveryAck(for: pm.messageID, to: PeerID(str: peerID)) meshService.sendDeliveryAck(for: pm.messageID, to: peerID)
case .delivered: case .delivered:
guard let messageID = String(data: payload, encoding: .utf8) else { return } guard let messageID = String(data: payload, encoding: .utf8) else { return }
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname { if let name = unifiedPeerService.getPeer(by: peerID.id)?.nickname {
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) { if let messages = privateChats[peerID.id], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .delivered(to: name, at: Date()) privateChats[peerID.id]?[idx].deliveryStatus = .delivered(to: name, at: Date())
objectWillChange.send() objectWillChange.send()
} }
} }
case .readReceipt: case .readReceipt:
guard let messageID = String(data: payload, encoding: .utf8) else { return } guard let messageID = String(data: payload, encoding: .utf8) else { return }
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname { if let name = unifiedPeerService.getPeer(by: peerID.id)?.nickname {
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) { if let messages = privateChats[peerID.id], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .read(by: name, at: Date()) privateChats[peerID.id]?[idx].deliveryStatus = .read(by: name, at: Date())
objectWillChange.send() objectWillChange.send()
} }
} }
@@ -4496,10 +4496,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let myNoiseHex = meshService.getNoiseService().getStaticPublicKeyData().hexEncodedString().lowercased() let myNoiseHex = meshService.getNoiseService().getStaticPublicKeyData().hexEncodedString().lowercased()
guard tlv.noiseKeyHex.lowercased() == myNoiseHex else { return } guard tlv.noiseKeyHex.lowercased() == myNoiseHex else { return }
// Deduplicate: ignore if we've already responded to this nonce for this peer // Deduplicate: ignore if we've already responded to this nonce for this peer
if let last = lastVerifyNonceByPeer[peerID], last == tlv.nonceA { return } if let last = lastVerifyNonceByPeer[peerID.id], last == tlv.nonceA { return }
lastVerifyNonceByPeer[peerID] = tlv.nonceA lastVerifyNonceByPeer[peerID.id] = tlv.nonceA
// Record inbound challenge time keyed by stable fingerprint if available // Record inbound challenge time keyed by stable fingerprint if available
if let fp = getFingerprint(for: peerID) { if let fp = getFingerprint(for: peerID.id) {
lastInboundVerifyChallengeAt[fp] = Date() lastInboundVerifyChallengeAt[fp] = Date()
// If we've already verified this fingerprint locally, treat this as mutual and toast immediately (responder side) // If we've already verified this fingerprint locally, treat this as mutual and toast immediately (responder side)
if verifiedFingerprints.contains(fp) { if verifiedFingerprints.contains(fp) {
@@ -4507,7 +4507,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let last = lastMutualToastAt[fp] ?? .distantPast let last = lastMutualToastAt[fp] ?? .distantPast
if now.timeIntervalSince(last) > 60 { // 1-minute throttle if now.timeIntervalSince(last) > 60 { // 1-minute throttle
lastMutualToastAt[fp] = now lastMutualToastAt[fp] = now
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID) let name = unifiedPeerService.getPeer(by: peerID.id)?.nickname ?? resolveNickname(for: peerID.id)
NotificationService.shared.sendLocalNotification( NotificationService.shared.sendLocalNotification(
title: "Mutual verification", title: "Mutual verification",
body: "You and \(name) verified each other", body: "You and \(name) verified each other",
@@ -4516,24 +4516,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} }
} }
meshService.sendVerifyResponse(to: PeerID(str: peerID), noiseKeyHex: tlv.noiseKeyHex, nonceA: tlv.nonceA) meshService.sendVerifyResponse(to: peerID, noiseKeyHex: tlv.noiseKeyHex, nonceA: tlv.nonceA)
// Silent response: no toast needed on responder // Silent response: no toast needed on responder
case .verifyResponse: case .verifyResponse:
guard let resp = VerificationService.shared.parseVerifyResponse(payload) else { return } guard let resp = VerificationService.shared.parseVerifyResponse(payload) else { return }
// Check pending for this peer // Check pending for this peer
guard let pending = pendingQRVerifications[peerID] else { return } guard let pending = pendingQRVerifications[peerID.id] else { return }
guard resp.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), resp.nonceA == pending.nonceA else { return } guard resp.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), resp.nonceA == pending.nonceA else { return }
// Verify signature with expected sign key // Verify signature with expected sign key
let ok = VerificationService.shared.verifyResponseSignature(noiseKeyHex: resp.noiseKeyHex, nonceA: resp.nonceA, signature: resp.signature, signerPublicKeyHex: pending.signKeyHex) let ok = VerificationService.shared.verifyResponseSignature(noiseKeyHex: resp.noiseKeyHex, nonceA: resp.nonceA, signature: resp.signature, signerPublicKeyHex: pending.signKeyHex)
if ok { if ok {
pendingQRVerifications.removeValue(forKey: peerID) pendingQRVerifications.removeValue(forKey: peerID.id)
if let fp = getFingerprint(for: peerID) { if let fp = getFingerprint(for: peerID.id) {
let short = fp.prefix(8) let short = fp.prefix(8)
SecureLogger.info("🔐 Marking verified fingerprint: \(short)", category: .security) SecureLogger.info("🔐 Marking verified fingerprint: \(short)", category: .security)
identityManager.setVerified(fingerprint: fp, verified: true) identityManager.setVerified(fingerprint: fp, verified: true)
identityManager.forceSave() identityManager.forceSave()
verifiedFingerprints.insert(fp) verifiedFingerprints.insert(fp)
let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID) let name = unifiedPeerService.getPeer(by: peerID.id)?.nickname ?? resolveNickname(for: peerID.id)
NotificationService.shared.sendLocalNotification( NotificationService.shared.sendLocalNotification(
title: "Verified", title: "Verified",
body: "You verified \(name)", body: "You verified \(name)",
@@ -4552,14 +4552,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
) )
} }
} }
updateEncryptionStatus(for: peerID) updateEncryptionStatus(for: peerID.id)
} }
} }
} }
} }
} }
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
Task { @MainActor in Task { @MainActor in
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines) let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
let publicMentions = parseMentions(from: normalized) let publicMentions = parseMentions(from: normalized)
@@ -4572,7 +4572,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil, originalSender: nil,
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: PeerID(str: peerID), senderPeerID: peerID,
mentions: publicMentions.isEmpty ? nil : publicMentions mentions: publicMentions.isEmpty ? nil : publicMentions
) )
handlePublicMessage(msg) handlePublicMessage(msg)
@@ -4622,7 +4622,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Peer Connection Events // MARK: - Peer Connection Events
func didConnectToPeer(_ peerID: String) { func didConnectToPeer(_ peerID: PeerID) {
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session) SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
// Handle all main actor work async // Handle all main actor work async
@@ -4630,7 +4630,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isConnected = true isConnected = true
// Register ephemeral session with identity manager // Register ephemeral session with identity manager
identityManager.registerEphemeralSession(peerID: PeerID(str: peerID), handshakeState: .none) identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
// Intentionally do not resend favorites on reconnect. // Intentionally do not resend favorites on reconnect.
// We only send our npub when a favorite is toggled on, or if our npub changes. // We only send our npub when a favorite is toggled on, or if our npub changes.
@@ -4639,36 +4639,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
objectWillChange.send() objectWillChange.send()
// Cache mapping to full Noise key for session continuity on disconnect // Cache mapping to full Noise key for session continuity on disconnect
if let peer = unifiedPeerService.getPeer(by: peerID) { if let peer = unifiedPeerService.getPeer(by: peerID.id) {
let noiseKeyHex = peer.noisePublicKey.hexEncodedString() let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
shortIDToNoiseKey[peerID] = noiseKeyHex shortIDToNoiseKey[peerID.id] = noiseKeyHex
} }
// Flush any queued messages for this peer via router // Flush any queued messages for this peer via router
messageRouter.flushOutbox(for: PeerID(str: peerID)) messageRouter.flushOutbox(for: peerID)
} }
//
} }
func didDisconnectFromPeer(_ peerID: String) { func didDisconnectFromPeer(_ peerID: PeerID) {
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session) SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
// Remove ephemeral session from identity manager // Remove ephemeral session from identity manager
identityManager.removeEphemeralSession(peerID: PeerID(str: peerID)) identityManager.removeEphemeralSession(peerID: peerID)
// If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite) // If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite)
var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID] var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID.id]
if derivedStableKeyHex == nil, if derivedStableKeyHex == nil,
let key = meshService.getNoiseService().getPeerPublicKeyData(PeerID(str: peerID)) { let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
derivedStableKeyHex = key.hexEncodedString() derivedStableKeyHex = key.hexEncodedString()
shortIDToNoiseKey[peerID] = derivedStableKeyHex shortIDToNoiseKey[peerID.id] = derivedStableKeyHex
} }
if let current = selectedPrivateChatPeer, current == peerID, if let current = selectedPrivateChatPeer, current == peerID,
let stableKeyHex = derivedStableKeyHex { let stableKeyHex = derivedStableKeyHex {
// Migrate messages view context to stable key so header shows favorite + Nostr globe // Migrate messages view context to stable key so header shows favorite + Nostr globe
if let messages = privateChats[peerID] { if let messages = privateChats[peerID.id] {
if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] } if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }
let existing = Set(privateChats[stableKeyHex]!.map { $0.id }) let existing = Set(privateChats[stableKeyHex]!.map { $0.id })
for msg in messages where !existing.contains(msg.id) { for msg in messages where !existing.contains(msg.id) {
@@ -4688,10 +4686,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
privateChats[stableKeyHex]?.append(updated) privateChats[stableKeyHex]?.append(updated)
} }
privateChats[stableKeyHex]?.sort { $0.timestamp < $1.timestamp } privateChats[stableKeyHex]?.sort { $0.timestamp < $1.timestamp }
privateChats.removeValue(forKey: peerID) privateChats.removeValue(forKey: peerID.id)
} }
if unreadPrivateMessages.contains(peerID) { if unreadPrivateMessages.contains(peerID.id) {
unreadPrivateMessages.remove(peerID) unreadPrivateMessages.remove(peerID.id)
unreadPrivateMessages.insert(stableKeyHex) unreadPrivateMessages.insert(stableKeyHex)
} }
selectedPrivateChatPeer = stableKeyHex selectedPrivateChatPeer = stableKeyHex
@@ -4706,7 +4704,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Clear sent read receipts for this peer since they'll need to be resent after reconnection // Clear sent read receipts for this peer since they'll need to be resent after reconnection
// Only clear receipts for messages from this specific peer // Only clear receipts for messages from this specific peer
if let messages = privateChats[peerID] { if let messages = privateChats[peerID.id] {
for message in messages { for message in messages {
// Remove read receipts for messages FROM this peer (not TO this peer) // Remove read receipts for messages FROM this peer (not TO this peer)
if message.senderPeerID == peerID { if message.senderPeerID == peerID {
@@ -4718,7 +4716,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// //
} }
func didUpdatePeerList(_ peers: [String]) { func didUpdatePeerList(_ peers: [PeerID]) {
// UI updates must run on the main thread. // UI updates must run on the main thread.
// The delegate callback is not guaranteed to be on the main thread. // The delegate callback is not guaranteed to be on the main thread.
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -4736,7 +4734,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
self.networkResetTimer = nil self.networkResetTimer = nil
// Count mesh peers that are connected OR recently reachable via mesh relays // Count mesh peers that are connected OR recently reachable via mesh relays
let meshPeers = peers.filter { peerID in let meshPeers = peers.filter { peerID in
self.meshService.isPeerConnected(PeerID(str: peerID)) || self.meshService.isPeerReachable(PeerID(str: peerID)) self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
} }
// Rising-edge only: previously zero peers, now > 0 peers // Rising-edge only: previously zero peers, now > 0 peers
@@ -4762,7 +4760,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Register ephemeral sessions for all connected peers // Register ephemeral sessions for all connected peers
for peerID in peers { for peerID in peers {
self.identityManager.registerEphemeralSession(peerID: PeerID(str: peerID), handshakeState: .none) self.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
} }
// Schedule UI refresh to ensure offline favorites are shown // Schedule UI refresh to ensure offline favorites are shown
+4 -4
View File
@@ -281,12 +281,12 @@ private final class MockBitchatDelegate: BitchatDelegate {
messageHandler(message) messageHandler(message)
} }
func didConnectToPeer(_ peerID: String) {} func didConnectToPeer(_ peerID: PeerID) {}
func didDisconnectFromPeer(_ peerID: String) {} func didDisconnectFromPeer(_ peerID: PeerID) {}
func didUpdatePeerList(_ peers: [String]) {} func didUpdatePeerList(_ peers: [PeerID]) {}
func isFavorite(fingerprint: String) -> Bool { return false } func isFavorite(fingerprint: String) -> Bool { return false }
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {} func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {} func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
func didUpdateBluetoothState(_ state: CBManagerState) {} func didUpdateBluetoothState(_ state: CBManagerState) {}
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {} func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
} }
+10 -10
View File
@@ -48,7 +48,7 @@ struct PrivateChatE2ETests {
// Alice sends private message to Bob // Alice sends private message to Bob
alice.sendPrivateMessage( alice.sendPrivateMessage(
TestConstants.testMessage1, TestConstants.testMessage1,
to: PeerID(str: bob.peerID), to: bob.peerID,
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
@@ -74,7 +74,7 @@ struct PrivateChatE2ETests {
// Alice sends private message to Bob // Alice sends private message to Bob
alice.sendPrivateMessage( alice.sendPrivateMessage(
TestConstants.testMessage1, TestConstants.testMessage1,
to: PeerID(str: bob.peerID), to: bob.peerID,
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
} }
@@ -99,7 +99,7 @@ struct PrivateChatE2ETests {
alice.sendPrivateMessage( alice.sendPrivateMessage(
TestConstants.testMessage1, TestConstants.testMessage1,
to: PeerID(str: bob.peerID), to: bob.peerID,
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
} }
@@ -119,10 +119,10 @@ struct PrivateChatE2ETests {
// Establish encrypted session // Establish encrypted session
do { do {
let handshake1 = try aliceManager.initiateHandshake(with: PeerID(str: bob.peerID)) let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID)
let handshake2 = try bobManager.handleIncomingHandshake(from: PeerID(str: alice.peerID), message: handshake1)! let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)!
let handshake3 = try aliceManager.handleIncomingHandshake(from: PeerID(str: bob.peerID), message: handshake2)! let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)!
_ = try bobManager.handleIncomingHandshake(from: PeerID(str: alice.peerID), message: handshake3) _ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3)
} catch { } catch {
Issue.record("Failed to establish Noise session: \(error)") Issue.record("Failed to establish Noise session: \(error)")
} }
@@ -135,7 +135,7 @@ struct PrivateChatE2ETests {
let message = BitchatMessage(packet.payload), let message = BitchatMessage(packet.payload),
message.isPrivate { message.isPrivate {
do { do {
let encrypted = try aliceManager.encrypt(packet.payload, for: PeerID(str: bob.peerID)) let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID)
let encryptedPacket = BitchatPacket( let encryptedPacket = BitchatPacket(
type: 0x02, // Encrypted message type type: 0x02, // Encrypted message type
senderID: packet.senderID, senderID: packet.senderID,
@@ -156,7 +156,7 @@ struct PrivateChatE2ETests {
// Decrypt incoming encrypted messages // Decrypt incoming encrypted messages
if packet.type == 0x02 { if packet.type == 0x02 {
do { do {
let decrypted = try bobManager.decrypt(packet.payload, from: PeerID(str: alice.peerID)) let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID)
if let message = BitchatMessage(decrypted) { if let message = BitchatMessage(decrypted) {
#expect(message.content == TestConstants.testMessage1) #expect(message.content == TestConstants.testMessage1)
#expect(message.isPrivate) #expect(message.isPrivate)
@@ -207,7 +207,7 @@ struct PrivateChatE2ETests {
// Alice sends private message to Charlie (through Bob) // Alice sends private message to Charlie (through Bob)
alice.sendPrivateMessage( alice.sendPrivateMessage(
TestConstants.testMessage1, TestConstants.testMessage1,
to: PeerID(str: charlie.peerID), to: charlie.peerID,
recipientNickname: TestConstants.testNickname3 recipientNickname: TestConstants.testNickname3
) )
} }
@@ -183,11 +183,11 @@ struct PublicChatE2ETests {
let msg = TestHelpers.createTestMessage( let msg = TestHelpers.createTestMessage(
content: TestConstants.testMessage1, content: TestConstants.testMessage1,
sender: TestConstants.testNickname1, sender: TestConstants.testNickname1,
senderPeerID: PeerID(str: alice.peerID) senderPeerID: alice.peerID
) )
if let payload = msg.toBinaryPayload() { if let payload = msg.toBinaryPayload() {
let pkt = TestHelpers.createTestPacket(senderID: PeerID(str: alice.peerID), payload: payload, ttl: 2) let pkt = TestHelpers.createTestPacket(senderID: alice.peerID, payload: payload, ttl: 2)
bob.simulateIncomingPacket(pkt) bob.simulateIncomingPacket(pkt)
} }
} }
@@ -407,7 +407,7 @@ struct PublicChatE2ETests {
if let relayPayload = relayMessage.toBinaryPayload() { if let relayPayload = relayMessage.toBinaryPayload() {
let relayPacket = BitchatPacket( let relayPacket = BitchatPacket(
type: packet.type, type: packet.type,
senderID: node.peerID.data(using: .utf8)!, senderID: node.peerID.id.data(using: .utf8)!,
recipientID: packet.recipientID, recipientID: packet.recipientID,
timestamp: packet.timestamp, timestamp: packet.timestamp,
payload: relayPayload, payload: relayPayload,
@@ -127,19 +127,19 @@ struct FragmentationTests {
extension FragmentationTests { extension FragmentationTests {
private final class CaptureDelegate: BitchatDelegate { private final class CaptureDelegate: BitchatDelegate {
var publicMessages: [(peerID: String, nickname: String, content: String)] = [] var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
func didReceiveMessage(_ message: BitchatMessage) {} func didReceiveMessage(_ message: BitchatMessage) {}
func didConnectToPeer(_ peerID: String) {} func didConnectToPeer(_ peerID: PeerID) {}
func didDisconnectFromPeer(_ peerID: String) {} func didDisconnectFromPeer(_ peerID: PeerID) {}
func didUpdatePeerList(_ peers: [String]) {} func didUpdatePeerList(_ peers: [PeerID]) {}
func isFavorite(fingerprint: String) -> Bool { false } func isFavorite(fingerprint: String) -> Bool { false }
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {} func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {} func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
func didUpdateBluetoothState(_ state: CBManagerState) {} func didUpdateBluetoothState(_ state: CBManagerState) {}
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
publicMessages.append((peerID, nickname, content)) publicMessages.append((peerID, nickname, content))
} }
func didReceiveRegionalPublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {} func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
} }
// Helper: build a large message packet (unencrypted public message) // Helper: build a large message packet (unencrypted public message)
@@ -484,14 +484,11 @@ final class IntegrationTests: XCTestCase {
guard let aliceManager = noiseManagers["Alice"], guard let aliceManager = noiseManagers["Alice"],
let bobManager = noiseManagers["Bob"], let bobManager = noiseManagers["Bob"],
let aliceStringPeerID = nodes["Alice"]?.peerID, let alicePeerID = nodes["Alice"]?.peerID,
let bobStringPeerID = nodes["Bob"]?.peerID else { let bobPeerID = nodes["Bob"]?.peerID else {
return XCTFail("Missing managers or peer IDs") return XCTFail("Missing managers or peer IDs")
} }
let alicePeerID = PeerID(str: aliceStringPeerID)
let bobPeerID = PeerID(str: bobStringPeerID)
// Baseline: encrypt from Alice, decrypt at Bob // Baseline: encrypt from Alice, decrypt at Bob
let plaintext1 = Data("hello-secure".utf8) let plaintext1 = Data("hello-secure".utf8)
let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID) let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID)
@@ -592,7 +589,7 @@ final class IntegrationTests: XCTestCase {
private func createNode(_ name: String, peerID: PeerID) { private func createNode(_ name: String, peerID: PeerID) {
let node = MockBluetoothMeshService() let node = MockBluetoothMeshService()
node.myPeerID = peerID.id node.myPeerID = peerID
node.mockNickname = name node.mockNickname = name
nodes[name] = node nodes[name] = node
@@ -669,9 +666,9 @@ final class IntegrationTests: XCTestCase {
let peer1ID = nodes[node1]?.peerID, let peer1ID = nodes[node1]?.peerID,
let peer2ID = nodes[node2]?.peerID else { return } let peer2ID = nodes[node2]?.peerID else { return }
let msg1 = try manager1.initiateHandshake(with: PeerID(str: peer2ID)) let msg1 = try manager1.initiateHandshake(with: peer2ID)
let msg2 = try manager2.handleIncomingHandshake(from: PeerID(str: peer1ID), message: msg1)! let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
let msg3 = try manager1.handleIncomingHandshake(from: PeerID(str: peer2ID), message: msg2)! let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
_ = try manager2.handleIncomingHandshake(from: PeerID(str: peer1ID), message: msg3) _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
} }
} }
+22 -22
View File
@@ -32,7 +32,7 @@ final class MockBLEService: NSObject {
// MARK: - Properties matching BLEService // MARK: - Properties matching BLEService
weak var delegate: BitchatDelegate? weak var delegate: BitchatDelegate?
var myPeerID: String = "MOCK1234" var myPeerID: PeerID = "MOCK1234"
var myNickname: String = "MockUser" var myNickname: String = "MockUser"
private let mockKeychain = MockKeychain() private let mockKeychain = MockKeychain()
@@ -40,7 +40,7 @@ final class MockBLEService: NSObject {
// Test-specific properties // Test-specific properties
var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = [] var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = []
var sentPackets: [BitchatPacket] = [] var sentPackets: [BitchatPacket] = []
var connectedPeers: Set<String> = [] var connectedPeers: Set<PeerID> = []
var messageDeliveryHandler: ((BitchatMessage) -> Void)? var messageDeliveryHandler: ((BitchatMessage) -> Void)?
var packetDeliveryHandler: ((BitchatPacket) -> Void)? var packetDeliveryHandler: ((BitchatPacket) -> Void)?
@@ -54,7 +54,7 @@ final class MockBLEService: NSObject {
return myNickname return myNickname
} }
var peerID: String { var peerID: PeerID {
return myPeerID return myPeerID
} }
@@ -72,8 +72,8 @@ final class MockBLEService: NSObject {
// MARK: - In-memory test bus (for E2E/Integration) // MARK: - In-memory test bus (for E2E/Integration)
/// Global per-process bus for deterministic routing in tests. /// Global per-process bus for deterministic routing in tests.
private static var registry: [String: MockBLEService] = [:] private static var registry: [PeerID: MockBLEService] = [:]
private static var adjacency: [String: Set<String>] = [:] private static var adjacency: [PeerID: Set<PeerID>] = [:]
/// Clears global bus state. Call from test `setUp()`. /// Clears global bus state. Call from test `setUp()`.
static func resetTestBus() { static func resetTestBus() {
@@ -94,7 +94,7 @@ final class MockBLEService: NSObject {
} }
/// Adds an undirected edge between two peerIDs. /// Adds an undirected edge between two peerIDs.
private static func connectPeers(_ a: String, _ b: String) { private static func connectPeers(_ a: PeerID, _ b: PeerID) {
var setA = adjacency[a] ?? [] var setA = adjacency[a] ?? []
setA.insert(b) setA.insert(b)
adjacency[a] = setA adjacency[a] = setA
@@ -104,7 +104,7 @@ final class MockBLEService: NSObject {
} }
/// Removes an undirected edge between two peerIDs. /// Removes an undirected edge between two peerIDs.
private static func disconnectPeers(_ a: String, _ b: String) { private static func disconnectPeers(_ a: PeerID, _ b: PeerID) {
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA } if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB } if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
} }
@@ -117,7 +117,7 @@ final class MockBLEService: NSObject {
// Mock implementation - do nothing // Mock implementation - do nothing
} }
func isPeerConnected(_ peerID: String) -> Bool { func isPeerConnected(_ peerID: PeerID) -> Bool {
return connectedPeers.contains(peerID) return connectedPeers.contains(peerID)
} }
@@ -125,15 +125,15 @@ final class MockBLEService: NSObject {
"MockPeer_\(peerID)" "MockPeer_\(peerID)"
} }
func getPeerNicknames() -> [String: String] { func getPeerNicknames() -> [PeerID: String] {
var nicknames: [String: String] = [:] var nicknames: [PeerID: String] = [:]
for peer in connectedPeers { for peer in connectedPeers {
nicknames[peer] = "MockPeer_\(peer)" nicknames[peer] = "MockPeer_\(peer)"
} }
return nicknames return nicknames
} }
func getPeers() -> [String: String] { func getPeers() -> [PeerID: String] {
return getPeerNicknames() return getPeerNicknames()
} }
@@ -147,14 +147,14 @@ final class MockBLEService: NSObject {
originalSender: nil, originalSender: nil,
isPrivate: recipientID != nil, isPrivate: recipientID != nil,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: PeerID(str: myPeerID), senderPeerID: myPeerID,
mentions: mentions.isEmpty ? nil : mentions mentions: mentions.isEmpty ? nil : mentions
) )
if let payload = message.toBinaryPayload() { if let payload = message.toBinaryPayload() {
let packet = BitchatPacket( let packet = BitchatPacket(
type: 0x01, type: 0x01,
senderID: myPeerID.data(using: .utf8)!, senderID: myPeerID.id.data(using: .utf8)!,
recipientID: recipientID?.data(using: .utf8), recipientID: recipientID?.data(using: .utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
@@ -182,7 +182,7 @@ final class MockBLEService: NSObject {
} }
} }
func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String) { func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) {
let message = BitchatMessage( let message = BitchatMessage(
id: messageID, id: messageID,
sender: myNickname, sender: myNickname,
@@ -192,15 +192,15 @@ final class MockBLEService: NSObject {
originalSender: nil, originalSender: nil,
isPrivate: true, isPrivate: true,
recipientNickname: recipientNickname, recipientNickname: recipientNickname,
senderPeerID: PeerID(str: myPeerID), senderPeerID: myPeerID,
mentions: nil mentions: nil
) )
if let payload = message.toBinaryPayload() { if let payload = message.toBinaryPayload() {
let packet = BitchatPacket( let packet = BitchatPacket(
type: 0x01, type: 0x01,
senderID: myPeerID.data(using: .utf8)!, senderID: myPeerID.id.data(using: .utf8)!,
recipientID: recipientPeerID.data(using: .utf8)!, recipientID: recipientPeerID.id.data(using: .utf8)!,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
signature: nil, signature: nil,
@@ -277,7 +277,7 @@ final class MockBLEService: NSObject {
// MARK: - Test Helper Methods // MARK: - Test Helper Methods
func simulateConnectedPeer(_ peerID: String) { func simulateConnectedPeer(_ peerID: PeerID) {
registerIfNeeded() registerIfNeeded()
MockBLEService.connectPeers(myPeerID, peerID) MockBLEService.connectPeers(myPeerID, peerID)
connectedPeers.insert(peerID) connectedPeers.insert(peerID)
@@ -285,7 +285,7 @@ final class MockBLEService: NSObject {
delegate?.didUpdatePeerList(Array(connectedPeers)) delegate?.didUpdatePeerList(Array(connectedPeers))
} }
func simulateDisconnectedPeer(_ peerID: String) { func simulateDisconnectedPeer(_ peerID: PeerID) {
MockBLEService.disconnectPeers(myPeerID, peerID) MockBLEService.disconnectPeers(myPeerID, peerID)
connectedPeers.remove(peerID) connectedPeers.remove(peerID)
delegate?.didDisconnectFromPeer(peerID) delegate?.didDisconnectFromPeer(peerID)
@@ -336,14 +336,14 @@ final class MockBLEService: NSObject {
packetDeliveryHandler?(packet) packetDeliveryHandler?(packet)
} }
func getConnectedPeers() -> [String] { func getConnectedPeers() -> [PeerID] {
return Array(connectedPeers) return Array(connectedPeers)
} }
// MARK: - Compatibility methods for old tests // MARK: - Compatibility methods for old tests
func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String? = nil) { func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String? = nil) {
sendPrivateMessage(content, to: recipientPeerID.id, recipientNickname: recipientNickname, messageID: messageID ?? UUID().uuidString) sendPrivateMessage(content, to: recipientPeerID, recipientNickname: recipientNickname, messageID: messageID ?? UUID().uuidString)
} }
} }
@@ -355,7 +355,7 @@ typealias MockSimplifiedBluetoothService = MockBLEService
extension MockBLEService { extension MockBLEService {
convenience init(peerID: PeerID, nickname: String) { convenience init(peerID: PeerID, nickname: String) {
self.init() self.init()
myPeerID = peerID.id myPeerID = peerID
mockNickname = nickname mockNickname = nickname
} }