import BitFoundation import BitLogger import Foundation /// The narrow surface `ChatLiveVoiceCoordinator` needs from its owner. /// /// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the /// minimal context it actually uses instead of holding an `unowned` back-ref /// to the whole `ChatViewModel`, keeping it independently testable. @MainActor protocol ChatLiveVoiceContext: AnyObject { var nickname: String { get } var myPeerID: PeerID { get } var selectedPrivateChatPeer: PeerID? { get } /// Whether the public mesh timeline is what's on screen (autoplay gate /// for public bursts). var isViewingPublicMeshTimeline: Bool { get } func isPeerBlocked(_ peerID: PeerID) -> Bool func resolveNickname(for peerID: PeerID) -> String /// Routes an inbound private message through the full pipeline /// (store append, unread state, notification, read receipt). func handlePrivateMessage(_ message: BitchatMessage) /// Appends directly to the public mesh timeline, bypassing the batched /// public pipeline: a live bubble must be removable when its burst is /// canceled or empty, which a pipeline-buffered entry is not (it would /// re-commit at the next flush). func appendPublicMeshMessage(_ message: BitchatMessage) /// Replace-or-append by message ID via the single-writer store intent. func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) /// Replace-or-append by message ID in the public mesh timeline. func upsertPublicMeshMessage(_ message: BitchatMessage) @discardableResult func removePrivateMessage(withID messageID: String) -> BitchatMessage? /// Records and sends the finalized note's read receipt after a live /// bubble adopts its wire-derivable message ID. func hasSentReadReceipt(_ messageID: String) -> Bool @discardableResult func markReadReceiptSent(_ messageID: String) -> Bool func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) /// Removes a message from whichever conversation holds it. func removeMessage(withID messageID: String, cleanupFile: Bool) /// Publishes who is currently talking live in the public mesh channel /// (floor-courtesy indicator on the composer mic), nil when nobody is. func setActivePublicVoiceTalker(_ nickname: String?) func notifyUIChanged() } extension ChatViewModel: ChatLiveVoiceContext { var isViewingPublicMeshTimeline: Bool { selectedPrivateChatPeer == nil && activeChannel == .mesh } func appendPublicMeshMessage(_ message: BitchatMessage) { _ = appendPublicMessage(message, to: ConversationID(channelID: .mesh)) } func upsertPublicMeshMessage(_ message: BitchatMessage) { conversations.upsertByID(message, in: ConversationID(channelID: .mesh)) } func setActivePublicVoiceTalker(_ nickname: String?) { if activePublicVoiceTalker != nickname { activePublicVoiceTalker = nickname } } } /// Where a live voice burst lives: a Noise DM or the public mesh timeline. enum VoiceBurstScope: Hashable { case directMessage case publicMesh } /// Assembles inbound live push-to-talk bursts (`NoisePayloadType.voiceFrame`): /// orders packets behind a jitter window, persists frames progressively as an /// ADTS `.aac` so even a partial burst is a replayable voice-note bubble, /// optionally plays the stream live, and absorbs the sender's finalized /// `.m4a` voice note (matched by the burst ID in its file name) into the same /// bubble so nobody sees a duplicate. @MainActor final class ChatLiveVoiceCoordinator { /// Burst IDs are sender-chosen, so they only identify a burst *within* /// an authenticated (peer, scope) pair: keying assemblies by the full /// triple stops an attacker who observed a public burst ID from racing /// a START to capture the real talker's frames. private struct AssemblyKey: Hashable { let peerID: PeerID let scope: VoiceBurstScope let burstID: Data } private final class Assembly { let burstID: Data let peerID: PeerID let scope: VoiceBurstScope let nickname: String let message: BitchatMessage var messageID: String { message.id } var messageTimestamp: Date { message.timestamp } let fileURL: URL var fileHandle: FileHandle? /// Data packets buffered ahead of `nextSeq` (seq -> frames). var buffered: [UInt16: [Data]] = [:] /// Next data-packet seq to deliver (seq 0 is START). var nextSeq: UInt16 = 1 var deliveredFrames = 0 var receivedBytes = 0 let firstPacketAt: Date var endInfo: (totalDataPackets: UInt16, durationMs: UInt32)? /// When a seq gap was first observed; after /// `ChatLiveVoiceCoordinator.gapSkipSeconds` the gap is skipped. var gapSince: Date? var player: PTTBurstPlayer? var idleTimeout: Task? var gapRedrain: Task? var key: AssemblyKey { AssemblyKey(peerID: peerID, scope: scope, burstID: burstID) } init(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, message: BitchatMessage, fileURL: URL, fileHandle: FileHandle) { self.burstID = burstID self.peerID = peerID self.scope = scope self.nickname = nickname self.message = message self.fileURL = fileURL self.fileHandle = fileHandle self.firstPacketAt = Date() } } private struct FinishedBurst { let messageID: String let peerID: PeerID let scope: VoiceBurstScope let fileURL: URL let messageTimestamp: Date let expiresAt: Date } /// How long a missing packet stalls delivery before being skipped. private static let gapSkipSeconds: TimeInterval = 0.5 private static let finishedBurstsCap = 32 private unowned let context: any ChatLiveVoiceContext private let fileStore: BLEIncomingFileStore /// Captures live in the store's directories, so file operations go /// through the store's (injectable) file manager. private var fileManager: FileManager { fileStore.fileManager } private var assemblies: [AssemblyKey: Assembly] = [:] private var finishedBursts: [AssemblyKey: FinishedBurst] = [:] /// Players still draining after their assembly — the sole strong owner — /// was discarded on burst END. Without this hold the player deallocates /// mid-tail (or mid session-acquire, killing the whole burst's audio) /// and its registered session token would only be reclaimed by the /// deinit backstop. Entries remove themselves via `onStopped`. private var drainingPlayers: [ObjectIdentifier: PTTBurstPlayer] = [:] /// `sweepsOnInit` exists for tests whose coordinator shares the real /// application-support directory: they pass `false` so parallel test /// runs never sweep each other's in-flight capture files. init(context: any ChatLiveVoiceContext, fileStore: BLEIncomingFileStore = BLEIncomingFileStore(), sweepsOnInit: Bool = true) { self.context = context self.fileStore = fileStore // Orphaned partial captures from a previous session (live-only bursts // whose finalized note never arrived) are dead weight the quota can // never reclaim (eviction skips voice_live_* by design) — sweep them // on startup. if sweepsOnInit { sweepStaleLiveCaptures() } } // MARK: - Inbound frames /// Inbound DM burst packet (`NoisePayloadType.voiceFrame`). func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) { handle(payload, from: peerID, scope: .directMessage, nickname: context.resolveNickname(for: peerID), timestamp: timestamp) } /// Inbound public burst packet (`MessageType.voiceFrame`), already /// signature-verified by the transport, which resolved the nickname. func handlePublicVoiceFramePayload(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) { handle(payload, from: peerID, scope: .publicMesh, nickname: nickname, timestamp: timestamp) } private func handle(_ payload: Data, from peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) { // Live voice off means classic-notes-only in both directions: no live // bubble, no partial file, no early notification — the finalized // voice note still arrives through the normal pipeline. guard PTTSettings.liveVoiceEnabled else { SecureLogger.debug("PTT: dropping inbound voice frame — live voice is toggled off", category: .session) return } guard let packet = VoiceBurstPacket.decode(payload) else { SecureLogger.warning("PTT: undecodable voice frame from \(peerID.id.prefix(8))… (\(payload.count) bytes: \(payload.prefix(16).hexEncodedString())…)", category: .session) return } guard !context.isPeerBlocked(peerID) else { SecureLogger.debug("PTT: dropping voice frame from blocked peer \(peerID.id.prefix(8))…", category: .session) return } // The sender is authenticated (Noise session or packet signature), // and the key binds the burst ID to that (peer, scope): a colliding // START from another peer opens its own assembly instead of // capturing this one's frames. let key = AssemblyKey(peerID: peerID, scope: scope, burstID: packet.burstID) if let assembly = assemblies[key] { apply(packet, to: assembly) return } switch packet.kind { case .start, .frames: // A data packet with no prior START (lost or mid-burst join) // still opens the assembly with the default codec. guard assemblies.count < TransportConfig.pttMaxConcurrentAssemblies else { SecureLogger.debug("PTT: dropping burst from \(peerID.id.prefix(8))… — assembly cap reached", category: .session) return } guard let assembly = makeAssembly(burstID: packet.burstID, peerID: peerID, scope: scope, nickname: nickname, timestamp: timestamp) else { return } assemblies[key] = assembly updatePublicTalkerIndicator() apply(packet, to: assembly) case .end, .canceled: // Control packet for a burst we never saw — nothing to do. break } } /// Whether this message is the bubble of a burst still streaming in. func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool { assemblies.values.contains { $0.messageID == message.id } } /// Stop every live file handle/player before the panic media directory is /// removed. This prevents an in-flight assembly from continuing to write /// through an unlinked file after the wipe returns. func resetForPanic() { for assembly in Array(assemblies.values) { cancelAssembly(assembly) } for player in drainingPlayers.values { player.stop() } drainingPlayers.removeAll(keepingCapacity: false) finishedBursts.removeAll(keepingCapacity: false) updatePublicTalkerIndicator() } /// Called for every inbound private message: when it is the finalized /// voice note of a burst we assembled (matched by burst ID in the file /// name), swap it into the existing live bubble and report `true` so the /// caller skips normal handling — no duplicate row, no second /// notification. func absorbFinalizedVoiceNote(_ message: BitchatMessage) -> Bool { let prefix = MimeType.Category.audio.messagePrefix guard message.content.hasPrefix(prefix), let burstID = Self.burstID(fromVoiceFileName: String(message.content.dropFirst(prefix.count))) else { return false } // Bind the note to the burst's authenticated sender and scope: an // attacker's burst reusing the same ID lives under its own key and // never matches the real sender's note (registry is capped at // `finishedBurstsCap`, so the linear scan is cheap). func matches(_ key: AssemblyKey) -> Bool { key.burstID == burstID && (message.senderPeerID == nil || key.peerID == message.senderPeerID) && message.isPrivate == (key.scope == .directMessage) } // The note usually lands after END, but a lost END or a fast transfer // can beat it — close out the live assembly first. if let assembly = assemblies.first(where: { matches($0.key) })?.value { finalize(assembly) } pruneFinishedBursts() guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false } let finished = entry.value // A DM live bubble starts before the finalized file exists and // therefore has a receiver-local random ID. Adopt the finalized // message's deterministic ID so delivery/read ACKs address the same // row as the sender's media placeholder. Public notes retain their // live-bubble ID because public transfers have no private receipts. let replacementID = finished.scope == .directMessage ? message.id : finished.messageID let replacement = BitchatMessage( id: replacementID, sender: message.sender, content: message.content, timestamp: finished.messageTimestamp, isRelay: false, originalSender: nil, isPrivate: finished.scope == .directMessage, recipientNickname: finished.scope == .directMessage ? context.nickname : nil, senderPeerID: finished.peerID, mentions: nil, deliveryStatus: message.deliveryStatus ) switch finished.scope { case .directMessage: // Capture read state before rekeying. The user may have read the // live bubble and navigated away before the finalized .m4a lands. let shouldSendAdoptedReadReceipt = context.hasSentReadReceipt(finished.messageID) || context.selectedPrivateChatPeer == finished.peerID // Insert first so replacing the only row in a DM never // transiently deletes its conversation, unread state, or current // selection. Then remove the receiver-local live-bubble alias. context.upsertPrivateMessage(replacement, in: finished.peerID) if replacementID != finished.messageID { context.removePrivateMessage(withID: finished.messageID) } // The live bubble may already have emitted a receiver-local READ // before the sender created its finalized media row. Re-emit once // for the adopted stable ID now that the file has arrived. if shouldSendAdoptedReadReceipt, context.markReadReceiptSent(replacementID) { let receipt = ReadReceipt( originalMessageID: replacementID, readerID: context.myPeerID, readerNickname: context.nickname ) context.sendMeshReadReceipt(receipt, to: finished.peerID) } case .publicMesh: context.upsertPublicMeshMessage(replacement) } // The complete .m4a replaces the partial live capture. WaveformCache.shared.purge(url: finished.fileURL) try? fileManager.removeItem(at: finished.fileURL) finishedBursts.removeValue(forKey: entry.key) context.notifyUIChanged() SecureLogger.debug("PTT: absorbed finalized note for burst \(burstID.hexEncodedString())", category: .session) return true } // MARK: - Assembly lifecycle private func makeAssembly(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) -> Assembly? { guard let fileURL = makeIncomingURL(burstID: burstID, peerID: peerID, scope: scope) else { SecureLogger.error("PTT: cannot resolve incoming media directory for burst \(burstID.hexEncodedString())", category: .session) return nil } // BCH-01-002: live captures share the incoming-media quota with // finalized transfers; reserve the burst's worst case up front. // Eviction skips voice_live_* names, so partials still streaming in // are safe no matter which caller triggers enforcement. fileStore.enforceQuota(reservingBytes: TransportConfig.pttMaxBurstBytes) fileManager.createFile(atPath: fileURL.path, contents: nil) guard let handle = try? FileHandle(forWritingTo: fileURL) else { SecureLogger.error("PTT: cannot open capture file for burst \(burstID.hexEncodedString())", category: .session) try? fileManager.removeItem(at: fileURL) return nil } let isPrivate = scope == .directMessage let message = BitchatMessage( sender: nickname, content: "\(MimeType.Category.audio.messagePrefix)\(fileURL.lastPathComponent)", timestamp: timestamp, isRelay: false, originalSender: nil, isPrivate: isPrivate, recipientNickname: isPrivate ? context.nickname : nil, senderPeerID: peerID ) let assembly = Assembly( burstID: burstID, peerID: peerID, scope: scope, nickname: nickname, message: message, fileURL: fileURL, fileHandle: handle ) // DM bubbles ride the full inbound pipeline (store append, unread, // notification). Public bubbles append directly to the store: the // batched public pipeline can't purge a buffered entry if the burst // is canceled before the flush. switch scope { case .directMessage: context.handlePrivateMessage(message) case .publicMesh: context.appendPublicMeshMessage(message) } // Live playback only when the user is looking at this conversation // with the app frontmost and live voice enabled. let isViewing = switch scope { case .directMessage: context.selectedPrivateChatPeer == peerID case .publicMesh: context.isViewingPublicMeshTimeline } if PTTSettings.liveVoiceEnabled, PTTSettings.isAppActive, isViewing { assembly.player = PTTBurstPlayer() } SecureLogger.debug("PTT: burst \(burstID.hexEncodedString()) started from \(peerID.id.prefix(8))…", category: .session) return assembly } /// Keeps the composer's floor-courtesy indicator pointing at whoever is /// currently talking live in the public mesh channel. private func updatePublicTalkerIndicator() { let talker = assemblies.values.first { $0.scope == .publicMesh }?.nickname context.setActivePublicVoiceTalker(talker) } private func apply(_ packet: VoiceBurstPacket, to assembly: Assembly) { assembly.receivedBytes += packet.encode().count let elapsed = Date().timeIntervalSince(assembly.firstPacketAt) // Flood guards: a real burst arrives at ~2 KB/s. guard assembly.receivedBytes <= TransportConfig.pttInboundMaxBytesPerSecond * Int(elapsed + 2), assembly.receivedBytes <= TransportConfig.pttMaxBurstBytes else { SecureLogger.warning("PTT: burst from \(assembly.peerID.id.prefix(8))… exceeded rate/size caps — finalizing", category: .security) finalize(assembly) return } rescheduleIdleTimeout(for: assembly) switch packet.kind { case .start(let codec): guard codec == .aacLC16kMono else { // Codec we can't decode: drop the burst; the finalized note // (whose MIME/magic the file handler validates) still arrives. cancelAssembly(assembly) return } case .frames(let frames): guard packet.seq >= assembly.nextSeq, assembly.buffered[packet.seq] == nil else { return } assembly.buffered[packet.seq] = frames drainInOrder(assembly) case .end(let totalDataPackets, let durationMs): assembly.endInfo = (totalDataPackets, durationMs) drainInOrder(assembly) finalizeIfComplete(assembly) case .canceled: cancelAssembly(assembly) } } private func drainInOrder(_ assembly: Assembly) { while true { if let frames = assembly.buffered.removeValue(forKey: assembly.nextSeq) { deliver(frames, to: assembly) assembly.nextSeq &+= 1 assembly.gapSince = nil continue } guard !assembly.buffered.isEmpty else { assembly.gapSince = nil return } // Packets buffered ahead of a hole. if let since = assembly.gapSince { guard Date().timeIntervalSince(since) >= Self.gapSkipSeconds, let smallest = assembly.buffered.keys.min() else { return } // Give up on the missing packet(s); playback underrun already // covered the audible gap. assembly.nextSeq = smallest assembly.gapSince = nil } else { assembly.gapSince = Date() scheduleGapRedrain(for: assembly) return } } } private func deliver(_ frames: [Data], to assembly: Assembly) { for frame in frames { do { try assembly.fileHandle?.write(contentsOf: ADTSFramer.frame(frame)) } catch { SecureLogger.error("PTT: incoming burst write failed: \(error)", category: .session) assembly.fileHandle = nil } } assembly.deliveredFrames += frames.count assembly.player?.enqueue(frames) } private func finalizeIfComplete(_ assembly: Assembly) { guard let end = assembly.endInfo else { return } // All data packets delivered when nextSeq passed the last one // (data seqs are 1...totalDataPackets). Otherwise stragglers may // still arrive; the gap-redrain or idle timeout closes the burst. if assembly.nextSeq > end.totalDataPackets { finalize(assembly) } } private func finalize(_ assembly: Assembly) { assembly.idleTimeout?.cancel() assembly.gapRedrain?.cancel() // Deliver whatever is decodable past any remaining holes. while !assembly.buffered.isEmpty, let smallest = assembly.buffered.keys.min() { assembly.nextSeq = smallest if let frames = assembly.buffered.removeValue(forKey: smallest) { deliver(frames, to: assembly) assembly.nextSeq &+= 1 } } try? assembly.fileHandle?.close() assembly.fileHandle = nil assemblies.removeValue(forKey: assembly.key) updatePublicTalkerIndicator() guard assembly.deliveredFrames > 0 else { // Nothing audible ever arrived — drop the empty bubble. removeBubble(of: assembly) try? fileManager.removeItem(at: assembly.fileURL) context.notifyUIChanged() return } if let player = assembly.player, !player.stopped { // Park the draining player: this method just dropped the // assembly, and nothing else holds the player strongly. It may // still be playing out its tail — or still acquiring the audio // session off-main — so it must stay alive until it stops. let id = ObjectIdentifier(player) drainingPlayers[id] = player player.onStopped = { [weak self] in self?.drainingPlayers.removeValue(forKey: id) } player.finishAfterDrain() } // The bubble's waveform may have been computed from a partial file. WaveformCache.shared.purge(url: assembly.fileURL) // The capture is the bubble's replayable audio from here on (unless a // finalized note arrives to swap in): move it off its voice_live_ // name so only genuinely in-flight files match the startup sweep and // the quota's live-capture guard — a kept fallback is never swept, // and it ages out of the quota like any finalized media. let fileURL = promoteToFallback(assembly.fileURL) // Republish so the row re-renders without its LIVE treatment — and // points at the promoted file — even if no note ever arrives. republishBubble(of: assembly, fileURL: fileURL) pruneFinishedBursts() finishedBursts[assembly.key] = FinishedBurst( messageID: assembly.messageID, peerID: assembly.peerID, scope: assembly.scope, fileURL: fileURL, messageTimestamp: assembly.messageTimestamp, expiresAt: Date().addingTimeInterval(TransportConfig.pttFinishedBurstRegistrySeconds) ) context.notifyUIChanged() SecureLogger.debug("PTT: burst \(assembly.burstID.hexEncodedString()) finalized (\(assembly.deliveredFrames) frames)", category: .session) } private func removeBubble(of assembly: Assembly) { switch assembly.scope { case .directMessage: context.removePrivateMessage(withID: assembly.messageID) case .publicMesh: context.removeMessage(withID: assembly.messageID, cleanupFile: false) } } private func republishBubble(of assembly: Assembly, fileURL: URL) { // Same row (same ID), content re-pointed at `fileURL`; the delivery // status is carried over because the inbound pipeline may have // updated it on the shared original. let message = BitchatMessage( id: assembly.messageID, sender: assembly.nickname, content: "\(MimeType.Category.audio.messagePrefix)\(fileURL.lastPathComponent)", timestamp: assembly.messageTimestamp, isRelay: false, isPrivate: assembly.scope == .directMessage, recipientNickname: assembly.scope == .directMessage ? context.nickname : nil, senderPeerID: assembly.peerID, deliveryStatus: assembly.message.deliveryStatus ) switch assembly.scope { case .directMessage: context.upsertPrivateMessage(message, in: assembly.peerID) case .publicMesh: context.upsertPublicMeshMessage(message) } } /// Moves a finished capture off its `voice_live_` prefix (onto plain /// `voice_`), so the in-flight patterns — the startup sweep and the /// quota's eviction guard — only ever match captures still streaming in. /// On failure the live name is kept: worst case the file is reclaimed at /// the next startup, exactly the pre-promotion behavior. private func promoteToFallback(_ liveURL: URL) -> URL { let liveName = liveURL.lastPathComponent guard liveName.hasPrefix(BLEIncomingFileStore.liveCapturePrefix) else { return liveURL } let fallbackName = "voice_" + liveName.dropFirst(BLEIncomingFileStore.liveCapturePrefix.count) let destination = liveURL.deletingLastPathComponent().appendingPathComponent(fallbackName) do { // A leftover from an earlier burst that reused the same // (peer, scope, burstID) triple would block the move. try? fileManager.removeItem(at: destination) try fileManager.moveItem(at: liveURL, to: destination) return destination } catch { SecureLogger.warning("PTT: keeping live-capture name for finished burst — promotion failed: \(error)", category: .session) return liveURL } } private func cancelAssembly(_ assembly: Assembly) { assembly.idleTimeout?.cancel() assembly.gapRedrain?.cancel() assembly.player?.stop() try? assembly.fileHandle?.close() assembly.fileHandle = nil assemblies.removeValue(forKey: assembly.key) updatePublicTalkerIndicator() removeBubble(of: assembly) WaveformCache.shared.purge(url: assembly.fileURL) try? fileManager.removeItem(at: assembly.fileURL) context.notifyUIChanged() } // MARK: - Timers private func rescheduleIdleTimeout(for assembly: Assembly) { assembly.idleTimeout?.cancel() let key = assembly.key assembly.idleTimeout = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttBurstEndTimeoutSeconds * 1_000_000_000)) guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return } // Talker went silent/out of range without an END. self.finalize(assembly) } } private func scheduleGapRedrain(for assembly: Assembly) { assembly.gapRedrain?.cancel() let key = assembly.key assembly.gapRedrain = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: UInt64((Self.gapSkipSeconds + 0.05) * 1_000_000_000)) guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return } self.drainInOrder(assembly) self.finalizeIfComplete(assembly) } } // MARK: - Helpers private func pruneFinishedBursts() { let now = Date() finishedBursts = finishedBursts.filter { $0.value.expiresAt > now } while finishedBursts.count >= Self.finishedBurstsCap { guard let oldest = finishedBursts.min(by: { $0.value.expiresAt < $1.value.expiresAt }) else { break } finishedBursts.removeValue(forKey: oldest.key) } } /// Extracts the 8-byte burst ID from a finalized note's file name /// (`voice_<16 hex>.m4a`, possibly uniquified by the incoming file store). static func burstID(fromVoiceFileName fileName: String) -> Data? { guard fileName.hasPrefix("voice_") else { return nil } let afterPrefix = fileName.dropFirst("voice_".count) let hex = String(afterPrefix.prefix(16)) guard hex.count == 16, hex.allSatisfy(\.isHexDigit) else { return nil } return Data(hexString: hex) } private static let incomingSubdirectory = "\(MimeType.Category.audio.mediaDir)/incoming" /// The peer ID and scope in the name mirror the assembly key: colliding /// burst IDs from different senders — or from the same sender across DM /// and public — land on distinct files instead of truncating each other. /// `burstID(fromVoiceFileName:)` still rejects every `voice_live_*` name, /// so live captures can never absorb a note. private func makeIncomingURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope) -> URL? { guard let directory = try? fileStore.incomingDirectory(subdirectory: Self.incomingSubdirectory) else { return nil } let scopeTag = scope == .directMessage ? "dm" : "mesh" return directory.appendingPathComponent("\(BLEIncomingFileStore.liveCapturePrefix)\(burstID.hexEncodedString())_\(peerID.id)_\(scopeTag).aac") } /// Deletes partial live captures left behind by a previous session. /// In-session cleanup needs no sweep: absorb, cancel, and empty-finalize /// delete their capture file, and finalize promotes keepers off the /// `voice_live_` name. So anything the sweep matches was orphaned by a /// crash mid-burst — safe to delete, because chat rows are in-memory /// only (ConversationStore never persists; the gossip archive replays /// `MessageType.message` packets only), so no row from a previous /// process can reference the file. private func sweepStaleLiveCaptures() { guard let directory = try? fileStore.incomingDirectory(subdirectory: Self.incomingSubdirectory), let contents = try? fileManager.contentsOfDirectory( at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] ) else { return } for url in contents where url.lastPathComponent.hasPrefix(BLEIncomingFileStore.liveCapturePrefix) && url.pathExtension == "aac" { try? fileManager.removeItem(at: url) } } }