diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index 3379cfe5..214c3ba2 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -5,7 +5,16 @@ import Foundation struct BLEIncomingFileStore { private static let quotaBytes: Int64 = 100 * 1024 * 1024 - private let fileManager: FileManager + /// Name prefix of in-flight live voice captures (progressively written by + /// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern — + /// deleting one mid-stream unlinks the inode under an open `FileHandle` + /// and kills playback — and the coordinator's startup sweep deletes any + /// orphans a previous session left behind. + static let liveCapturePrefix = "voice_live_" + + /// Exposed so callers that write progressively into the store's + /// directories (live voice captures) share the same file manager. + let fileManager: FileManager private let baseDirectory: URL? private let dateProvider: () -> Date @@ -15,6 +24,14 @@ struct BLEIncomingFileStore { self.dateProvider = dateProvider } + /// Resolves (and creates) an incoming-media directory for callers that + /// write progressively instead of via `save` (live voice captures). + func incomingDirectory(subdirectory: String) throws -> URL { + let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + return directory + } + func save( data: Data, preferredName: String?, @@ -39,6 +56,11 @@ struct BLEIncomingFileStore { } } + /// Frees least-recently-modified incoming files until `reservingBytes` + /// fits under the quota. Files named `voice_live_*` (in-flight live + /// captures) are never evicted regardless of who triggers enforcement — + /// a finalized transfer can arrive at quota while a burst is still + /// streaming — but they still count toward usage. func enforceQuota(reservingBytes: Int) { do { let base = try filesDirectory() @@ -72,6 +94,7 @@ struct BLEIncomingFileStore { var freedSpace: Int64 = 0 for file in allFiles.sorted(by: { $0.modified < $1.modified }) { guard freedSpace < needToFree else { break } + guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue } do { try fileManager.removeItem(at: file.url) freedSpace += file.size diff --git a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift index 62f13653..8f4baded 100644 --- a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift +++ b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift @@ -59,7 +59,7 @@ extension ChatViewModel: ChatLiveVoiceContext { } /// Where a live voice burst lives: a Noise DM or the public mesh timeline. -enum VoiceBurstScope: Equatable { +enum VoiceBurstScope: Hashable { case directMessage case publicMesh } @@ -72,6 +72,16 @@ enum VoiceBurstScope: Equatable { /// 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 @@ -97,6 +107,8 @@ final class ChatLiveVoiceCoordinator { 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 @@ -123,11 +135,26 @@ final class ChatLiveVoiceCoordinator { private static let finishedBurstsCap = 32 private unowned let context: any ChatLiveVoiceContext - private var assemblies: [Data: Assembly] = [:] - private var finishedBursts: [Data: FinishedBurst] = [:] + 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] = [:] - init(context: any ChatLiveVoiceContext) { + /// `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 @@ -160,11 +187,12 @@ final class ChatLiveVoiceCoordinator { return } - if let assembly = assemblies[packet.burstID] { - // The sender is authenticated (Noise session or packet - // signature); a different peer or scope reusing the same burst - // ID is a collision or a replay — drop it. - guard assembly.peerID == peerID, assembly.scope == scope else { 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 } @@ -178,7 +206,7 @@ final class ChatLiveVoiceCoordinator { return } guard let assembly = makeAssembly(burstID: packet.burstID, peerID: peerID, scope: scope, nickname: nickname, timestamp: timestamp) else { return } - assemblies[packet.burstID] = assembly + assemblies[key] = assembly updatePublicTalkerIndicator() apply(packet, to: assembly) case .end, .canceled: @@ -203,17 +231,25 @@ final class ChatLiveVoiceCoordinator { 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[burstID] { + if let assembly = assemblies.first(where: { matches($0.key) })?.value { finalize(assembly) } pruneFinishedBursts() - guard let finished = finishedBursts[burstID] else { return false } - // Bind the note to the burst's authenticated sender and scope. - guard message.senderPeerID == nil || message.senderPeerID == finished.peerID else { return false } - guard message.isPrivate == (finished.scope == .directMessage) else { return false } + guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false } + let finished = entry.value let replacement = BitchatMessage( id: finished.messageID, @@ -237,8 +273,8 @@ final class ChatLiveVoiceCoordinator { // The complete .m4a replaces the partial live capture. WaveformCache.shared.purge(url: finished.fileURL) - try? FileManager.default.removeItem(at: finished.fileURL) - finishedBursts.removeValue(forKey: burstID) + 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) @@ -248,14 +284,19 @@ final class ChatLiveVoiceCoordinator { // MARK: - Assembly lifecycle private func makeAssembly(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) -> Assembly? { - guard let fileURL = Self.makeIncomingURL(burstID: burstID) else { + 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 } - FileManager.default.createFile(atPath: fileURL.path, contents: 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.default.removeItem(at: fileURL) + try? fileManager.removeItem(at: fileURL) return nil } @@ -413,13 +454,13 @@ final class ChatLiveVoiceCoordinator { } try? assembly.fileHandle?.close() assembly.fileHandle = nil - assemblies.removeValue(forKey: assembly.burstID) + assemblies.removeValue(forKey: assembly.key) updatePublicTalkerIndicator() guard assembly.deliveredFrames > 0 else { // Nothing audible ever arrived — drop the empty bubble. removeBubble(of: assembly) - try? FileManager.default.removeItem(at: assembly.fileURL) + try? fileManager.removeItem(at: assembly.fileURL) context.notifyUIChanged() return } @@ -427,16 +468,22 @@ final class ChatLiveVoiceCoordinator { assembly.player?.finishAfterDrain() // The bubble's waveform may have been computed from a partial file. WaveformCache.shared.purge(url: assembly.fileURL) - // Republish so the row re-renders without its LIVE treatment even if - // no finalized note ever arrives to swap in. - republishBubble(of: assembly) + // 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.burstID] = FinishedBurst( + finishedBursts[assembly.key] = FinishedBurst( messageID: assembly.messageID, peerID: assembly.peerID, scope: assembly.scope, - fileURL: assembly.fileURL, + fileURL: fileURL, messageTimestamp: assembly.messageTimestamp, expiresAt: Date().addingTimeInterval(TransportConfig.pttFinishedBurstRegistrySeconds) ) @@ -453,12 +500,48 @@ final class ChatLiveVoiceCoordinator { } } - private func republishBubble(of assembly: Assembly) { + 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(assembly.message, in: assembly.peerID) + context.upsertPrivateMessage(message, in: assembly.peerID) case .publicMesh: - context.upsertPublicMeshMessage(assembly.message) + 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 } } @@ -468,11 +551,11 @@ final class ChatLiveVoiceCoordinator { assembly.player?.stop() try? assembly.fileHandle?.close() assembly.fileHandle = nil - assemblies.removeValue(forKey: assembly.burstID) + assemblies.removeValue(forKey: assembly.key) updatePublicTalkerIndicator() removeBubble(of: assembly) WaveformCache.shared.purge(url: assembly.fileURL) - try? FileManager.default.removeItem(at: assembly.fileURL) + try? fileManager.removeItem(at: assembly.fileURL) context.notifyUIChanged() } @@ -480,10 +563,10 @@ final class ChatLiveVoiceCoordinator { private func rescheduleIdleTimeout(for assembly: Assembly) { assembly.idleTimeout?.cancel() - let burstID = assembly.burstID + 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[burstID] else { return } + guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return } // Talker went silent/out of range without an END. self.finalize(assembly) } @@ -491,10 +574,10 @@ final class ChatLiveVoiceCoordinator { private func scheduleGapRedrain(for assembly: Assembly) { assembly.gapRedrain?.cancel() - let burstID = assembly.burstID + 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[burstID] else { return } + guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return } self.drainInOrder(assembly) self.finalizeIfComplete(assembly) } @@ -521,21 +604,37 @@ final class ChatLiveVoiceCoordinator { return Data(hexString: hex) } - private static func makeIncomingURL(burstID: Data) -> URL? { - guard let base = try? FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) else { return nil } - let directory = base - .appendingPathComponent("files", isDirectory: true) - .appendingPathComponent("\(MimeType.Category.audio.mediaDir)/incoming", isDirectory: true) - do { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) - } catch { - return nil + 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) } - return directory.appendingPathComponent("voice_live_\(burstID.hexEncodedString()).aac") } } diff --git a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift index 52f5172b..18e25ef4 100644 --- a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift +++ b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift @@ -63,20 +63,57 @@ struct ChatLiveVoiceCoordinatorTests { coordinator.handleVoiceFramePayload(from: peerID, payload: packet.encode(), timestamp: Date()) } - private func incomingFileURL(burstID: Data) -> URL? { + private func captureSuffix(burstID: Data, peerID: PeerID, scope: VoiceBurstScope) -> String { + "\(burstID.hexEncodedString())_\(peerID.id)_\(scope == .directMessage ? "dm" : "mesh").aac" + } + + /// Name of a capture still streaming in. + private func liveCaptureName(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> String { + "voice_live_" + captureSuffix(burstID: burstID, peerID: peerID, scope: scope) + } + + /// Name a finished capture is promoted to when it becomes the bubble's + /// replayable fallback. + private func fallbackName(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> String { + "voice_" + captureSuffix(burstID: burstID, peerID: peerID, scope: scope) + } + + private func incomingFileURL(named name: String) -> URL? { guard let base = try? FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false ) else { return nil } return base .appendingPathComponent("files/voicenotes/incoming", isDirectory: true) - .appendingPathComponent("voice_live_\(burstID.hexEncodedString()).aac") + .appendingPathComponent(name) + } + + private func incomingFileURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> URL? { + incomingFileURL(named: liveCaptureName(burstID: burstID, peerID: peerID, scope: scope)) + } + + private func fallbackFileURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> URL? { + incomingFileURL(named: fallbackName(burstID: burstID, peerID: peerID, scope: scope)) + } + + /// Fresh store rooted in its own temp directory so quota/sweep tests + /// never touch the shared application-support media directories. + private func makeTempStore() throws -> (store: BLEIncomingFileStore, incoming: URL, cleanup: () -> Void) { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("ptt-store-\(UUID().uuidString)", isDirectory: true) + let store = BLEIncomingFileStore(baseDirectory: base) + let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming") + return (store, incoming, { try? FileManager.default.removeItem(at: base) }) + } + + private func setModificationDate(_ date: Date, at url: URL) throws { + try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) } @Test func burstCreatesBubbleAndPersistsFramesInOrder() throws { let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context) + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) let burstID = makeBurstID(0xA1) - defer { incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } } + defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } } let frame1 = Data(repeating: 0x01, count: 60) let frame2 = Data(repeating: 0x02, count: 60) @@ -86,7 +123,7 @@ struct ChatLiveVoiceCoordinatorTests { let bubble = try #require(context.handledPrivateMessages.first) #expect(bubble.isPrivate) #expect(bubble.senderPeerID == peer) - #expect(bubble.content == "[voice] voice_live_\(burstID.hexEncodedString()).aac") + #expect(bubble.content == "[voice] voice_live_\(burstID.hexEncodedString())_\(peer.id)_dm.aac") #expect(coordinator.isLiveVoiceMessage(bubble)) // Deliver out of order: seq 2 buffers behind the seq-1 hole, then @@ -95,21 +132,26 @@ struct ChatLiveVoiceCoordinatorTests { send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([frame1]))), to: coordinator, from: peer) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 3, kind: .end(totalDataPackets: 2, durationMs: 128))), to: coordinator, from: peer) - let url = try #require(incomingFileURL(burstID: burstID)) + // The finished capture is promoted off its voice_live_ name. + let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer)) let written = try Data(contentsOf: url) var expected = ADTSFramer.frame(frame1) expected.append(ADTSFramer.frame(frame2)) #expect(written == expected) + let liveURL = try #require(incomingFileURL(burstID: burstID, peerID: peer)) + #expect(!FileManager.default.fileExists(atPath: liveURL.path)) - // Burst ended: no longer live, bubble republished for re-render. + // Burst ended: no longer live, bubble republished pointing at the + // promoted file. #expect(!coordinator.isLiveVoiceMessage(bubble)) - #expect(context.upsertedMessages.contains { $0.message.id == bubble.id }) + let republished = try #require(context.upsertedMessages.last { $0.message.id == bubble.id }) + #expect(republished.message.content == "[voice] \(fallbackName(burstID: burstID, peerID: peer))") #expect(context.removedMessageIDs.isEmpty) } @Test func absorbsFinalizedNoteIntoLiveBubble() throws { let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context) + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) let burstID = makeBurstID(0xB2) let hex = burstID.hexEncodedString() @@ -134,7 +176,8 @@ struct ChatLiveVoiceCoordinatorTests { #expect(replacement.message.id == bubble.id) #expect(replacement.message.content == note.content) #expect(replacement.peerID == peer) - let url = try #require(incomingFileURL(burstID: burstID)) + // The promoted partial capture is deleted in favor of the note. + let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer)) #expect(!FileManager.default.fileExists(atPath: url.path)) // Absorption is one-shot. @@ -143,7 +186,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func absorbIgnoresUnrelatedVoiceNotes() throws { let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context) + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) // A classic voice note (date-stamped name) and a live-capture name // must both pass through untouched. @@ -167,7 +210,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func canceledBurstRemovesBubbleAndFile() throws { let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context) + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) let burstID = makeBurstID(0xC3) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 9, count: 40)]))), to: coordinator, from: peer) @@ -175,14 +218,14 @@ struct ChatLiveVoiceCoordinatorTests { send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .canceled)), to: coordinator, from: peer) #expect(context.removedMessageIDs == [bubble.id]) - let url = try #require(incomingFileURL(burstID: burstID)) + let url = try #require(incomingFileURL(burstID: burstID, peerID: peer)) #expect(!FileManager.default.fileExists(atPath: url.path)) #expect(!coordinator.isLiveVoiceMessage(bubble)) } @Test func emptyBurstLeavesNoBubble() throws { let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context) + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) let burstID = makeBurstID(0xD4) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) @@ -195,7 +238,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func ignoresBlockedPeersAndUnknownControlPackets() throws { let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context) + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) context.blockedPeers = [peer] send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE5), seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) @@ -210,12 +253,12 @@ struct ChatLiveVoiceCoordinatorTests { @Test func concurrentAssemblyCapDropsExtraBursts() throws { let context = MockChatLiveVoiceContext() - let coordinator = ChatLiveVoiceCoordinator(context: context) + let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) var cleanup: [Data] = [] defer { for burstID in cleanup { - incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } + incomingFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } } } for i in 0..