Files
bitchat/bitchatTests/ChatLiveVoiceCoordinatorTests.swift
T

662 lines
36 KiB
Swift

//
// ChatLiveVoiceCoordinatorTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
@MainActor
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
var nickname = "me"
var myPeerID = PeerID(str: "0102030405060708")
var selectedPrivateChatPeer: PeerID?
var isViewingPublicMeshTimeline = false
var blockedPeers: Set<PeerID> = []
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 sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
private(set) var talkerUpdates: [String?] = []
private(set) var privateMutationLog: [String] = []
private var readReceiptMessageIDs: Set<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))
privateMutationLog.append("upsert:\(message.id)")
}
func upsertPublicMeshMessage(_ message: BitchatMessage) {
upsertedPublicMessages.append(message)
}
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
removedMessageIDs.append(messageID)
privateMutationLog.append("remove:\(messageID)")
return nil
}
func hasSentReadReceipt(_ messageID: String) -> Bool {
readReceiptMessageIDs.contains(messageID)
}
func markReadReceiptSent(_ messageID: String) -> Bool {
readReceiptMessageIDs.insert(messageID).inserted
}
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
sentReadReceipts.append((receipt, peerID))
}
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()
context.selectedPrivateChatPeer = peer
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0xB2)
let hex = burstID.hexEncodedString()
let fileName = "voice_\(hex).m4a"
let stableMessageID = try #require(PrivateMediaMessageIdentity.stableID(
senderPeerID: peer,
recipientPeerID: context.myPeerID,
fileName: fileName
))
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)
// The user read the live bubble, then left before the finalized file
// arrived. Stable-ID adoption must preserve that read state.
#expect(context.markReadReceiptSent(bubble.id))
context.selectedPrivateChatPeer = nil
let note = BitchatMessage(
id: stableMessageID,
sender: "alice",
content: "[voice] \(fileName)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: peer
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
// The finalized note adopts the sender-correlatable ID, removes the
// receiver-local live ID, and emits a fresh READ now that the sender
// has created its finalized media row.
let replacement = try #require(context.upsertedMessages.last)
#expect(replacement.message.id == stableMessageID)
#expect(replacement.message.content == note.content)
#expect(replacement.peerID == peer)
#expect(context.removedMessageIDs.contains(bubble.id))
#expect(Array(context.privateMutationLog.suffix(2)) == [
"upsert:\(stableMessageID)",
"remove:\(bubble.id)"
])
#expect(context.sentReadReceipts.count == 1)
#expect(context.sentReadReceipts.first?.receipt.originalMessageID == stableMessageID)
#expect(context.sentReadReceipts.first?.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..<TransportConfig.pttMaxConcurrentAssemblies {
let burstID = makeBurstID(UInt8(0x10 + i))
cleanup.append(burstID)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
}
#expect(context.handledPrivateMessages.count == TransportConfig.pttMaxConcurrentAssemblies)
send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xFF), seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
#expect(context.handledPrivateMessages.count == TransportConfig.pttMaxConcurrentAssemblies)
}
@Test func liveVoiceToggleOffDropsInboundFrames() throws {
let previous = PTTSettings.liveVoiceEnabled
PTTSettings.liveVoiceEnabled = false
defer { PTTSettings.liveVoiceEnabled = previous }
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0xE8)
// Off means classic-notes-only: no live bubble, no partial file.
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 5, count: 40)]))), to: coordinator, from: peer)
#expect(context.handledPrivateMessages.isEmpty)
let url = try #require(incomingFileURL(burstID: burstID, peerID: peer))
#expect(!FileManager.default.fileExists(atPath: url.path))
}
@Test func publicBurstCreatesMeshBubbleAndTracksTalker() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0x71)
defer {
incomingFileURL(burstID: burstID, peerID: peer, scope: .publicMesh).map { try? FileManager.default.removeItem(at: $0) }
fallbackFileURL(burstID: burstID, peerID: peer, scope: .publicMesh).map { try? FileManager.default.removeItem(at: $0) }
}
func sendPublic(_ packet: VoiceBurstPacket) {
coordinator.handlePublicVoiceFramePayload(from: peer, nickname: "bob", payload: packet.encode(), timestamp: Date())
}
sendPublic(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))))
sendPublic(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 3, count: 50)]))))
// Bubble appends straight to the mesh store with the verified
// nickname, and the floor-courtesy indicator names the talker.
#expect(context.handledPrivateMessages.isEmpty)
let bubble = try #require(context.appendedPublicMessages.first)
#expect(!bubble.isPrivate)
#expect(bubble.sender == "bob")
#expect(context.talkerUpdates.last == "bob")
sendPublic(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))))
// Burst over: talker cleared, bubble republished into the mesh store.
#expect(context.talkerUpdates.last == .some(nil))
#expect(context.upsertedPublicMessages.contains { $0.id == bubble.id })
// The broadcast finalized note absorbs into the same bubble.
let note = BitchatMessage(
sender: "bob", content: "[voice] voice_\(burstID.hexEncodedString()).m4a", timestamp: Date(),
isRelay: false, isPrivate: false, senderPeerID: peer
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
let replacement = try #require(context.upsertedPublicMessages.last)
#expect(replacement.id == bubble.id)
#expect(replacement.content == note.content)
#expect(!replacement.isPrivate)
}
@Test func absorbEnforcesScopeBinding() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0x72)
defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } }
// A DM burst...
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 4, count: 40)]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
// ...must not be replaced by a *public* note claiming the same burst.
let publicNote = BitchatMessage(
sender: "alice", content: "[voice] voice_\(burstID.hexEncodedString()).m4a", timestamp: Date(),
isRelay: false, isPrivate: false, senderPeerID: peer
)
#expect(!coordinator.absorbFinalizedVoiceNote(publicNote))
}
@Test func burstIDParsingFromFileNames() {
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_00112233445566ff.m4a") == Data(hexString: "00112233445566ff"))
// Uniquified copies keep the leading hex run.
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_00112233445566ff (1).m4a") == Data(hexString: "00112233445566ff"))
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_20260708_120000.m4a") == nil)
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff.aac") == nil)
// Per-peer, per-scope live capture names must stay unabsorbable too.
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff_aaaabbbbcccc0001.aac") == nil)
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff_aaaabbbbcccc0001_dm.aac") == nil)
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff_aaaabbbbcccc0001_mesh.aac") == nil)
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "other.m4a") == nil)
}
@Test func collidingBurstIDFromAnotherPeerCannotHijackAssembly() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0x73)
let attacker = PeerID(str: "ddddeeeeffff0002")
defer {
fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) }
incomingFileURL(burstID: burstID, peerID: attacker).map { try? FileManager.default.removeItem(at: $0) }
}
// The victim starts a burst; the attacker races a START reusing the
// observed burst ID from a different peer.
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: attacker)
// Two distinct bubbles on two distinct files — no capture.
#expect(context.handledPrivateMessages.count == 2)
let victimBubble = try #require(context.handledPrivateMessages.first)
let attackerBubble = try #require(context.handledPrivateMessages.last)
#expect(victimBubble.id != attackerBubble.id)
#expect(victimBubble.content != attackerBubble.content)
// The victim's frames still land in the victim's file, untouched by
// the attacker's assembly.
let victimFrame = Data(repeating: 0x0A, count: 60)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([victimFrame]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
let victimURL = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
#expect(try Data(contentsOf: victimURL) == ADTSFramer.frame(victimFrame))
let attackerURL = try #require(incomingFileURL(burstID: burstID, peerID: attacker))
#expect((try? Data(contentsOf: attackerURL))?.isEmpty == true)
#expect(!coordinator.isLiveVoiceMessage(victimBubble))
#expect(coordinator.isLiveVoiceMessage(attackerBubble))
}
@Test func sameBurstIDCoexistsAcrossScopes() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0x74)
defer {
fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) }
fallbackFileURL(burstID: burstID, peerID: peer, scope: .publicMesh).map { try? FileManager.default.removeItem(at: $0) }
}
// A DM burst and a public burst reusing the same burst ID open
// independent assemblies instead of colliding.
let dmFrame = Data(repeating: 6, count: 50)
let publicFrame = Data(repeating: 7, count: 50)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([dmFrame]))), to: coordinator, from: peer)
coordinator.handlePublicVoiceFramePayload(
from: peer,
nickname: "bob",
payload: try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([publicFrame]))).encode(),
timestamp: Date()
)
#expect(context.handledPrivateMessages.count == 1)
#expect(context.appendedPublicMessages.count == 1)
let dmBubble = try #require(context.handledPrivateMessages.first)
let publicBubble = try #require(context.appendedPublicMessages.first)
// Ending the public burst leaves the DM assembly live.
coordinator.handlePublicVoiceFramePayload(
from: peer,
nickname: "bob",
payload: try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))).encode(),
timestamp: Date()
)
#expect(!coordinator.isLiveVoiceMessage(publicBubble))
#expect(coordinator.isLiveVoiceMessage(dmBubble))
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
#expect(!coordinator.isLiveVoiceMessage(dmBubble))
// The scope in the file name keeps the two captures on distinct
// paths: both survive with intact contents instead of one truncating
// or deleting the other (both promoted off their live names by now).
let dmURL = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
let publicURL = try #require(fallbackFileURL(burstID: burstID, peerID: peer, scope: .publicMesh))
#expect(dmURL != publicURL)
#expect(try Data(contentsOf: dmURL) == ADTSFramer.frame(dmFrame))
#expect(try Data(contentsOf: publicURL) == ADTSFramer.frame(publicFrame))
// Absorption still routes each note by its scope.
let dmNote = BitchatMessage(
sender: "alice", content: "[voice] voice_\(burstID.hexEncodedString()).m4a", timestamp: Date(),
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
)
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
#expect(try #require(context.upsertedMessages.last).message.id == dmNote.id)
#expect(context.removedMessageIDs.contains(dmBubble.id))
}
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0x75)
let hex = burstID.hexEncodedString()
let attacker = PeerID(str: "ddddeeeeffff0002")
defer {
fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) }
fallbackFileURL(burstID: burstID, peerID: attacker).map { try? FileManager.default.removeItem(at: $0) }
}
// The attacker's colliding burst finishes FIRST, then the victim's.
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 1, count: 40)]))), to: coordinator, from: attacker)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: attacker)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 2, count: 40)]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
#expect(context.handledPrivateMessages.count == 2)
let attackerBubble = try #require(context.handledPrivateMessages.first)
let victimBubble = try #require(context.handledPrivateMessages.last)
// The real sender's note absorbs into the real sender's bubble.
let note = BitchatMessage(
sender: "alice", content: "[voice] voice_\(hex).m4a", timestamp: Date(),
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
let replacement = try #require(context.upsertedMessages.last)
#expect(replacement.message.id == note.id)
#expect(replacement.peerID == peer)
#expect(context.removedMessageIDs.contains(victimBubble.id))
// The attacker's note can only ever claim the attacker's own bubble.
let attackerNote = BitchatMessage(
sender: "mallory", content: "[voice] voice_\(hex).m4a", timestamp: Date(),
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: attacker
)
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
let attackerReplacement = try #require(context.upsertedMessages.last)
#expect(attackerReplacement.message.id == attackerNote.id)
#expect(attackerReplacement.peerID == attacker)
#expect(context.removedMessageIDs.contains(attackerBubble.id))
// Both registry entries are consumed — nothing left to hijack.
#expect(!coordinator.absorbFinalizedVoiceNote(note))
}
@Test func liveBurstEnforcesIncomingMediaQuota() throws {
let (store, incoming, cleanup) = try makeTempStore()
defer { cleanup() }
// Pre-seed enough finalized media that the burst's reservation pushes
// usage over the 100 MB quota: the oldest file must be evicted.
let oldURL = incoming.appendingPathComponent("voice_old.m4a")
let newURL = incoming.appendingPathComponent("voice_new.m4a")
try Data(count: 60 * 1024 * 1024).write(to: oldURL)
try Data(count: 45 * 1024 * 1024).write(to: newURL)
try setModificationDate(Date(timeIntervalSinceNow: -3600), at: oldURL)
try setModificationDate(Date(timeIntervalSinceNow: -60), at: newURL)
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store)
let burstID = makeBurstID(0x76)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
// Oldest evicted, newer survivor + reservation fit under the quota,
// and the capture file lives under the injected store's base.
#expect(!FileManager.default.fileExists(atPath: oldURL.path))
#expect(FileManager.default.fileExists(atPath: newURL.path))
let liveURL = incoming.appendingPathComponent(liveCaptureName(burstID: burstID, peerID: peer))
#expect(FileManager.default.fileExists(atPath: liveURL.path))
let remaining = try FileManager.default.contentsOfDirectory(at: incoming, includingPropertiesForKeys: [.fileSizeKey])
.compactMap { try $0.resourceValues(forKeys: [.fileSizeKey]).fileSize }
.reduce(0, +)
#expect(remaining + TransportConfig.pttMaxBurstBytes <= 100 * 1024 * 1024)
}
@Test func inFlightLiveCaptureIsNeverEvicted() throws {
let (store, incoming, cleanup) = try makeTempStore()
defer { cleanup() }
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store)
let burstID = makeBurstID(0x77)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 8, count: 60)]))), to: coordinator, from: peer)
let liveURL = incoming.appendingPathComponent(liveCaptureName(burstID: burstID, peerID: peer))
#expect(FileManager.default.fileExists(atPath: liveURL.path))
// Make the streaming partial the LRU-oldest candidate, then blow the
// quota and open a second burst to trigger eviction.
try setModificationDate(Date(timeIntervalSinceNow: -7200), at: liveURL)
let bigURL = incoming.appendingPathComponent("voice_big.m4a")
try Data(count: 105 * 1024 * 1024).write(to: bigURL)
send(try #require(VoiceBurstPacket(burstID: makeBurstID(0x78), seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
// Eviction skipped the in-flight partial and deleted the big file.
#expect(FileManager.default.fileExists(atPath: liveURL.path))
#expect(!FileManager.default.fileExists(atPath: bigURL.path))
}
@Test func startupSweepRemovesStaleLiveCapturesOnly() throws {
let (store, incoming, cleanup) = try makeTempStore()
defer { cleanup() }
// A partial capture orphaned by a previous session; a finalized note
// and a promoted fallback capture that must both survive the sweep.
let stale = incoming.appendingPathComponent("voice_live_00aa00aa00aa00aa_ddddeeeeffff0002_dm.aac")
let finalized = incoming.appendingPathComponent("voice_00112233445566ff.m4a")
let promoted = incoming.appendingPathComponent("voice_00bb00bb00bb00bb_ddddeeeeffff0002_dm.aac")
try Data([0x01]).write(to: stale)
try Data([0x02]).write(to: finalized)
try Data([0x03]).write(to: promoted)
let context = MockChatLiveVoiceContext()
_ = ChatLiveVoiceCoordinator(context: context, fileStore: store)
#expect(!FileManager.default.fileExists(atPath: stale.path))
#expect(FileManager.default.fileExists(atPath: finalized.path))
#expect(FileManager.default.fileExists(atPath: promoted.path))
}
@Test func finalizedFallbackSurvivesNextStartupSweep() throws {
let (store, incoming, cleanup) = try makeTempStore()
defer { cleanup() }
// A burst finishes without its finalized note (live-only burst or
// sender out of range): the capture is the row's only audio.
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store)
let burstID = makeBurstID(0x79)
let frame = Data(repeating: 0x0B, count: 60)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([frame]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
// The republished row points at the promoted (non-live) name, and
// that file holds the burst's audio.
let republished = try #require(context.upsertedMessages.last)
#expect(republished.message.content == "[voice] \(fallbackName(burstID: burstID, peerID: peer))")
let fallbackURL = incoming.appendingPathComponent(fallbackName(burstID: burstID, peerID: peer))
#expect(try Data(contentsOf: fallbackURL) == ADTSFramer.frame(frame))
// A later coordinator startup (same store) sweeps in-flight partials
// only: the row's fallback audio must survive and stay playable.
_ = ChatLiveVoiceCoordinator(context: MockChatLiveVoiceContext(), fileStore: store)
#expect(try Data(contentsOf: fallbackURL) == ADTSFramer.frame(frame))
}
@Test func promotedFallbackIsQuotaEvictable() throws {
let (store, incoming, cleanup) = try makeTempStore()
defer { cleanup() }
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store)
let burstID = makeBurstID(0x7A)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 0x0C, count: 60)]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
let fallbackURL = incoming.appendingPathComponent(fallbackName(burstID: burstID, peerID: peer))
#expect(FileManager.default.fileExists(atPath: fallbackURL.path))
// Once promoted, the capture is ordinary finalized media: as the
// LRU-oldest file it is evicted, not skipped, when quota pressure
// arrives.
try setModificationDate(Date(timeIntervalSinceNow: -7200), at: fallbackURL)
let bigURL = incoming.appendingPathComponent("voice_big.m4a")
try Data(count: 105 * 1024 * 1024).write(to: bigURL)
store.enforceQuota(reservingBytes: 0)
#expect(!FileManager.default.fileExists(atPath: fallbackURL.path))
}
}