diff --git a/bitchat/Features/media/ImageUtils.swift b/bitchat/Features/media/ImageUtils.swift index a75cc6db..b49f92ca 100644 --- a/bitchat/Features/media/ImageUtils.swift +++ b/bitchat/Features/media/ImageUtils.swift @@ -15,17 +15,10 @@ enum ImageUtilsError: Error { enum ImageUtils { private static let compressionQuality: CGFloat = 0.82 private static let targetImageBytes: Int = 45_000 + private static let maxSourceImageBytes: Int = 10 * 1024 * 1024 static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { - // Security H1: Check file size BEFORE reading into memory - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - guard let fileSize = attrs[.size] as? Int else { - throw ImageUtilsError.invalidImage - } - // Allow up to 10MB source images (will be scaled down) - guard fileSize <= 10 * 1024 * 1024 else { - throw ImageUtilsError.invalidImage - } + try validateImageSource(at: url) let data = try Data(contentsOf: url) #if os(iOS) @@ -37,6 +30,22 @@ enum ImageUtils { #endif } + static func validateImageSource(at url: URL) throws { + // Security H1: Check file size BEFORE reading into memory. + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + guard let fileSize = attrs[.size] as? Int, + fileSize > 0, + fileSize <= maxSourceImageBytes else { + throw ImageUtilsError.invalidImage + } + + let options = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithURL(url as CFURL, options), + CGImageSourceGetType(source) != nil else { + throw ImageUtilsError.invalidImage + } + } + #if os(iOS) static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL { return try autoreleasepool { diff --git a/bitchat/Nostr/Base64URLCoding.swift b/bitchat/Nostr/Base64URLCoding.swift new file mode 100644 index 00000000..bbc04ea6 --- /dev/null +++ b/bitchat/Nostr/Base64URLCoding.swift @@ -0,0 +1,22 @@ +import Foundation + +enum Base64URLCoding { + static func encode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func decode(_ string: String) -> Data? { + var base64 = string + let padding = (4 - (base64.count % 4)) % 4 + if padding > 0 { + base64 += String(repeating: "=", count: padding) + } + base64 = base64 + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + return Data(base64Encoded: base64) + } +} diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 4a05eaf7..4b15bb58 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -303,7 +303,7 @@ struct NostrProtocol { combined.append(nonce24) combined.append(sealed.ciphertext) combined.append(sealed.tag) - return "v2:" + base64URLEncode(combined) + return "v2:" + Base64URLCoding.encode(combined) } private static func decrypt( @@ -314,7 +314,7 @@ struct NostrProtocol { // Expect NIP-44 v2 format guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } let encoded = String(ciphertext.dropFirst(3)) - guard let data = base64URLDecode(encoded), + guard let data = Base64URLCoding.decode(encoded), data.count > (24 + 16), let senderPubkeyData = Data(hexString: senderPubkey) else { throw NostrError.invalidCiphertext @@ -580,24 +580,9 @@ enum NostrError: Error { case encryptionFailed } -// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url) +// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305) private extension NostrProtocol { - static func base64URLEncode(_ data: Data) -> String { - return data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - static func base64URLDecode(_ s: String) -> Data? { - var str = s - let pad = (4 - (str.count % 4)) % 4 - if pad > 0 { str += String(repeating: "=", count: pad) } - str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") - return Data(base64Encoded: str) - } - static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data { let derivedKey = HKDF.deriveKey( inputKeyMaterial: SymmetricKey(data: sharedSecretData), diff --git a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift new file mode 100644 index 00000000..9e81d9ce --- /dev/null +++ b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift @@ -0,0 +1,116 @@ +import BitFoundation +import Foundation + +struct BLEAnnouncePreflightAcceptance { + let announcement: AnnouncementPacket + let derivedPeerID: PeerID +} + +enum BLEAnnouncePreflightRejection: Equatable { + case malformed + case senderMismatch(derivedPeerID: PeerID) + case selfAnnounce + case stale(ageSeconds: Double) +} + +enum BLEAnnouncePreflightDecision { + case accept(BLEAnnouncePreflightAcceptance) + case reject(BLEAnnouncePreflightRejection) +} + +enum BLEAnnouncePreflightPolicy { + static func evaluate( + packet: BitchatPacket, + from peerID: PeerID, + localPeerID: PeerID, + now: Date + ) -> BLEAnnouncePreflightDecision { + guard let announcement = AnnouncementPacket.decode(from: packet.payload) else { + return .reject(.malformed) + } + + let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey) + guard derivedPeerID == peerID else { + return .reject(.senderMismatch(derivedPeerID: derivedPeerID)) + } + + guard peerID != localPeerID else { + return .reject(.selfAnnounce) + } + + guard !BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) else { + return .reject(.stale(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds( + timestampMilliseconds: packet.timestamp, + now: now + ))) + } + + return .accept(BLEAnnouncePreflightAcceptance( + announcement: announcement, + derivedPeerID: derivedPeerID + )) + } +} + +enum BLEAnnounceTrustRejection: Equatable { + case missingSignature + case invalidSignature + case keyMismatch +} + +enum BLEAnnounceTrustDecision: Equatable { + case verified + case reject(BLEAnnounceTrustRejection) + + var isVerified: Bool { + self == .verified + } +} + +enum BLEAnnounceTrustPolicy { + static func evaluate( + hasSignature: Bool, + signatureValid: Bool, + existingNoisePublicKey: Data?, + announcedNoisePublicKey: Data + ) -> BLEAnnounceTrustDecision { + if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey { + return .reject(.keyMismatch) + } + + guard hasSignature else { + return .reject(.missingSignature) + } + + guard signatureValid else { + return .reject(.invalidSignature) + } + + return .verified + } +} + +struct BLEAnnounceResponsePlan: Equatable { + let shouldNotifyPeerConnected: Bool + let shouldScheduleInitialSync: Bool + let shouldSendAnnounceBack: Bool + let shouldScheduleAfterglow: Bool +} + +enum BLEAnnounceResponsePolicy { + static func plan( + isDirectAnnounce: Bool, + isNewPeer: Bool, + isReconnectedPeer: Bool, + shouldSendAnnounceBack: Bool + ) -> BLEAnnounceResponsePlan { + let shouldNotifyPeerConnected = isDirectAnnounce && (isNewPeer || isReconnectedPeer) + + return BLEAnnounceResponsePlan( + shouldNotifyPeerConnected: shouldNotifyPeerConnected, + shouldScheduleInitialSync: shouldNotifyPeerConnected, + shouldSendAnnounceBack: shouldSendAnnounceBack, + shouldScheduleAfterglow: isNewPeer + ) + } +} diff --git a/bitchat/Services/BLE/BLEAnnounceThrottle.swift b/bitchat/Services/BLE/BLEAnnounceThrottle.swift new file mode 100644 index 00000000..6bd214b3 --- /dev/null +++ b/bitchat/Services/BLE/BLEAnnounceThrottle.swift @@ -0,0 +1,31 @@ +import Foundation + +struct BLEAnnounceThrottle { + private var lastSent: Date + private let normalMinimumInterval: TimeInterval + private let forcedMinimumInterval: TimeInterval + + init( + lastSent: Date = .distantPast, + normalMinimumInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval, + forcedMinimumInterval: TimeInterval = TransportConfig.bleForceAnnounceMinIntervalSeconds + ) { + self.lastSent = lastSent + self.normalMinimumInterval = normalMinimumInterval + self.forcedMinimumInterval = forcedMinimumInterval + } + + func elapsed(since now: Date) -> TimeInterval { + now.timeIntervalSince(lastSent) + } + + mutating func shouldSend(force: Bool, now: Date) -> Bool { + let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval + guard elapsed(since: now) >= minimumInterval else { + return false + } + + lastSent = now + return true + } +} diff --git a/bitchat/Services/BLE/BLEDirectedRelaySpool.swift b/bitchat/Services/BLE/BLEDirectedRelaySpool.swift new file mode 100644 index 00000000..9f890eb6 --- /dev/null +++ b/bitchat/Services/BLE/BLEDirectedRelaySpool.swift @@ -0,0 +1,71 @@ +import BitFoundation +import Foundation + +struct BLEDirectedRelaySpoolEntry { + let recipient: PeerID + let packet: BitchatPacket +} + +struct BLEDirectedRelaySpool { + private struct StoredPacket { + let packet: BitchatPacket + let enqueuedAt: Date + } + + private var packetsByRecipient: [PeerID: [String: StoredPacket]] = [:] + + var isEmpty: Bool { + packetsByRecipient.isEmpty + } + + var count: Int { + packetsByRecipient.values.reduce(0) { $0 + $1.count } + } + + @discardableResult + mutating func enqueue( + packet: BitchatPacket, + recipient: PeerID, + messageID: String, + enqueuedAt: Date + ) -> Bool { + var packets = packetsByRecipient[recipient] ?? [:] + guard packets[messageID] == nil else { + return false + } + + packets[messageID] = StoredPacket(packet: packet, enqueuedAt: enqueuedAt) + packetsByRecipient[recipient] = packets + return true + } + + mutating func drainUnexpired(now: Date, window: TimeInterval) -> [BLEDirectedRelaySpoolEntry] { + var entries: [BLEDirectedRelaySpoolEntry] = [] + + for (recipient, packets) in packetsByRecipient { + for stored in packets.values where now.timeIntervalSince(stored.enqueuedAt) <= window { + entries.append(BLEDirectedRelaySpoolEntry(recipient: recipient, packet: stored.packet)) + } + } + + packetsByRecipient.removeAll() + return entries + } + + mutating func pruneExpired(now: Date, window: TimeInterval) { + guard !packetsByRecipient.isEmpty else { return } + + var pruned: [PeerID: [String: StoredPacket]] = [:] + for (recipient, packets) in packetsByRecipient { + let freshPackets = packets.filter { now.timeIntervalSince($0.value.enqueuedAt) <= window } + if !freshPackets.isEmpty { + pruned[recipient] = freshPackets + } + } + packetsByRecipient = pruned + } + + mutating func removeAll() { + packetsByRecipient.removeAll() + } +} diff --git a/bitchat/Services/BLE/BLEFileTransferPolicy.swift b/bitchat/Services/BLE/BLEFileTransferPolicy.swift new file mode 100644 index 00000000..0eedc429 --- /dev/null +++ b/bitchat/Services/BLE/BLEFileTransferPolicy.swift @@ -0,0 +1,71 @@ +import BitFoundation +import Foundation + +struct BLEFileTransferDeliveryPlan: Equatable { + let isPrivateMessage: Bool + let shouldTrackForSync: Bool +} + +enum BLEFileTransferPolicy { + static func isSelfEcho(packet: BitchatPacket, from peerID: PeerID, localPeerID: PeerID) -> Bool { + peerID == localPeerID && packet.ttl != 0 + } + + static func deliveryPlan(packet: BitchatPacket, localPeerID: PeerID) -> BLEFileTransferDeliveryPlan? { + guard let recipientID = packet.recipientID else { + return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true) + } + + let isBroadcast = recipientID.allSatisfy { $0 == 0xFF } + if isBroadcast { + return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true) + } + + guard PeerID(hexData: recipientID) == localPeerID else { + return nil + } + + return BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false) + } +} + +struct BLEIncomingFileAcceptance { + let filePacket: BitchatFilePacket + let mime: MimeType +} + +enum BLEIncomingFileRejection: Error, Equatable { + case malformedPayload + case payloadTooLarge(bytes: Int) + case unsupportedMime(mimeType: String?, bytes: Int) + case magicMismatch(mime: MimeType, bytes: Int, prefixHex: String) +} + +enum BLEIncomingFileValidator { + static func validate(payload: Data) -> Result { + guard let filePacket = BitchatFilePacket.decode(payload) else { + return .failure(.malformedPayload) + } + + guard FileTransferLimits.isValidPayload(filePacket.content.count) else { + return .failure(.payloadTooLarge(bytes: filePacket.content.count)) + } + + guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else { + return .failure(.unsupportedMime( + mimeType: filePacket.mimeType, + bytes: filePacket.content.count + )) + } + + guard mime.matches(data: filePacket.content) else { + return .failure(.magicMismatch( + mime: mime, + bytes: filePacket.content.count, + prefixHex: filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ") + )) + } + + return .success(BLEIncomingFileAcceptance(filePacket: filePacket, mime: mime)) + } +} diff --git a/bitchat/Services/BLE/BLEMaintenancePolicy.swift b/bitchat/Services/BLE/BLEMaintenancePolicy.swift new file mode 100644 index 00000000..2f062b2d --- /dev/null +++ b/bitchat/Services/BLE/BLEMaintenancePolicy.swift @@ -0,0 +1,62 @@ +import Foundation + +struct BLEMaintenancePlan: Equatable { + let shouldSendAnnounce: Bool + let shouldEnsureAdvertising: Bool + let shouldRunCleanup: Bool + let shouldFlushDirectedSpool: Bool + let shouldResetCounter: Bool +} + +enum BLEMaintenancePolicy { + static func plan( + cycle: Int, + connectedCount: Int, + peerRegistryIsEmpty: Bool, + elapsedSinceLastAnnounce: TimeInterval, + hasRecentTraffic: Bool, + connectedAnnounceJitterOffset: TimeInterval? = nil, + highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold + ) -> BLEMaintenancePlan { + BLEMaintenancePlan( + shouldSendAnnounce: shouldSendAnnounce( + connectedCount: connectedCount, + elapsedSinceLastAnnounce: elapsedSinceLastAnnounce, + hasRecentTraffic: hasRecentTraffic, + connectedAnnounceJitterOffset: connectedAnnounceJitterOffset, + highDegreeThreshold: highDegreeThreshold + ), + shouldEnsureAdvertising: peerRegistryIsEmpty, + shouldRunCleanup: cycle.isMultiple(of: 3), + shouldFlushDirectedSpool: !cycle.isMultiple(of: 2), + shouldResetCounter: cycle >= 6 + ) + } + + static func shouldSendAnnounce( + connectedCount: Int, + elapsedSinceLastAnnounce: TimeInterval, + hasRecentTraffic: Bool, + connectedAnnounceJitterOffset: TimeInterval? = nil, + highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold + ) -> Bool { + if hasRecentTraffic && elapsedSinceLastAnnounce >= 10.0 { + return true + } + + guard connectedCount > 0 else { + return elapsedSinceLastAnnounce >= TransportConfig.bleAnnounceIntervalSeconds + } + + let highDegree = connectedCount >= highDegreeThreshold + let base = highDegree ? + TransportConfig.bleConnectedAnnounceBaseSecondsDense : + TransportConfig.bleConnectedAnnounceBaseSecondsSparse + let jitter = highDegree ? + TransportConfig.bleConnectedAnnounceJitterDense : + TransportConfig.bleConnectedAnnounceJitterSparse + let jitterOffset = connectedAnnounceJitterOffset ?? Double.random(in: -jitter...jitter) + + return elapsedSinceLastAnnounce >= base + jitterOffset + } +} diff --git a/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift b/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift new file mode 100644 index 00000000..3924d149 --- /dev/null +++ b/bitchat/Services/BLE/BLEOutboundLinkPlanner.swift @@ -0,0 +1,87 @@ +import BitFoundation +import Foundation + +struct BLEOutboundLinkPlan: Equatable { + let directedPeerHint: PeerID? + let fragmentChunkSize: Int? + let selectedLinks: BLEFanoutSelection + let shouldSpoolDirectedPacket: Bool +} + +enum BLEOutboundLinkPlanner { + static func plan( + packet: BitchatPacket, + dataCount: Int, + peripheralIDs: [String], + peripheralWriteLimits: [Int], + centralIDs: [String], + centralNotifyLimits: [Int], + ingressRecord: BLEIngressLinkRecord?, + excludedLinks: Set, + directedOnlyPeer: PeerID? + ) -> BLEOutboundLinkPlan { + if let minLimit = minimumLinkLimit( + peripheralWriteLimits: peripheralWriteLimits, + centralNotifyLimits: centralNotifyLimits + ), packet.type != MessageType.fragment.rawValue, + dataCount > minLimit { + return BLEOutboundLinkPlan( + directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer), + fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit), + selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []), + shouldSpoolDirectedPacket: false + ) + } + + let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer) + let selectedLinks = BLEFanoutSelector.selectLinks( + peripheralIDs: peripheralIDs, + centralIDs: centralIDs, + ingressLink: ingressRecord?.link, + excludedLinks: excludedLinks, + directedPeerHint: directedPeerHint, + packetType: packet.type, + messageID: BLEOutboundPacketPolicy.messageID(for: packet) + ) + + return BLEOutboundLinkPlan( + directedPeerHint: directedPeerHint, + fragmentChunkSize: nil, + selectedLinks: selectedLinks, + shouldSpoolDirectedPacket: shouldSpoolDirectedPacket( + directedPeerHint: directedPeerHint, + selectedLinks: selectedLinks, + packetType: packet.type + ) + ) + } + + static func directedPeerHint(for packet: BitchatPacket, explicitPeer: PeerID?) -> PeerID? { + if let explicitPeer { return explicitPeer } + if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty { + return recipient + } + return nil + } + + static func minimumLinkLimit(peripheralWriteLimits: [Int], centralNotifyLimits: [Int]) -> Int? { + [peripheralWriteLimits.min(), centralNotifyLimits.min()] + .compactMap { $0 } + .min() + } + + static func shouldSpoolDirectedPacket( + directedPeerHint: PeerID?, + selectedLinks: BLEFanoutSelection, + packetType: UInt8 + ) -> Bool { + guard directedPeerHint != nil, + selectedLinks.peripheralIDs.isEmpty, + selectedLinks.centralIDs.isEmpty else { + return false + } + + return packetType == MessageType.noiseEncrypted.rawValue || + packetType == MessageType.noiseHandshake.rawValue + } +} diff --git a/bitchat/Services/BLE/BLEPacketFreshnessPolicy.swift b/bitchat/Services/BLE/BLEPacketFreshnessPolicy.swift new file mode 100644 index 00000000..2559d855 --- /dev/null +++ b/bitchat/Services/BLE/BLEPacketFreshnessPolicy.swift @@ -0,0 +1,27 @@ +import Foundation + +enum BLEPacketFreshnessPolicy { + static let defaultMaxAgeSeconds: TimeInterval = 900 + + static func isBroadcastRecipient(_ recipientID: Data?) -> Bool { + guard let recipientID else { return true } + return recipientID.count == 8 && recipientID.allSatisfy { $0 == 0xFF } + } + + static func isStale( + timestampMilliseconds: UInt64, + now: Date, + maxAgeSeconds: TimeInterval = defaultMaxAgeSeconds + ) -> Bool { + let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000) + let maxAgeMilliseconds = UInt64(maxAgeSeconds * 1000) + guard nowMilliseconds >= maxAgeMilliseconds else { return false } + return timestampMilliseconds < nowMilliseconds - maxAgeMilliseconds + } + + static func ageSeconds(timestampMilliseconds: UInt64, now: Date) -> Double { + let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000) + guard nowMilliseconds >= timestampMilliseconds else { return 0 } + return Double(nowMilliseconds - timestampMilliseconds) / 1000.0 + } +} diff --git a/bitchat/Services/BLE/BLEPeerEventDebouncer.swift b/bitchat/Services/BLE/BLEPeerEventDebouncer.swift new file mode 100644 index 00000000..9b3ac4f3 --- /dev/null +++ b/bitchat/Services/BLE/BLEPeerEventDebouncer.swift @@ -0,0 +1,25 @@ +import BitFoundation +import Foundation + +struct BLEPeerEventDebouncer { + private var lastEmitByPeer: [PeerID: Date] = [:] + + var count: Int { + lastEmitByPeer.count + } + + @discardableResult + mutating func shouldEmit(peerID: PeerID, now: Date, minimumInterval: TimeInterval) -> Bool { + if let lastEmit = lastEmitByPeer[peerID], + now.timeIntervalSince(lastEmit) < minimumInterval { + return false + } + + lastEmitByPeer[peerID] = now + return true + } + + mutating func removeAll() { + lastEmitByPeer.removeAll() + } +} diff --git a/bitchat/Services/BLE/BLEPeerPublishCoalescer.swift b/bitchat/Services/BLE/BLEPeerPublishCoalescer.swift new file mode 100644 index 00000000..b3290baa --- /dev/null +++ b/bitchat/Services/BLE/BLEPeerPublishCoalescer.swift @@ -0,0 +1,43 @@ +import Foundation + +enum BLEPeerPublishDecision: Equatable { + case publishNow + case schedule(delay: TimeInterval) + case skip +} + +struct BLEPeerPublishCoalescer { + private var lastPublishAt: Date + private var publishPending: Bool + private let minimumInterval: TimeInterval + + init( + lastPublishAt: Date = .distantPast, + publishPending: Bool = false, + minimumInterval: TimeInterval = 0.1 + ) { + self.lastPublishAt = lastPublishAt + self.publishPending = publishPending + self.minimumInterval = minimumInterval + } + + mutating func requestPublish(now: Date) -> BLEPeerPublishDecision { + let elapsed = now.timeIntervalSince(lastPublishAt) + if elapsed >= minimumInterval { + lastPublishAt = now + return .publishNow + } + + guard !publishPending else { + return .skip + } + + publishPending = true + return .schedule(delay: minimumInterval - elapsed) + } + + mutating func scheduledPublishFired(now: Date) { + lastPublishAt = now + publishPending = false + } +} diff --git a/bitchat/Services/BLE/BLEPublicMessagePolicy.swift b/bitchat/Services/BLE/BLEPublicMessagePolicy.swift new file mode 100644 index 00000000..e81ddec4 --- /dev/null +++ b/bitchat/Services/BLE/BLEPublicMessagePolicy.swift @@ -0,0 +1,42 @@ +import BitFoundation +import Foundation + +struct BLEPublicMessageAcceptance: Equatable { + let shouldTrackForSync: Bool +} + +enum BLEPublicMessageRejection: Equatable { + case selfEcho + case staleBroadcast(ageSeconds: Double) +} + +enum BLEPublicMessageDecision: Equatable { + case accept(BLEPublicMessageAcceptance) + case reject(BLEPublicMessageRejection) +} + +enum BLEPublicMessagePolicy { + static func evaluate( + packet: BitchatPacket, + from peerID: PeerID, + localPeerID: PeerID, + now: Date + ) -> BLEPublicMessageDecision { + if peerID == localPeerID && packet.ttl != 0 { + return .reject(.selfEcho) + } + + let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) + if isBroadcast, + BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) { + return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds( + timestampMilliseconds: packet.timestamp, + now: now + ))) + } + + return .accept(BLEPublicMessageAcceptance( + shouldTrackForSync: isBroadcast && packet.type == MessageType.message.rawValue + )) + } +} diff --git a/bitchat/Services/BLE/BLERouteForwardingPolicy.swift b/bitchat/Services/BLE/BLERouteForwardingPolicy.swift new file mode 100644 index 00000000..8c43e0c3 --- /dev/null +++ b/bitchat/Services/BLE/BLERouteForwardingPolicy.swift @@ -0,0 +1,93 @@ +import BitFoundation +import Foundation + +struct BLERouteForwardingPlan { + let shouldSuppressFloodRelay: Bool + let forwardPacket: BitchatPacket? + let nextHop: PeerID? + + static let allowFloodRelay = BLERouteForwardingPlan( + shouldSuppressFloodRelay: false, + forwardPacket: nil, + nextHop: nil + ) + + static let suppressFloodRelay = BLERouteForwardingPlan( + shouldSuppressFloodRelay: true, + forwardPacket: nil, + nextHop: nil + ) + + static func forward(_ packet: BitchatPacket, to nextHop: PeerID) -> BLERouteForwardingPlan { + BLERouteForwardingPlan( + shouldSuppressFloodRelay: true, + forwardPacket: packet, + nextHop: nextHop + ) + } +} + +struct BLERouteForwardingPolicy { + static func plan( + for packet: BitchatPacket, + localPeerID: PeerID, + localRoutingData: Data?, + routingPeer: (Data) -> PeerID?, + isPeerConnected: (PeerID) -> Bool + ) -> BLERouteForwardingPlan { + if PeerID(hexData: packet.recipientID) == localPeerID { + return .suppressFloodRelay + } + + guard let route = packet.route, !route.isEmpty else { + return .allowFloodRelay + } + + guard packet.ttl > 1 else { + return .suppressFloodRelay + } + + guard let localRoutingData else { + return .allowFloodRelay + } + + guard let localIndex = route.firstIndex(of: localRoutingData) else { + return forward(packet, toRouteData: route[0], routingPeer: routingPeer, isPeerConnected: isPeerConnected) + } + + if localIndex == route.count - 1 { + guard let destinationPeer = PeerID(hexData: packet.recipientID), + isPeerConnected(destinationPeer) else { + return .allowFloodRelay + } + return .forward(relayed(packet), to: destinationPeer) + } + + return forward( + packet, + toRouteData: route[localIndex + 1], + routingPeer: routingPeer, + isPeerConnected: isPeerConnected + ) + } + + private static func forward( + _ packet: BitchatPacket, + toRouteData routeData: Data, + routingPeer: (Data) -> PeerID?, + isPeerConnected: (PeerID) -> Bool + ) -> BLERouteForwardingPlan { + guard let nextPeer = routingPeer(routeData), + isPeerConnected(nextPeer) else { + return .allowFloodRelay + } + + return .forward(relayed(packet), to: nextPeer) + } + + private static func relayed(_ packet: BitchatPacket) -> BitchatPacket { + var relayPacket = packet + relayPacket.ttl = packet.ttl - 1 + return relayPacket + } +} diff --git a/bitchat/Services/BLE/BLEScanDutyPolicy.swift b/bitchat/Services/BLE/BLEScanDutyPolicy.swift new file mode 100644 index 00000000..5d85ae59 --- /dev/null +++ b/bitchat/Services/BLE/BLEScanDutyPolicy.swift @@ -0,0 +1,35 @@ +import Foundation + +enum BLEScanDutyPlan: Equatable { + case continuous + case dutyCycle(onDuration: TimeInterval, offDuration: TimeInterval) +} + +enum BLEScanDutyPolicy { + static func plan( + dutyEnabled: Bool, + appIsActive: Bool, + connectedCount: Int, + hasRecentTraffic: Bool, + highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold + ) -> BLEScanDutyPlan { + let forceContinuousScan = connectedCount <= 2 || hasRecentTraffic + let shouldDutyCycle = dutyEnabled && appIsActive && connectedCount > 0 && !forceContinuousScan + + guard shouldDutyCycle else { + return .continuous + } + + if connectedCount >= highDegreeThreshold { + return .dutyCycle( + onDuration: TransportConfig.bleDutyOnDurationDense, + offDuration: TransportConfig.bleDutyOffDurationDense + ) + } + + return .dutyCycle( + onDuration: TransportConfig.bleDutyOnDuration, + offDuration: TransportConfig.bleDutyOffDuration + ) + } +} diff --git a/bitchat/Services/BLE/BLEScheduledRelayStore.swift b/bitchat/Services/BLE/BLEScheduledRelayStore.swift new file mode 100644 index 00000000..1aa66882 --- /dev/null +++ b/bitchat/Services/BLE/BLEScheduledRelayStore.swift @@ -0,0 +1,43 @@ +import Foundation + +struct BLEScheduledRelayStore { + private var relays: [String: DispatchWorkItem] = [:] + + var count: Int { + relays.count + } + + var isEmpty: Bool { + relays.isEmpty + } + + mutating func schedule(_ workItem: DispatchWorkItem, messageID: String) { + relays[messageID] = workItem + } + + @discardableResult + mutating func remove(messageID: String) -> DispatchWorkItem? { + relays.removeValue(forKey: messageID) + } + + @discardableResult + mutating func cancel(messageID: String) -> Bool { + guard let workItem = relays.removeValue(forKey: messageID) else { + return false + } + + workItem.cancel() + return true + } + + mutating func cancelAll() { + relays.values.forEach { $0.cancel() } + relays.removeAll() + } + + mutating func removeAllIfOverCapacity(_ maxCount: Int) { + if relays.count > maxCount { + relays.removeAll() + } + } +} diff --git a/bitchat/Services/BLE/BLESelfBroadcastTracker.swift b/bitchat/Services/BLE/BLESelfBroadcastTracker.swift new file mode 100644 index 00000000..83756cfe --- /dev/null +++ b/bitchat/Services/BLE/BLESelfBroadcastTracker.swift @@ -0,0 +1,40 @@ +import BitFoundation +import Foundation + +struct BLESelfBroadcastTracker { + private struct Entry { + let messageID: String + let sentAt: Date + } + + private var entriesByDedupID: [String: Entry] = [:] + + var isEmpty: Bool { + entriesByDedupID.isEmpty + } + + var count: Int { + entriesByDedupID.count + } + + mutating func record(messageID: String, packet: BitchatPacket, sentAt: Date) { + entriesByDedupID[Self.dedupID(for: packet)] = Entry(messageID: messageID, sentAt: sentAt) + } + + mutating func takeMessageID(for packet: BitchatPacket) -> String? { + entriesByDedupID.removeValue(forKey: Self.dedupID(for: packet))?.messageID + } + + mutating func prune(before cutoff: Date) { + guard !entriesByDedupID.isEmpty else { return } + entriesByDedupID = entriesByDedupID.filter { cutoff <= $0.value.sentAt } + } + + mutating func removeAll() { + entriesByDedupID.removeAll() + } + + static func dedupID(for packet: BitchatPacket) -> String { + "\(packet.senderID.hexEncodedString())-\(packet.timestamp)-\(packet.type)" + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 5c79448a..13cd420e 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -46,7 +46,7 @@ final class BLEService: NSObject { // 4. Efficient Message Deduplication private let messageDeduplicator = MessageDeduplicator() - private var selfBroadcastMessageIDs: [String: (id: String, timestamp: Date)] = [:] + private var selfBroadcastTracker = BLESelfBroadcastTracker() private let meshTopology = MeshTopologyTracker() // 5. Fragment Reassembly (necessary for messages > MTU) @@ -55,8 +55,7 @@ final class BLEService: NSObject { private let incomingFileStore = BLEIncomingFileStore() // Simple announce throttling - private var lastAnnounceSent = Date.distantPast - private let announceMinInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval + private var announceThrottle = BLEAnnounceThrottle() // Application state tracking (thread-safe) #if os(iOS) @@ -96,7 +95,7 @@ final class BLEService: NSObject { // Accumulate long write chunks per central until a full frame decodes private var pendingWriteBuffers = BLEInboundWriteBuffer() // Relay jitter scheduling to reduce redundant floods - private var scheduledRelays: [String: DispatchWorkItem] = [:] + private var scheduledRelays = BLEScheduledRelayStore() // Track short-lived traffic bursts to adapt announces/scanning under load private var recentTrafficTracker = BLERecentTrafficTracker() @@ -106,12 +105,11 @@ final class BLEService: NSObject { private var pendingPeripheralWrites = BLEOutboundWriteBuffer() // Debounce duplicate disconnect notifies - private var recentDisconnectNotifies: [PeerID: Date] = [:] + private var disconnectNotifyDebouncer = BLEPeerEventDebouncer() // Store-and-forward for directed messages when we have no links - // Keyed by recipient short peerID -> messageID -> (packet, enqueuedAt) - private var pendingDirectedRelays: [PeerID: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:] + private var pendingDirectedRelays = BLEDirectedRelaySpool() // Debounce for 'reconnected' logs - private var lastReconnectLogAt: [PeerID: Date] = [:] + private var reconnectLogDebouncer = BLEPeerEventDebouncer() // MARK: - Gossip Sync private var gossipSyncManager: GossipSyncManager? @@ -133,24 +131,19 @@ final class BLEService: NSObject { private var dutyActive: Bool = false // Debounced publish to coalesce rapid changes - private var lastPeerPublishAt: Date = .distantPast - private var peerPublishPending: Bool = false - private let peerPublishMinInterval: TimeInterval = 0.1 + private var peerPublishCoalescer = BLEPeerPublishCoalescer() private func requestPeerDataPublish() { - let now = Date() - let elapsed = now.timeIntervalSince(lastPeerPublishAt) - if elapsed >= peerPublishMinInterval { - lastPeerPublishAt = now + switch peerPublishCoalescer.requestPublish(now: Date()) { + case .publishNow: publishFullPeerData() - } else if !peerPublishPending { - peerPublishPending = true - let delay = peerPublishMinInterval - elapsed + case .schedule(let delay): messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self else { return } - self.lastPeerPublishAt = Date() - self.peerPublishPending = false + self.peerPublishCoalescer.scheduledPublishFired(now: Date()) self.publishFullPeerData() } + case .skip: + break } } @@ -290,8 +283,7 @@ final class BLEService: NSObject { pendingDirectedRelays.removeAll() ingressLinks.removeAll() recentTrafficTracker.removeAll() - scheduledRelays.values.forEach { $0.cancel() } - scheduledRelays.removeAll() + scheduledRelays.cancelAll() return transfers } @@ -304,7 +296,7 @@ final class BLEService: NSObject { pendingWriteBuffers.removeAll() connectionScheduler.reset() } - recentDisconnectNotifies.removeAll() + disconnectNotifyDebouncer.removeAll() noiseService.clearEphemeralStateForPanic() noiseService.clearPersistentIdentity() @@ -319,7 +311,7 @@ final class BLEService: NSObject { messageDeduplicator.reset() messageQueue.async(flags: .barrier) { [weak self] in - self?.selfBroadcastMessageIDs.removeAll() + self?.selfBroadcastTracker.removeAll() } requestPeerDataPublish() startServices() @@ -363,11 +355,10 @@ final class BLEService: NSObject { return } // Pre-mark our own broadcast as processed to avoid handling relayed self copy - let senderHex = signedPacket.senderID.hexEncodedString() - let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)" + let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket) messageDeduplicator.markProcessed(dedupID) if let messageID { - selfBroadcastMessageIDs[dedupID] = (id: messageID, timestamp: sendDate) + selfBroadcastTracker.record(messageID: messageID, packet: signedPacket, sentAt: sendDate) } // Call synchronously since we're already on background queue broadcastPacket(signedPacket) @@ -899,68 +890,49 @@ final class BLEService: NSObject { } private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) { - // Determine the last-hop peer/link for this message to avoid echoing back over either BLE role. - let messageID = BLEOutboundPacketPolicy.messageID(for: packet) let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) } let excludedPeerLinks = links(to: ingressRecord?.peerID) - let directedPeerHint: PeerID? = { - if let explicit = directedOnlyPeer { return explicit } - if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty { - return recipient - } - return nil - }() let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data) let states = snapshotPeripheralStates() - var minCentralWriteLen: Int? - for s in states where s.isConnected { - let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse) - minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m - } + let connectedStates = states.filter { $0.isConnected } let subscribedCentrals = characteristic == nil ? [] : snapshotSubscribedCentrals().centrals - let minNotifyLen = subscribedCentrals.map { $0.maximumUpdateValueLength }.min() + let connectedPeripheralIDs = connectedStates.map { $0.peripheral.identifier.uuidString } + let centralIDs = subscribedCentrals.map { $0.identifier.uuidString } + let plan = BLEOutboundLinkPlanner.plan( + packet: packet, + dataCount: data.count, + peripheralIDs: connectedPeripheralIDs, + peripheralWriteLimits: connectedStates.map { $0.peripheral.maximumWriteValueLength(for: .withoutResponse) }, + centralIDs: centralIDs, + centralNotifyLimits: subscribedCentrals.map { $0.maximumUpdateValueLength }, + ingressRecord: ingressRecord, + excludedLinks: excludedPeerLinks, + directedOnlyPeer: directedOnlyPeer + ) - // Avoid re-fragmenting fragment packets - if packet.type != MessageType.fragment.rawValue, - let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min(), - data.count > minLen { - let chunk = BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLen) + if let chunk = plan.fragmentChunkSize { sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer) return } - // Build link lists and apply K-of-N fanout for broadcasts; always exclude ingress link - let connectedPeripheralIDs: [String] = states.filter { $0.isConnected }.map { $0.peripheral.identifier.uuidString } - let centralIDs = subscribedCentrals.map { $0.identifier.uuidString } - - let selectedLinks = BLEFanoutSelector.selectLinks( - peripheralIDs: connectedPeripheralIDs, - centralIDs: centralIDs, - ingressLink: ingressRecord?.link, - excludedLinks: excludedPeerLinks, - directedPeerHint: directedPeerHint, - packetType: packet.type, - messageID: messageID - ) // If directed and we currently have no links to forward on, spool for a short window - if let only = directedPeerHint, - selectedLinks.peripheralIDs.isEmpty && selectedLinks.centralIDs.isEmpty, - (packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) { + if let only = plan.directedPeerHint, + plan.shouldSpoolDirectedPacket { spoolDirectedPacket(packet, recipientPeerID: only) } // Writes to selected connected peripherals - for s in states where s.isConnected { + for s in connectedStates { let pid = s.peripheral.identifier.uuidString - guard selectedLinks.peripheralIDs.contains(pid) else { continue } + guard plan.selectedLinks.peripheralIDs.contains(pid) else { continue } if let ch = s.characteristic { writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority) } } // Notify selected subscribed centrals if let ch = characteristic { - let targets = subscribedCentrals.filter { selectedLinks.centralIDs.contains($0.identifier.uuidString) } + let targets = subscribedCentrals.filter { plan.selectedLinks.centralIDs.contains($0.identifier.uuidString) } if !targets.isEmpty { let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false if !success { @@ -984,10 +956,12 @@ final class BLEService: NSObject { let msgID = BLEOutboundPacketPolicy.messageID(for: packet) collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:] - if byMsg[msgID] == nil { - byMsg[msgID] = (packet: packet, enqueuedAt: Date()) - self.pendingDirectedRelays[recipientPeerID] = byMsg + if self.pendingDirectedRelays.enqueue( + packet: packet, + recipient: recipientPeerID, + messageID: msgID, + enqueuedAt: Date() + ) { SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: .session) } } @@ -995,23 +969,15 @@ final class BLEService: NSObject { private func flushDirectedSpool() { // Move items out and attempt broadcast; if still no links, they'll be re-spooled - let toSend: [(String, BitchatPacket)] = collectionsQueue.sync(flags: .barrier) { - var out: [(String, BitchatPacket)] = [] - let now = Date() - for (recipient, dict) in pendingDirectedRelays { - for (_, entry) in dict { - if now.timeIntervalSince(entry.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds { - out.append((recipient.id, entry.packet)) - } - } - // Clear recipient bucket; items will be re-spooled if still no links - pendingDirectedRelays.removeValue(forKey: recipient) - } - return out + let toSend = collectionsQueue.sync(flags: .barrier) { + pendingDirectedRelays.drainUnexpired( + now: Date(), + window: TransportConfig.bleDirectedSpoolWindowSeconds + ) } guard !toSend.isEmpty else { return } - for (_, packet) in toSend { - messageQueue.async { [weak self] in self?.broadcastPacket(packet) } + for entry in toSend { + messageQueue.async { [weak self] in self?.broadcastPacket(entry.packet) } } } @@ -1039,7 +1005,7 @@ final class BLEService: NSObject { } private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) { - if peerID == myPeerID && packet.ttl != 0 { return } + if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: myPeerID) { return } let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID } guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( @@ -1053,39 +1019,30 @@ final class BLEService: NSObject { return } - // Skip directed packets that are not intended for us - if let recipient = packet.recipientID { - if PeerID(hexData: recipient) != myPeerID && !recipient.allSatisfy({ $0 == 0xFF }) { - return - } + guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: myPeerID) else { + return } - - if let recipient = packet.recipientID, - recipient.allSatisfy({ $0 == 0xFF }) { - gossipSyncManager?.onPublicPacketSeen(packet) - } else if packet.recipientID == nil { + if deliveryPlan.shouldTrackForSync { gossipSyncManager?.onPublicPacketSeen(packet) } - guard let filePacket = BitchatFilePacket.decode(packet.payload) else { + let filePacket: BitchatFilePacket + let mime: MimeType + switch BLEIncomingFileValidator.validate(payload: packet.payload) { + case .success(let acceptance): + filePacket = acceptance.filePacket + mime = acceptance.mime + case .failure(.malformedPayload): SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) return - } - - guard FileTransferLimits.isValidPayload(filePacket.content.count) else { - SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(filePacket.content.count) bytes)", category: .security) + case .failure(.payloadTooLarge(let bytes)): + SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) return - } - - guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else { - SecureLogger.warning("🚫 MIME REJECT: '\(filePacket.mimeType ?? "")' not supported. Size=\(filePacket.content.count)b from \(peerID.id.prefix(8))...", category: .security) + case .failure(.unsupportedMime(let mimeType, let bytes)): + SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security) return - } - - // Validate content matches declared MIME type (magic byte check) - guard mime.matches(data: filePacket.content) else { - let prefix = filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ") - SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(filePacket.content.count)b prefix=[\(prefix)] from \(peerID.id.prefix(8))...", category: .security) + case .failure(.magicMismatch(let mime, let bytes, let prefixHex)): + SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security) return } @@ -1102,9 +1059,7 @@ final class BLEService: NSObject { return } - let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID - - if isPrivateMessage { + if deliveryPlan.isPrivateMessage { updatePeerLastSeen(peerID) } @@ -1115,7 +1070,7 @@ final class BLEService: NSObject { timestamp: ts, isRelay: false, originalSender: nil, - isPrivate: isPrivateMessage, + isPrivate: deliveryPlan.isPrivateMessage, recipientNickname: nil, senderPeerID: peerID ) @@ -1186,18 +1141,10 @@ final class BLEService: NSObject { } private func sendAnnounce(forceSend: Bool = false) { // Throttle announces to prevent flooding - let now = Date() - let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent) - - // Even forced sends should respect a minimum interval to avoid overwhelming BLE - let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval - - if timeSinceLastAnnounce < minInterval { - // Skipping announce (rate limited) + if !announceThrottle.shouldSend(force: forceSend, now: Date()) { return } - lastAnnounceSent = now - + // Reduced logging - only log errors, not every announce // Create announce payload with both noise and signing public keys @@ -2355,56 +2302,20 @@ extension BLEService { } private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool { - if PeerID(hexData: packet.recipientID) == myPeerID { - return true - } - - guard let route = packet.route, !route.isEmpty else { return false } let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData) - guard let selfData = myRoutingData else { return false } - - // Route contains only intermediate hops (start and end excluded) - // If we're not in the route, we're the sender - forward to first hop - guard let index = route.firstIndex(of: selfData) else { - // We're the sender, forward to first intermediate hop - guard packet.ttl > 1 else { return true } - let firstHopData = route[0] - guard let nextPeer = routingPeer(from: firstHopData), - isPeerConnected(nextPeer) else { - return false - } - var relayPacket = packet - relayPacket.ttl = packet.ttl - 1 - sendPacketDirected(relayPacket, to: nextPeer) - return true + let plan = BLERouteForwardingPolicy.plan( + for: packet, + localPeerID: myPeerID, + localRoutingData: myRoutingData, + routingPeer: routingPeer(from:), + isPeerConnected: isPeerConnected(_:) + ) + + if let forwardPacket = plan.forwardPacket, let nextHop = plan.nextHop { + sendPacketDirected(forwardPacket, to: nextHop) } - // We're an intermediate node in the route - // If we're the last intermediate hop, forward to destination - if index == route.count - 1 { - guard packet.ttl > 1 else { return true } - guard let destinationPeer = PeerID(hexData: packet.recipientID), - isPeerConnected(destinationPeer) else { - return false - } - var relayPacket = packet - relayPacket.ttl = packet.ttl - 1 - sendPacketDirected(relayPacket, to: destinationPeer) - return true - } - - // Forward to next intermediate hop - guard packet.ttl > 1 else { return true } - let nextHopData = route[index + 1] - guard let nextPeer = routingPeer(from: nextHopData), - isPeerConnected(nextPeer) else { - return false - } - - var relayPacket = packet - relayPacket.ttl = packet.ttl - 1 - sendPacketDirected(relayPacket, to: nextPeer) - return true + return plan.shouldSuppressFloodRelay } /// Safely fetch the current direct-link state for a peer using the BLE queue. @@ -2955,24 +2866,7 @@ extension BLEService { SecureLogger.debug("📦 Handling packet type \(packet.type) from \(senderID.id.prefix(8))…, messageID: \(messageID.prefix(24))…", category: .session) } - if context.shouldDeduplicate && messageDeduplicator.isDuplicate(messageID) { - // Announce packets (type 1) are sent every 10 seconds for peer discovery - // It's normal to see these as duplicates - don't log them to reduce noise - if context.logsHandlingDetails { - SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID.prefix(24))…", category: .session) - } - // In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging. - // In denser graphs, cancel the pending relay to reduce redundant floods. - let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } - if BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: connectedCount) { - collectionsQueue.async(flags: .barrier) { [weak self] in - if let task = self?.scheduledRelays.removeValue(forKey: messageID) { - task.cancel() - } - } - } - return // Duplicate ignored - } + if dropDuplicatePacketIfNeeded(context: context, messageID: messageID) { return } // Update peer info without verbose logging - update the peer we received from, not the original sender updatePeerLastSeen(peerID) @@ -3019,92 +2913,114 @@ extension BLEService { return } - // Relay if TTL > 1 and we're not the original sender - // Relay decision and scheduling (extracted via RelayController) - do { - let degree = collectionsQueue.sync { peerRegistry.connectedCount } - let decision = BLEReceivePipeline.relayDecision( - for: packet, - senderID: senderID, - localPeerID: myPeerID, - degree: degree, - highDegreeThreshold: highDegreeThreshold - ) - guard decision.shouldRelay else { return } - let work = DispatchWorkItem { [weak self] in - guard let self = self else { return } - // Remove scheduled task before executing - self.collectionsQueue.async(flags: .barrier) { [weak self] in - _ = self?.scheduledRelays.removeValue(forKey: messageID) - } - var relayPacket = packet - relayPacket.ttl = decision.newTTL - self.broadcastPacket(relayPacket) - } - // Track the scheduled relay so duplicates can cancel it - collectionsQueue.async(flags: .barrier) { [weak self] in - self?.scheduledRelays[messageID] = work - } - messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work) + scheduleRelayIfNeeded(packet, senderID: senderID, messageID: messageID) + } + + private func dropDuplicatePacketIfNeeded(context: BLEReceivedPacketContext, messageID: String) -> Bool { + guard context.shouldDeduplicate, messageDeduplicator.isDuplicate(messageID) else { + return false } + + if context.logsHandlingDetails { + SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID.prefix(24))…", category: .session) + } + + let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } + if BLEReceivePipeline.shouldCancelScheduledRelayForDuplicate(connectedPeerCount: connectedCount) { + collectionsQueue.async(flags: .barrier) { [weak self] in + self?.scheduledRelays.cancel(messageID: messageID) + } + } + + return true + } + + private func scheduleRelayIfNeeded(_ packet: BitchatPacket, senderID: PeerID, messageID: String) { + let degree = collectionsQueue.sync { peerRegistry.connectedCount } + let decision = BLEReceivePipeline.relayDecision( + for: packet, + senderID: senderID, + localPeerID: myPeerID, + degree: degree, + highDegreeThreshold: highDegreeThreshold + ) + guard decision.shouldRelay else { return } + + let work = DispatchWorkItem { [weak self] in + guard let self = self else { return } + self.collectionsQueue.async(flags: .barrier) { [weak self] in + self?.scheduledRelays.remove(messageID: messageID) + } + var relayPacket = packet + relayPacket.ttl = decision.newTTL + self.broadcastPacket(relayPacket) + } + + collectionsQueue.async(flags: .barrier) { [weak self] in + self?.scheduledRelays.schedule(work, messageID: messageID) + } + messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work) } private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { - guard let announcement = AnnouncementPacket.decode(from: packet.payload) else { + let now = Date() + let preflight = BLEAnnouncePreflightPolicy.evaluate( + packet: packet, + from: peerID, + localPeerID: myPeerID, + now: now + ) + + let announcement: AnnouncementPacket + switch preflight { + case .accept(let acceptance): + announcement = acceptance.announcement + case .reject(.malformed): SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session) return - } - - // Verify that the sender's derived ID from the announced noise public key matches the packet senderID - // This helps detect relayed or spoofed announces. Only warn in release; assert in debug. - let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey) - if derivedFromKey != peerID { + case .reject(.senderMismatch(let derivedFromKey)): SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security) return - } - - // Don't add ourselves as a peer - if peerID == myPeerID { + case .reject(.selfAnnounce): + return + case .reject(.stale(let ageSeconds)): + SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) return - } - - // Reject stale announces to prevent ghost peers from appearing - // Use same 15-minute window as gossip sync (900 seconds) - let maxAnnounceAgeSeconds: TimeInterval = 900 - let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) - let ageThresholdMs = UInt64(maxAnnounceAgeSeconds * 1000) - if nowMs >= ageThresholdMs { - let cutoffMs = nowMs - ageThresholdMs - if packet.timestamp < cutoffMs { - SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(Double(nowMs - packet.timestamp) / 1000.0)s)", category: .session) - return - } } // Suppress announce logs to reduce noise // Precompute signature verification outside barrier to reduce contention let existingPeerForVerify = collectionsQueue.sync { peerRegistry.info(for: peerID) } - var verifiedAnnounce = false - if packet.signature != nil { - verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) - if !verifiedAnnounce { + let hasSignature = packet.signature != nil + let signatureValid: Bool + if hasSignature { + signatureValid = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) + if !signatureValid { SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security) } + } else { + signatureValid = false } - if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey { + let trustDecision = BLEAnnounceTrustPolicy.evaluate( + hasSignature: hasSignature, + signatureValid: signatureValid, + existingNoisePublicKey: existingPeerForVerify?.noisePublicKey, + announcedNoisePublicKey: announcement.noisePublicKey + ) + if case .reject(.keyMismatch) = trustDecision { SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) - verifiedAnnounce = false } + let verifiedAnnounce = trustDecision.isVerified var isNewPeer = false var isReconnectedPeer = false let directLinkState = linkState(for: peerID) + let isDirectAnnounce = packet.ttl == messageTTL collectionsQueue.sync(flags: .barrier) { let hasPeripheralConnection = directLinkState.hasPeripheral let hasCentralSubscription = directLinkState.hasCentral - let isDirectAnnounce = (packet.ttl == messageTTL) // Require verified announce; ignore otherwise (no backward compatibility) if !verifiedAnnounce { @@ -3121,7 +3037,7 @@ extension BLEService { noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, - now: Date() + now: now ) isNewPeer = update.isNewPeer isReconnectedPeer = update.wasDisconnected @@ -3132,12 +3048,12 @@ extension BLEService { if update.isNewPeer { SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session) } else if update.wasDisconnected { - // Debounce 'reconnected' logs within short window - if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds { - // Skip duplicate log - } else { + if reconnectLogDebouncer.shouldEmit( + peerID: peerID, + now: now, + minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds + ) { SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session) - lastReconnectLogAt[peerID] = now } } else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname { SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session) @@ -3158,6 +3074,18 @@ extension BLEService { claimedNickname: announcement.nickname ) + let announceBackID = "announce-back-\(peerID)" + let shouldSendBack = !messageDeduplicator.contains(announceBackID) + if shouldSendBack { + messageDeduplicator.markProcessed(announceBackID) + } + let responsePlan = BLEAnnounceResponsePolicy.plan( + isDirectAnnounce: isDirectAnnounce, + isNewPeer: isNewPeer, + isReconnectedPeer: isReconnectedPeer, + shouldSendAnnounceBack: shouldSendBack + ) + // Notify UI on main thread notifyUI { [weak self] in guard let self = self else { return } @@ -3166,10 +3094,12 @@ extension BLEService { let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } // Only notify of connection for new or reconnected peers when it is a direct announce - if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) { + if responsePlan.shouldNotifyPeerConnected { self.deliverTransportEvent(.peerConnected(peerID)) // Schedule initial unicast sync to this peer - self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0) + if responsePlan.shouldScheduleInitialSync { + self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0) + } } self.requestPeerDataPublish() @@ -3178,22 +3108,15 @@ extension BLEService { // Track for sync (include our own and others' announces) gossipSyncManager?.onPublicPacketSeen(packet) - - // Send announce back for bidirectional discovery (only once per peer) - let announceBackID = "announce-back-\(peerID)" - let shouldSendBack = !messageDeduplicator.contains(announceBackID) - if shouldSendBack { - messageDeduplicator.markProcessed(announceBackID) - } - if shouldSendBack { + if responsePlan.shouldSendAnnounceBack { // Reciprocate announce for bidirectional discovery // Force send to ensure the peer receives our announce sendAnnounce(forceSend: true) } // Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop - if isNewPeer { + if responsePlan.shouldScheduleAfterglow { let delay = Double.random(in: 0.3...0.6) messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.sendAnnounce(forceSend: true) @@ -3213,29 +3136,23 @@ extension BLEService { // Mention parsing moved to ChatViewModel private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) { - // Ignore self-origin public messages except when returned via sync (TTL==0). - // This allows our own messages to be surfaced when they come back via - // the sync path without re-processing regular relayed copies. - if peerID == myPeerID && packet.ttl != 0 { return } + let now = Date() + let messageDecision = BLEPublicMessagePolicy.evaluate( + packet: packet, + from: peerID, + localPeerID: myPeerID, + now: now + ) - // Reject stale broadcast messages to prevent old messages from appearing - // Use same 15-minute window as gossip sync (900 seconds) - // Check if this is a broadcast message (recipient is all 0xFF or nil) - let isBroadcast: Bool = { - guard let r = packet.recipientID else { return true } - return r.count == 8 && r.allSatisfy { $0 == 0xFF } - }() - if isBroadcast { - let maxMessageAgeSeconds: TimeInterval = 900 - let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) - let ageThresholdMs = UInt64(maxMessageAgeSeconds * 1000) - if nowMs >= ageThresholdMs { - let cutoffMs = nowMs - ageThresholdMs - if packet.timestamp < cutoffMs { - SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(Double(nowMs - packet.timestamp) / 1000.0)s)", category: .session) - return - } - } + let messagePolicy: BLEPublicMessageAcceptance + switch messageDecision { + case .accept(let acceptance): + messagePolicy = acceptance + case .reject(.selfEcho): + return + case .reject(.staleBroadcast(let ageSeconds)): + SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) + return } // Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks. @@ -3252,11 +3169,7 @@ extension BLEService { return } - let isBroadcastRecipient: Bool = { - guard let r = packet.recipientID else { return true } - return r.count == 8 && r.allSatisfy { $0 == 0xFF } - }() - if isBroadcastRecipient && packet.type == MessageType.message.rawValue { + if messagePolicy.shouldTrackForSync { gossipSyncManager?.onPublicPacketSeen(packet) } @@ -3274,9 +3187,7 @@ extension BLEService { let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) var resolvedSelfMessageID: String? = nil if peerID == myPeerID { - let senderHex = packet.senderID.hexEncodedString() - let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)" - resolvedSelfMessageID = selfBroadcastMessageIDs.removeValue(forKey: dedupID)?.id + resolvedSelfMessageID = selfBroadcastTracker.takeMessageID(for: packet) } notifyUI { [weak self] in self?.deliverTransportEvent( @@ -3352,32 +3263,14 @@ extension BLEService { SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session) - switch noisePayloadType { - case .privateMessage: - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.deliverTransportEvent(.noisePayloadReceived(peerID: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts)) - } - case .delivered: - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.deliverTransportEvent(.noisePayloadReceived(peerID: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts)) - } - case .readReceipt: - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.deliverTransportEvent(.noisePayloadReceived(peerID: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)) - } - case .verifyChallenge: - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.deliverTransportEvent(.noisePayloadReceived(peerID: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)) - } - case .verifyResponse: - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.deliverTransportEvent(.noisePayloadReceived(peerID: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)) - } + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + notifyUI { [weak self] in + self?.deliverTransportEvent(.noisePayloadReceived( + peerID: peerID, + type: noisePayloadType, + payload: Data(payloadData), + timestamp: ts + )) } } catch NoiseEncryptionError.sessionNotEstablished { // We received an encrypted message before establishing a session with this peer. @@ -3422,13 +3315,12 @@ extension BLEService { // Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window @MainActor private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) { - let now = Date() - let last = recentDisconnectNotifies[peerID] - if last == nil || now.timeIntervalSince(last!) >= TransportConfig.bleDisconnectNotifyDebounceSeconds { + if disconnectNotifyDebouncer.shouldEmit( + peerID: peerID, + now: Date(), + minimumInterval: TransportConfig.bleDisconnectNotifyDebounceSeconds + ) { deliverTransportEvent(.peerDisconnected(peerID)) - recentDisconnectNotifies[peerID] = now - } else { - // Suppressed duplicate disconnect notification } } @@ -3449,36 +3341,27 @@ extension BLEService { private func performMaintenance() { maintenanceCounter += 1 - - // Adaptive announce: reduce frequency when we have connected peers + let now = Date() let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } - let elapsed = now.timeIntervalSince(lastAnnounceSent) - if connectedCount == 0 { - // Discovery mode: keep frequent announces - if elapsed >= TransportConfig.bleAnnounceIntervalSeconds { sendAnnounce(forceSend: true) } - } else { - // Connected mode: announce less often; much less in dense networks - let base = connectedCount >= TransportConfig.bleHighDegreeThreshold ? - TransportConfig.bleConnectedAnnounceBaseSecondsDense : TransportConfig.bleConnectedAnnounceBaseSecondsSparse - let jitter = connectedCount >= TransportConfig.bleHighDegreeThreshold ? - TransportConfig.bleConnectedAnnounceJitterDense : TransportConfig.bleConnectedAnnounceJitterSparse - let target = base + Double.random(in: -jitter...jitter) - if elapsed >= target { sendAnnounce(forceSend: true) } - } - - // Activity-driven quick-announce: if we've seen any packet in last 5s and it has - // been >=10s since the last announce, send a presence nudge. + let elapsed = announceThrottle.elapsed(since: now) let recentSeen = collectionsQueue.sync { () -> Bool in recentTrafficTracker.hasTraffic(within: 5.0, now: now) } - if recentSeen && elapsed >= 10.0 { + let hasNoPeers = collectionsQueue.sync { peerRegistry.isEmpty } + let plan = BLEMaintenancePolicy.plan( + cycle: maintenanceCounter, + connectedCount: connectedCount, + peerRegistryIsEmpty: hasNoPeers, + elapsedSinceLastAnnounce: elapsed, + hasRecentTraffic: recentSeen + ) + + if plan.shouldSendAnnounce { sendAnnounce(forceSend: true) } - - // If we have no peers, ensure we're scanning and advertising - let hasNoPeers = collectionsQueue.sync { peerRegistry.isEmpty } - if hasNoPeers { + + if plan.shouldEnsureAdvertising { // Ensure we're advertising as peripheral if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising { pm.startAdvertising(buildAdvertisementData()) @@ -3493,12 +3376,12 @@ extension BLEService { checkPeerConnectivity() // Every 30 seconds (3 cycles): Cleanup - if maintenanceCounter % 3 == 0 { + if plan.shouldRunCleanup { performCleanup() } // Attempt to flush any spooled directed messages periodically (~every 5 seconds) - if maintenanceCounter % 2 == 1 { + if plan.shouldFlushDirectedSpool { flushDirectedSpool() } @@ -3510,7 +3393,7 @@ extension BLEService { // No rotating alias: nothing to refresh // Reset counter to prevent overflow (every 60 seconds) - if maintenanceCounter >= 6 { + if plan.shouldResetCounter { maintenanceCounter = 0 } } @@ -3577,12 +3460,8 @@ extension BLEService { // Clean up stale scheduled relays that somehow persisted (> 2s) collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - if !self.scheduledRelays.isEmpty { - // Nothing to compare times to; just cap the size defensively - if self.scheduledRelays.count > 512 { - self.scheduledRelays.removeAll() - } - } + // Nothing to compare times to; just cap the size defensively + self.scheduledRelays.removeAllIfOverCapacity(512) } // Clean ingress link records older than configured seconds @@ -3593,21 +3472,17 @@ extension BLEService { self.ingressLinks.prune(before: cutoff) } // Clean expired directed spooled items - if !self.pendingDirectedRelays.isEmpty { - var cleaned: [PeerID: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:] - for (recipient, dict) in self.pendingDirectedRelays { - let pruned = dict.filter { now.timeIntervalSince($0.value.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds } - if !pruned.isEmpty { cleaned[recipient] = pruned } - } - self.pendingDirectedRelays = cleaned - } + self.pendingDirectedRelays.pruneExpired( + now: now, + window: TransportConfig.bleDirectedSpoolWindowSeconds + ) } messageQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } - guard !self.selfBroadcastMessageIDs.isEmpty else { return } + guard !self.selfBroadcastTracker.isEmpty else { return } let cutoff = now.addingTimeInterval(-TransportConfig.messageDedupMaxAgeSeconds) - self.selfBroadcastMessageIDs = self.selfBroadcastMessageIDs.filter { cutoff <= $0.value.timestamp } + self.selfBroadcastTracker.prune(before: cutoff) } } @@ -3626,23 +3501,25 @@ extension BLEService { now: Date() ) } - let forceScanOn = (connectedCount <= 2) || hasRecentTraffic - let shouldDuty = dutyEnabled && active && connectedCount > 0 && !forceScanOn - if shouldDuty { + let scanPlan = BLEScanDutyPolicy.plan( + dutyEnabled: dutyEnabled, + appIsActive: active, + connectedCount: connectedCount, + hasRecentTraffic: hasRecentTraffic + ) + + switch scanPlan { + case .dutyCycle(let onDuration, let offDuration): + let durationsChanged = dutyOnDuration != onDuration || dutyOffDuration != offDuration + dutyOnDuration = onDuration + dutyOffDuration = offDuration + if scanDutyTimer == nil { // Start timer to toggle scanning on/off let t = DispatchSource.makeTimerSource(queue: bleQueue) // Start with scanning ON; we'll turn OFF after onDuration if !central.isScanning { startScanning() } dutyActive = true - // Adjust duty cycle under dense networks to save battery - if connectedCount >= TransportConfig.bleHighDegreeThreshold { - dutyOnDuration = TransportConfig.bleDutyOnDurationDense - dutyOffDuration = TransportConfig.bleDutyOffDurationDense - } else { - dutyOnDuration = TransportConfig.bleDutyOnDuration - dutyOffDuration = TransportConfig.bleDutyOffDuration - } t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) t.setEventHandler { [weak self] in guard let self = self, let c = self.centralManager else { return } @@ -3659,8 +3536,12 @@ extension BLEService { } t.resume() scanDutyTimer = t + } else if durationsChanged { + scanDutyTimer?.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) + if !central.isScanning { startScanning() } + dutyActive = true } - } else { + case .continuous: // Cancel duty cycle and ensure scanning is ON for discovery scanDutyTimer?.cancel() scanDutyTimer = nil diff --git a/bitchat/ViewModels/ChatBluetoothAlertPolicy.swift b/bitchat/ViewModels/ChatBluetoothAlertPolicy.swift new file mode 100644 index 00000000..0e7168e4 --- /dev/null +++ b/bitchat/ViewModels/ChatBluetoothAlertPolicy.swift @@ -0,0 +1,35 @@ +import CoreBluetooth +import Foundation + +struct ChatBluetoothAlertUpdate: Equatable { + let isPresented: Bool + let message: String? +} + +enum ChatBluetoothAlertPolicy { + static func update(for state: CBManagerState) -> ChatBluetoothAlertUpdate { + switch state { + case .poweredOff: + ChatBluetoothAlertUpdate( + isPresented: true, + message: String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off") + ) + case .unauthorized: + ChatBluetoothAlertUpdate( + isPresented: true, + message: String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing") + ) + case .unsupported: + ChatBluetoothAlertUpdate( + isPresented: true, + message: String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support") + ) + case .poweredOn: + ChatBluetoothAlertUpdate(isPresented: false, message: "") + case .unknown, .resetting: + ChatBluetoothAlertUpdate(isPresented: false, message: nil) + @unknown default: + ChatBluetoothAlertUpdate(isPresented: false, message: nil) + } + } +} diff --git a/bitchat/ViewModels/ChatFavoriteTogglePolicy.swift b/bitchat/ViewModels/ChatFavoriteTogglePolicy.swift new file mode 100644 index 00000000..2cded1f2 --- /dev/null +++ b/bitchat/ViewModels/ChatFavoriteTogglePolicy.swift @@ -0,0 +1,67 @@ +import Foundation + +struct ChatFavoriteStatusSnapshot: Equatable { + let peerNickname: String + let peerNostrPublicKey: String? + let isFavorite: Bool + let theyFavoritedUs: Bool + + init( + peerNickname: String, + peerNostrPublicKey: String?, + isFavorite: Bool, + theyFavoritedUs: Bool + ) { + self.peerNickname = peerNickname + self.peerNostrPublicKey = peerNostrPublicKey + self.isFavorite = isFavorite + self.theyFavoritedUs = theyFavoritedUs + } + + init(_ relationship: FavoritesPersistenceService.FavoriteRelationship) { + self.peerNickname = relationship.peerNickname + self.peerNostrPublicKey = relationship.peerNostrPublicKey + self.isFavorite = relationship.isFavorite + self.theyFavoritedUs = relationship.theyFavoritedUs + } +} + +enum ChatFavoritePersistenceAction: Equatable { + case add(nickname: String, nostrKey: String?) + case remove +} + +enum ChatFavoriteNotificationDecision: Equatable { + case none + case send(isFavorite: Bool) +} + +struct ChatFavoriteTogglePlan: Equatable { + let persistenceAction: ChatFavoritePersistenceAction + let notification: ChatFavoriteNotificationDecision +} + +enum ChatFavoriteTogglePolicy { + static func plan( + currentStatus: ChatFavoriteStatusSnapshot?, + fallbackNickname: String?, + bridgedNostrKey: String? + ) -> ChatFavoriteTogglePlan { + let wasFavorite = currentStatus?.isFavorite ?? false + + if wasFavorite { + return ChatFavoriteTogglePlan( + persistenceAction: .remove, + notification: .send(isFavorite: false) + ) + } + + return ChatFavoriteTogglePlan( + persistenceAction: .add( + nickname: currentStatus?.peerNickname ?? fallbackNickname ?? "Unknown", + nostrKey: currentStatus?.peerNostrPublicKey ?? bridgedNostrKey + ), + notification: currentStatus?.theyFavoritedUs == true ? .send(isFavorite: true) : .none + ) + } +} diff --git a/bitchat/ViewModels/ChatMediaPreparation.swift b/bitchat/ViewModels/ChatMediaPreparation.swift new file mode 100644 index 00000000..6852d397 --- /dev/null +++ b/bitchat/ViewModels/ChatMediaPreparation.swift @@ -0,0 +1,57 @@ +import BitFoundation +import Foundation + +enum ChatMediaPreparationError: Error, Equatable { + case encodingFailed + case voiceNoteTooLarge(bytes: Int) + case imageTooLarge(bytes: Int) +} + +struct ChatPreparedImage { + let outputURL: URL + let packet: BitchatFilePacket +} + +enum ChatMediaPreparation { + static func prepareVoiceNotePacket(at url: URL) throws -> BitchatFilePacket { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard let fileSize = attributes[.size] as? Int else { + throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 0) + } + guard fileSize <= FileTransferLimits.maxVoiceNoteBytes else { + throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: fileSize) + } + + let data = try Data(contentsOf: url) + let packet = BitchatFilePacket( + fileName: url.lastPathComponent, + fileSize: UInt64(data.count), + mimeType: "audio/mp4", + content: data + ) + guard packet.encode() != nil else { throw ChatMediaPreparationError.encodingFailed } + return packet + } + + static func prepareImagePacket(from sourceURL: URL) throws -> ChatPreparedImage { + let outputURL = try ImageUtils.processImage(at: sourceURL) + do { + let data = try Data(contentsOf: outputURL) + guard data.count <= FileTransferLimits.maxImageBytes else { + throw ChatMediaPreparationError.imageTooLarge(bytes: data.count) + } + + let packet = BitchatFilePacket( + fileName: outputURL.lastPathComponent, + fileSize: UInt64(data.count), + mimeType: "image/jpeg", + content: data + ) + guard packet.encode() != nil else { throw ChatMediaPreparationError.encodingFailed } + return ChatPreparedImage(outputURL: outputURL, packet: packet) + } catch { + try? FileManager.default.removeItem(at: outputURL) + throw error + } + } +} diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index 9a608052..ec15a4ab 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -8,10 +8,6 @@ import UIKit @MainActor final class ChatMediaTransferCoordinator { - private enum MediaSendError: Error { - case encodingFailed - } - private unowned let viewModel: ChatViewModel private(set) var transferIdToMessageIDs: [String: [String]] = [:] @@ -40,26 +36,7 @@ final class ChatMediaTransferCoordinator { Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } do { - let attributes = try FileManager.default.attributesOfItem(atPath: url.path) - guard let fileSize = attributes[.size] as? Int, - fileSize <= FileTransferLimits.maxVoiceNoteBytes else { - let size = (attributes[.size] as? Int) ?? 0 - SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) - try? FileManager.default.removeItem(at: url) - await MainActor.run { - self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large") - } - return - } - - let data = try Data(contentsOf: url) - let packet = BitchatFilePacket( - fileName: url.lastPathComponent, - fileSize: UInt64(data.count), - mimeType: "audio/mp4", - content: data - ) - guard packet.encode() != nil else { throw MediaSendError.encodingFailed } + let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url) await MainActor.run { self.registerTransfer(transferId: transferId, messageID: messageID) @@ -69,6 +46,12 @@ final class ChatMediaTransferCoordinator { self.viewModel.meshService.sendFileBroadcast(packet, transferId: transferId) } } + } catch ChatMediaPreparationError.voiceNoteTooLarge(let size) { + SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) + try? FileManager.default.removeItem(at: url) + await MainActor.run { + self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large") + } } catch { SecureLogger.error("Voice note send failed: \(error)", category: .session) await MainActor.run { @@ -120,52 +103,43 @@ final class ChatMediaTransferCoordinator { let targetPeer = viewModel.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.") + return + } + Task.detached(priority: .userInitiated) { [weak self] in guard let self else { return } - var processedURL: URL? do { - let outputURL = try ImageUtils.processImage(at: sourceURL) - processedURL = outputURL - let data = try Data(contentsOf: outputURL) - guard data.count <= FileTransferLimits.maxImageBytes else { - SecureLogger.warning("Processed image exceeds size limit (\(data.count) bytes)", category: .session) - await MainActor.run { - self.viewModel.addSystemMessage("Image is too large to send.") - } - try? FileManager.default.removeItem(at: outputURL) - return - } - - let packet = BitchatFilePacket( - fileName: outputURL.lastPathComponent, - fileSize: UInt64(data.count), - mimeType: "image/jpeg", - content: data - ) - guard packet.encode() != nil else { throw MediaSendError.encodingFailed } + let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL) await MainActor.run { let message = self.enqueueMediaMessage( - content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", + content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)", targetPeer: targetPeer ) let messageID = message.id let transferId = self.makeTransferID(messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID) if let peerID = targetPeer { - self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) + self.viewModel.meshService.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId) } else { - self.viewModel.meshService.sendFileBroadcast(packet, transferId: transferId) + self.viewModel.meshService.sendFileBroadcast(prepared.packet, transferId: transferId) } } + } catch ChatMediaPreparationError.imageTooLarge(let size) { + SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session) + await MainActor.run { + self.viewModel.addSystemMessage("Image is too large to send.") + } } catch { SecureLogger.error("Image send preparation failed: \(error)", category: .session) await MainActor.run { self.viewModel.addSystemMessage("Failed to prepare image for sending.") } - if let processedURL { - try? FileManager.default.removeItem(at: processedURL) - } } } } diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index 091f7c87..d282a6f5 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -848,7 +848,7 @@ private extension ChatNostrCoordinator { let maxBytes = FileTransferLimits.maxFramedFileBytes let maxEncoded = ((maxBytes + 2) / 3) * 4 guard encoded.count <= maxEncoded else { return nil } - guard let packetData = ChatViewModel.base64URLDecode(encoded), + guard let packetData = Base64URLCoding.decode(encoded), packetData.count <= maxBytes else { return nil diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 4bb4585d..6a3047cb 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -33,6 +33,52 @@ final class ChatPeerIdentityCoordinator { viewModel.unifiedPeerService.isBlocked(peerID) } + @MainActor + func hasUnreadMessages(for peerID: PeerID) -> Bool { + var noiseKeyPeerID: PeerID? + var nostrPeerID: PeerID? + + if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + noiseKeyPeerID = PeerID(hexData: peer.noisePublicKey) + if let nostrHex = peer.nostrPublicKey { + nostrPeerID = PeerID(nostr_: nostrHex) + } + } + + let context = ChatUnreadPeerContext( + peerID: peerID, + noiseKeyPeerID: noiseKeyPeerID, + nostrPeerID: nostrPeerID, + nickname: viewModel.meshService.peerNickname(peerID: peerID) + ) + + return ChatUnreadStateResolver.hasUnreadMessages( + for: context, + unreadPrivateMessages: viewModel.unreadPrivateMessages, + privateChats: viewModel.privateChats + ) + } + + @MainActor + func toggleFavorite(peerID: PeerID) { + if let noisePublicKey = peerID.noiseKey { + toggleFavoriteForNoiseKey(noisePublicKey, peerID: peerID) + return + } + + viewModel.unifiedPeerService.toggleFavorite(peerID) + viewModel.objectWillChange.send() + } + + @MainActor + func isFavorite(peerID: PeerID) -> Bool { + if let noisePublicKey = peerID.noiseKey { + return FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)?.isFavorite ?? false + } + + return viewModel.unifiedPeerService.getPeer(by: peerID)?.isFavorite ?? false + } + @MainActor func updatePrivateChatPeerIfNeeded() { guard let chatFingerprint = viewModel.selectedPrivateChatFingerprint, @@ -376,4 +422,42 @@ private extension ChatPeerIdentityCoordinator { } return .noiseSecured } + + @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() + return + } + + let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) + let fallbackNickname = viewModel.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender + let plan = ChatFavoriteTogglePolicy.plan( + currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init), + fallbackNickname: fallbackNickname, + bridgedNostrKey: viewModel.idBridge.getNostrPublicKey(for: noisePublicKey) + ) + + switch plan.persistenceAction { + case .add(let nickname, let nostrKey): + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: noisePublicKey, + peerNostrPublicKey: nostrKey, + peerNickname: nickname + ) + + case .remove: + FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) + } + + viewModel.objectWillChange.send() + + if case .send(let isFavorite) = plan.notification { + viewModel.sendFavoriteNotificationViaNostr( + noisePublicKey: noisePublicKey, + isFavorite: isFavorite + ) + } + } } diff --git a/bitchat/ViewModels/ChatUnreadStateResolver.swift b/bitchat/ViewModels/ChatUnreadStateResolver.swift new file mode 100644 index 00000000..378f6f2f --- /dev/null +++ b/bitchat/ViewModels/ChatUnreadStateResolver.swift @@ -0,0 +1,43 @@ +import BitFoundation +import Foundation + +struct ChatUnreadPeerContext { + let peerID: PeerID + let noiseKeyPeerID: PeerID? + let nostrPeerID: PeerID? + let nickname: String? +} + +enum ChatUnreadStateResolver { + static func hasUnreadMessages( + for context: ChatUnreadPeerContext, + unreadPrivateMessages: Set, + privateChats: [PeerID: [BitchatMessage]] + ) -> Bool { + if unreadPrivateMessages.contains(context.peerID) { + return true + } + + if let noiseKeyPeerID = context.noiseKeyPeerID, + unreadPrivateMessages.contains(noiseKeyPeerID) { + return true + } + + if let nostrPeerID = context.nostrPeerID, + unreadPrivateMessages.contains(nostrPeerID) { + return true + } + + guard let peerNickname = context.nickname?.lowercased(), !peerNickname.isEmpty else { + return false + } + + return unreadPrivateMessages.contains { unreadPeerID in + guard unreadPeerID.isGeoDM, + let firstMessage = privateChats[unreadPeerID]?.first else { + return false + } + return firstMessage.sender.lowercased() == peerNickname + } + } +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 10662a19..c37362b1 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -118,7 +118,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) // MARK: - Published Properties - + @Published var messages: [BitchatMessage] = [] @Published var currentColorScheme: ColorScheme = .light private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed @@ -137,7 +137,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } } } - + // MARK: - Service Delegates let commandProcessor: CommandProcessor @@ -159,7 +159,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele lazy var nostrCoordinator = ChatNostrCoordinator(viewModel: self) lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(viewModel: self) lazy var verificationCoordinator = ChatVerificationCoordinator(viewModel: self) - + // Computed properties for compatibility @MainActor var connectedPeers: Set { unifiedPeerService.connectedPeerIDs } @@ -173,7 +173,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } var selectedPrivateChatPeer: PeerID? { get { privateChatManager.selectedPeer } - set { + set { if let peerID = newValue { privateChatManager.startChat(with: peerID) } else { @@ -190,7 +190,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele synchronizePrivateConversationStore() } } - + /// Check if there are any unread messages (including from temporary Nostr peer IDs) var hasAnyUnreadMessages: Bool { !unreadPrivateMessages.isEmpty @@ -202,7 +202,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func openMostRelevantPrivateChat() { peerIdentityCoordinator.openMostRelevantPrivateChat() } - + // var peerIDToPublicKeyFingerprint: [PeerID: String] { get { peerIdentityStore.peerFingerprintsByPeerID } @@ -257,16 +257,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } var peerIndex: [PeerID: BitchatPeer] = [:] - + // MARK: - Autocomplete Properties - + @Published var autocompleteSuggestions: [String] = [] @Published var showAutocomplete: Bool = false @Published var autocompleteRange: NSRange? = nil @Published var selectedAutocompleteIndex: Int = 0 - + // MARK: - Services and Storage - + let meshService: Transport let idBridge: NostrIdentityBridge let identityManager: SecureIdentityStateManagerProtocol @@ -275,7 +275,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele let peerIdentityStore: PeerIdentityStore let locationPresenceStore: LocationPresenceStore let locationManager: LocationChannelManager - + var nostrRelayManager: NostrRelayManager? private let userDefaults = UserDefaults.standard let keychain: KeychainManagerProtocol @@ -311,18 +311,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Ensure we set up DM subscription only once per app session var nostrHandlersSetup: Bool = false var geoChannelCoordinator: GeoChannelCoordinator? - + // MARK: - Caches - + // MARK: - Social Features (Delegated to PeerStateManager) - + @MainActor var favoritePeers: Set { unifiedPeerService.favoritePeers } @MainActor var blockedUsers: Set { unifiedPeerService.blockedUsers } - + // MARK: - Encryption and Security - + // Noise Protocol encryption status var peerEncryptionStatus: [PeerID: EncryptionStatus] { get { peerIdentityStore.encryptionStatuses } @@ -332,7 +332,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele get { peerIdentityStore.verifiedFingerprints } set { peerIdentityStore.setVerifiedFingerprints(newValue) } } // Set of verified fingerprints - + // Bluetooth state management @Published var showBluetoothAlert = false @Published var bluetoothAlertMessage = "" @@ -368,10 +368,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Sampling subscriptions for multiple geohashes (when channel sheet is open) var geoSamplingSubs: [String: String] = [:] // subID -> geohash var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time - - + // MARK: - Message Delivery Tracking - + var cancellables = Set() var transferIdToMessageIDs: [String: [String]] { @@ -385,14 +384,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // MARK: - Public message batching (UI perf) let publicMessagePipeline: PublicMessagePipeline @Published var isBatchingPublic: Bool = false - + // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed var sentReadReceipts: Set = [] { // messageID set didSet { // Only persist if there are changes guard oldValue != sentReadReceipts else { return } - + // Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read) if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) { UserDefaults.standard.set(data, forKey: "sentReadReceipts") @@ -404,15 +403,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Track which GeoDM messages we've already sent a delivery ACK for (by messageID) var sentGeoDeliveryAcks: Set = [] - + // Track app startup phase to prevent marking old messages as unread var isStartupPhase = true // Announce Tor initial readiness once per launch to avoid duplicates var torInitialReadyAnnounced: Bool = false - + // Track Nostr pubkey mappings for unknown senders var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey - + // MARK: - Initialization @MainActor @@ -487,21 +486,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ChatViewModelBootstrapper(viewModel: self).configure() initializeConversationStore() } - + // MARK: - Deinitialization - + deinit { // No need to force UserDefaults synchronization } - - - - - - // MARK: - Nickname Management - + func loadNickname() { if let savedNickname = userDefaults.string(forKey: nicknameKey) { nickname = savedNickname.trimmed @@ -510,163 +503,53 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele saveNickname() } } - + func saveNickname() { userDefaults.set(nickname, forKey: nicknameKey) // Persist nickname; no need to force synchronize - + // Send announce with new nickname to all peers meshService.sendBroadcastAnnounce() } - + func validateAndSaveNickname() { nickname = nickname.trimmedOrNilIfEmpty ?? "anon\(Int.random(in: 1000...9999))" saveNickname() } // MARK: - Blocked Users Management (Delegated to PeerStateManager) - - + /// Check if a peer has unread messages, including messages stored under stable Noise keys and temporary Nostr peer IDs @MainActor func hasUnreadMessages(for peerID: PeerID) -> Bool { - // First check direct unread messages - if unreadPrivateMessages.contains(peerID) { - return true - } - - // Check if messages are stored under the stable Noise key hex - if let peer = unifiedPeerService.getPeer(by: peerID) { - let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - if unreadPrivateMessages.contains(noiseKeyHex) { - return true - } - // Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey - if let nostrHex = peer.nostrPublicKey { - let convKey = PeerID(nostr_: nostrHex) - if unreadPrivateMessages.contains(convKey) { - return true - } - } - } - - // Get the peer's nickname to check for temporary Nostr peer IDs - let peerNickname = meshService.peerNickname(peerID: peerID)?.lowercased() ?? "" - - // Check if any temporary Nostr peer IDs have unread messages from this nickname - for unreadPeerID in unreadPrivateMessages { - if unreadPeerID.isGeoDM { - // Check if messages from this temporary peer match the nickname - if let messages = privateChats[unreadPeerID], - let firstMessage = messages.first, - firstMessage.sender.lowercased() == peerNickname { - return true - } - } - } - - return false + peerIdentityCoordinator.hasUnreadMessages(for: peerID) } - + @MainActor func toggleFavorite(peerID: PeerID) { - // Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars) - // Ephemeral peer IDs are 8 bytes = 16 hex characters - // Noise public keys are 32 bytes = 64 hex characters - - if let noisePublicKey = peerID.noiseKey { - // This is a stable Noise key hex (used in private chats) - // Find the ephemeral peer ID for this Noise key - let ephemeralPeerID = unifiedPeerService.peers.first { peer in - peer.noisePublicKey == noisePublicKey - }?.peerID - - if let ephemeralID = ephemeralPeerID { - // Found the ephemeral peer, use normal toggle - unifiedPeerService.toggleFavorite(ephemeralID) - // Also trigger UI update - objectWillChange.send() - } else { - // No ephemeral peer found, directly toggle via FavoritesPersistenceService - let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) - let wasFavorite = currentStatus?.isFavorite ?? false - - if wasFavorite { - // Remove favorite - FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) - } else { - // Add favorite - get nickname from current status or from private chat messages - var nickname = currentStatus?.peerNickname - - // If no nickname in status, try to get from private chat messages - if nickname == nil, let messages = privateChats[peerID], !messages.isEmpty { - // Get the nickname from the first message where this peer was the sender - nickname = messages.first { $0.senderPeerID == peerID }?.sender - } - - let finalNickname = nickname ?? "Unknown" - let nostrKey = currentStatus?.peerNostrPublicKey ?? idBridge.getNostrPublicKey(for: noisePublicKey) - - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: noisePublicKey, - peerNostrPublicKey: nostrKey, - peerNickname: finalNickname - ) - } - - // Trigger UI update - objectWillChange.send() - - // Send favorite notification via Nostr if we're mutual favorites - if !wasFavorite && currentStatus?.theyFavoritedUs == true { - // We just favorited them and they already favorite us - send via Nostr - sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: true) - } else if wasFavorite { - // We're unfavoriting - send via Nostr if they still favorite us - sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: false) - } - } - } else { - // This is an ephemeral peer ID (16 hex chars), use normal toggle - unifiedPeerService.toggleFavorite(peerID) - // Trigger UI update - objectWillChange.send() - } + peerIdentityCoordinator.toggleFavorite(peerID: peerID) } - + @MainActor func isFavorite(peerID: PeerID) -> Bool { - // Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars) - if let noisePublicKey = peerID.noiseKey { - // This is a Noise public key - if let status = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) { - return status.isFavorite - } - } else { - // This is an ephemeral peer ID - check with UnifiedPeerService - if let peer = unifiedPeerService.getPeer(by: peerID) { - return peer.isFavorite - } - } - - return false + peerIdentityCoordinator.isFavorite(peerID: peerID) } - + // MARK: - Public Key and Identity Management - + @MainActor func isPeerBlocked(_ peerID: PeerID) -> Bool { peerIdentityCoordinator.isPeerBlocked(peerID) } - + // Helper method to update selectedPrivateChatPeer if fingerprint matches @MainActor func updatePrivateChatPeerIfNeeded() { peerIdentityCoordinator.updatePrivateChatPeerIfNeeded() } - + // MARK: - Message Sending - + /// Sends a message through the BitChat network. /// - Parameter content: The message content to send /// - Note: Automatically handles command processing if content starts with '/' @@ -675,14 +558,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func sendMessage(_ content: String) { outgoingCoordinator.sendMessage(content) } - - - - - - - - // MARK: - Geohash Participants @@ -762,14 +637,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) } - - func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex) } - - // MARK: - Media Transfers private enum MediaSendError: Error { @@ -778,13 +649,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele case copyFailed } - - - - - - - func currentPublicSender() -> (name: String, peerID: PeerID) { publicConversationCoordinator.currentPublicSender() } @@ -794,14 +658,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele peerIdentityCoordinator.nicknameForPeer(peerID) } - - @MainActor func removeMessage(withID messageID: String, cleanupFile: Bool = false) { publicConversationCoordinator.removeMessage(withID: messageID, cleanupFile: cleanupFile) } - /// Add a local system message to a private chat (no network send) @MainActor func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) { @@ -819,37 +680,21 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele privateChats[peerID]?.append(systemMessage) objectWillChange.send() } - + // MARK: - Bluetooth State Management - + /// Updates the Bluetooth state and shows appropriate alerts /// - Parameter state: The current Bluetooth manager state @MainActor func updateBluetoothState(_ state: CBManagerState) { bluetoothState = state - - switch state { - case .poweredOff: - bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off") - showBluetoothAlert = true - case .unauthorized: - bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing") - showBluetoothAlert = true - case .unsupported: - bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support") - showBluetoothAlert = true - case .poweredOn: - // Hide alert when Bluetooth is powered on - showBluetoothAlert = false - bluetoothAlertMessage = "" - case .unknown, .resetting: - // Don't show alerts for transient states - showBluetoothAlert = false - @unknown default: - showBluetoothAlert = false + let alertUpdate = ChatBluetoothAlertPolicy.update(for: state) + showBluetoothAlert = alertUpdate.isPresented + if let message = alertUpdate.message { + bluetoothAlertMessage = message } } - + // MARK: - Private Chat Management /// Initiates a private chat session with a peer. @@ -859,33 +704,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func startPrivateChat(with peerID: PeerID) { peerIdentityCoordinator.startPrivateChat(with: peerID) } - + @MainActor func endPrivateChat() { peerIdentityCoordinator.endPrivateChat() } - + @MainActor @objc func handlePeerStatusUpdate(_ notification: Notification) { peerIdentityCoordinator.handlePeerStatusUpdate() } - + @objc func handleFavoriteStatusChanged(_ notification: Notification) { peerIdentityCoordinator.handleFavoriteStatusChanged(notification) } - + // MARK: - App Lifecycle - + @MainActor func handleDidBecomeActive() { lifecycleCoordinator.handleDidBecomeActive() } - + @MainActor func handleScreenshotCaptured() { lifecycleCoordinator.handleScreenshotCaptured() } - + /// Save identity state without stopping services (for backgrounding) func saveIdentityState() { lifecycleCoordinator.saveIdentityState() @@ -894,38 +739,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @objc func applicationWillTerminate() { lifecycleCoordinator.applicationWillTerminate() } - - @MainActor - private func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID, originalTransport: String? = nil) { - // First, try to resolve the current peer ID in case they reconnected with a new ID - var actualPeerID = peerID - - // Check if this peer ID exists in current nicknames - if meshService.peerNickname(peerID: peerID) == nil { - // Peer not found with this ID, try to find by fingerprint or nickname - if let oldNoiseKey = Data(hexString: peerID.id), - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: oldNoiseKey) { - let peerNickname = favoriteStatus.peerNickname - - // Search for the current peer ID with the same nickname - for (currentPeerID, currentNickname) in meshService.getPeerNicknames() { - if currentNickname == peerNickname { - SecureLogger.info("📖 Resolved updated peer ID for read receipt: \(peerID) -> \(currentPeerID)", category: .session) - actualPeerID = currentPeerID - break - } - } - } - } - - // If this originated over Nostr, skip (handled by Nostr code paths) - if originalTransport == "nostr" { - return - } - // Use router to decide (mesh if reachable, else Nostr if available) - messageRouter.sendReadReceipt(receipt, to: actualPeerID) - } - + @MainActor func markPrivateMessagesAsRead(from peerID: PeerID) { lifecycleCoordinator.markPrivateMessagesAsRead(from: peerID) @@ -939,20 +753,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { lifecycleCoordinator.getPrivateChatMessages(for: peerID) } - + @MainActor func getPeerIDForNickname(_ nickname: String) -> PeerID? { peerIdentityCoordinator.getPeerIDForNickname(nickname) } - - + // MARK: - Emergency Functions - + // PANIC: Emergency data clearing for activist safety @MainActor func panicClearAllData() { // Messages are processed immediately - nothing to flush - + // Clear all messages messages.removeAll() timelineStore = PublicTimelineStore( @@ -961,38 +774,38 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele ) privateChatManager.privateChats.removeAll() privateChatManager.unreadMessages.removeAll() - + // Delete all keychain data (including Noise and Nostr keys) _ = keychain.deleteAllKeychainData() - + // Clear UserDefaults identity data userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey") userDefaults.removeObject(forKey: "bitchat.messageRetentionKey") - + // Reset nickname to anonymous nickname = "anon\(Int.random(in: 1000...9999))" saveNickname() - + // Clear favorites and peer mappings // Clear through SecureIdentityStateManager instead of directly identityManager.clearAllIdentityData() peerIdentityStore.clearAll() locationPresenceStore.reset() - + // Clear persistent favorites from keychain FavoritesPersistenceService.shared.clearAllFavorites() - + // Identity manager has cleared persisted identity data above - + // Clear autocomplete state autocompleteSuggestions.removeAll() showAutocomplete = false autocompleteRange = nil selectedAutocompleteIndex = 0 - + // Clear selected private chat selectedPrivateChatPeer = nil - + // Clear read receipt tracking sentReadReceipts.removeAll() deduplicationService.clearAll() @@ -1001,10 +814,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Disconnect from Nostr relays and clear subscriptions nostrRelayManager?.disconnect() nostrRelayManager = nil - + // Clear Nostr identity associations idBridge.clearAllAssociations() - + // Disconnect from all peers and clear persistent identity // This will force creation of a new identity (new fingerprint) on next launch meshService.emergencyDisconnectAll() @@ -1015,19 +828,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele initializeConversationStore() // No need to force UserDefaults synchronization - + // Reinitialize Nostr with new identity // This will generate new Nostr keys derived from new Noise keys Task { @MainActor in // Small delay to ensure cleanup completes try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds - + // Reinitialize Nostr relay manager with new identity nostrRelayManager = NostrRelayManager() setupNostrMessageHandling() nostrRelayManager?.connect() } - + // Delete ALL media files (incoming and outgoing) in background Task.detached(priority: .utility) { do { @@ -1088,17 +901,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele #endif // MARK: - Autocomplete - + func updateAutocomplete(for text: String, cursorPosition: Int) { composerCoordinator.updateAutocomplete(for: text, cursorPosition: cursorPosition) } - + func completeNickname(_ nickname: String, in text: inout String) -> Int { composerCoordinator.completeNickname(nickname, in: &text) } - + // MARK: - Message Formatting - + @MainActor func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { messageFormatter.formatMessageAsText(message, colorScheme: colorScheme) @@ -1110,29 +923,28 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } // MARK: - Noise Protocol Support - + @MainActor func updateEncryptionStatusForPeers() { peerIdentityCoordinator.updateEncryptionStatusForPeers() } - + @MainActor func updateEncryptionStatus(for peerID: PeerID) { peerIdentityCoordinator.updateEncryptionStatus(for: peerID) } - + @MainActor func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { peerIdentityCoordinator.getEncryptionStatus(for: peerID) } - + // Clear caches when data changes @MainActor func invalidateEncryptionCache(for peerID: PeerID? = nil) { peerIdentityCoordinator.invalidateEncryptionCache(for: peerID) } - - + // MARK: - Message Handling @MainActor @@ -1222,38 +1034,38 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func clearCurrentPublicTimeline() { publicConversationCoordinator.clearCurrentPublicTimeline() } - + // MARK: - Message Management - + private func addMessage(_ message: BitchatMessage) { // Check for duplicates guard !messages.contains(where: { $0.id == message.id }) else { return } messages.append(message) trimMessagesIfNeeded() } - + // MARK: - Peer Lookup Helpers - + func getPeer(byID peerID: PeerID) -> BitchatPeer? { return peerIndex[peerID] } - + @MainActor func getFingerprint(for peerID: PeerID) -> String? { peerIdentityCoordinator.getFingerprint(for: peerID) } - + /// Helper to resolve nickname for a peer ID through various sources @MainActor func resolveNickname(for peerID: PeerID) -> String { peerIdentityCoordinator.resolveNickname(for: peerID) } - + @MainActor func getMyFingerprint() -> String { peerIdentityCoordinator.getMyFingerprint() } - + @MainActor func verifyFingerprint(for peerID: PeerID) { verificationCoordinator.verifyFingerprint(for: peerID) @@ -1263,27 +1075,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func unverifyFingerprint(for peerID: PeerID) { verificationCoordinator.unverifyFingerprint(for: peerID) } - + @MainActor func loadVerifiedFingerprints() { verificationCoordinator.loadVerifiedFingerprints() } - + func setupNoiseCallbacks() { verificationCoordinator.setupNoiseCallbacks() } - + // MARK: - BitchatDelegate Methods - + // MARK: - Command Handling - + /// Processes IRC-style commands starting with '/'. /// - Parameter command: The full command string including the leading slash /// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help @MainActor func handleCommand(_ command: String) { let result = commandProcessor.process(command) - + switch result { case .success(let message): if let msg = message { @@ -1296,14 +1108,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele break } } - + // MARK: - Message Reception @MainActor func didReceiveTransportEvent(_ event: TransportEvent) { receiveTransportEvent(event) } - + func didReceiveMessage(_ message: BitchatMessage) { transportEventCoordinator.didReceiveMessage(message) } @@ -1348,48 +1160,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func didConnectToPeer(_ peerID: PeerID) { transportEventCoordinator.didConnectToPeer(peerID) } - + func didDisconnectFromPeer(_ peerID: PeerID) { transportEventCoordinator.didDisconnectFromPeer(peerID) } - + func didUpdatePeerList(_ peers: [PeerID]) { peerListCoordinator.didUpdatePeerList(peers) } - + @MainActor func cleanupOldReadReceipts() { deliveryCoordinator.cleanupOldReadReceipts() } - + func parseMentions(from content: String) -> [String] { composerCoordinator.parseMentions(from: content) } - + func isFavorite(fingerprint: String) -> Bool { return identityManager.isFavorite(fingerprint: fingerprint) } - + // MARK: - Delivery Tracking - + func didReceiveReadReceipt(_ receipt: ReadReceipt) { performDeliveryUpdate { coordinator in coordinator.didReceiveReadReceipt(receipt) } } - + func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { performDeliveryUpdate { coordinator in coordinator.didUpdateMessageDeliveryStatus(messageID, status: status) } } - + func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { performDeliveryUpdate { coordinator in coordinator.updateMessageDeliveryStatus(messageID, status: status) } } - + // MARK: - Helper for System Messages func addSystemMessage(_ content: String, timestamp: Date = Date()) { publicConversationCoordinator.addSystemMessage(content, timestamp: timestamp) @@ -1420,41 +1232,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func sendPublicRaw(_ content: String) { publicConversationCoordinator.sendPublicRaw(content) } - - - - - - - // MARK: - Base64URL utils - static func base64URLDecode(_ s: String) -> Data? { - var str = s.replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - // Add padding if needed - let rem = str.count % 4 - if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) } - return Data(base64Encoded: str) - } - - // - - - - - - - - - - - /// Handle incoming public message @MainActor func handlePublicMessage(_ message: BitchatMessage) { publicConversationCoordinator.handlePublicMessage(message) } - + /// Check for mentions and send notifications func checkForMentions(_ message: BitchatMessage) { publicConversationCoordinator.checkForMentions(message) diff --git a/bitchatTests/ChatBluetoothAlertPolicyTests.swift b/bitchatTests/ChatBluetoothAlertPolicyTests.swift new file mode 100644 index 00000000..8d368988 --- /dev/null +++ b/bitchatTests/ChatBluetoothAlertPolicyTests.swift @@ -0,0 +1,40 @@ +import CoreBluetooth +import Testing +@testable import bitchat + +struct ChatBluetoothAlertPolicyTests { + @Test + func poweredOffShowsAlertMessage() { + let update = ChatBluetoothAlertPolicy.update(for: .poweredOff) + + #expect(update.isPresented) + #expect(update.message?.isEmpty == false) + } + + @Test + func unauthorizedShowsAlertMessage() { + let update = ChatBluetoothAlertPolicy.update(for: .unauthorized) + + #expect(update.isPresented) + #expect(update.message?.isEmpty == false) + } + + @Test + func poweredOnHidesAndClearsAlertMessage() { + let update = ChatBluetoothAlertPolicy.update(for: .poweredOn) + + #expect(!update.isPresented) + #expect(update.message == "") + } + + @Test + func transientStatesHideWithoutChangingAlertMessage() { + let unknown = ChatBluetoothAlertPolicy.update(for: .unknown) + let resetting = ChatBluetoothAlertPolicy.update(for: .resetting) + + #expect(!unknown.isPresented) + #expect(unknown.message == nil) + #expect(!resetting.isPresented) + #expect(resetting.message == nil) + } +} diff --git a/bitchatTests/ChatFavoriteTogglePolicyTests.swift b/bitchatTests/ChatFavoriteTogglePolicyTests.swift new file mode 100644 index 00000000..39e3c420 --- /dev/null +++ b/bitchatTests/ChatFavoriteTogglePolicyTests.swift @@ -0,0 +1,70 @@ +import Testing +@testable import bitchat + +struct ChatFavoriteTogglePolicyTests { + @Test + func addingWithoutExistingStatusUsesFallbackNicknameAndBridgeKey() { + let plan = ChatFavoriteTogglePolicy.plan( + currentStatus: nil, + fallbackNickname: "alice", + bridgedNostrKey: "npub-alice" + ) + + #expect(plan == ChatFavoriteTogglePlan( + persistenceAction: .add(nickname: "alice", nostrKey: "npub-alice"), + notification: .none + )) + } + + @Test + func addingMutualFavoriteSendsPositiveNotification() { + let plan = ChatFavoriteTogglePolicy.plan( + currentStatus: ChatFavoriteStatusSnapshot( + peerNickname: "alice", + peerNostrPublicKey: "npub-current", + isFavorite: false, + theyFavoritedUs: true + ), + fallbackNickname: "fallback", + bridgedNostrKey: "npub-bridge" + ) + + #expect(plan == ChatFavoriteTogglePlan( + persistenceAction: .add(nickname: "alice", nostrKey: "npub-current"), + notification: .send(isFavorite: true) + )) + } + + @Test + func addingWithoutAnyNicknameUsesUnknown() { + let plan = ChatFavoriteTogglePolicy.plan( + currentStatus: nil, + fallbackNickname: nil, + bridgedNostrKey: nil + ) + + #expect(plan == ChatFavoriteTogglePlan( + persistenceAction: .add(nickname: "Unknown", nostrKey: nil), + notification: .none + )) + } + + @Test + func removingFavoriteSendsNegativeNotification() { + let plan = ChatFavoriteTogglePolicy.plan( + currentStatus: ChatFavoriteStatusSnapshot( + peerNickname: "alice", + peerNostrPublicKey: "npub-current", + isFavorite: true, + theyFavoritedUs: false + ), + fallbackNickname: nil, + bridgedNostrKey: nil + ) + + #expect(plan == ChatFavoriteTogglePlan( + persistenceAction: .remove, + notification: .send(isFavorite: false) + )) + } +} diff --git a/bitchatTests/ChatMediaPreparationTests.swift b/bitchatTests/ChatMediaPreparationTests.swift new file mode 100644 index 00000000..ee47f7ed --- /dev/null +++ b/bitchatTests/ChatMediaPreparationTests.swift @@ -0,0 +1,92 @@ +import BitFoundation +import Foundation +import Testing +#if os(iOS) +import UIKit +#else +import AppKit +#endif +@testable import bitchat + +struct ChatMediaPreparationTests { + @Test + func prepareVoiceNotePacket_buildsEncodedAudioPacket() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url, options: .atomic) + defer { try? FileManager.default.removeItem(at: url) } + + let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url) + + #expect(packet.fileName == url.lastPathComponent) + #expect(packet.mimeType == "audio/mp4") + #expect(packet.fileSize == 5) + #expect(packet.encode() != nil) + } + + @Test + func prepareVoiceNotePacket_rejectsOversizedAudio() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-too-large-\(UUID().uuidString).m4a") + try Data(repeating: 0x55, count: FileTransferLimits.maxVoiceNoteBytes + 1).write(to: url, options: .atomic) + defer { try? FileManager.default.removeItem(at: url) } + + #expect(throws: ChatMediaPreparationError.voiceNoteTooLarge(bytes: FileTransferLimits.maxVoiceNoteBytes + 1)) { + try ChatMediaPreparation.prepareVoiceNotePacket(at: url) + } + } + + @Test + func prepareImagePacket_rejectsInvalidImage() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("invalid-\(UUID().uuidString).jpg") + try Data("not-an-image".utf8).write(to: url, options: .atomic) + defer { try? FileManager.default.removeItem(at: url) } + + #expect(throws: ImageUtilsError.invalidImage) { + try ChatMediaPreparation.prepareImagePacket(from: url) + } + } + + @Test + func prepareImagePacket_buildsEncodedJpegPacket() throws { + let sourceURL = try makeTemporaryImageURL() + defer { try? FileManager.default.removeItem(at: sourceURL) } + + let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL) + defer { try? FileManager.default.removeItem(at: prepared.outputURL) } + + #expect(prepared.packet.fileName == prepared.outputURL.lastPathComponent) + #expect(prepared.packet.mimeType == "image/jpeg") + #expect(prepared.packet.fileSize == UInt64(prepared.packet.content.count)) + #expect(prepared.packet.encode() != nil) + } +} + +private func makeTemporaryImageURL() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("image-\(UUID().uuidString).png") + #if os(iOS) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)) + let image = renderer.image { context in + UIColor.systemTeal.setFill() + context.fill(CGRect(x: 0, y: 0, width: 64, height: 64)) + } + guard let data = image.pngData() else { + throw ChatMediaPreparationTestError.imageEncodingFailed + } + #else + let image = NSImage(size: NSSize(width: 64, height: 64)) + image.lockFocus() + NSColor.systemTeal.setFill() + NSRect(x: 0, y: 0, width: 64, height: 64).fill() + image.unlockFocus() + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let data = bitmap.representation(using: .png, properties: [:]) else { + throw ChatMediaPreparationTestError.imageEncodingFailed + } + #endif + try data.write(to: url, options: .atomic) + return url +} + +private enum ChatMediaPreparationTestError: Error { + case imageEncodingFailed +} diff --git a/bitchatTests/ChatUnreadStateResolverTests.swift b/bitchatTests/ChatUnreadStateResolverTests.swift new file mode 100644 index 00000000..37b80b57 --- /dev/null +++ b/bitchatTests/ChatUnreadStateResolverTests.swift @@ -0,0 +1,115 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct ChatUnreadStateResolverTests { + @Test + func directUnreadPeerReturnsTrue() { + let peerID = PeerID(str: "peer-a") + let context = ChatUnreadPeerContext( + peerID: peerID, + noiseKeyPeerID: nil, + nostrPeerID: nil, + nickname: nil + ) + + #expect(ChatUnreadStateResolver.hasUnreadMessages( + for: context, + unreadPrivateMessages: [peerID], + privateChats: [:] + )) + } + + @Test + func stableNoiseKeyUnreadReturnsTrue() { + let peerID = PeerID(str: "ephemeral") + let stableID = PeerID(str: "stable") + let context = ChatUnreadPeerContext( + peerID: peerID, + noiseKeyPeerID: stableID, + nostrPeerID: nil, + nickname: nil + ) + + #expect(ChatUnreadStateResolver.hasUnreadMessages( + for: context, + unreadPrivateMessages: [stableID], + privateChats: [:] + )) + } + + @Test + func nostrConversationUnreadReturnsTrue() { + let peerID = PeerID(str: "ephemeral") + let nostrID = PeerID(nostr_: "abcdef") + let context = ChatUnreadPeerContext( + peerID: peerID, + noiseKeyPeerID: nil, + nostrPeerID: nostrID, + nickname: nil + ) + + #expect(ChatUnreadStateResolver.hasUnreadMessages( + for: context, + unreadPrivateMessages: [nostrID], + privateChats: [:] + )) + } + + @Test + func temporaryGeoDMWithMatchingNicknameReturnsTrue() { + let peerID = PeerID(str: "mesh-peer") + let geoDM = PeerID(nostr_: "0123456789abcdef") + let context = ChatUnreadPeerContext( + peerID: peerID, + noiseKeyPeerID: nil, + nostrPeerID: nil, + nickname: "Alice" + ) + let message = BitchatMessage( + sender: "alice", + content: "hi", + timestamp: Date(timeIntervalSince1970: 1), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: "me", + senderPeerID: geoDM + ) + + #expect(ChatUnreadStateResolver.hasUnreadMessages( + for: context, + unreadPrivateMessages: [geoDM], + privateChats: [geoDM: [message]] + )) + } + + @Test + func unmatchedUnreadStateReturnsFalse() { + let peerID = PeerID(str: "mesh-peer") + let geoDM = PeerID(nostr_: "0123456789abcdef") + let context = ChatUnreadPeerContext( + peerID: peerID, + noiseKeyPeerID: nil, + nostrPeerID: nil, + nickname: "Alice" + ) + let message = BitchatMessage( + sender: "bob", + content: "hi", + timestamp: Date(timeIntervalSince1970: 1), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: "me", + senderPeerID: geoDM + ) + + #expect(!ChatUnreadStateResolver.hasUnreadMessages( + for: context, + unreadPrivateMessages: [geoDM], + privateChats: [geoDM: [message]] + )) + } +} diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index d5f95b8f..7e8f0b9a 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -797,6 +797,7 @@ struct ChatViewModelGeoDMTests { } } +@Suite(.serialized) struct ChatViewModelMediaTransferTests { @Test @MainActor @@ -931,7 +932,7 @@ struct ChatViewModelMediaTransferTests { let didNotify = await TestHelpers.waitUntil({ viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") }) - }, timeout: 2.0) + }, timeout: 5.0) #expect(didNotify) #expect(transport.sentPrivateFiles.isEmpty) #expect(viewModel.privateChats[peerID]?.isEmpty != false) diff --git a/bitchatTests/Nostr/Base64URLCodingTests.swift b/bitchatTests/Nostr/Base64URLCodingTests.swift new file mode 100644 index 00000000..2203addf --- /dev/null +++ b/bitchatTests/Nostr/Base64URLCodingTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import bitchat + +struct Base64URLCodingTests { + @Test + func encodesWithoutPaddingOrURLUnsafeCharacters() { + let encoded = Base64URLCoding.encode(Data([0xff, 0xee, 0xdd, 0xcc])) + + #expect(!encoded.contains("=")) + #expect(!encoded.contains("+")) + #expect(!encoded.contains("/")) + } + + @Test + func decodesUnpaddedValue() throws { + let data = try #require(Base64URLCoding.decode("_-7dzA")) + + #expect(data == Data([0xff, 0xee, 0xdd, 0xcc])) + } + + @Test + func roundTripsData() throws { + let original = Data("hello bitchat".utf8) + + let decoded = try #require(Base64URLCoding.decode(Base64URLCoding.encode(original))) + + #expect(decoded == original) + } +} diff --git a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift new file mode 100644 index 00000000..c0226b81 --- /dev/null +++ b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift @@ -0,0 +1,233 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEAnnounceHandlingPolicyTests { + @Test + func preflightAcceptsMatchingFreshAnnounce() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x11, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: peerID, timestamp: timestamp(now)) + + let decision = BLEAnnouncePreflightPolicy.evaluate( + packet: packet, + from: peerID, + localPeerID: PeerID(str: "0102030405060708"), + now: now + ) + + guard case .accept(let accepted) = decision else { + Issue.record("Expected announce preflight acceptance") + return + } + #expect(accepted.announcement.nickname == "Alice") + #expect(accepted.derivedPeerID == peerID) + } + + @Test + func preflightRejectsMalformedPayload() { + let now = Date(timeIntervalSince1970: 1_000) + let peerID = PeerID(str: "1122334455667788") + let packet = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: peerID.id) ?? Data(), + recipientID: nil, + timestamp: timestamp(now), + payload: Data([0x01, 0x20]), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + + let decision = BLEAnnouncePreflightPolicy.evaluate( + packet: packet, + from: peerID, + localPeerID: PeerID(str: "0102030405060708"), + now: now + ) + + guard case .reject(.malformed) = decision else { + Issue.record("Expected malformed announce rejection") + return + } + } + + @Test + func preflightRejectsSenderMismatchWithDerivedPeerID() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x22, count: 32) + let derivedPeerID = PeerID(publicKey: noiseKey) + let claimedPeerID = PeerID(str: "1122334455667788") + let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: claimedPeerID, timestamp: timestamp(now)) + + let decision = BLEAnnouncePreflightPolicy.evaluate( + packet: packet, + from: claimedPeerID, + localPeerID: PeerID(str: "0102030405060708"), + now: now + ) + + guard case .reject(.senderMismatch(let rejectedDerivedPeerID)) = decision else { + Issue.record("Expected sender mismatch rejection") + return + } + #expect(rejectedDerivedPeerID == derivedPeerID) + } + + @Test + func preflightRejectsSelfAnnounceAfterSenderMatches() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x33, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: peerID, timestamp: timestamp(now)) + + let decision = BLEAnnouncePreflightPolicy.evaluate( + packet: packet, + from: peerID, + localPeerID: peerID, + now: now + ) + + guard case .reject(.selfAnnounce) = decision else { + Issue.record("Expected self announce rejection") + return + } + } + + @Test + func preflightRejectsStaleAnnounceWithAge() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x44, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let oldTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000) + let packet = try makeAnnouncePacket(noisePublicKey: noiseKey, peerID: peerID, timestamp: oldTimestamp) + + let decision = BLEAnnouncePreflightPolicy.evaluate( + packet: packet, + from: peerID, + localPeerID: PeerID(str: "0102030405060708"), + now: now + ) + + guard case .reject(.stale(let ageSeconds)) = decision else { + Issue.record("Expected stale announce rejection") + return + } + #expect(abs(ageSeconds - 901) < 0.001) + } + + @Test + func trustPolicyRequiresSignature() { + let decision = BLEAnnounceTrustPolicy.evaluate( + hasSignature: false, + signatureValid: false, + existingNoisePublicKey: nil, + announcedNoisePublicKey: Data(repeating: 0x11, count: 32) + ) + + #expect(decision == .reject(.missingSignature)) + #expect(!decision.isVerified) + } + + @Test + func trustPolicyRejectsInvalidSignature() { + let decision = BLEAnnounceTrustPolicy.evaluate( + hasSignature: true, + signatureValid: false, + existingNoisePublicKey: nil, + announcedNoisePublicKey: Data(repeating: 0x11, count: 32) + ) + + #expect(decision == .reject(.invalidSignature)) + } + + @Test + func trustPolicyRejectsExistingKeyMismatch() { + let decision = BLEAnnounceTrustPolicy.evaluate( + hasSignature: true, + signatureValid: true, + existingNoisePublicKey: Data(repeating: 0xAA, count: 32), + announcedNoisePublicKey: Data(repeating: 0xBB, count: 32) + ) + + #expect(decision == .reject(.keyMismatch)) + } + + @Test + func trustPolicyAcceptsValidSignatureWithMatchingExistingKey() { + let noiseKey = Data(repeating: 0xCC, count: 32) + + let decision = BLEAnnounceTrustPolicy.evaluate( + hasSignature: true, + signatureValid: true, + existingNoisePublicKey: noiseKey, + announcedNoisePublicKey: noiseKey + ) + + #expect(decision == .verified) + #expect(decision.isVerified) + } + + @Test + func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() { + let directNew = BLEAnnounceResponsePolicy.plan( + isDirectAnnounce: true, + isNewPeer: true, + isReconnectedPeer: false, + shouldSendAnnounceBack: false + ) + #expect(directNew.shouldNotifyPeerConnected) + #expect(directNew.shouldScheduleInitialSync) + #expect(directNew.shouldScheduleAfterglow) + + let relayedNew = BLEAnnounceResponsePolicy.plan( + isDirectAnnounce: false, + isNewPeer: true, + isReconnectedPeer: false, + shouldSendAnnounceBack: true + ) + #expect(!relayedNew.shouldNotifyPeerConnected) + #expect(!relayedNew.shouldScheduleInitialSync) + #expect(relayedNew.shouldSendAnnounceBack) + #expect(relayedNew.shouldScheduleAfterglow) + + let directExisting = BLEAnnounceResponsePolicy.plan( + isDirectAnnounce: true, + isNewPeer: false, + isReconnectedPeer: false, + shouldSendAnnounceBack: false + ) + #expect(!directExisting.shouldNotifyPeerConnected) + #expect(!directExisting.shouldScheduleInitialSync) + #expect(!directExisting.shouldScheduleAfterglow) + } + + private func makeAnnouncePacket( + noisePublicKey: Data, + peerID: PeerID, + timestamp: UInt64 + ) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: "Alice", + noisePublicKey: noisePublicKey, + signingPublicKey: Data(repeating: 0x99, count: 32), + directNeighbors: nil + ) + let payload = try #require(announcement.encode()) + + return BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: peerID.id) ?? Data(), + recipientID: nil, + timestamp: timestamp, + payload: payload, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + } + + private func timestamp(_ date: Date) -> UInt64 { + UInt64(date.timeIntervalSince1970 * 1000) + } +} diff --git a/bitchatTests/Services/BLEAnnounceThrottleTests.swift b/bitchatTests/Services/BLEAnnounceThrottleTests.swift new file mode 100644 index 00000000..3ca46203 --- /dev/null +++ b/bitchatTests/Services/BLEAnnounceThrottleTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import bitchat + +struct BLEAnnounceThrottleTests { + @Test + func firstAnnounceIsAllowed() { + var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) + + let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100)) + + #expect(shouldSend) + } + + @Test + func regularAnnounceUsesNormalMinimumInterval() { + let now = Date(timeIntervalSince1970: 100) + var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) + + let first = throttle.shouldSend(force: false, now: now) + let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9)) + let afterInterval = throttle.shouldSend(force: false, now: now.addingTimeInterval(10)) + + #expect(first) + #expect(!suppressed) + #expect(afterInterval) + } + + @Test + func forcedAnnounceUsesShorterMinimumInterval() { + let now = Date(timeIntervalSince1970: 100) + var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) + + let first = throttle.shouldSend(force: false, now: now) + let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9)) + let afterInterval = throttle.shouldSend(force: true, now: now.addingTimeInterval(2)) + + #expect(first) + #expect(!suppressed) + #expect(afterInterval) + } + + @Test + func elapsedReportsTimeSinceAcceptedSend() { + let now = Date(timeIntervalSince1970: 100) + var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) + + throttle.shouldSend(force: false, now: now) + + #expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3) + } +} diff --git a/bitchatTests/Services/BLEDirectedRelaySpoolTests.swift b/bitchatTests/Services/BLEDirectedRelaySpoolTests.swift new file mode 100644 index 00000000..a8fa6fb5 --- /dev/null +++ b/bitchatTests/Services/BLEDirectedRelaySpoolTests.swift @@ -0,0 +1,94 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEDirectedRelaySpoolTests { + @Test + func enqueueDeduplicatesByRecipientAndMessageID() { + let recipient = PeerID(str: "1122334455667788") + let now = Date() + var spool = BLEDirectedRelaySpool() + + let inserted = spool.enqueue( + packet: makePacket(payload: [0x01]), + recipient: recipient, + messageID: "message-1", + enqueuedAt: now + ) + let duplicate = spool.enqueue( + packet: makePacket(payload: [0x02]), + recipient: recipient, + messageID: "message-1", + enqueuedAt: now + ) + + #expect(inserted) + #expect(!duplicate) + #expect(spool.count == 1) + } + + @Test + func drainUnexpiredReturnsFreshPacketsAndClearsSpool() { + let recipient = PeerID(str: "1122334455667788") + let now = Date() + var spool = BLEDirectedRelaySpool() + let freshPacket = makePacket(payload: [0x01]) + let expiredPacket = makePacket(payload: [0x02]) + + spool.enqueue(packet: freshPacket, recipient: recipient, messageID: "fresh", enqueuedAt: now.addingTimeInterval(-1)) + spool.enqueue(packet: expiredPacket, recipient: recipient, messageID: "old", enqueuedAt: now.addingTimeInterval(-20)) + + let drained = spool.drainUnexpired(now: now, window: 5) + + #expect(drained.count == 1) + #expect(drained.first?.recipient == recipient) + #expect(drained.first?.packet.payload == freshPacket.payload) + #expect(spool.isEmpty) + } + + @Test + func pruneExpiredKeepsFreshPacketsAcrossRecipients() { + let firstRecipient = PeerID(str: "1122334455667788") + let secondRecipient = PeerID(str: "8877665544332211") + let now = Date() + var spool = BLEDirectedRelaySpool() + + spool.enqueue(packet: makePacket(payload: [0x01]), recipient: firstRecipient, messageID: "fresh-1", enqueuedAt: now) + spool.enqueue(packet: makePacket(payload: [0x02]), recipient: secondRecipient, messageID: "fresh-2", enqueuedAt: now.addingTimeInterval(-2)) + spool.enqueue(packet: makePacket(payload: [0x03]), recipient: secondRecipient, messageID: "old", enqueuedAt: now.addingTimeInterval(-10)) + + spool.pruneExpired(now: now, window: 5) + + #expect(spool.count == 2) + let drained = spool.drainUnexpired(now: now, window: 5) + #expect(Set(drained.map(\.recipient)) == Set([firstRecipient, secondRecipient])) + } + + @Test + func removeAllClearsStoredPackets() { + var spool = BLEDirectedRelaySpool() + + spool.enqueue( + packet: makePacket(payload: [0x01]), + recipient: PeerID(str: "1122334455667788"), + messageID: "message-1", + enqueuedAt: Date() + ) + spool.removeAll() + + #expect(spool.isEmpty) + } + + private func makePacket(payload: [UInt8]) -> BitchatPacket { + BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: "8877665544332211") ?? Data(), + recipientID: Data(hexString: "1122334455667788"), + timestamp: 1234, + payload: Data(payload), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + } +} diff --git a/bitchatTests/Services/BLEFileTransferPolicyTests.swift b/bitchatTests/Services/BLEFileTransferPolicyTests.swift new file mode 100644 index 00000000..2010f0e8 --- /dev/null +++ b/bitchatTests/Services/BLEFileTransferPolicyTests.swift @@ -0,0 +1,143 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEFileTransferPolicyTests { + @Test + func selfEchoRejectsOnlyNonSyncReplayFromLocalPeer() { + let localPeerID = PeerID(str: "1122334455667788") + let localPacket = makePacket(sender: localPeerID, ttl: 3) + let replayPacket = makePacket(sender: localPeerID, ttl: 0) + let remotePacket = makePacket(sender: PeerID(str: "8877665544332211"), ttl: 3) + + #expect(BLEFileTransferPolicy.isSelfEcho(packet: localPacket, from: localPeerID, localPeerID: localPeerID)) + #expect(!BLEFileTransferPolicy.isSelfEcho(packet: replayPacket, from: localPeerID, localPeerID: localPeerID)) + #expect(!BLEFileTransferPolicy.isSelfEcho(packet: remotePacket, from: PeerID(str: "8877665544332211"), localPeerID: localPeerID)) + } + + @Test + func deliveryPlanTracksPublicBroadcastsForSync() { + let localPeerID = PeerID(str: "1122334455667788") + + #expect(BLEFileTransferPolicy.deliveryPlan( + packet: makePacket(sender: PeerID(str: "8877665544332211"), recipientID: nil), + localPeerID: localPeerID + ) == BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)) + + #expect(BLEFileTransferPolicy.deliveryPlan( + packet: makePacket( + sender: PeerID(str: "8877665544332211"), + recipientID: Data(repeating: 0xFF, count: 8) + ), + localPeerID: localPeerID + ) == BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)) + } + + @Test + func deliveryPlanAcceptsOnlyDirectedPacketsForLocalPeer() { + let localPeerID = PeerID(str: "1122334455667788") + let otherPeerID = PeerID(str: "8877665544332211") + + #expect(BLEFileTransferPolicy.deliveryPlan( + packet: makePacket( + sender: otherPeerID, + recipientID: Data(hexString: localPeerID.id) + ), + localPeerID: localPeerID + ) == BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false)) + + #expect(BLEFileTransferPolicy.deliveryPlan( + packet: makePacket( + sender: otherPeerID, + recipientID: Data(hexString: otherPeerID.id) + ), + localPeerID: localPeerID + ) == nil) + } + + @Test + func validatorAcceptsMatchingMimeAndMagicBytes() throws { + let payload = try makeFilePayload( + mimeType: "application/pdf", + content: Data("%PDF-1.7".utf8) + ) + + let result = BLEIncomingFileValidator.validate(payload: payload) + + guard case .success(let acceptance) = result else { + Issue.record("Expected file validation success") + return + } + #expect(acceptance.mime == .pdf) + #expect(acceptance.filePacket.content == Data("%PDF-1.7".utf8)) + } + + @Test + func validatorRejectsMalformedPayload() { + let result = BLEIncomingFileValidator.validate(payload: Data([0x01, 0x02, 0x03])) + + #expect(result.rejection == .malformedPayload) + } + + @Test + func validatorRejectsUnsupportedMime() throws { + let payload = try makeFilePayload( + mimeType: nil, + content: Data([0x4D, 0x5A, 0x00, 0x00]) + ) + + let result = BLEIncomingFileValidator.validate(payload: payload) + + #expect(result.rejection == .unsupportedMime(mimeType: nil, bytes: 4)) + } + + @Test + func validatorRejectsMimeMagicMismatch() throws { + let payload = try makeFilePayload( + mimeType: "image/png", + content: Data([0x00, 0x01, 0x02, 0x03]) + ) + + let result = BLEIncomingFileValidator.validate(payload: payload) + + #expect(result.rejection == .magicMismatch( + mime: .png, + bytes: 4, + prefixHex: "00 01 02 03" + )) + } + + private func makePacket( + sender: PeerID, + ttl: UInt8 = 3, + recipientID: Data? = nil + ) -> BitchatPacket { + BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: recipientID, + timestamp: 900_000, + payload: Data([0x01, 0x02]), + signature: nil, + ttl: ttl + ) + } + + private func makeFilePayload(mimeType: String?, content: Data) throws -> Data { + let packet = BitchatFilePacket( + fileName: "sample", + fileSize: UInt64(content.count), + mimeType: mimeType, + content: content + ) + return try #require(packet.encode()) + } +} + +private extension Result where Failure == BLEIncomingFileRejection { + var rejection: BLEIncomingFileRejection? { + guard case .failure(let rejection) = self else { return nil } + return rejection + } +} diff --git a/bitchatTests/Services/BLEMaintenancePolicyTests.swift b/bitchatTests/Services/BLEMaintenancePolicyTests.swift new file mode 100644 index 00000000..68157f6c --- /dev/null +++ b/bitchatTests/Services/BLEMaintenancePolicyTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing +@testable import bitchat + +@Suite("BLE maintenance policy tests") +struct BLEMaintenancePolicyTests { + @Test("discovery mode keeps announces frequent and advertising on") + func discoveryModeAnnouncesAndEnsuresAdvertising() { + let plan = BLEMaintenancePolicy.plan( + cycle: 1, + connectedCount: 0, + peerRegistryIsEmpty: true, + elapsedSinceLastAnnounce: TransportConfig.bleAnnounceIntervalSeconds, + hasRecentTraffic: false + ) + + #expect(plan.shouldSendAnnounce) + #expect(plan.shouldEnsureAdvertising) + #expect(plan.shouldFlushDirectedSpool) + #expect(!plan.shouldRunCleanup) + #expect(!plan.shouldResetCounter) + } + + @Test("connected sparse meshes use sparse announce cadence") + func connectedSparseMeshUsesSparseAnnounceCadence() { + let beforeTarget = BLEMaintenancePolicy.plan( + cycle: 2, + connectedCount: 2, + peerRegistryIsEmpty: false, + elapsedSinceLastAnnounce: TransportConfig.bleConnectedAnnounceBaseSecondsSparse - 0.1, + hasRecentTraffic: false, + connectedAnnounceJitterOffset: 0 + ) + let atTarget = BLEMaintenancePolicy.plan( + cycle: 2, + connectedCount: 2, + peerRegistryIsEmpty: false, + elapsedSinceLastAnnounce: TransportConfig.bleConnectedAnnounceBaseSecondsSparse, + hasRecentTraffic: false, + connectedAnnounceJitterOffset: 0 + ) + + #expect(!beforeTarget.shouldSendAnnounce) + #expect(atTarget.shouldSendAnnounce) + #expect(!beforeTarget.shouldEnsureAdvertising) + } + + @Test("dense meshes use dense announce cadence") + func denseMeshUsesDenseAnnounceCadence() { + let beforeTarget = BLEMaintenancePolicy.plan( + cycle: 3, + connectedCount: TransportConfig.bleHighDegreeThreshold, + peerRegistryIsEmpty: false, + elapsedSinceLastAnnounce: TransportConfig.bleConnectedAnnounceBaseSecondsDense - 0.1, + hasRecentTraffic: false, + connectedAnnounceJitterOffset: 0 + ) + let atTarget = BLEMaintenancePolicy.plan( + cycle: 3, + connectedCount: TransportConfig.bleHighDegreeThreshold, + peerRegistryIsEmpty: false, + elapsedSinceLastAnnounce: TransportConfig.bleConnectedAnnounceBaseSecondsDense, + hasRecentTraffic: false, + connectedAnnounceJitterOffset: 0 + ) + + #expect(!beforeTarget.shouldSendAnnounce) + #expect(atTarget.shouldSendAnnounce) + #expect(atTarget.shouldRunCleanup) + } + + @Test("recent traffic can request a quick announce") + func recentTrafficRequestsQuickAnnounce() { + let plan = BLEMaintenancePolicy.plan( + cycle: 4, + connectedCount: 2, + peerRegistryIsEmpty: false, + elapsedSinceLastAnnounce: 10.0, + hasRecentTraffic: true, + connectedAnnounceJitterOffset: TransportConfig.bleConnectedAnnounceJitterSparse + ) + + #expect(plan.shouldSendAnnounce) + #expect(!plan.shouldFlushDirectedSpool) + } + + @Test("maintenance cadence controls cleanup spool flushing and counter reset") + func maintenanceCadenceControlsPeriodicWork() { + let cleanupAndFlush = BLEMaintenancePolicy.plan( + cycle: 3, + connectedCount: 1, + peerRegistryIsEmpty: false, + elapsedSinceLastAnnounce: 0, + hasRecentTraffic: false, + connectedAnnounceJitterOffset: 0 + ) + let reset = BLEMaintenancePolicy.plan( + cycle: 6, + connectedCount: 1, + peerRegistryIsEmpty: false, + elapsedSinceLastAnnounce: 0, + hasRecentTraffic: false, + connectedAnnounceJitterOffset: 0 + ) + + #expect(cleanupAndFlush.shouldRunCleanup) + #expect(cleanupAndFlush.shouldFlushDirectedSpool) + #expect(!cleanupAndFlush.shouldResetCounter) + #expect(reset.shouldRunCleanup) + #expect(!reset.shouldFlushDirectedSpool) + #expect(reset.shouldResetCounter) + } +} diff --git a/bitchatTests/Services/BLEOutboundLinkPlannerTests.swift b/bitchatTests/Services/BLEOutboundLinkPlannerTests.swift new file mode 100644 index 00000000..9d10e218 --- /dev/null +++ b/bitchatTests/Services/BLEOutboundLinkPlannerTests.swift @@ -0,0 +1,138 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEOutboundLinkPlannerTests { + @Test + func recipientIDSuppliesDirectedHintAndUsesAllAvailableLinks() throws { + let recipient = PeerID(str: "1122334455667788") + let packet = makePacket(type: .noiseEncrypted, recipient: recipient) + + let plan = BLEOutboundLinkPlanner.plan( + packet: packet, + dataCount: 32, + peripheralIDs: ["p1", "p2"], + peripheralWriteLimits: [128, 128], + centralIDs: ["c1"], + centralNotifyLimits: [128], + ingressRecord: nil, + excludedLinks: [], + directedOnlyPeer: nil + ) + + #expect(plan.directedPeerHint == recipient) + #expect(plan.fragmentChunkSize == nil) + #expect(plan.selectedLinks.peripheralIDs == Set(["p1", "p2"])) + #expect(plan.selectedLinks.centralIDs == Set(["c1"])) + #expect(!plan.shouldSpoolDirectedPacket) + } + + @Test + func oversizedNonFragmentPacketPlansFragmentationFromSmallestLinkLimit() { + let packet = makePacket(type: .message) + let smallestLimit = 96 + + let plan = BLEOutboundLinkPlanner.plan( + packet: packet, + dataCount: 160, + peripheralIDs: ["p1"], + peripheralWriteLimits: [128], + centralIDs: ["c1"], + centralNotifyLimits: [smallestLimit], + ingressRecord: nil, + excludedLinks: [], + directedOnlyPeer: nil + ) + + #expect(plan.fragmentChunkSize == BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: smallestLimit)) + #expect(plan.selectedLinks.peripheralIDs.isEmpty) + #expect(plan.selectedLinks.centralIDs.isEmpty) + #expect(!plan.shouldSpoolDirectedPacket) + } + + @Test + func fragmentPacketsBypassFragmentationPlanning() { + let packet = makePacket(type: .fragment) + + let plan = BLEOutboundLinkPlanner.plan( + packet: packet, + dataCount: 512, + peripheralIDs: ["p1"], + peripheralWriteLimits: [64], + centralIDs: ["c1"], + centralNotifyLimits: [64], + ingressRecord: nil, + excludedLinks: [], + directedOnlyPeer: nil + ) + + #expect(plan.fragmentChunkSize == nil) + #expect(plan.selectedLinks.peripheralIDs == Set(["p1"])) + #expect(plan.selectedLinks.centralIDs == Set(["c1"])) + } + + @Test + func directedEncryptedPacketSpoolsWhenNoLinksAreAvailable() { + let recipient = PeerID(str: "1122334455667788") + let packet = makePacket(type: .noiseEncrypted, recipient: recipient) + + let plan = BLEOutboundLinkPlanner.plan( + packet: packet, + dataCount: 32, + peripheralIDs: [], + peripheralWriteLimits: [], + centralIDs: [], + centralNotifyLimits: [], + ingressRecord: nil, + excludedLinks: [], + directedOnlyPeer: recipient + ) + + #expect(plan.directedPeerHint == recipient) + #expect(plan.shouldSpoolDirectedPacket) + } + + @Test + func publicBroadcastDoesNotSpoolWhenNoLinksAreAvailable() { + let packet = makePacket(type: .message) + + let plan = BLEOutboundLinkPlanner.plan( + packet: packet, + dataCount: 32, + peripheralIDs: [], + peripheralWriteLimits: [], + centralIDs: [], + centralNotifyLimits: [], + ingressRecord: nil, + excludedLinks: [], + directedOnlyPeer: nil + ) + + #expect(plan.directedPeerHint == nil) + #expect(!plan.shouldSpoolDirectedPacket) + } + + @Test + func minimumLinkLimitUsesTheSmallestPresentRoleLimit() { + #expect(BLEOutboundLinkPlanner.minimumLinkLimit(peripheralWriteLimits: [80, 120], centralNotifyLimits: []) == 80) + #expect(BLEOutboundLinkPlanner.minimumLinkLimit(peripheralWriteLimits: [], centralNotifyLimits: [60, 90]) == 60) + #expect(BLEOutboundLinkPlanner.minimumLinkLimit(peripheralWriteLimits: [], centralNotifyLimits: []) == nil) + } + + private func makePacket( + type: MessageType, + sender: PeerID = PeerID(str: "8877665544332211"), + recipient: PeerID? = nil + ) -> BitchatPacket { + BitchatPacket( + type: type.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: recipient.flatMap { Data(hexString: $0.id) }, + timestamp: 1234, + payload: Data([0x01, 0x02]), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + } +} diff --git a/bitchatTests/Services/BLEPacketFreshnessPolicyTests.swift b/bitchatTests/Services/BLEPacketFreshnessPolicyTests.swift new file mode 100644 index 00000000..78a84b58 --- /dev/null +++ b/bitchatTests/Services/BLEPacketFreshnessPolicyTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +@testable import bitchat + +struct BLEPacketFreshnessPolicyTests { + @Test + func nilRecipientCountsAsBroadcast() { + #expect(BLEPacketFreshnessPolicy.isBroadcastRecipient(nil)) + } + + @Test + func allOnesEightByteRecipientCountsAsBroadcast() { + #expect(BLEPacketFreshnessPolicy.isBroadcastRecipient(Data(repeating: 0xFF, count: 8))) + } + + @Test + func directedRecipientIsNotBroadcast() { + #expect(!BLEPacketFreshnessPolicy.isBroadcastRecipient(Data([0, 1, 2, 3, 4, 5, 6, 7]))) + } + + @Test + func recentPacketIsNotStale() { + let now = Date(timeIntervalSince1970: 1000) + let timestamp = UInt64((now.timeIntervalSince1970 - 100) * 1000) + + #expect(!BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: timestamp, now: now)) + } + + @Test + func oldPacketIsStale() { + let now = Date(timeIntervalSince1970: 1000) + let timestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000) + + #expect(BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: timestamp, now: now)) + } + + @Test + func futurePacketAgeIsZero() { + let now = Date(timeIntervalSince1970: 1000) + let timestamp = UInt64((now.timeIntervalSince1970 + 10) * 1000) + + #expect(BLEPacketFreshnessPolicy.ageSeconds(timestampMilliseconds: timestamp, now: now) == 0) + } +} diff --git a/bitchatTests/Services/BLEPeerEventDebouncerTests.swift b/bitchatTests/Services/BLEPeerEventDebouncerTests.swift new file mode 100644 index 00000000..9a259b57 --- /dev/null +++ b/bitchatTests/Services/BLEPeerEventDebouncerTests.swift @@ -0,0 +1,52 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEPeerEventDebouncerTests { + @Test + func suppressesRepeatedPeerEventsInsideInterval() { + let peerID = PeerID(str: "1122334455667788") + let now = Date(timeIntervalSince1970: 100) + var debouncer = BLEPeerEventDebouncer() + + let first = debouncer.shouldEmit(peerID: peerID, now: now, minimumInterval: 5) + let suppressed = debouncer.shouldEmit(peerID: peerID, now: now.addingTimeInterval(4.9), minimumInterval: 5) + let afterInterval = debouncer.shouldEmit(peerID: peerID, now: now.addingTimeInterval(5), minimumInterval: 5) + + #expect(first) + #expect(!suppressed) + #expect(afterInterval) + } + + @Test + func tracksPeersIndependently() { + let first = PeerID(str: "1122334455667788") + let second = PeerID(str: "8877665544332211") + let now = Date(timeIntervalSince1970: 100) + var debouncer = BLEPeerEventDebouncer() + + let firstEmit = debouncer.shouldEmit(peerID: first, now: now, minimumInterval: 5) + let secondEmit = debouncer.shouldEmit(peerID: second, now: now.addingTimeInterval(1), minimumInterval: 5) + let firstRepeat = debouncer.shouldEmit(peerID: first, now: now.addingTimeInterval(1), minimumInterval: 5) + + #expect(firstEmit) + #expect(secondEmit) + #expect(!firstRepeat) + #expect(debouncer.count == 2) + } + + @Test + func removeAllClearsDebounceState() { + let peerID = PeerID(str: "1122334455667788") + let now = Date(timeIntervalSince1970: 100) + var debouncer = BLEPeerEventDebouncer() + + debouncer.shouldEmit(peerID: peerID, now: now, minimumInterval: 5) + debouncer.removeAll() + + #expect(debouncer.count == 0) + let afterClear = debouncer.shouldEmit(peerID: peerID, now: now.addingTimeInterval(1), minimumInterval: 5) + #expect(afterClear) + } +} diff --git a/bitchatTests/Services/BLEPeerPublishCoalescerTests.swift b/bitchatTests/Services/BLEPeerPublishCoalescerTests.swift new file mode 100644 index 00000000..a633adb4 --- /dev/null +++ b/bitchatTests/Services/BLEPeerPublishCoalescerTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import bitchat + +struct BLEPeerPublishCoalescerTests { + @Test + func firstRequestPublishesImmediately() { + var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1) + + #expect(coalescer.requestPublish(now: Date(timeIntervalSince1970: 100)) == .publishNow) + } + + @Test + func rapidRequestSchedulesAfterRemainingInterval() { + let now = Date(timeIntervalSince1970: 100) + var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1) + + _ = coalescer.requestPublish(now: now) + + if case .schedule(let delay) = coalescer.requestPublish(now: now.addingTimeInterval(0.04)) { + #expect(abs(delay - 0.06) < 0.001) + } else { + Issue.record("Expected a scheduled publish") + } + } + + @Test + func requestSkipsWhilePublishIsAlreadyPending() { + let now = Date(timeIntervalSince1970: 100) + var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1) + + _ = coalescer.requestPublish(now: now) + _ = coalescer.requestPublish(now: now.addingTimeInterval(0.04)) + + #expect(coalescer.requestPublish(now: now.addingTimeInterval(0.05)) == .skip) + } + + @Test + func elapsedRequestPublishesImmediatelyEvenWithPendingPublish() { + let now = Date(timeIntervalSince1970: 100) + var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1) + + _ = coalescer.requestPublish(now: now) + _ = coalescer.requestPublish(now: now.addingTimeInterval(0.04)) + + #expect(coalescer.requestPublish(now: now.addingTimeInterval(0.11)) == .publishNow) + } + + @Test + func scheduledPublishFiredClearsPendingState() { + let now = Date(timeIntervalSince1970: 100) + var coalescer = BLEPeerPublishCoalescer(minimumInterval: 0.1) + + _ = coalescer.requestPublish(now: now) + _ = coalescer.requestPublish(now: now.addingTimeInterval(0.04)) + coalescer.scheduledPublishFired(now: now.addingTimeInterval(0.1)) + + if case .schedule(let delay) = coalescer.requestPublish(now: now.addingTimeInterval(0.15)) { + #expect(abs(delay - 0.05) < 0.001) + } else { + Issue.record("Expected a scheduled publish") + } + } +} diff --git a/bitchatTests/Services/BLEPublicMessagePolicyTests.swift b/bitchatTests/Services/BLEPublicMessagePolicyTests.swift new file mode 100644 index 00000000..fc725194 --- /dev/null +++ b/bitchatTests/Services/BLEPublicMessagePolicyTests.swift @@ -0,0 +1,125 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEPublicMessagePolicyTests { + @Test + func selfAuthoredNonSyncReplayIsRejected() { + let localPeerID = PeerID(str: "1122334455667788") + let packet = makePacket(sender: localPeerID, ttl: 3) + + let decision = BLEPublicMessagePolicy.evaluate( + packet: packet, + from: localPeerID, + localPeerID: localPeerID, + now: Date(timeIntervalSince1970: 1_000) + ) + + #expect(decision == .reject(.selfEcho)) + } + + @Test + func selfAuthoredSyncReplayIsAccepted() { + let localPeerID = PeerID(str: "1122334455667788") + let packet = makePacket(sender: localPeerID, ttl: 0) + + let decision = BLEPublicMessagePolicy.evaluate( + packet: packet, + from: localPeerID, + localPeerID: localPeerID, + now: Date(timeIntervalSince1970: 1_000) + ) + + #expect(decision == .accept(BLEPublicMessageAcceptance(shouldTrackForSync: true))) + } + + @Test + func staleBroadcastIsRejectedWithAge() { + let now = Date(timeIntervalSince1970: 1_000) + let sender = PeerID(str: "8877665544332211") + let packet = makePacket( + sender: sender, + timestamp: UInt64((now.timeIntervalSince1970 - 901) * 1000), + recipientID: nil + ) + + let decision = BLEPublicMessagePolicy.evaluate( + packet: packet, + from: sender, + localPeerID: PeerID(str: "1122334455667788"), + now: now + ) + + #expect(decision == .reject(.staleBroadcast(ageSeconds: 901))) + } + + @Test + func staleDirectedMessageIsAcceptedAndNotTrackedForSync() { + let now = Date(timeIntervalSince1970: 1_000) + let sender = PeerID(str: "8877665544332211") + let recipient = PeerID(str: "1122334455667788") + let packet = makePacket( + sender: sender, + timestamp: UInt64((now.timeIntervalSince1970 - 901) * 1000), + recipientID: Data(hexString: recipient.id) + ) + + let decision = BLEPublicMessagePolicy.evaluate( + packet: packet, + from: sender, + localPeerID: recipient, + now: now + ) + + #expect(decision == .accept(BLEPublicMessageAcceptance(shouldTrackForSync: false))) + } + + @Test + func freshBroadcastMessageIsTrackedForSync() { + let sender = PeerID(str: "8877665544332211") + let packet = makePacket(sender: sender) + + let decision = BLEPublicMessagePolicy.evaluate( + packet: packet, + from: sender, + localPeerID: PeerID(str: "1122334455667788"), + now: Date(timeIntervalSince1970: 1_000) + ) + + #expect(decision == .accept(BLEPublicMessageAcceptance(shouldTrackForSync: true))) + } + + @Test + func broadcastNonMessageIsNotTrackedForSync() { + let sender = PeerID(str: "8877665544332211") + let packet = makePacket(sender: sender, type: .leave) + + let decision = BLEPublicMessagePolicy.evaluate( + packet: packet, + from: sender, + localPeerID: PeerID(str: "1122334455667788"), + now: Date(timeIntervalSince1970: 1_000) + ) + + #expect(decision == .accept(BLEPublicMessageAcceptance(shouldTrackForSync: false))) + } + + private func makePacket( + sender: PeerID, + type: MessageType = .message, + timestamp: UInt64 = 900_000, + ttl: UInt8 = 3, + recipientID: Data? = nil + ) -> BitchatPacket { + BitchatPacket( + type: type.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: recipientID, + timestamp: timestamp, + payload: Data("Hello".utf8), + signature: nil, + ttl: ttl + ) + } +} diff --git a/bitchatTests/Services/BLERouteForwardingPolicyTests.swift b/bitchatTests/Services/BLERouteForwardingPolicyTests.swift new file mode 100644 index 00000000..e6bfce05 --- /dev/null +++ b/bitchatTests/Services/BLERouteForwardingPolicyTests.swift @@ -0,0 +1,154 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("BLE route forwarding policy tests") +struct BLERouteForwardingPolicyTests { + @Test("local recipient suppresses flood relay") + func localRecipientSuppressesFloodRelay() { + let local = peer("1111111111111111") + let remote = peer("2222222222222222") + let packet = makePacket(sender: remote, recipient: local) + + let plan = forwardingPlan(packet, local: local) + + #expect(plan.shouldSuppressFloodRelay) + #expect(plan.forwardPacket == nil) + #expect(plan.nextHop == nil) + } + + @Test("unrouted packets are left for flood relay") + func unroutedPacketAllowsFloodRelay() { + let local = peer("1111111111111111") + let remote = peer("2222222222222222") + let packet = makePacket(sender: remote, recipient: peer("3333333333333333"), route: nil) + + let plan = forwardingPlan(packet, local: local) + + #expect(!plan.shouldSuppressFloodRelay) + #expect(plan.forwardPacket == nil) + #expect(plan.nextHop == nil) + } + + @Test("origin forwards routed packet to first hop") + func originForwardsToFirstHop() { + let local = peer("1111111111111111") + let firstHop = peer("2222222222222222") + let destination = peer("3333333333333333") + let packet = makePacket(sender: local, recipient: destination, ttl: 6, route: [routeData(firstHop)]) + + let plan = forwardingPlan(packet, local: local, connected: [firstHop]) + + #expect(plan.shouldSuppressFloodRelay) + #expect(plan.nextHop == firstHop) + #expect(plan.forwardPacket?.ttl == 5) + } + + @Test("origin leaves packet for flood relay when first hop is unavailable") + func originAllowsFloodWhenFirstHopUnavailable() { + let local = peer("1111111111111111") + let firstHop = peer("2222222222222222") + let destination = peer("3333333333333333") + let packet = makePacket(sender: local, recipient: destination, route: [routeData(firstHop)]) + + let plan = forwardingPlan(packet, local: local, connected: []) + + #expect(!plan.shouldSuppressFloodRelay) + #expect(plan.forwardPacket == nil) + #expect(plan.nextHop == nil) + } + + @Test("intermediate forwards routed packet to next route hop") + func intermediateForwardsToNextHop() { + let previous = peer("1111111111111111") + let local = peer("2222222222222222") + let nextHop = peer("3333333333333333") + let destination = peer("4444444444444444") + let packet = makePacket( + sender: previous, + recipient: destination, + ttl: 4, + route: [routeData(local), routeData(nextHop)] + ) + + let plan = forwardingPlan(packet, local: local, connected: [nextHop]) + + #expect(plan.shouldSuppressFloodRelay) + #expect(plan.nextHop == nextHop) + #expect(plan.forwardPacket?.ttl == 3) + } + + @Test("last intermediate forwards routed packet to destination") + func lastIntermediateForwardsToDestination() { + let previous = peer("1111111111111111") + let local = peer("2222222222222222") + let destination = peer("3333333333333333") + let packet = makePacket( + sender: previous, + recipient: destination, + ttl: 4, + route: [routeData(local)] + ) + + let plan = forwardingPlan(packet, local: local, connected: [destination]) + + #expect(plan.shouldSuppressFloodRelay) + #expect(plan.nextHop == destination) + #expect(plan.forwardPacket?.ttl == 3) + } + + @Test("expired routed packets suppress further relay") + func expiredRoutedPacketSuppressesRelay() { + let local = peer("1111111111111111") + let firstHop = peer("2222222222222222") + let destination = peer("3333333333333333") + let packet = makePacket(sender: local, recipient: destination, ttl: 1, route: [routeData(firstHop)]) + + let plan = forwardingPlan(packet, local: local, connected: [firstHop]) + + #expect(plan.shouldSuppressFloodRelay) + #expect(plan.forwardPacket == nil) + #expect(plan.nextHop == nil) + } + + private func forwardingPlan( + _ packet: BitchatPacket, + local: PeerID, + connected: Set = [] + ) -> BLERouteForwardingPlan { + BLERouteForwardingPolicy.plan( + for: packet, + localPeerID: local, + localRoutingData: local.routingData, + routingPeer: PeerID.init(routingData:), + isPeerConnected: { connected.contains($0) } + ) + } + + private func makePacket( + sender: PeerID, + recipient: PeerID?, + ttl: UInt8 = 7, + route: [Data]? = [] + ) -> BitchatPacket { + BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: routeData(sender), + recipientID: recipient.map { routeData($0) }, + timestamp: 1, + payload: Data([0x01, 0x02]), + signature: nil, + ttl: ttl, + route: route + ) + } + + private func peer(_ id: String) -> PeerID { + PeerID(str: id) + } + + private func routeData(_ peerID: PeerID) -> Data { + peerID.routingData ?? Data() + } +} diff --git a/bitchatTests/Services/BLEScanDutyPolicyTests.swift b/bitchatTests/Services/BLEScanDutyPolicyTests.swift new file mode 100644 index 00000000..965f01b8 --- /dev/null +++ b/bitchatTests/Services/BLEScanDutyPolicyTests.swift @@ -0,0 +1,75 @@ +import Testing +@testable import bitchat + +struct BLEScanDutyPolicyTests { + @Test + func continuousScanWhenDutyIsDisabledInactiveDisconnectedOrTrafficIsRecent() { + #expect(BLEScanDutyPolicy.plan( + dutyEnabled: false, + appIsActive: true, + connectedCount: 4, + hasRecentTraffic: false + ) == .continuous) + + #expect(BLEScanDutyPolicy.plan( + dutyEnabled: true, + appIsActive: false, + connectedCount: 4, + hasRecentTraffic: false + ) == .continuous) + + #expect(BLEScanDutyPolicy.plan( + dutyEnabled: true, + appIsActive: true, + connectedCount: 0, + hasRecentTraffic: false + ) == .continuous) + + #expect(BLEScanDutyPolicy.plan( + dutyEnabled: true, + appIsActive: true, + connectedCount: 4, + hasRecentTraffic: true + ) == .continuous) + } + + @Test + func continuousScanForSmallMeshes() { + #expect(BLEScanDutyPolicy.plan( + dutyEnabled: true, + appIsActive: true, + connectedCount: 2, + hasRecentTraffic: false + ) == .continuous) + } + + @Test + func dutyCyclesSparseActiveMeshes() { + let plan = BLEScanDutyPolicy.plan( + dutyEnabled: true, + appIsActive: true, + connectedCount: 3, + hasRecentTraffic: false + ) + + #expect(plan == .dutyCycle( + onDuration: TransportConfig.bleDutyOnDuration, + offDuration: TransportConfig.bleDutyOffDuration + )) + } + + @Test + func dutyCyclesDenseActiveMeshesWithDenseDurations() { + let plan = BLEScanDutyPolicy.plan( + dutyEnabled: true, + appIsActive: true, + connectedCount: TransportConfig.bleHighDegreeThreshold, + hasRecentTraffic: false + ) + + #expect(plan == .dutyCycle( + onDuration: TransportConfig.bleDutyOnDurationDense, + offDuration: TransportConfig.bleDutyOffDurationDense + )) + } +} diff --git a/bitchatTests/Services/BLEScheduledRelayStoreTests.swift b/bitchatTests/Services/BLEScheduledRelayStoreTests.swift new file mode 100644 index 00000000..84de9978 --- /dev/null +++ b/bitchatTests/Services/BLEScheduledRelayStoreTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import bitchat + +@Suite("BLE scheduled relay store tests") +struct BLEScheduledRelayStoreTests { + @Test("schedule tracks relay count and remove returns work item") + func scheduleAndRemove() { + var store = BLEScheduledRelayStore() + let first = DispatchWorkItem {} + let second = DispatchWorkItem {} + + store.schedule(first, messageID: "one") + store.schedule(second, messageID: "two") + + #expect(store.count == 2) + #expect(!store.isEmpty) + let removed = store.remove(messageID: "one") + #expect(removed.map { $0 === first } == true) + #expect(store.count == 1) + let missing = store.remove(messageID: "missing") + #expect(missing == nil) + } + + @Test("cancel removes and cancels a scheduled relay") + func cancelScheduledRelay() { + var store = BLEScheduledRelayStore() + let workItem = DispatchWorkItem {} + + store.schedule(workItem, messageID: "relay") + + let didCancel = store.cancel(messageID: "relay") + #expect(didCancel) + #expect(workItem.isCancelled) + #expect(store.isEmpty) + let didCancelAgain = store.cancel(messageID: "relay") + #expect(!didCancelAgain) + } + + @Test("cancel all cancels every scheduled relay") + func cancelAllScheduledRelays() { + var store = BLEScheduledRelayStore() + let first = DispatchWorkItem {} + let second = DispatchWorkItem {} + + store.schedule(first, messageID: "first") + store.schedule(second, messageID: "second") + store.cancelAll() + + #expect(first.isCancelled) + #expect(second.isCancelled) + #expect(store.isEmpty) + } + + @Test("capacity guard only removes when over limit") + func capacityGuardRemovesOnlyWhenOverLimit() { + var store = BLEScheduledRelayStore() + + store.schedule(DispatchWorkItem {}, messageID: "one") + store.schedule(DispatchWorkItem {}, messageID: "two") + store.removeAllIfOverCapacity(2) + #expect(store.count == 2) + + store.schedule(DispatchWorkItem {}, messageID: "three") + store.removeAllIfOverCapacity(2) + #expect(store.isEmpty) + } +} diff --git a/bitchatTests/Services/BLESelfBroadcastTrackerTests.swift b/bitchatTests/Services/BLESelfBroadcastTrackerTests.swift new file mode 100644 index 00000000..2f08c9ed --- /dev/null +++ b/bitchatTests/Services/BLESelfBroadcastTrackerTests.swift @@ -0,0 +1,70 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLESelfBroadcastTrackerTests { + @Test + func recordAndTakeMessageIDForMatchingPacket() { + let packet = makePacket(timestamp: 1234) + var tracker = BLESelfBroadcastTracker() + + tracker.record(messageID: "local-message", packet: packet, sentAt: Date()) + + #expect(tracker.takeMessageID(for: packet) == "local-message") + #expect(tracker.takeMessageID(for: packet) == nil) + #expect(tracker.isEmpty) + } + + @Test + func differentPacketDoesNotResolveMessageID() { + let packet = makePacket(timestamp: 1234) + let otherPacket = makePacket(timestamp: 1235) + var tracker = BLESelfBroadcastTracker() + + tracker.record(messageID: "local-message", packet: packet, sentAt: Date()) + + #expect(tracker.takeMessageID(for: otherPacket) == nil) + #expect(tracker.count == 1) + } + + @Test + func pruneRemovesEntriesBeforeCutoff() { + let now = Date(timeIntervalSince1970: 100) + let oldPacket = makePacket(timestamp: 1) + let freshPacket = makePacket(timestamp: 2) + var tracker = BLESelfBroadcastTracker() + + tracker.record(messageID: "old", packet: oldPacket, sentAt: now.addingTimeInterval(-10)) + tracker.record(messageID: "fresh", packet: freshPacket, sentAt: now) + + tracker.prune(before: now.addingTimeInterval(-5)) + + #expect(tracker.takeMessageID(for: oldPacket) == nil) + #expect(tracker.takeMessageID(for: freshPacket) == "fresh") + } + + @Test + func removeAllClearsTrackedMessages() { + let packet = makePacket(timestamp: 1234) + var tracker = BLESelfBroadcastTracker() + + tracker.record(messageID: "local-message", packet: packet, sentAt: Date()) + tracker.removeAll() + + #expect(tracker.isEmpty) + #expect(tracker.takeMessageID(for: packet) == nil) + } + + private func makePacket(timestamp: UInt64) -> BitchatPacket { + BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: "8877665544332211") ?? Data(), + recipientID: nil, + timestamp: timestamp, + payload: Data("hello".utf8), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + } +}