mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Live push-to-talk defaulted ON in both directions: holding the mic in a public channel streamed your voice to everyone in radio range before you released, and inbound bursts auto-played out of the speaker. A single buried toggle defaulting on was not consent — both behaviors are now opt-in (PTTSettings.liveVoiceEnabled defaults false), and the settings copy says exactly what turning it on does. Recording could also not be aborted one-handed: release always sent, and the HUD's cancel button required lifting the finger — which sent. Sliding the finger off the mic button (>60pt) now arms cancel — the HUD flips from the timer to "release to cancel" — and sliding back re-arms send. The armed flag resets on every exit path (cancel, finish, panic), pinned by a new test. 2 new strings + 1 updated, all 30 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
488 lines
18 KiB
Swift
488 lines
18 KiB
Swift
//
|
|
// VoiceRecorderTests.swift
|
|
// bitchatTests
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import Foundation
|
|
import Testing
|
|
@testable import bitchat
|
|
|
|
/// One-shot event that bridges synchronous production seams to async tests
|
|
/// without blocking a shared dispatch worker while waiting for the seam.
|
|
private final class VoiceRecorderAsyncEvent: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var isSignaled = false
|
|
private var waiters: [CheckedContinuation<Void, Never>] = []
|
|
|
|
func wait() async {
|
|
await withCheckedContinuation { continuation in
|
|
let resumeImmediately = lock.withLock { () -> Bool in
|
|
guard !isSignaled else { return true }
|
|
waiters.append(continuation)
|
|
return false
|
|
}
|
|
if resumeImmediately {
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
func signal() {
|
|
let continuations = lock.withLock { () -> [CheckedContinuation<Void, Never>] in
|
|
guard !isSignaled else { return [] }
|
|
isSignaled = true
|
|
defer { waiters.removeAll() }
|
|
return waiters
|
|
}
|
|
continuations.forEach { $0.resume() }
|
|
}
|
|
}
|
|
|
|
private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private let activationGate = DispatchSemaphore(value: 0)
|
|
private let activationBegan = VoiceRecorderAsyncEvent()
|
|
private let shouldGateFirstActivation: Bool
|
|
private var gatedFirstActivation = false
|
|
private var _activationCalls: [Bool] = []
|
|
|
|
init(gateFirstActivation: Bool = false) {
|
|
self.shouldGateFirstActivation = gateFirstActivation
|
|
}
|
|
|
|
var activationCalls: [Bool] { lock.withLock { _activationCalls } }
|
|
|
|
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
|
|
|
|
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
|
|
let shouldWait = lock.withLock { () -> Bool in
|
|
_activationCalls.append(active)
|
|
guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false }
|
|
gatedFirstActivation = true
|
|
return true
|
|
}
|
|
if shouldWait {
|
|
activationBegan.signal()
|
|
activationGate.wait()
|
|
}
|
|
}
|
|
|
|
func waitUntilActivationBegan() async {
|
|
await activationBegan.wait()
|
|
}
|
|
|
|
func resumeActivation() {
|
|
activationGate.signal()
|
|
}
|
|
}
|
|
|
|
private final class TestVoiceAudioRecorder: VoiceAudioRecording {
|
|
let prepareResult: Bool
|
|
let recordResult: Bool
|
|
|
|
private let lock = NSLock()
|
|
private var _isRecording = false
|
|
private var _isMeteringEnabled = false
|
|
private var _prepareCallCount = 0
|
|
private var _recordedDurations: [TimeInterval] = []
|
|
private var _stopCallCount = 0
|
|
|
|
init(prepareResult: Bool, recordResult: Bool) {
|
|
self.prepareResult = prepareResult
|
|
self.recordResult = recordResult
|
|
}
|
|
|
|
var isRecording: Bool { lock.withLock { _isRecording } }
|
|
var isMeteringEnabled: Bool {
|
|
get { lock.withLock { _isMeteringEnabled } }
|
|
set { lock.withLock { _isMeteringEnabled = newValue } }
|
|
}
|
|
var prepareCallCount: Int { lock.withLock { _prepareCallCount } }
|
|
var recordedDurations: [TimeInterval] { lock.withLock { _recordedDurations } }
|
|
var stopCallCount: Int { lock.withLock { _stopCallCount } }
|
|
|
|
func prepareToRecord() -> Bool {
|
|
lock.withLock { _prepareCallCount += 1 }
|
|
return prepareResult
|
|
}
|
|
|
|
func record(forDuration duration: TimeInterval) -> Bool {
|
|
lock.withLock {
|
|
_recordedDurations.append(duration)
|
|
if recordResult {
|
|
_isRecording = true
|
|
}
|
|
}
|
|
return recordResult
|
|
}
|
|
|
|
func stop() {
|
|
lock.withLock {
|
|
_stopCallCount += 1
|
|
_isRecording = false
|
|
}
|
|
}
|
|
|
|
/// Models `record(forDuration:)` reaching its duration cap before the
|
|
/// caller invokes `VoiceRecorder.stopRecording(owner:)`.
|
|
func simulateAutomaticStop() {
|
|
lock.withLock { _isRecording = false }
|
|
}
|
|
}
|
|
|
|
private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
|
|
struct Plan {
|
|
let prepareResult: Bool
|
|
let recordResult: Bool
|
|
|
|
static let success = Plan(prepareResult: true, recordResult: true)
|
|
}
|
|
|
|
private let lock = NSLock()
|
|
private var plans: [Plan]
|
|
private var _recorders: [TestVoiceAudioRecorder] = []
|
|
private var _urls: [URL] = []
|
|
|
|
init(plans: [Plan]) {
|
|
self.plans = plans
|
|
}
|
|
|
|
var recorders: [TestVoiceAudioRecorder] { lock.withLock { _recorders } }
|
|
var urls: [URL] { lock.withLock { _urls } }
|
|
|
|
func makeRecorder(url: URL) throws -> any VoiceAudioRecording {
|
|
let plan = lock.withLock { plans.isEmpty ? .success : plans.removeFirst() }
|
|
// AVAudioRecorder creates its output during initialization. A real
|
|
// byte on disk lets the tests distinguish preserve from delete.
|
|
try Data([0x01]).write(to: url)
|
|
let recorder = TestVoiceAudioRecorder(
|
|
prepareResult: plan.prepareResult,
|
|
recordResult: plan.recordResult
|
|
)
|
|
lock.withLock {
|
|
_recorders.append(recorder)
|
|
_urls.append(url)
|
|
}
|
|
return recorder
|
|
}
|
|
}
|
|
|
|
/// One-shot async gate that proves `VoiceRecorder.stopRecording` has reached
|
|
/// its actor-reentrant padding boundary, then holds it there until the test has
|
|
/// exercised a competing owner. Unlike `Task.yield()` plus a short real sleep,
|
|
/// this remains deterministic when the full test suite saturates the executor.
|
|
private final class VoiceRecorderPaddingGate: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private let entered = VoiceRecorderAsyncEvent()
|
|
private var isOpen = false
|
|
private var openWaiters: [CheckedContinuation<Void, Never>] = []
|
|
|
|
func wait() async {
|
|
await withCheckedContinuation { continuation in
|
|
let resumeImmediately = lock.withLock { () -> Bool in
|
|
guard !isOpen else { return true }
|
|
openWaiters.append(continuation)
|
|
return false
|
|
}
|
|
entered.signal()
|
|
if resumeImmediately {
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
func waitUntilEntered() async {
|
|
await entered.wait()
|
|
}
|
|
|
|
func open() {
|
|
let waiters = lock.withLock { () -> [CheckedContinuation<Void, Never>] in
|
|
isOpen = true
|
|
defer { openWaiters.removeAll() }
|
|
return openWaiters
|
|
}
|
|
waiters.forEach { $0.resume() }
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct VoiceRecorderTests {
|
|
private func makeTemporaryDirectory() throws -> URL {
|
|
let url = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("voice-recorder-tests-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
return url
|
|
}
|
|
|
|
@Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws {
|
|
let directory = try makeTemporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let session = VoiceRecorderTestSession(gateFirstActivation: true)
|
|
let coordinator = AudioSessionCoordinator(session: session)
|
|
let factory = TestVoiceAudioRecorderFactory(plans: [.success])
|
|
let voiceRecorder = VoiceRecorder(
|
|
sessionCoordinator: coordinator,
|
|
recorderFactory: factory,
|
|
permissionGranted: { true },
|
|
paddingInterval: 0,
|
|
outputDirectory: directory
|
|
)
|
|
let owner = VoiceRecorder.RecordingOwner()
|
|
|
|
let startTask = Task { try await voiceRecorder.startRecording(owner: owner) }
|
|
await session.waitUntilActivationBegan()
|
|
|
|
await voiceRecorder.cancelRecording(owner: owner)
|
|
session.resumeActivation()
|
|
|
|
do {
|
|
_ = try await startTask.value
|
|
Issue.record("The canceled session acquire unexpectedly started recording")
|
|
} catch {
|
|
#expect(error is CancellationError)
|
|
}
|
|
await coordinator.drain()
|
|
|
|
#expect(factory.recorders.isEmpty)
|
|
#expect(session.activationCalls == [true, false])
|
|
}
|
|
|
|
@Test func prepareFailureCleansUpAndAllowsTheNextRecording() async throws {
|
|
try await verifyFailedStart(
|
|
firstPlan: .init(prepareResult: false, recordResult: true),
|
|
expectedPrepareCalls: 1,
|
|
expectedRecordCalls: 0
|
|
)
|
|
}
|
|
|
|
@Test func recordFailureCleansUpAndAllowsTheNextRecording() async throws {
|
|
try await verifyFailedStart(
|
|
firstPlan: .init(prepareResult: true, recordResult: false),
|
|
expectedPrepareCalls: 1,
|
|
expectedRecordCalls: 1
|
|
)
|
|
}
|
|
|
|
@Test func automaticStopReturnsAndPreservesFileThenNextRecordingWorks() async throws {
|
|
let directory = try makeTemporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let session = VoiceRecorderTestSession()
|
|
let coordinator = AudioSessionCoordinator(session: session)
|
|
let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success])
|
|
let voiceRecorder = VoiceRecorder(
|
|
sessionCoordinator: coordinator,
|
|
recorderFactory: factory,
|
|
permissionGranted: { true },
|
|
paddingInterval: 0,
|
|
outputDirectory: directory
|
|
)
|
|
let firstOwner = VoiceRecorder.RecordingOwner()
|
|
|
|
let firstURL = try await voiceRecorder.startRecording(owner: firstOwner)
|
|
let firstRecorder = try #require(factory.recorders.first)
|
|
#expect(firstRecorder.recordedDurations == [120])
|
|
firstRecorder.simulateAutomaticStop()
|
|
|
|
let finishedURL = await voiceRecorder.stopRecording(owner: firstOwner)
|
|
await coordinator.drain()
|
|
#expect(finishedURL == firstURL)
|
|
#expect(firstRecorder.stopCallCount == 0)
|
|
#expect(FileManager.default.fileExists(atPath: firstURL.path))
|
|
#expect(session.activationCalls == [true, false])
|
|
|
|
let secondOwner = VoiceRecorder.RecordingOwner()
|
|
let secondURL = try await voiceRecorder.startRecording(owner: secondOwner)
|
|
#expect(secondURL != firstURL)
|
|
#expect(FileManager.default.fileExists(atPath: firstURL.path))
|
|
#expect(factory.recorders.count == 2)
|
|
let secondRecorder = try #require(factory.recorders.last)
|
|
|
|
#expect(await voiceRecorder.stopRecording(owner: secondOwner) == secondURL)
|
|
await coordinator.drain()
|
|
#expect(secondRecorder.stopCallCount == 1)
|
|
#expect(session.activationCalls == [true, false, true, false])
|
|
#expect(FileManager.default.fileExists(atPath: firstURL.path))
|
|
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
|
}
|
|
|
|
@Test func rejectedNewHoldCancelCannotDeleteHoldFinishingDuringPadding() async throws {
|
|
let directory = try makeTemporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let session = VoiceRecorderTestSession()
|
|
let coordinator = AudioSessionCoordinator(session: session)
|
|
let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success])
|
|
let paddingGate = VoiceRecorderPaddingGate()
|
|
let voiceRecorder = VoiceRecorder(
|
|
sessionCoordinator: coordinator,
|
|
recorderFactory: factory,
|
|
permissionGranted: { true },
|
|
paddingInterval: 0.05,
|
|
outputDirectory: directory,
|
|
testingHooks: .init(waitForStopPadding: { _ in await paddingGate.wait() })
|
|
)
|
|
let finishingHold = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
|
let rejectedHold = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
|
|
|
try await finishingHold.start()
|
|
let firstURL = try #require(factory.urls.first)
|
|
let finishTask = Task { await finishingHold.finish() }
|
|
await paddingGate.waitUntilEntered()
|
|
|
|
await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) {
|
|
try await rejectedHold.start()
|
|
}
|
|
// This is the view-model error path that used to globally cancel the
|
|
// shared recorder and delete `firstURL` during the padding sleep.
|
|
await rejectedHold.cancel()
|
|
paddingGate.open()
|
|
|
|
#expect(await finishTask.value == firstURL)
|
|
await coordinator.drain()
|
|
#expect(FileManager.default.fileExists(atPath: firstURL.path))
|
|
#expect(factory.recorders[0].stopCallCount == 1)
|
|
#expect(session.activationCalls == [true, false])
|
|
}
|
|
|
|
@Test func stalePreviousHoldCancelCannotStopNewRecording() async throws {
|
|
let directory = try makeTemporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let session = VoiceRecorderTestSession()
|
|
let coordinator = AudioSessionCoordinator(session: session)
|
|
let factory = TestVoiceAudioRecorderFactory(plans: [.success, .success])
|
|
let voiceRecorder = VoiceRecorder(
|
|
sessionCoordinator: coordinator,
|
|
recorderFactory: factory,
|
|
permissionGranted: { true },
|
|
paddingInterval: 0,
|
|
outputDirectory: directory
|
|
)
|
|
let previousHold = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
|
let currentHold = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
|
|
|
try await previousHold.start()
|
|
let firstURL = try #require(factory.urls.first)
|
|
#expect(await previousHold.finish() == firstURL)
|
|
|
|
try await currentHold.start()
|
|
let secondURL = try #require(factory.urls.last)
|
|
let secondRecorder = try #require(factory.recorders.last)
|
|
await previousHold.cancel()
|
|
|
|
#expect(secondRecorder.isRecording)
|
|
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
|
#expect(await currentHold.finish() == secondURL)
|
|
await coordinator.drain()
|
|
#expect(secondRecorder.stopCallCount == 1)
|
|
#expect(FileManager.default.fileExists(atPath: firstURL.path))
|
|
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
|
}
|
|
|
|
@Test func classicSessionPanicStopsRecorderAndDeletesFileBeforeReturning() async throws {
|
|
let directory = try makeTemporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let rawSession = VoiceRecorderTestSession()
|
|
let coordinator = AudioSessionCoordinator(session: rawSession)
|
|
let factory = TestVoiceAudioRecorderFactory(plans: [.success])
|
|
let voiceRecorder = VoiceRecorder(
|
|
sessionCoordinator: coordinator,
|
|
recorderFactory: factory,
|
|
permissionGranted: { true },
|
|
paddingInterval: 0,
|
|
outputDirectory: directory
|
|
)
|
|
let capture = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
|
|
|
try await capture.start()
|
|
let url = try #require(factory.urls.first)
|
|
let recorder = try #require(factory.recorders.first)
|
|
|
|
capture.panicCancelSynchronously()
|
|
|
|
#expect(recorder.stopCallCount == 1)
|
|
#expect(!recorder.isRecording)
|
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
|
await coordinator.drain()
|
|
#expect(rawSession.activationCalls == [true, false])
|
|
}
|
|
|
|
private func verifyFailedStart(
|
|
firstPlan: TestVoiceAudioRecorderFactory.Plan,
|
|
expectedPrepareCalls: Int,
|
|
expectedRecordCalls: Int
|
|
) async throws {
|
|
let directory = try makeTemporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let session = VoiceRecorderTestSession()
|
|
let coordinator = AudioSessionCoordinator(session: session)
|
|
let factory = TestVoiceAudioRecorderFactory(plans: [firstPlan, .success])
|
|
let voiceRecorder = VoiceRecorder(
|
|
sessionCoordinator: coordinator,
|
|
recorderFactory: factory,
|
|
permissionGranted: { true },
|
|
paddingInterval: 0,
|
|
outputDirectory: directory
|
|
)
|
|
let failedOwner = VoiceRecorder.RecordingOwner()
|
|
|
|
await #expect(throws: VoiceRecorder.RecorderError.failedToStartRecording) {
|
|
try await voiceRecorder.startRecording(owner: failedOwner)
|
|
}
|
|
await coordinator.drain()
|
|
|
|
let failedRecorder = try #require(factory.recorders.first)
|
|
let failedURL = try #require(factory.urls.first)
|
|
#expect(failedRecorder.prepareCallCount == expectedPrepareCalls)
|
|
#expect(failedRecorder.recordedDurations.count == expectedRecordCalls)
|
|
#expect(!FileManager.default.fileExists(atPath: failedURL.path))
|
|
#expect(session.activationCalls == [true, false])
|
|
|
|
let nextOwner = VoiceRecorder.RecordingOwner()
|
|
let nextURL = try await voiceRecorder.startRecording(owner: nextOwner)
|
|
#expect(FileManager.default.fileExists(atPath: nextURL.path))
|
|
#expect(await voiceRecorder.stopRecording(owner: nextOwner) == nextURL)
|
|
await coordinator.drain()
|
|
#expect(session.activationCalls == [true, false, true, false])
|
|
}
|
|
}
|
|
|
|
// MARK: - Slide-to-cancel state
|
|
|
|
/// Pins the slide-away-to-cancel arming semantics on the recording view
|
|
/// model: release used to ALWAYS send (the HUD cancel button required
|
|
/// lifting the finger — which sent), so the armed flag must reset on every
|
|
/// exit path or a stale value could discard a wanted recording.
|
|
struct VoiceRecordingCancelArmingTests {
|
|
|
|
@Test @MainActor
|
|
func cancelArmingTogglesAndResetsOnEveryExitPath() {
|
|
let vm = VoiceRecordingViewModel()
|
|
#expect(!vm.isCancelArmed)
|
|
|
|
vm.setCancelArmed(true)
|
|
#expect(vm.isCancelArmed)
|
|
vm.setCancelArmed(false)
|
|
#expect(!vm.isCancelArmed)
|
|
|
|
vm.setCancelArmed(true)
|
|
vm.cancel()
|
|
#expect(!vm.isCancelArmed)
|
|
|
|
vm.setCancelArmed(true)
|
|
vm.finish(completion: nil)
|
|
#expect(!vm.isCancelArmed)
|
|
|
|
vm.setCancelArmed(true)
|
|
vm.panicWipe()
|
|
#expect(!vm.isCancelArmed)
|
|
}
|
|
}
|