mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
Extract BLE and chat architecture policies (#1324)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -15,17 +15,10 @@ enum ImageUtilsError: Error {
|
|||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.82
|
private static let compressionQuality: CGFloat = 0.82
|
||||||
private static let targetImageBytes: Int = 45_000
|
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 {
|
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
// Security H1: Check file size BEFORE reading into memory
|
try validateImageSource(at: url)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -37,6 +30,22 @@ enum ImageUtils {
|
|||||||
#endif
|
#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)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -303,7 +303,7 @@ struct NostrProtocol {
|
|||||||
combined.append(nonce24)
|
combined.append(nonce24)
|
||||||
combined.append(sealed.ciphertext)
|
combined.append(sealed.ciphertext)
|
||||||
combined.append(sealed.tag)
|
combined.append(sealed.tag)
|
||||||
return "v2:" + base64URLEncode(combined)
|
return "v2:" + Base64URLCoding.encode(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
private static func decrypt(
|
||||||
@@ -314,7 +314,7 @@ struct NostrProtocol {
|
|||||||
// Expect NIP-44 v2 format
|
// Expect NIP-44 v2 format
|
||||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||||
let encoded = String(ciphertext.dropFirst(3))
|
let encoded = String(ciphertext.dropFirst(3))
|
||||||
guard let data = base64URLDecode(encoded),
|
guard let data = Base64URLCoding.decode(encoded),
|
||||||
data.count > (24 + 16),
|
data.count > (24 + 16),
|
||||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||||
throw NostrError.invalidCiphertext
|
throw NostrError.invalidCiphertext
|
||||||
@@ -580,24 +580,9 @@ enum NostrError: Error {
|
|||||||
case encryptionFailed
|
case encryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||||
|
|
||||||
private extension NostrProtocol {
|
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 {
|
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BLEIngressLinkID>,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// 4. Efficient Message Deduplication
|
// 4. Efficient Message Deduplication
|
||||||
private let messageDeduplicator = MessageDeduplicator()
|
private let messageDeduplicator = MessageDeduplicator()
|
||||||
private var selfBroadcastMessageIDs: [String: (id: String, timestamp: Date)] = [:]
|
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||||||
private let meshTopology = MeshTopologyTracker()
|
private let meshTopology = MeshTopologyTracker()
|
||||||
|
|
||||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||||
@@ -55,8 +55,7 @@ final class BLEService: NSObject {
|
|||||||
private let incomingFileStore = BLEIncomingFileStore()
|
private let incomingFileStore = BLEIncomingFileStore()
|
||||||
|
|
||||||
// Simple announce throttling
|
// Simple announce throttling
|
||||||
private var lastAnnounceSent = Date.distantPast
|
private var announceThrottle = BLEAnnounceThrottle()
|
||||||
private let announceMinInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval
|
|
||||||
|
|
||||||
// Application state tracking (thread-safe)
|
// Application state tracking (thread-safe)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -96,7 +95,7 @@ final class BLEService: NSObject {
|
|||||||
// Accumulate long write chunks per central until a full frame decodes
|
// Accumulate long write chunks per central until a full frame decodes
|
||||||
private var pendingWriteBuffers = BLEInboundWriteBuffer()
|
private var pendingWriteBuffers = BLEInboundWriteBuffer()
|
||||||
// Relay jitter scheduling to reduce redundant floods
|
// 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
|
// Track short-lived traffic bursts to adapt announces/scanning under load
|
||||||
private var recentTrafficTracker = BLERecentTrafficTracker()
|
private var recentTrafficTracker = BLERecentTrafficTracker()
|
||||||
|
|
||||||
@@ -106,12 +105,11 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
private var pendingPeripheralWrites = BLEOutboundWriteBuffer()
|
private var pendingPeripheralWrites = BLEOutboundWriteBuffer()
|
||||||
// Debounce duplicate disconnect notifies
|
// Debounce duplicate disconnect notifies
|
||||||
private var recentDisconnectNotifies: [PeerID: Date] = [:]
|
private var disconnectNotifyDebouncer = BLEPeerEventDebouncer()
|
||||||
// Store-and-forward for directed messages when we have no links
|
// Store-and-forward for directed messages when we have no links
|
||||||
// Keyed by recipient short peerID -> messageID -> (packet, enqueuedAt)
|
private var pendingDirectedRelays = BLEDirectedRelaySpool()
|
||||||
private var pendingDirectedRelays: [PeerID: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
|
|
||||||
// Debounce for 'reconnected' logs
|
// Debounce for 'reconnected' logs
|
||||||
private var lastReconnectLogAt: [PeerID: Date] = [:]
|
private var reconnectLogDebouncer = BLEPeerEventDebouncer()
|
||||||
|
|
||||||
// MARK: - Gossip Sync
|
// MARK: - Gossip Sync
|
||||||
private var gossipSyncManager: GossipSyncManager?
|
private var gossipSyncManager: GossipSyncManager?
|
||||||
@@ -133,24 +131,19 @@ final class BLEService: NSObject {
|
|||||||
private var dutyActive: Bool = false
|
private var dutyActive: Bool = false
|
||||||
|
|
||||||
// Debounced publish to coalesce rapid changes
|
// Debounced publish to coalesce rapid changes
|
||||||
private var lastPeerPublishAt: Date = .distantPast
|
private var peerPublishCoalescer = BLEPeerPublishCoalescer()
|
||||||
private var peerPublishPending: Bool = false
|
|
||||||
private let peerPublishMinInterval: TimeInterval = 0.1
|
|
||||||
private func requestPeerDataPublish() {
|
private func requestPeerDataPublish() {
|
||||||
let now = Date()
|
switch peerPublishCoalescer.requestPublish(now: Date()) {
|
||||||
let elapsed = now.timeIntervalSince(lastPeerPublishAt)
|
case .publishNow:
|
||||||
if elapsed >= peerPublishMinInterval {
|
|
||||||
lastPeerPublishAt = now
|
|
||||||
publishFullPeerData()
|
publishFullPeerData()
|
||||||
} else if !peerPublishPending {
|
case .schedule(let delay):
|
||||||
peerPublishPending = true
|
|
||||||
let delay = peerPublishMinInterval - elapsed
|
|
||||||
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.lastPeerPublishAt = Date()
|
self.peerPublishCoalescer.scheduledPublishFired(now: Date())
|
||||||
self.peerPublishPending = false
|
|
||||||
self.publishFullPeerData()
|
self.publishFullPeerData()
|
||||||
}
|
}
|
||||||
|
case .skip:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,8 +283,7 @@ final class BLEService: NSObject {
|
|||||||
pendingDirectedRelays.removeAll()
|
pendingDirectedRelays.removeAll()
|
||||||
ingressLinks.removeAll()
|
ingressLinks.removeAll()
|
||||||
recentTrafficTracker.removeAll()
|
recentTrafficTracker.removeAll()
|
||||||
scheduledRelays.values.forEach { $0.cancel() }
|
scheduledRelays.cancelAll()
|
||||||
scheduledRelays.removeAll()
|
|
||||||
return transfers
|
return transfers
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +296,7 @@ final class BLEService: NSObject {
|
|||||||
pendingWriteBuffers.removeAll()
|
pendingWriteBuffers.removeAll()
|
||||||
connectionScheduler.reset()
|
connectionScheduler.reset()
|
||||||
}
|
}
|
||||||
recentDisconnectNotifies.removeAll()
|
disconnectNotifyDebouncer.removeAll()
|
||||||
|
|
||||||
noiseService.clearEphemeralStateForPanic()
|
noiseService.clearEphemeralStateForPanic()
|
||||||
noiseService.clearPersistentIdentity()
|
noiseService.clearPersistentIdentity()
|
||||||
@@ -319,7 +311,7 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
messageDeduplicator.reset()
|
messageDeduplicator.reset()
|
||||||
messageQueue.async(flags: .barrier) { [weak self] in
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.selfBroadcastMessageIDs.removeAll()
|
self?.selfBroadcastTracker.removeAll()
|
||||||
}
|
}
|
||||||
requestPeerDataPublish()
|
requestPeerDataPublish()
|
||||||
startServices()
|
startServices()
|
||||||
@@ -363,11 +355,10 @@ final class BLEService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Pre-mark our own broadcast as processed to avoid handling relayed self copy
|
// Pre-mark our own broadcast as processed to avoid handling relayed self copy
|
||||||
let senderHex = signedPacket.senderID.hexEncodedString()
|
let dedupID = BLESelfBroadcastTracker.dedupID(for: signedPacket)
|
||||||
let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)"
|
|
||||||
messageDeduplicator.markProcessed(dedupID)
|
messageDeduplicator.markProcessed(dedupID)
|
||||||
if let messageID {
|
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
|
// Call synchronously since we're already on background queue
|
||||||
broadcastPacket(signedPacket)
|
broadcastPacket(signedPacket)
|
||||||
@@ -899,68 +890,49 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) {
|
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 ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
|
||||||
let excludedPeerLinks = links(to: ingressRecord?.peerID)
|
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 outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
|
||||||
|
|
||||||
let states = snapshotPeripheralStates()
|
let states = snapshotPeripheralStates()
|
||||||
var minCentralWriteLen: Int?
|
let connectedStates = states.filter { $0.isConnected }
|
||||||
for s in states where s.isConnected {
|
|
||||||
let m = s.peripheral.maximumWriteValueLength(for: .withoutResponse)
|
|
||||||
minCentralWriteLen = minCentralWriteLen.map { min($0, m) } ?? m
|
|
||||||
}
|
|
||||||
let subscribedCentrals = characteristic == nil ? [] : snapshotSubscribedCentrals().centrals
|
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 let chunk = plan.fragmentChunkSize {
|
||||||
if packet.type != MessageType.fragment.rawValue,
|
|
||||||
let minLen = [minCentralWriteLen, minNotifyLen].compactMap({ $0 }).min(),
|
|
||||||
data.count > minLen {
|
|
||||||
let chunk = BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLen)
|
|
||||||
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
|
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
|
||||||
return
|
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 directed and we currently have no links to forward on, spool for a short window
|
||||||
if let only = directedPeerHint,
|
if let only = plan.directedPeerHint,
|
||||||
selectedLinks.peripheralIDs.isEmpty && selectedLinks.centralIDs.isEmpty,
|
plan.shouldSpoolDirectedPacket {
|
||||||
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
|
|
||||||
spoolDirectedPacket(packet, recipientPeerID: only)
|
spoolDirectedPacket(packet, recipientPeerID: only)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writes to selected connected peripherals
|
// Writes to selected connected peripherals
|
||||||
for s in states where s.isConnected {
|
for s in connectedStates {
|
||||||
let pid = s.peripheral.identifier.uuidString
|
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 {
|
if let ch = s.characteristic {
|
||||||
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
|
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Notify selected subscribed centrals
|
// Notify selected subscribed centrals
|
||||||
if let ch = characteristic {
|
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 {
|
if !targets.isEmpty {
|
||||||
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
|
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
|
||||||
if !success {
|
if !success {
|
||||||
@@ -984,10 +956,12 @@ final class BLEService: NSObject {
|
|||||||
let msgID = BLEOutboundPacketPolicy.messageID(for: packet)
|
let msgID = BLEOutboundPacketPolicy.messageID(for: packet)
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:]
|
if self.pendingDirectedRelays.enqueue(
|
||||||
if byMsg[msgID] == nil {
|
packet: packet,
|
||||||
byMsg[msgID] = (packet: packet, enqueuedAt: Date())
|
recipient: recipientPeerID,
|
||||||
self.pendingDirectedRelays[recipientPeerID] = byMsg
|
messageID: msgID,
|
||||||
|
enqueuedAt: Date()
|
||||||
|
) {
|
||||||
SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: .session)
|
SecureLogger.debug("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -995,23 +969,15 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
private func flushDirectedSpool() {
|
private func flushDirectedSpool() {
|
||||||
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
||||||
let toSend: [(String, BitchatPacket)] = collectionsQueue.sync(flags: .barrier) {
|
let toSend = collectionsQueue.sync(flags: .barrier) {
|
||||||
var out: [(String, BitchatPacket)] = []
|
pendingDirectedRelays.drainUnexpired(
|
||||||
let now = Date()
|
now: Date(),
|
||||||
for (recipient, dict) in pendingDirectedRelays {
|
window: TransportConfig.bleDirectedSpoolWindowSeconds
|
||||||
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
|
|
||||||
}
|
}
|
||||||
guard !toSend.isEmpty else { return }
|
guard !toSend.isEmpty else { return }
|
||||||
for (_, packet) in toSend {
|
for entry in toSend {
|
||||||
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
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) {
|
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 }
|
let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID }
|
||||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
@@ -1053,39 +1019,30 @@ final class BLEService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip directed packets that are not intended for us
|
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: myPeerID) else {
|
||||||
if let recipient = packet.recipientID {
|
|
||||||
if PeerID(hexData: recipient) != myPeerID && !recipient.allSatisfy({ $0 == 0xFF }) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
if deliveryPlan.shouldTrackForSync {
|
||||||
|
|
||||||
if let recipient = packet.recipientID,
|
|
||||||
recipient.allSatisfy({ $0 == 0xFF }) {
|
|
||||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
|
||||||
} else if packet.recipientID == nil {
|
|
||||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
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)
|
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||||
return
|
return
|
||||||
}
|
case .failure(.payloadTooLarge(let bytes)):
|
||||||
|
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
|
||||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(filePacket.content.count) bytes)", category: .security)
|
|
||||||
return
|
return
|
||||||
}
|
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
||||||
|
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
||||||
guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else {
|
|
||||||
SecureLogger.warning("🚫 MIME REJECT: '\(filePacket.mimeType ?? "<empty>")' not supported. Size=\(filePacket.content.count)b from \(peerID.id.prefix(8))...", category: .security)
|
|
||||||
return
|
return
|
||||||
}
|
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)
|
||||||
// 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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1102,9 +1059,7 @@ final class BLEService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID
|
if deliveryPlan.isPrivateMessage {
|
||||||
|
|
||||||
if isPrivateMessage {
|
|
||||||
updatePeerLastSeen(peerID)
|
updatePeerLastSeen(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1115,7 +1070,7 @@ final class BLEService: NSObject {
|
|||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: isPrivateMessage,
|
isPrivate: deliveryPlan.isPrivateMessage,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: peerID
|
senderPeerID: peerID
|
||||||
)
|
)
|
||||||
@@ -1186,17 +1141,9 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
private func sendAnnounce(forceSend: Bool = false) {
|
private func sendAnnounce(forceSend: Bool = false) {
|
||||||
// Throttle announces to prevent flooding
|
// Throttle announces to prevent flooding
|
||||||
let now = Date()
|
if !announceThrottle.shouldSend(force: forceSend, 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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastAnnounceSent = now
|
|
||||||
|
|
||||||
// Reduced logging - only log errors, not every announce
|
// Reduced logging - only log errors, not every announce
|
||||||
|
|
||||||
@@ -2355,56 +2302,20 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func forwardAlongRouteIfNeeded(_ packet: BitchatPacket) -> Bool {
|
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)
|
let myRoutingData = routingData(for: myPeerID) ?? (myPeerIDData.isEmpty ? nil : myPeerIDData)
|
||||||
guard let selfData = myRoutingData else { return false }
|
let plan = BLERouteForwardingPolicy.plan(
|
||||||
|
for: packet,
|
||||||
|
localPeerID: myPeerID,
|
||||||
|
localRoutingData: myRoutingData,
|
||||||
|
routingPeer: routingPeer(from:),
|
||||||
|
isPeerConnected: isPeerConnected(_:)
|
||||||
|
)
|
||||||
|
|
||||||
// Route contains only intermediate hops (start and end excluded)
|
if let forwardPacket = plan.forwardPacket, let nextHop = plan.nextHop {
|
||||||
// If we're not in the route, we're the sender - forward to first hop
|
sendPacketDirected(forwardPacket, to: nextHop)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We're an intermediate node in the route
|
return plan.shouldSuppressFloodRelay
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Safely fetch the current direct-link state for a peer using the BLE queue.
|
/// 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)
|
SecureLogger.debug("📦 Handling packet type \(packet.type) from \(senderID.id.prefix(8))…, messageID: \(messageID.prefix(24))…", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.shouldDeduplicate && messageDeduplicator.isDuplicate(messageID) {
|
if dropDuplicatePacketIfNeeded(context: context, messageID: messageID) { return }
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update peer info without verbose logging - update the peer we received from, not the original sender
|
// Update peer info without verbose logging - update the peer we received from, not the original sender
|
||||||
updatePeerLastSeen(peerID)
|
updatePeerLastSeen(peerID)
|
||||||
@@ -3019,9 +2913,29 @@ extension BLEService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relay if TTL > 1 and we're not the original sender
|
scheduleRelayIfNeeded(packet, senderID: senderID, messageID: messageID)
|
||||||
// Relay decision and scheduling (extracted via RelayController)
|
}
|
||||||
do {
|
|
||||||
|
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 degree = collectionsQueue.sync { peerRegistry.connectedCount }
|
||||||
let decision = BLEReceivePipeline.relayDecision(
|
let decision = BLEReceivePipeline.relayDecision(
|
||||||
for: packet,
|
for: packet,
|
||||||
@@ -3031,80 +2945,82 @@ extension BLEService {
|
|||||||
highDegreeThreshold: highDegreeThreshold
|
highDegreeThreshold: highDegreeThreshold
|
||||||
)
|
)
|
||||||
guard decision.shouldRelay else { return }
|
guard decision.shouldRelay else { return }
|
||||||
|
|
||||||
let work = DispatchWorkItem { [weak self] in
|
let work = DispatchWorkItem { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
// Remove scheduled task before executing
|
|
||||||
self.collectionsQueue.async(flags: .barrier) { [weak self] in
|
self.collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
_ = self?.scheduledRelays.removeValue(forKey: messageID)
|
self?.scheduledRelays.remove(messageID: messageID)
|
||||||
}
|
}
|
||||||
var relayPacket = packet
|
var relayPacket = packet
|
||||||
relayPacket.ttl = decision.newTTL
|
relayPacket.ttl = decision.newTTL
|
||||||
self.broadcastPacket(relayPacket)
|
self.broadcastPacket(relayPacket)
|
||||||
}
|
}
|
||||||
// Track the scheduled relay so duplicates can cancel it
|
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.scheduledRelays[messageID] = work
|
self?.scheduledRelays.schedule(work, messageID: messageID)
|
||||||
}
|
}
|
||||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)
|
messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
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)
|
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
||||||
return
|
return
|
||||||
}
|
case .reject(.senderMismatch(let derivedFromKey)):
|
||||||
|
|
||||||
// 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 {
|
|
||||||
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
||||||
return
|
return
|
||||||
}
|
case .reject(.selfAnnounce):
|
||||||
|
|
||||||
// Don't add ourselves as a peer
|
|
||||||
if peerID == myPeerID {
|
|
||||||
return
|
return
|
||||||
}
|
case .reject(.stale(let ageSeconds)):
|
||||||
|
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
||||||
// 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
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Suppress announce logs to reduce noise
|
// Suppress announce logs to reduce noise
|
||||||
|
|
||||||
// Precompute signature verification outside barrier to reduce contention
|
// Precompute signature verification outside barrier to reduce contention
|
||||||
let existingPeerForVerify = collectionsQueue.sync { peerRegistry.info(for: peerID) }
|
let existingPeerForVerify = collectionsQueue.sync { peerRegistry.info(for: peerID) }
|
||||||
var verifiedAnnounce = false
|
let hasSignature = packet.signature != nil
|
||||||
if packet.signature != nil {
|
let signatureValid: Bool
|
||||||
verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
|
if hasSignature {
|
||||||
if !verifiedAnnounce {
|
signatureValid = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
|
||||||
|
if !signatureValid {
|
||||||
SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security)
|
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)
|
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||||
verifiedAnnounce = false
|
|
||||||
}
|
}
|
||||||
|
let verifiedAnnounce = trustDecision.isVerified
|
||||||
|
|
||||||
var isNewPeer = false
|
var isNewPeer = false
|
||||||
var isReconnectedPeer = false
|
var isReconnectedPeer = false
|
||||||
let directLinkState = linkState(for: peerID)
|
let directLinkState = linkState(for: peerID)
|
||||||
|
let isDirectAnnounce = packet.ttl == messageTTL
|
||||||
|
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
let hasPeripheralConnection = directLinkState.hasPeripheral
|
let hasPeripheralConnection = directLinkState.hasPeripheral
|
||||||
let hasCentralSubscription = directLinkState.hasCentral
|
let hasCentralSubscription = directLinkState.hasCentral
|
||||||
let isDirectAnnounce = (packet.ttl == messageTTL)
|
|
||||||
|
|
||||||
// Require verified announce; ignore otherwise (no backward compatibility)
|
// Require verified announce; ignore otherwise (no backward compatibility)
|
||||||
if !verifiedAnnounce {
|
if !verifiedAnnounce {
|
||||||
@@ -3121,7 +3037,7 @@ extension BLEService {
|
|||||||
noisePublicKey: announcement.noisePublicKey,
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
signingPublicKey: announcement.signingPublicKey,
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||||
now: Date()
|
now: now
|
||||||
)
|
)
|
||||||
isNewPeer = update.isNewPeer
|
isNewPeer = update.isNewPeer
|
||||||
isReconnectedPeer = update.wasDisconnected
|
isReconnectedPeer = update.wasDisconnected
|
||||||
@@ -3132,12 +3048,12 @@ extension BLEService {
|
|||||||
if update.isNewPeer {
|
if update.isNewPeer {
|
||||||
SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
|
SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
|
||||||
} else if update.wasDisconnected {
|
} else if update.wasDisconnected {
|
||||||
// Debounce 'reconnected' logs within short window
|
if reconnectLogDebouncer.shouldEmit(
|
||||||
if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds {
|
peerID: peerID,
|
||||||
// Skip duplicate log
|
now: now,
|
||||||
} else {
|
minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds
|
||||||
|
) {
|
||||||
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
|
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
|
||||||
lastReconnectLogAt[peerID] = now
|
|
||||||
}
|
}
|
||||||
} else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname {
|
} else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname {
|
||||||
SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session)
|
SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session)
|
||||||
@@ -3158,6 +3074,18 @@ extension BLEService {
|
|||||||
claimedNickname: announcement.nickname
|
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
|
// Notify UI on main thread
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -3166,11 +3094,13 @@ extension BLEService {
|
|||||||
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
|
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
|
||||||
|
|
||||||
// Only notify of connection for new or reconnected peers when it is a direct announce
|
// Only notify of connection for new or reconnected peers when it is a direct announce
|
||||||
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
|
if responsePlan.shouldNotifyPeerConnected {
|
||||||
self.deliverTransportEvent(.peerConnected(peerID))
|
self.deliverTransportEvent(.peerConnected(peerID))
|
||||||
// Schedule initial unicast sync to this peer
|
// Schedule initial unicast sync to this peer
|
||||||
|
if responsePlan.shouldScheduleInitialSync {
|
||||||
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.requestPeerDataPublish()
|
self.requestPeerDataPublish()
|
||||||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||||||
@@ -3179,21 +3109,14 @@ extension BLEService {
|
|||||||
// Track for sync (include our own and others' announces)
|
// Track for sync (include our own and others' announces)
|
||||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||||
|
|
||||||
// Send announce back for bidirectional discovery (only once per peer)
|
if responsePlan.shouldSendAnnounceBack {
|
||||||
let announceBackID = "announce-back-\(peerID)"
|
|
||||||
let shouldSendBack = !messageDeduplicator.contains(announceBackID)
|
|
||||||
if shouldSendBack {
|
|
||||||
messageDeduplicator.markProcessed(announceBackID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldSendBack {
|
|
||||||
// Reciprocate announce for bidirectional discovery
|
// Reciprocate announce for bidirectional discovery
|
||||||
// Force send to ensure the peer receives our announce
|
// Force send to ensure the peer receives our announce
|
||||||
sendAnnounce(forceSend: true)
|
sendAnnounce(forceSend: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
|
// 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)
|
let delay = Double.random(in: 0.3...0.6)
|
||||||
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
self?.sendAnnounce(forceSend: true)
|
self?.sendAnnounce(forceSend: true)
|
||||||
@@ -3213,29 +3136,23 @@ extension BLEService {
|
|||||||
// Mention parsing moved to ChatViewModel
|
// Mention parsing moved to ChatViewModel
|
||||||
|
|
||||||
private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
// Ignore self-origin public messages except when returned via sync (TTL==0).
|
let now = Date()
|
||||||
// This allows our own messages to be surfaced when they come back via
|
let messageDecision = BLEPublicMessagePolicy.evaluate(
|
||||||
// the sync path without re-processing regular relayed copies.
|
packet: packet,
|
||||||
if peerID == myPeerID && packet.ttl != 0 { return }
|
from: peerID,
|
||||||
|
localPeerID: myPeerID,
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
|
||||||
// Reject stale broadcast messages to prevent old messages from appearing
|
let messagePolicy: BLEPublicMessageAcceptance
|
||||||
// Use same 15-minute window as gossip sync (900 seconds)
|
switch messageDecision {
|
||||||
// Check if this is a broadcast message (recipient is all 0xFF or nil)
|
case .accept(let acceptance):
|
||||||
let isBroadcast: Bool = {
|
messagePolicy = acceptance
|
||||||
guard let r = packet.recipientID else { return true }
|
case .reject(.selfEcho):
|
||||||
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
return
|
||||||
}()
|
case .reject(.staleBroadcast(let ageSeconds)):
|
||||||
if isBroadcast {
|
SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
||||||
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
|
return
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
|
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
|
||||||
@@ -3252,11 +3169,7 @@ extension BLEService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let isBroadcastRecipient: Bool = {
|
if messagePolicy.shouldTrackForSync {
|
||||||
guard let r = packet.recipientID else { return true }
|
|
||||||
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
|
|
||||||
}()
|
|
||||||
if isBroadcastRecipient && packet.type == MessageType.message.rawValue {
|
|
||||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3274,9 +3187,7 @@ extension BLEService {
|
|||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
var resolvedSelfMessageID: String? = nil
|
var resolvedSelfMessageID: String? = nil
|
||||||
if peerID == myPeerID {
|
if peerID == myPeerID {
|
||||||
let senderHex = packet.senderID.hexEncodedString()
|
resolvedSelfMessageID = selfBroadcastTracker.takeMessageID(for: packet)
|
||||||
let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)"
|
|
||||||
resolvedSelfMessageID = selfBroadcastMessageIDs.removeValue(forKey: dedupID)?.id
|
|
||||||
}
|
}
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
self?.deliverTransportEvent(
|
self?.deliverTransportEvent(
|
||||||
@@ -3352,32 +3263,14 @@ extension BLEService {
|
|||||||
|
|
||||||
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session)
|
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)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
self?.deliverTransportEvent(.noisePayloadReceived(peerID: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts))
|
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||||
}
|
peerID: peerID,
|
||||||
case .delivered:
|
type: noisePayloadType,
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
payload: Data(payloadData),
|
||||||
notifyUI { [weak self] in
|
timestamp: ts
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
} catch NoiseEncryptionError.sessionNotEstablished {
|
||||||
// We received an encrypted message before establishing a session with this peer.
|
// 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
|
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
|
||||||
@MainActor
|
@MainActor
|
||||||
private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {
|
private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) {
|
||||||
let now = Date()
|
if disconnectNotifyDebouncer.shouldEmit(
|
||||||
let last = recentDisconnectNotifies[peerID]
|
peerID: peerID,
|
||||||
if last == nil || now.timeIntervalSince(last!) >= TransportConfig.bleDisconnectNotifyDebounceSeconds {
|
now: Date(),
|
||||||
|
minimumInterval: TransportConfig.bleDisconnectNotifyDebounceSeconds
|
||||||
|
) {
|
||||||
deliverTransportEvent(.peerDisconnected(peerID))
|
deliverTransportEvent(.peerDisconnected(peerID))
|
||||||
recentDisconnectNotifies[peerID] = now
|
|
||||||
} else {
|
|
||||||
// Suppressed duplicate disconnect notification
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3450,35 +3342,26 @@ extension BLEService {
|
|||||||
private func performMaintenance() {
|
private func performMaintenance() {
|
||||||
maintenanceCounter += 1
|
maintenanceCounter += 1
|
||||||
|
|
||||||
// Adaptive announce: reduce frequency when we have connected peers
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
|
let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount }
|
||||||
let elapsed = now.timeIntervalSince(lastAnnounceSent)
|
let elapsed = announceThrottle.elapsed(since: now)
|
||||||
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 recentSeen = collectionsQueue.sync { () -> Bool in
|
let recentSeen = collectionsQueue.sync { () -> Bool in
|
||||||
recentTrafficTracker.hasTraffic(within: 5.0, now: now)
|
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)
|
sendAnnounce(forceSend: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have no peers, ensure we're scanning and advertising
|
if plan.shouldEnsureAdvertising {
|
||||||
let hasNoPeers = collectionsQueue.sync { peerRegistry.isEmpty }
|
|
||||||
if hasNoPeers {
|
|
||||||
// Ensure we're advertising as peripheral
|
// Ensure we're advertising as peripheral
|
||||||
if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising {
|
if let pm = peripheralManager, pm.state == .poweredOn && !pm.isAdvertising {
|
||||||
pm.startAdvertising(buildAdvertisementData())
|
pm.startAdvertising(buildAdvertisementData())
|
||||||
@@ -3493,12 +3376,12 @@ extension BLEService {
|
|||||||
checkPeerConnectivity()
|
checkPeerConnectivity()
|
||||||
|
|
||||||
// Every 30 seconds (3 cycles): Cleanup
|
// Every 30 seconds (3 cycles): Cleanup
|
||||||
if maintenanceCounter % 3 == 0 {
|
if plan.shouldRunCleanup {
|
||||||
performCleanup()
|
performCleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to flush any spooled directed messages periodically (~every 5 seconds)
|
// Attempt to flush any spooled directed messages periodically (~every 5 seconds)
|
||||||
if maintenanceCounter % 2 == 1 {
|
if plan.shouldFlushDirectedSpool {
|
||||||
flushDirectedSpool()
|
flushDirectedSpool()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3510,7 +3393,7 @@ extension BLEService {
|
|||||||
// No rotating alias: nothing to refresh
|
// No rotating alias: nothing to refresh
|
||||||
|
|
||||||
// Reset counter to prevent overflow (every 60 seconds)
|
// Reset counter to prevent overflow (every 60 seconds)
|
||||||
if maintenanceCounter >= 6 {
|
if plan.shouldResetCounter {
|
||||||
maintenanceCounter = 0
|
maintenanceCounter = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3577,12 +3460,8 @@ extension BLEService {
|
|||||||
// Clean up stale scheduled relays that somehow persisted (> 2s)
|
// Clean up stale scheduled relays that somehow persisted (> 2s)
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if !self.scheduledRelays.isEmpty {
|
|
||||||
// Nothing to compare times to; just cap the size defensively
|
// Nothing to compare times to; just cap the size defensively
|
||||||
if self.scheduledRelays.count > 512 {
|
self.scheduledRelays.removeAllIfOverCapacity(512)
|
||||||
self.scheduledRelays.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean ingress link records older than configured seconds
|
// Clean ingress link records older than configured seconds
|
||||||
@@ -3593,21 +3472,17 @@ extension BLEService {
|
|||||||
self.ingressLinks.prune(before: cutoff)
|
self.ingressLinks.prune(before: cutoff)
|
||||||
}
|
}
|
||||||
// Clean expired directed spooled items
|
// Clean expired directed spooled items
|
||||||
if !self.pendingDirectedRelays.isEmpty {
|
self.pendingDirectedRelays.pruneExpired(
|
||||||
var cleaned: [PeerID: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
|
now: now,
|
||||||
for (recipient, dict) in self.pendingDirectedRelays {
|
window: TransportConfig.bleDirectedSpoolWindowSeconds
|
||||||
let pruned = dict.filter { now.timeIntervalSince($0.value.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds }
|
)
|
||||||
if !pruned.isEmpty { cleaned[recipient] = pruned }
|
|
||||||
}
|
|
||||||
self.pendingDirectedRelays = cleaned
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
messageQueue.async(flags: .barrier) { [weak self] in
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard !self.selfBroadcastMessageIDs.isEmpty else { return }
|
guard !self.selfBroadcastTracker.isEmpty else { return }
|
||||||
let cutoff = now.addingTimeInterval(-TransportConfig.messageDedupMaxAgeSeconds)
|
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()
|
now: Date()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
let forceScanOn = (connectedCount <= 2) || hasRecentTraffic
|
let scanPlan = BLEScanDutyPolicy.plan(
|
||||||
let shouldDuty = dutyEnabled && active && connectedCount > 0 && !forceScanOn
|
dutyEnabled: dutyEnabled,
|
||||||
if shouldDuty {
|
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 {
|
if scanDutyTimer == nil {
|
||||||
// Start timer to toggle scanning on/off
|
// Start timer to toggle scanning on/off
|
||||||
let t = DispatchSource.makeTimerSource(queue: bleQueue)
|
let t = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||||
// Start with scanning ON; we'll turn OFF after onDuration
|
// Start with scanning ON; we'll turn OFF after onDuration
|
||||||
if !central.isScanning { startScanning() }
|
if !central.isScanning { startScanning() }
|
||||||
dutyActive = true
|
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.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
|
||||||
t.setEventHandler { [weak self] in
|
t.setEventHandler { [weak self] in
|
||||||
guard let self = self, let c = self.centralManager else { return }
|
guard let self = self, let c = self.centralManager else { return }
|
||||||
@@ -3659,8 +3536,12 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
t.resume()
|
t.resume()
|
||||||
scanDutyTimer = t
|
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
|
// Cancel duty cycle and ensure scanning is ON for discovery
|
||||||
scanDutyTimer?.cancel()
|
scanDutyTimer?.cancel()
|
||||||
scanDutyTimer = nil
|
scanDutyTimer = nil
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,6 @@ import UIKit
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ChatMediaTransferCoordinator {
|
final class ChatMediaTransferCoordinator {
|
||||||
private enum MediaSendError: Error {
|
|
||||||
case encodingFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
private unowned let viewModel: ChatViewModel
|
private unowned let viewModel: ChatViewModel
|
||||||
|
|
||||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||||
@@ -40,26 +36,7 @@ final class ChatMediaTransferCoordinator {
|
|||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||||
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 }
|
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
@@ -69,6 +46,12 @@ final class ChatMediaTransferCoordinator {
|
|||||||
self.viewModel.meshService.sendFileBroadcast(packet, transferId: transferId)
|
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 {
|
} catch {
|
||||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -120,52 +103,43 @@ final class ChatMediaTransferCoordinator {
|
|||||||
|
|
||||||
let targetPeer = viewModel.selectedPrivateChatPeer
|
let targetPeer = viewModel.selectedPrivateChatPeer
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
var processedURL: URL?
|
|
||||||
do {
|
do {
|
||||||
let outputURL = try ImageUtils.processImage(at: sourceURL)
|
try ImageUtils.validateImageSource(at: sourceURL)
|
||||||
processedURL = outputURL
|
} catch {
|
||||||
let data = try Data(contentsOf: outputURL)
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||||
guard data.count <= FileTransferLimits.maxImageBytes else {
|
viewModel.addSystemMessage("Failed to prepare image for sending.")
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let packet = BitchatFilePacket(
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
fileName: outputURL.lastPathComponent,
|
guard let self else { return }
|
||||||
fileSize: UInt64(data.count),
|
do {
|
||||||
mimeType: "image/jpeg",
|
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
||||||
content: data
|
|
||||||
)
|
|
||||||
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
|
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
let message = self.enqueueMediaMessage(
|
let message = self.enqueueMediaMessage(
|
||||||
content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)",
|
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||||
targetPeer: targetPeer
|
targetPeer: targetPeer
|
||||||
)
|
)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = self.makeTransferID(messageID: messageID)
|
let transferId = self.makeTransferID(messageID: messageID)
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
self.viewModel.meshService.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
|
||||||
} else {
|
} 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 {
|
} catch {
|
||||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.viewModel.addSystemMessage("Failed to prepare image for sending.")
|
self.viewModel.addSystemMessage("Failed to prepare image for sending.")
|
||||||
}
|
}
|
||||||
if let processedURL {
|
|
||||||
try? FileManager.default.removeItem(at: processedURL)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -848,7 +848,7 @@ private extension ChatNostrCoordinator {
|
|||||||
let maxBytes = FileTransferLimits.maxFramedFileBytes
|
let maxBytes = FileTransferLimits.maxFramedFileBytes
|
||||||
let maxEncoded = ((maxBytes + 2) / 3) * 4
|
let maxEncoded = ((maxBytes + 2) / 3) * 4
|
||||||
guard encoded.count <= maxEncoded else { return nil }
|
guard encoded.count <= maxEncoded else { return nil }
|
||||||
guard let packetData = ChatViewModel.base64URLDecode(encoded),
|
guard let packetData = Base64URLCoding.decode(encoded),
|
||||||
packetData.count <= maxBytes
|
packetData.count <= maxBytes
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -33,6 +33,52 @@ final class ChatPeerIdentityCoordinator {
|
|||||||
viewModel.unifiedPeerService.isBlocked(peerID)
|
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
|
@MainActor
|
||||||
func updatePrivateChatPeerIfNeeded() {
|
func updatePrivateChatPeerIfNeeded() {
|
||||||
guard let chatFingerprint = viewModel.selectedPrivateChatFingerprint,
|
guard let chatFingerprint = viewModel.selectedPrivateChatFingerprint,
|
||||||
@@ -376,4 +422,42 @@ private extension ChatPeerIdentityCoordinator {
|
|||||||
}
|
}
|
||||||
return .noiseSecured
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PeerID>,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -369,7 +369,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
var geoSamplingSubs: [String: String] = [:] // subID -> geohash
|
var geoSamplingSubs: [String: String] = [:] // subID -> geohash
|
||||||
var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
|
var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Message Delivery Tracking
|
// MARK: - Message Delivery Tracking
|
||||||
|
|
||||||
var cancellables = Set<AnyCancellable>()
|
var cancellables = Set<AnyCancellable>()
|
||||||
@@ -494,12 +493,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// No need to force UserDefaults synchronization
|
// No need to force UserDefaults synchronization
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Nickname Management
|
// MARK: - Nickname Management
|
||||||
|
|
||||||
func loadNickname() {
|
func loadNickname() {
|
||||||
@@ -526,130 +519,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// MARK: - Blocked Users Management (Delegated to PeerStateManager)
|
// 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
|
/// Check if a peer has unread messages, including messages stored under stable Noise keys and temporary Nostr peer IDs
|
||||||
@MainActor
|
@MainActor
|
||||||
func hasUnreadMessages(for peerID: PeerID) -> Bool {
|
func hasUnreadMessages(for peerID: PeerID) -> Bool {
|
||||||
// First check direct unread messages
|
peerIdentityCoordinator.hasUnreadMessages(for: peerID)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func toggleFavorite(peerID: PeerID) {
|
func toggleFavorite(peerID: PeerID) {
|
||||||
// Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars)
|
peerIdentityCoordinator.toggleFavorite(peerID: peerID)
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func isFavorite(peerID: PeerID) -> Bool {
|
func isFavorite(peerID: PeerID) -> Bool {
|
||||||
// Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars)
|
peerIdentityCoordinator.isFavorite(peerID: peerID)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public Key and Identity Management
|
// MARK: - Public Key and Identity Management
|
||||||
@@ -676,14 +559,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
outgoingCoordinator.sendMessage(content)
|
outgoingCoordinator.sendMessage(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Geohash Participants
|
// MARK: - Geohash Participants
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -762,14 +637,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
||||||
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
|
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Media Transfers
|
// MARK: - Media Transfers
|
||||||
|
|
||||||
private enum MediaSendError: Error {
|
private enum MediaSendError: Error {
|
||||||
@@ -778,13 +649,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
case copyFailed
|
case copyFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func currentPublicSender() -> (name: String, peerID: PeerID) {
|
func currentPublicSender() -> (name: String, peerID: PeerID) {
|
||||||
publicConversationCoordinator.currentPublicSender()
|
publicConversationCoordinator.currentPublicSender()
|
||||||
}
|
}
|
||||||
@@ -794,14 +658,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
peerIdentityCoordinator.nicknameForPeer(peerID)
|
peerIdentityCoordinator.nicknameForPeer(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
|
func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
|
||||||
publicConversationCoordinator.removeMessage(withID: messageID, cleanupFile: cleanupFile)
|
publicConversationCoordinator.removeMessage(withID: messageID, cleanupFile: cleanupFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Add a local system message to a private chat (no network send)
|
/// Add a local system message to a private chat (no network send)
|
||||||
@MainActor
|
@MainActor
|
||||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
|
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
|
||||||
@@ -827,26 +688,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
@MainActor
|
@MainActor
|
||||||
func updateBluetoothState(_ state: CBManagerState) {
|
func updateBluetoothState(_ state: CBManagerState) {
|
||||||
bluetoothState = state
|
bluetoothState = state
|
||||||
|
let alertUpdate = ChatBluetoothAlertPolicy.update(for: state)
|
||||||
switch state {
|
showBluetoothAlert = alertUpdate.isPresented
|
||||||
case .poweredOff:
|
if let message = alertUpdate.message {
|
||||||
bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off")
|
bluetoothAlertMessage = message
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,37 +740,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
lifecycleCoordinator.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
|
@MainActor
|
||||||
func markPrivateMessagesAsRead(from peerID: PeerID) {
|
func markPrivateMessagesAsRead(from peerID: PeerID) {
|
||||||
lifecycleCoordinator.markPrivateMessagesAsRead(from: peerID)
|
lifecycleCoordinator.markPrivateMessagesAsRead(from: peerID)
|
||||||
@@ -945,7 +759,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
peerIdentityCoordinator.getPeerIDForNickname(nickname)
|
peerIdentityCoordinator.getPeerIDForNickname(nickname)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Emergency Functions
|
// MARK: - Emergency Functions
|
||||||
|
|
||||||
// PANIC: Emergency data clearing for activist safety
|
// PANIC: Emergency data clearing for activist safety
|
||||||
@@ -1132,7 +945,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
peerIdentityCoordinator.invalidateEncryptionCache(for: peerID)
|
peerIdentityCoordinator.invalidateEncryptionCache(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Message Handling
|
// MARK: - Message Handling
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -1421,34 +1233,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
publicConversationCoordinator.sendPublicRaw(content)
|
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
|
/// Handle incoming public message
|
||||||
@MainActor
|
@MainActor
|
||||||
func handlePublicMessage(_ message: BitchatMessage) {
|
func handlePublicMessage(_ message: BitchatMessage) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -797,6 +797,7 @@ struct ChatViewModelGeoDMTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
struct ChatViewModelMediaTransferTests {
|
struct ChatViewModelMediaTransferTests {
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
@@ -931,7 +932,7 @@ struct ChatViewModelMediaTransferTests {
|
|||||||
|
|
||||||
let didNotify = await TestHelpers.waitUntil({
|
let didNotify = await TestHelpers.waitUntil({
|
||||||
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
|
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
|
||||||
}, timeout: 2.0)
|
}, timeout: 5.0)
|
||||||
#expect(didNotify)
|
#expect(didNotify)
|
||||||
#expect(transport.sentPrivateFiles.isEmpty)
|
#expect(transport.sentPrivateFiles.isEmpty)
|
||||||
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
|
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PeerID> = []
|
||||||
|
) -> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user