// // ChatLiveVoiceCoordinatorTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import Testing import Foundation import BitFoundation @testable import bitchat @MainActor private final class MockChatLiveVoiceContext: ChatLiveVoiceContext { var nickname = "me" var selectedPrivateChatPeer: PeerID? var isViewingPublicMeshTimeline = false var blockedPeers: Set = [] private(set) var handledPrivateMessages: [BitchatMessage] = [] private(set) var appendedPublicMessages: [BitchatMessage] = [] private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = [] private(set) var upsertedPublicMessages: [BitchatMessage] = [] private(set) var removedMessageIDs: [String] = [] private(set) var talkerUpdates: [String?] = [] func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } func resolveNickname(for peerID: PeerID) -> String { "alice" } func handlePrivateMessage(_ message: BitchatMessage) { handledPrivateMessages.append(message) } func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) } func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) { upsertedMessages.append((message, peerID)) } func upsertPublicMeshMessage(_ message: BitchatMessage) { upsertedPublicMessages.append(message) } @discardableResult func removePrivateMessage(withID messageID: String) -> BitchatMessage? { removedMessageIDs.append(messageID) return nil } func removeMessage(withID messageID: String, cleanupFile: Bool) { removedMessageIDs.append(messageID) } func setActivePublicVoiceTalker(_ nickname: String?) { if talkerUpdates.last ?? nil != nickname { talkerUpdates.append(nickname) } } func notifyUIChanged() {} } @MainActor struct ChatLiveVoiceCoordinatorTests { private let peer = PeerID(str: "aaaabbbbcccc0001") private func makeBurstID(_ fill: UInt8) -> Data { Data(repeating: fill, count: VoiceBurstPacket.burstIDSize) } private func send(_ packet: VoiceBurstPacket, to coordinator: ChatLiveVoiceCoordinator, from peerID: PeerID) { coordinator.handleVoiceFramePayload(from: peerID, payload: packet.encode(), timestamp: Date()) } 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(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, sweepsOnInit: false) let burstID = makeBurstID(0xA1) 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) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) #expect(context.handledPrivateMessages.count == 1) let bubble = try #require(context.handledPrivateMessages.first) #expect(bubble.isPrivate) #expect(bubble.senderPeerID == peer) #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 // seq 1 releases both in order. send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .frames([frame2]))), to: coordinator, from: peer) 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) // 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 pointing at the // promoted file. #expect(!coordinator.isLiveVoiceMessage(bubble)) 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, sweepsOnInit: false) let burstID = makeBurstID(0xB2) let hex = burstID.hexEncodedString() send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer) let bubble = try #require(context.handledPrivateMessages.first) let note = BitchatMessage( sender: "alice", content: "[voice] voice_\(hex).m4a", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer ) #expect(coordinator.absorbFinalizedVoiceNote(note)) // The note replaced the live bubble in place: same message ID, new // content, partial capture deleted. let replacement = try #require(context.upsertedMessages.last) #expect(replacement.message.id == bubble.id) #expect(replacement.message.content == note.content) #expect(replacement.peerID == peer) // 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. #expect(!coordinator.absorbFinalizedVoiceNote(note)) } @Test func absorbIgnoresUnrelatedVoiceNotes() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) // A classic voice note (date-stamped name) and a live-capture name // must both pass through untouched. let classic = BitchatMessage( sender: "alice", content: "[voice] voice_20260708_1201.m4a", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer ) #expect(!coordinator.absorbFinalizedVoiceNote(classic)) let liveCapture = BitchatMessage( sender: "alice", content: "[voice] voice_live_aabbccdd00112233.aac", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer ) #expect(!coordinator.absorbFinalizedVoiceNote(liveCapture)) // Unknown burst ID. let unknown = BitchatMessage( sender: "alice", content: "[voice] voice_ffffffffffffffff.m4a", timestamp: Date(), isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer ) #expect(!coordinator.absorbFinalizedVoiceNote(unknown)) } @Test func canceledBurstRemovesBubbleAndFile() throws { let context = MockChatLiveVoiceContext() 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) let bubble = try #require(context.handledPrivateMessages.first) 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, 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, sweepsOnInit: false) let burstID = makeBurstID(0xD4) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) let bubble = try #require(context.handledPrivateMessages.first) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .end(totalDataPackets: 0, durationMs: 0))), to: coordinator, from: peer) // Nothing audible arrived: the placeholder bubble is withdrawn. #expect(context.removedMessageIDs == [bubble.id]) } @Test func ignoresBlockedPeersAndUnknownControlPackets() throws { let context = MockChatLiveVoiceContext() 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) #expect(context.handledPrivateMessages.isEmpty) context.blockedPeers = [] // END/CANCELED for a burst that never started must not create state. send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE6), seq: 5, kind: .end(totalDataPackets: 4, durationMs: 256))), to: coordinator, from: peer) send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE7), seq: 5, kind: .canceled)), to: coordinator, from: peer) #expect(context.handledPrivateMessages.isEmpty) } @Test func concurrentAssemblyCapDropsExtraBursts() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) var cleanup: [Data] = [] defer { for burstID in cleanup { incomingFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } } } for i in 0..