PeerID 29/n: BLEService + remove dupe funcs from #823 (#838)

* PeerID 28/n: `ChatViewModel.getShortIDForNoiseKey`

* PeerID 29/n: `BLEService` + remove dupe funcs from #823

* `handleFileTransfer` to use PeerID

* `sendMessage` and `sendPrivateMessage`

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
This commit is contained in:
Islam
2025-10-19 20:35:41 +02:00
committed by GitHub
co-authored by jack
parent c3a1af7023
commit 0dd999af6b
+96 -661
View File
@@ -334,7 +334,7 @@ final class BLEService: NSObject {
} }
// Ensure this runs on message queue to avoid main thread blocking // Ensure this runs on message queue to avoid main thread blocking
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) { func sendMessage(_ content: String, mentions: [String] = [], to recipientID: PeerID? = nil, messageID: String? = nil, timestamp: Date? = nil) {
// Call directly if already on messageQueue, otherwise dispatch // Call directly if already on messageQueue, otherwise dispatch
if DispatchQueue.getSpecific(key: messageQueueKey) == nil { if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
messageQueue.async { [weak self] in messageQueue.async { [weak self] in
@@ -605,7 +605,7 @@ final class BLEService: NSObject {
} }
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
sendPrivateMessage(content, to: peerID.id, messageID: messageID) sendPrivateMessage(content, to: peerID, messageID: messageID)
} }
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) { func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
@@ -735,12 +735,12 @@ final class BLEService: NSObject {
private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) { private func sendEncrypted(_ packet: BitchatPacket, data: Data, pad: Bool) {
guard let recipientID = packet.recipientID else { return } guard let recipientID = packet.recipientID else { return }
let recipientPeerID = recipientID.hexEncodedString() let recipientPeerID = PeerID(hexData: recipientID)
var sentEncrypted = false var sentEncrypted = false
// Per-link limits for the specific peer // Per-link limits for the specific peer
var peripheralMaxLen: Int? var peripheralMaxLen: Int?
if let perUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[PeerID(str: recipientPeerID)] : bleQueue.sync(execute: { peerToPeripheralUUID[PeerID(str: recipientPeerID)] }) { if let perUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }) {
if let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[perUUID] : bleQueue.sync(execute: { peripherals[perUUID] }) { if let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[perUUID] : bleQueue.sync(execute: { peripherals[perUUID] }) {
peripheralMaxLen = state.peripheral.maximumWriteValueLength(for: .withoutResponse) peripheralMaxLen = state.peripheral.maximumWriteValueLength(for: .withoutResponse)
} }
@@ -748,7 +748,7 @@ final class BLEService: NSObject {
var centralMaxLen: Int? var centralMaxLen: Int?
do { do {
let (centrals, mapping) = snapshotSubscribedCentrals() let (centrals, mapping) = snapshotSubscribedCentrals()
if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == PeerID(str: recipientPeerID) }) { if let central = centrals.first(where: { mapping[$0.identifier.uuidString] == recipientPeerID }) {
centralMaxLen = central.maximumUpdateValueLength centralMaxLen = central.maximumUpdateValueLength
} }
} }
@@ -766,7 +766,7 @@ final class BLEService: NSObject {
} }
// Direct write via peripheral link // Direct write via peripheral link
if let peripheralUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[PeerID(str: recipientPeerID)] : bleQueue.sync(execute: { peerToPeripheralUUID[PeerID(str: recipientPeerID)] }), if let peripheralUUID = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peerToPeripheralUUID[recipientPeerID] : bleQueue.sync(execute: { peerToPeripheralUUID[recipientPeerID] }),
let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }), let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }),
state.isConnected, state.isConnected,
let characteristic = state.characteristic { let characteristic = state.characteristic {
@@ -777,7 +777,7 @@ final class BLEService: NSObject {
// Notify via central link (dual-role) // Notify via central link (dual-role)
if let characteristic = characteristic, !sentEncrypted { if let characteristic = characteristic, !sentEncrypted {
let (centrals, mapping) = snapshotSubscribedCentrals() let (centrals, mapping) = snapshotSubscribedCentrals()
for central in centrals where mapping[central.identifier.uuidString] == PeerID(str: recipientPeerID) { for central in centrals where mapping[central.identifier.uuidString] == recipientPeerID {
let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false let success = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: [central]) ?? false
if success { sentEncrypted = true; break } if success { sentEncrypted = true; break }
enqueuePendingNotification(data: data, centrals: [central], context: "encrypted") enqueuePendingNotification(data: data, centrals: [central], context: "encrypted")
@@ -816,13 +816,13 @@ final class BLEService: NSObject {
} }
} }
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: String?) { private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) {
// Determine last-hop link for this message to avoid echoing back // Determine last-hop link for this message to avoid echoing back
let messageID = makeMessageID(for: packet) let messageID = makeMessageID(for: packet)
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link } let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
let directedPeerHint: String? = { let directedPeerHint: PeerID? = {
if let explicit = directedOnlyPeer { return explicit } if let explicit = directedOnlyPeer { return explicit }
if let recipient = packet.recipientID?.hexEncodedString(), !recipient.isEmpty { if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty {
return recipient return recipient
} }
return nil return nil
@@ -915,20 +915,20 @@ final class BLEService: NSObject {
} }
// Directed send helper (unicast to a specific peerID) without altering packet contents // Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: String) { private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
guard let data = packet.toBinaryData(padding: false) else { return } guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID) sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
} }
// MARK: - Directed store-and-forward // MARK: - Directed store-and-forward
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) { private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: PeerID) {
let msgID = makeMessageID(for: packet) let msgID = makeMessageID(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[PeerID(str: recipientPeerID)] ?? [:] var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:]
if byMsg[msgID] == nil { if byMsg[msgID] == nil {
byMsg[msgID] = (packet: packet, enqueuedAt: Date()) byMsg[msgID] = (packet: packet, enqueuedAt: Date())
self.pendingDirectedRelays[PeerID(str: recipientPeerID)] = byMsg self.pendingDirectedRelays[recipientPeerID] = byMsg
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)
} }
} }
@@ -983,615 +983,8 @@ final class BLEService: NSObject {
// CoreBluetooth will handle fragmentation at L2CAP layer // CoreBluetooth will handle fragmentation at L2CAP layer
writeOrEnqueue(data, to: peripheral, characteristic: characteristic) writeOrEnqueue(data, to: peripheral, characteristic: characteristic)
} }
// MARK: - Fragmentation (Required for messages > BLE MTU)
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: String? = nil, transferId: String? = nil) {
guard let fullData = packet.toBinaryData(padding: pad) else { return }
// Fragment the unpadded frame; each fragment will be encoded independently
let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
let chunk = maxChunk ?? defaultFragmentSize
let safeChunk = max(64, chunk)
let fragments = stride(from: 0, to: fullData.count, by: safeChunk).map { offset in
Data(fullData[offset..<min(offset + safeChunk, fullData.count)])
}
guard !fragments.isEmpty else { return }
// Lightweight pacing to reduce floods and allow BLE buffers to drain private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) {
// Also briefly pause scanning during long fragment trains to save battery
let totalFragments = fragments.count
if totalFragments > 4 {
bleQueue.async { [weak self] in
guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
if c.isScanning { c.stopScan() }
// Resume scanning after we expect last fragment to be sent
let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs) // ~8ms per fragment
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in
self?.startScanning()
}
}
}
let perFragMs = (directedOnlyPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs
let transferIdentifier: String? = {
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
let id = transferId ?? packet.payload.sha256Hex()
collectionsQueue.sync(flags: .barrier) {
self.activeTransfers[id] = ActiveTransferState(totalFragments: totalFragments, sentFragments: 0, workItems: [])
}
TransferProgressManager.shared.start(id: id, totalFragments: totalFragments)
return id
}()
var scheduledItems: [(item: DispatchWorkItem, index: Int)] = []
for (index, fragment) in fragments.enumerated() {
var payload = Data()
payload.append(fragmentID)
payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
payload.append(contentsOf: withUnsafeBytes(of: UInt16(fragments.count).bigEndian) { Data($0) })
payload.append(packet.type)
payload.append(fragment)
let fragmentRecipient: Data? = {
if let only = directedOnlyPeer { return Data(hexString: only) }
return packet.recipientID
}()
let fragmentPacket = BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: packet.senderID,
recipientID: fragmentRecipient,
timestamp: packet.timestamp,
payload: payload,
signature: nil,
ttl: packet.ttl
)
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
if let transferId = transferIdentifier {
let isActive = self.collectionsQueue.sync { self.activeTransfers[transferId] != nil }
guard isActive else { return }
}
self.broadcastPacket(fragmentPacket)
if let transferId = transferIdentifier {
self.markFragmentSent(transferId: transferId)
}
}
scheduledItems.append((item: workItem, index: index))
}
if let transferId = transferIdentifier {
let workItems = scheduledItems.map { $0.item }
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self, var state = self.activeTransfers[transferId] else { return }
state.workItems = workItems
self.activeTransfers[transferId] = state
}
}
for (workItem, index) in scheduledItems {
let delayMs = index * perFragMs
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)
}
}
private func markFragmentSent(transferId: String) {
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self, var state = self.activeTransfers[transferId] else { return }
state.sentFragments = min(state.sentFragments + 1, state.totalFragments)
self.activeTransfers[transferId] = state
TransferProgressManager.shared.recordFragmentSent(id: transferId)
if state.sentFragments >= state.totalFragments {
self.activeTransfers.removeValue(forKey: transferId)
}
}
}
private func handleFragment(_ packet: BitchatPacket, from peerID: String) {
// Don't process our own fragments
if peerID == myPeerID {
return
}
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type
guard packet.payload.count >= 13 else { return }
// Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian
var senderU64: UInt64 = 0
for b in packet.senderID.prefix(8) { senderU64 = (senderU64 << 8) | UInt64(b) }
var fragU64: UInt64 = 0
for b in packet.payload.prefix(8) { fragU64 = (fragU64 << 8) | UInt64(b) }
// Parse big-endian UInt16 safely without alignment assumptions
let idxHi = UInt16(packet.payload[8])
let idxLo = UInt16(packet.payload[9])
let index = Int((idxHi << 8) | idxLo)
let totHi = UInt16(packet.payload[10])
let totLo = UInt16(packet.payload[11])
let total = Int((totHi << 8) | totLo)
let originalType = packet.payload[12]
let fragmentData = packet.payload.suffix(from: 13)
// Sanity checks - add reasonable upper bound on total to prevent DoS
guard total > 0 && total <= 10000 && index >= 0 && index < total else { return }
// Compute fragment key for this assembly
let key = FragmentKey(sender: senderU64, id: fragU64)
// Critical section: Store fragment and check completion status
var shouldReassemble: Bool = false
var fragmentsToReassemble: [Int: Data]? = nil
collectionsQueue.sync(flags: .barrier) {
if incomingFragments[key] == nil {
// Cap in-flight assemblies to prevent memory/battery blowups
if incomingFragments.count >= maxInFlightAssemblies {
// Evict the oldest assembly by timestamp
if let oldest = fragmentMetadata.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
incomingFragments.removeValue(forKey: oldest)
fragmentMetadata.removeValue(forKey: oldest)
}
}
incomingFragments[key] = [:]
fragmentMetadata[key] = (originalType, total, Date())
SecureLogger.debug("📦 Started fragment assembly id=\(String(format: "%016llx", fragU64)) total=\(total)", category: .session)
}
// Check cumulative size before storing this fragment
let currentSize = incomingFragments[key]?.values.reduce(0) { $0 + $1.count } ?? 0
let assemblyLimit: Int = {
if originalType == MessageType.fileTransfer.rawValue {
// Allow headroom for TLV metadata and binary framing overhead.
return FileTransferLimits.maxFramedFileBytes
}
return FileTransferLimits.maxPayloadBytes
}()
guard currentSize + fragmentData.count <= assemblyLimit else {
// Exceeds size limit - evict this assembly
SecureLogger.warning(
"🚫 Fragment assembly exceeds size limit (\(currentSize + fragmentData.count) bytes > \(assemblyLimit)), evicting",
category: .security
)
incomingFragments.removeValue(forKey: key)
fragmentMetadata.removeValue(forKey: key)
shouldReassemble = false
fragmentsToReassemble = nil
return
}
incomingFragments[key]?[index] = Data(fragmentData)
SecureLogger.debug("📦 Fragment \(index + 1)/\(total) (len=\(fragmentData.count)) for id=\(String(format: "%016llx", fragU64))", category: .session)
// Check if complete
if let fragments = incomingFragments[key], fragments.count == total {
shouldReassemble = true
fragmentsToReassemble = fragments
} else {
shouldReassemble = false
fragmentsToReassemble = nil
}
}
// Heavy work outside lock: reassemble and decode
guard shouldReassemble, let fragments = fragmentsToReassemble else { return }
var reassembled = Data()
for i in 0..<total {
if let fragment = fragments[i] {
reassembled.append(fragment)
}
}
// Decode the original packet bytes we reassembled, so flags/compression are preserved
if let originalPacket = BinaryProtocol.decode(reassembled) {
SecureLogger.debug("✅ Reassembled packet id=\(String(format: "%016llx", fragU64)) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
handleReceivedPacket(originalPacket, from: peerID)
} else {
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: .session)
}
// Critical section: Cleanup completed assembly
collectionsQueue.sync(flags: .barrier) {
incomingFragments.removeValue(forKey: key)
fragmentMetadata.removeValue(forKey: key)
}
}
// MARK: - Packet Reception
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: String) {
// Deduplication (thread-safe)
let senderID = packet.senderID.hexEncodedString()
// Include packet type in message ID to prevent collisions between different packet types
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
// Only log non-announce packets to reduce noise
if packet.type != MessageType.announce.rawValue {
// Log packet details for debugging
SecureLogger.debug("📦 Handling packet type \(packet.type) from \(senderID), messageID: \(messageID)", category: .session)
}
// Efficient deduplication
// Important: do not dedup fragment packets globally (each piece must pass)
// Special case: allow our own packets recovered via sync (TTL==0) to pass
// through even if we've marked them as seen at send time.
let allowSelfSyncReplay = (packet.ttl == 0) && (senderID == myPeerID)
if packet.type != MessageType.fragment.rawValue && !allowSelfSyncReplay && messageDeduplicator.isDuplicate(messageID) {
// Announce packets (type 1) are sent every 10 seconds for peer discovery
// It's normal to see these as duplicates - don't log them to reduce noise
if packet.type != MessageType.announce.rawValue {
SecureLogger.debug("⚠️ Duplicate packet ignored: \(messageID)", 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 { peers.values.filter { $0.isConnected }.count }
if connectedCount > 2 {
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
updatePeerLastSeen(PeerID(str: peerID))
// Track recent traffic timestamps for adaptive behavior
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
let now = Date()
self.recentPacketTimestamps.append(now)
// keep last N timestamps within window
let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
if self.recentPacketTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
}
self.recentPacketTimestamps.removeAll { $0 < cutoff }
}
// Process by type
switch MessageType(rawValue: packet.type) {
case .announce:
handleAnnounce(packet, from: senderID)
case .message:
handleMessage(packet, from: senderID)
case .requestSync:
handleRequestSync(packet, from: senderID)
case .noiseHandshake:
handleNoiseHandshake(packet, from: PeerID(str: senderID))
case .noiseEncrypted:
handleNoiseEncrypted(packet, from: PeerID(str: senderID))
case .fragment:
handleFragment(packet, from: senderID)
case .fileTransfer:
handleFileTransfer(packet, from: senderID)
case .leave:
handleLeave(packet, from: PeerID(str: senderID))
case .none:
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
break
}
// Relay if TTL > 1 and we're not the original sender
// Relay decision and scheduling (extracted via RelayController)
do {
let degree = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
let decision = RelayController.decide(
ttl: packet.ttl,
senderIsSelf: senderID == myPeerID,
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
isAnnounce: packet.type == MessageType.announce.rawValue,
degree: degree,
highDegreeThreshold: highDegreeThreshold
)
guard decision.shouldRelay else { return }
let work = DispatchWorkItem { [weak self] in
guard let self = self else { return }
// Remove scheduled task before executing
self.collectionsQueue.async(flags: .barrier) { [weak self] in
_ = self?.scheduledRelays.removeValue(forKey: messageID)
}
var relayPacket = packet
relayPacket.ttl = decision.newTTL
self.broadcastPacket(relayPacket)
}
// Track the scheduled relay so duplicates can cancel it
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.scheduledRelays[messageID] = work
}
messageQueue.asyncAfter(deadline: .now() + .milliseconds(decision.delayMs), execute: work)
}
}
private func handleAnnounce(_ packet: BitchatPacket, from peerID: String) {
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
SecureLogger.error("❌ Failed to decode announce packet from \(peerID)", category: .session)
return
}
// Verify that the sender's derived ID from the announced noise public key matches the packet senderID
// This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey).id
if derivedFromKey != peerID {
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))", category: .security)
}
// Don't add ourselves as a peer
if peerID == myPeerID {
return
}
// Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention
let existingPeerForVerify = collectionsQueue.sync { peers[PeerID(str: peerID)] }
var verifiedAnnounce = false
if packet.signature != nil {
verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
if !verifiedAnnounce {
SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: .security)
}
}
if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: .security)
verifiedAnnounce = false
}
// Track if this is a new or reconnected peer
var isNewPeer = false
var isReconnectedPeer = false
collectionsQueue.sync(flags: .barrier) {
// Check if we have an actual BLE connection to this peer
let peripheralUUID = peerToPeripheralUUID[PeerID(str: peerID)]
let hasPeripheralConnection = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true
// Check if this peer is subscribed to us as a central
// Note: We can't identify which specific central is which peer without additional mapping
let hasCentralSubscription = centralToPeerID.values.contains(PeerID(str: peerID))
// Direct announces arrive with full TTL (no prior hop)
let isDirectAnnounce = (packet.ttl == messageTTL)
// Check if we already have this peer (might be reconnecting)
let existingPeer = peers[PeerID(str: peerID)]
let wasDisconnected = existingPeer?.isConnected == false
// Set flags for use outside the sync block
isNewPeer = (existingPeer == nil)
isReconnectedPeer = wasDisconnected
// Use precomputed verification result
let verified = verifiedAnnounce
// Require verified announce; ignore otherwise (no backward compatibility)
if !verified {
SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.prefix(8))", category: .security)
return
}
// Update or create peer info
if let existing = existingPeer, existing.isConnected {
// Update lastSeen and identity info
peers[PeerID(str: peerID)] = PeerInfo(
peerID: existing.peerID,
nickname: announcement.nickname,
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isVerifiedNickname: true,
lastSeen: Date()
)
} else {
// New peer or reconnecting peer
peers[PeerID(str: peerID)] = PeerInfo(
peerID: PeerID(str: peerID),
nickname: announcement.nickname,
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isVerifiedNickname: true,
lastSeen: Date()
)
}
// Log connection status only for direct connectivity changes; debounce to reduce spam
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
let now = Date()
if existingPeer == nil {
SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
} else if wasDisconnected {
// Debounce 'reconnected' logs within short window
if let last = lastReconnectLogAt[PeerID(str: peerID)], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds {
// Skip duplicate log
} else {
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
lastReconnectLogAt[PeerID(str: peerID)] = now
}
} else if existingPeer?.nickname != announcement.nickname {
SecureLogger.debug("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: .session)
}
}
}
// Persist cryptographic identity and signing key for robust offline verification
do {
// Derive fingerprint from Noise public key
let hash = SHA256.hash(data: announcement.noisePublicKey)
let fingerprint = hash.map { String(format: "%02x", $0) }.joined()
identityManager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
claimedNickname: announcement.nickname
)
}
// Record this announce for lightweight rebroadcast buffer (exclude self)
// (recentAnnounces has been removed in the refactor)
// Notify UI on main thread
notifyUI { [weak self] in
guard let self = self else { return }
// Get current peer list (after addition)
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
// Only notify of connection for new or reconnected peers when it is a direct announce
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
self.delegate?.didConnectToPeer(PeerID(str: peerID))
// Schedule initial unicast sync to this peer
self.gossipSyncManager?.scheduleInitialSyncToPeer(PeerID(str: peerID), delaySeconds: 1.0)
}
self.requestPeerDataPublish()
self.delegate?.didUpdatePeerList(currentPeerIDs)
}
// Track for sync (include our own and others' announces)
gossipSyncManager?.onPublicPacketSeen(packet)
// Send announce back for bidirectional discovery (only once per peer)
let announceBackID = "announce-back-\(peerID)"
let shouldSendBack = !messageDeduplicator.contains(announceBackID)
if shouldSendBack {
messageDeduplicator.markProcessed(announceBackID)
}
if shouldSendBack {
// Reciprocate announce for bidirectional discovery
// Force send to ensure the peer receives our announce
sendAnnounce(forceSend: true)
}
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
if isNewPeer {
let delay = Double.random(in: 0.3...0.6)
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.sendAnnounce(forceSend: true)
}
}
}
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
private func handleRequestSync(_ packet: BitchatPacket, from peerID: String) {
guard let req = RequestSyncPacket.decode(from: packet.payload) else {
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID)", category: .session)
return
}
gossipSyncManager?.handleRequestSync(from: PeerID(str: peerID), request: req)
}
// Mention parsing moved to ChatViewModel
private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
// Ignore self-origin public messages except when returned via sync (TTL==0).
// This allows our own messages to be surfaced when they come back via
// the sync path without re-processing regular relayed copies.
if peerID == myPeerID && packet.ttl != 0 { return }
var accepted = false
var senderNickname: String = ""
// Snapshot peers dictionary to avoid mutating-while-iterating crashes when checking collisions.
let peersSnapshot = collectionsQueue.sync { peers }
// If the packet is from ourselves (e.g., recovered via sync TTL==0), accept immediately
if peerID == myPeerID {
accepted = true
senderNickname = myNickname
}
else if let info = peersSnapshot[PeerID(str: peerID)], info.isVerifiedNickname {
// Known verified peer path
accepted = true
senderNickname = info.nickname
// Handle nickname collisions
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID.id != peerID } || (myNickname == info.nickname)
if hasCollision {
senderNickname += "#" + String(peerID.prefix(4))
}
} else {
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
// Find candidate identities by peerID prefix (16 hex)
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: peerID))
for candidate in candidates {
if let signingKey = candidate.signingPublicKey,
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
accepted = true
// Prefer persisted social petname or claimed nickname
if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) {
senderNickname = social.localPetname ?? social.claimedNickname
} else {
senderNickname = "anon" + String(peerID.prefix(4))
}
break
}
}
}
// If still not accepted and this is a sync-returned packet (TTL==0),
// accept with a generic nickname so history can be restored even for
// peers we haven't verified yet.
if !accepted && packet.ttl == 0 {
accepted = true
senderNickname = "anon" + String(peerID.prefix(4))
}
}
// Track broadcast messages for sync (treat nil or 0xFF..0xFF as broadcast)
let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
}()
if isBroadcastRecipient && packet.type == MessageType.message.rawValue {
gossipSyncManager?.onPublicPacketSeen(packet)
}
guard accepted else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))", category: .security)
return
}
guard let content = String(data: packet.payload, encoding: .utf8) else {
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
return
}
// Determine if we have a direct link to the sender
let hasDirectLink: Bool = collectionsQueue.sync {
let perUUID = peerToPeripheralUUID[PeerID(str: peerID)]
let perConnected = perUUID != nil && peripherals[perUUID!]?.isConnected == true
let hasCentral = centralToPeerID.values.contains(PeerID(str: peerID))
return perConnected || hasCentral
}
let pathTag = hasDirectLink ? "direct" : "mesh"
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.delegate?.didReceivePublicMessage(from: PeerID(str: peerID), nickname: senderNickname, content: content, timestamp: ts)
}
}
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: String) {
if peerID == myPeerID && packet.ttl != 0 { return } if peerID == myPeerID && packet.ttl != 0 { return }
var accepted = false var accepted = false
@@ -1602,22 +995,22 @@ final class BLEService: NSObject {
if peerID == myPeerID { if peerID == myPeerID {
accepted = true accepted = true
senderNickname = myNickname senderNickname = myNickname
} else if let info = peersSnapshot[PeerID(str: peerID)], info.isVerifiedNickname { } else if let info = peersSnapshot[peerID], info.isVerifiedNickname {
accepted = true accepted = true
senderNickname = info.nickname senderNickname = info.nickname
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID.id != peerID } || (myNickname == info.nickname) let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
if hasCollision { if hasCollision {
senderNickname += "#" + String(peerID.prefix(4)) senderNickname += "#" + String(peerID.id.prefix(4))
} }
} else if let info = peersSnapshot[PeerID(str: peerID)], info.isConnected { } else if let info = peersSnapshot[peerID], info.isConnected {
accepted = true accepted = true
senderNickname = info.nickname.isEmpty ? "anon" + String(peerID.prefix(4)) : info.nickname senderNickname = info.nickname.isEmpty ? "anon" + String(peerID.id.prefix(4)) : info.nickname
let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID.id != peerID } || (myNickname == info.nickname) let hasCollision = peersSnapshot.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname)
if hasCollision { if hasCollision {
senderNickname += "#" + String(peerID.prefix(4)) senderNickname += "#" + String(peerID.id.prefix(4))
} }
} else if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { } else if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: peerID)) let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
for candidate in candidates { for candidate in candidates {
if let signingKey = candidate.signingPublicKey, if let signingKey = candidate.signingPublicKey,
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
@@ -1625,22 +1018,22 @@ final class BLEService: NSObject {
if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) { if let social = identityManager.getSocialIdentity(for: candidate.fingerprint) {
senderNickname = social.localPetname ?? social.claimedNickname senderNickname = social.localPetname ?? social.claimedNickname
} else { } else {
senderNickname = "anon" + String(peerID.prefix(4)) senderNickname = "anon" + String(peerID.id.prefix(4))
} }
break break
} }
} }
if !accepted && packet.ttl == 0 { if !accepted && packet.ttl == 0 {
accepted = true accepted = true
senderNickname = "anon" + String(peerID.prefix(4)) senderNickname = "anon" + String(peerID.id.prefix(4))
} }
} else if packet.ttl == 0 { } else if packet.ttl == 0 {
accepted = true accepted = true
senderNickname = "anon" + String(peerID.prefix(4)) senderNickname = "anon" + String(peerID.id.prefix(4))
} }
guard accepted else { guard accepted else {
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.prefix(8))", category: .security) SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return return
} }
@@ -1673,14 +1066,14 @@ final class BLEService: NSObject {
// Validate MIME type against whitelist // Validate MIME type against whitelist
guard isAllowedMimeType(mime) else { guard isAllowedMimeType(mime) else {
SecureLogger.warning("🚫 MIME REJECT: '\(mime)' not in whitelist. Size=\(filePacket.content.count)b from \(peerID.prefix(8))...", category: .security) SecureLogger.warning("🚫 MIME REJECT: '\(mime)' not in whitelist. Size=\(filePacket.content.count)b from \(peerID.id.prefix(8))...", category: .security)
return return
} }
// Validate content matches declared MIME type (magic byte check) // Validate content matches declared MIME type (magic byte check)
guard validateContentMatchesMime(data: filePacket.content, declaredMime: mime) else { guard validateContentMatchesMime(data: filePacket.content, declaredMime: mime) else {
let prefix = filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ") 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.prefix(8))...", category: .security) SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(filePacket.content.count)b prefix=[\(prefix)] from \(peerID.id.prefix(8))...", category: .security)
return return
} }
@@ -1735,7 +1128,7 @@ final class BLEService: NSObject {
}() }()
if isPrivateMessage { if isPrivateMessage {
updatePeerLastSeen(PeerID(str: peerID)) updatePeerLastSeen(peerID)
} }
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
@@ -1747,10 +1140,10 @@ final class BLEService: NSObject {
originalSender: nil, originalSender: nil,
isPrivate: isPrivateMessage, isPrivate: isPrivateMessage,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: PeerID(str: peerID) senderPeerID: peerID
) )
SecureLogger.debug("📁 Stored incoming media from \(peerID.prefix(8))… -> \(destination.lastPathComponent)", category: .session) SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
notifyUI { [weak self] in notifyUI { [weak self] in
self?.delegate?.didReceiveMessage(message) self?.delegate?.didReceiveMessage(message)
@@ -1770,7 +1163,7 @@ final class BLEService: NSObject {
} }
SecureLogger.debug("📤 Sending favorite notification to \(peerID): \(content)", category: .session) SecureLogger.debug("📤 Sending favorite notification to \(peerID): \(content)", category: .session)
sendPrivateMessage(content, to: peerID.id, messageID: UUID().uuidString) sendPrivateMessage(content, to: peerID, messageID: UUID().uuidString)
} }
func sendBroadcastAnnounce() { func sendBroadcastAnnounce() {
@@ -2119,7 +1512,7 @@ extension BLEService: GossipSyncManager.Delegate {
} }
func sendPacket(to peerID: PeerID, packet: BitchatPacket) { func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
sendPacketDirected(packet, to: peerID.id) sendPacketDirected(packet, to: peerID)
} }
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket { func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
@@ -3164,8 +2557,7 @@ extension BLEService {
// MARK: Private Message Handling // MARK: Private Message Handling
private func sendPrivateMessage(_ content: String, to recipientID: String, messageID: String) { private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) {
let recipientID = PeerID(str: recipientID)
SecureLogger.debug("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: .session) SecureLogger.debug("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: .session)
// Check if we have an established Noise session // Check if we have an established Noise session
@@ -3323,7 +2715,7 @@ extension BLEService {
// MARK: Fragmentation (Required for messages > BLE MTU) // MARK: Fragmentation (Required for messages > BLE MTU)
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil) { private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil, transferId: String? = nil) {
guard let fullData = packet.toBinaryData(padding: pad) else { return } guard let fullData = packet.toBinaryData(padding: pad) else { return }
// Fragment the unpadded frame; each fragment will be encoded independently // Fragment the unpadded frame; each fragment will be encoded independently
@@ -3333,6 +2725,8 @@ extension BLEService {
let fragments = stride(from: 0, to: fullData.count, by: safeChunk).map { offset in let fragments = stride(from: 0, to: fullData.count, by: safeChunk).map { offset in
Data(fullData[offset..<min(offset + safeChunk, fullData.count)]) Data(fullData[offset..<min(offset + safeChunk, fullData.count)])
} }
guard !fragments.isEmpty else { return }
// Lightweight pacing to reduce floods and allow BLE buffers to drain // Lightweight pacing to reduce floods and allow BLE buffers to drain
// Also briefly pause scanning during long fragment trains to save battery // Also briefly pause scanning during long fragment trains to save battery
let totalFragments = fragments.count let totalFragments = fragments.count
@@ -3347,6 +2741,19 @@ extension BLEService {
} }
} }
} }
let perFragMs = (directedOnlyPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs
let transferIdentifier: String? = {
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
let id = transferId ?? packet.payload.sha256Hex()
collectionsQueue.sync(flags: .barrier) {
self.activeTransfers[id] = ActiveTransferState(totalFragments: totalFragments, sentFragments: 0, workItems: [])
}
TransferProgressManager.shared.start(id: id, totalFragments: totalFragments)
return id
}()
var scheduledItems: [(item: DispatchWorkItem, index: Int)] = []
for (index, fragment) in fragments.enumerated() { for (index, fragment) in fragments.enumerated() {
var payload = Data() var payload = Data()
@@ -3371,11 +2778,47 @@ extension BLEService {
signature: nil, signature: nil,
ttl: packet.ttl ttl: packet.ttl
) )
// Pace fragments with small jitter to avoid bursts
let perFragMs = (directedOnlyPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
if let transferId = transferIdentifier {
let isActive = self.collectionsQueue.sync { self.activeTransfers[transferId] != nil }
guard isActive else { return }
}
self.broadcastPacket(fragmentPacket)
if let transferId = transferIdentifier {
self.markFragmentSent(transferId: transferId)
}
}
scheduledItems.append((item: workItem, index: index))
}
if let transferId = transferIdentifier {
let workItems = scheduledItems.map { $0.item }
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self, var state = self.activeTransfers[transferId] else { return }
state.workItems = workItems
self.activeTransfers[transferId] = state
}
}
for (workItem, index) in scheduledItems {
let delayMs = index * perFragMs let delayMs = index * perFragMs
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)
self?.broadcastPacket(fragmentPacket) }
}
// MARK: - Fragmentation (Required for messages > BLE MTU)
private func markFragmentSent(transferId: String) {
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self, var state = self.activeTransfers[transferId] else { return }
state.sentFragments = min(state.sentFragments + 1, state.totalFragments)
self.activeTransfers[transferId] = state
TransferProgressManager.shared.recordFragmentSent(id: transferId)
if state.sentFragments >= state.totalFragments {
self.activeTransfers.removeValue(forKey: transferId)
} }
} }
} }
@@ -3436,6 +2879,7 @@ extension BLEService {
} }
incomingFragments[key] = [:] incomingFragments[key] = [:]
fragmentMetadata[key] = (originalType, total, Date()) fragmentMetadata[key] = (originalType, total, Date())
SecureLogger.debug("📦 Started fragment assembly id=\(String(format: "%016llx", fragU64)) total=\(total)", category: .session)
} }
// Check cumulative size before storing this fragment // Check cumulative size before storing this fragment
@@ -3462,6 +2906,7 @@ extension BLEService {
} }
incomingFragments[key]?[index] = Data(fragmentData) incomingFragments[key]?[index] = Data(fragmentData)
SecureLogger.debug("📦 Fragment \(index + 1)/\(total) (len=\(fragmentData.count)) for id=\(String(format: "%016llx", fragU64))", category: .session)
// Check if complete // Check if complete
if let fragments = incomingFragments[key], fragments.count == total { if let fragments = incomingFragments[key], fragments.count == total {
@@ -3485,6 +2930,7 @@ extension BLEService {
// Decode the original packet bytes we reassembled, so flags/compression are preserved // Decode the original packet bytes we reassembled, so flags/compression are preserved
if let originalPacket = BinaryProtocol.decode(reassembled) { if let originalPacket = BinaryProtocol.decode(reassembled) {
SecureLogger.debug("✅ Reassembled packet id=\(String(format: "%016llx", fragU64)) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
handleReceivedPacket(originalPacket, from: peerID) handleReceivedPacket(originalPacket, from: peerID)
} else { } else {
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: .session) SecureLogger.error("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: .session)
@@ -3581,7 +3027,7 @@ extension BLEService {
handleFragment(packet, from: senderID) handleFragment(packet, from: senderID)
case .fileTransfer: case .fileTransfer:
handleFileTransfer(packet, from: senderID.id) handleFileTransfer(packet, from: senderID)
case .leave: case .leave:
handleLeave(packet, from: senderID) handleLeave(packet, from: senderID)
@@ -3759,18 +3205,7 @@ extension BLEService {
) )
// Record this announce for lightweight rebroadcast buffer (exclude self) // Record this announce for lightweight rebroadcast buffer (exclude self)
if peerID != myPeerID { // (recentAnnounces has been removed in the refactor)
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
self.recentAnnounceBySender[peerID] = packet
if !self.recentAnnounceOrder.contains(peerID) { self.recentAnnounceOrder.append(peerID) }
// Trim to cap, oldest first
while self.recentAnnounceOrder.count > self.recentAnnounceBufferCap {
let victim = self.recentAnnounceOrder.removeFirst()
self.recentAnnounceBySender.removeValue(forKey: victim)
}
}
}
// Notify UI on main thread // Notify UI on main thread
notifyUI { [weak self] in notifyUI { [weak self] in