From 34bae4a89d8f00abf09887ab5b27018a486bcc74 Mon Sep 17 00:00:00 2001 From: Islam <2553451+qalandarov@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:26:13 +0100 Subject: [PATCH] Refactor Voice Messages (#1075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Extract voice-related code to a dedicated VM * Minor optimization of permission request + fix ui bug When isPreparing is being set to true before permission is granted, the UI is shown for a fraction of a second and it’s even worse if the permission is being asked constantly if the user drags the finger even when the alert is shown * Remove dead code * Remove unnecessary delegate conformance * `actor VoiceRecorder` + other minor improvements * Centralized opening system settings + open for mic access * Better voice-recording state handling with Enum * Remove manual timer management * Simplify formatting + avoid jumping UI w/ countdown * Add `requestingPermission` state to avoid repetitive calls * Improve guarding of the states after awaits * Fix potential “actor reentrancy across awaits” issue * Remove short-circuiting on .idle + guard before error * Fix playbackLabel’s formatting for duration vs remainder --------- Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com> --- .../voice/VoiceNotePlaybackController.swift | 12 ++ bitchat/Features/voice/VoiceRecorder.swift | 174 ++++++++---------- bitchat/Utils/SystemSettings.swift | 44 +++++ .../ViewModels/VoiceRecordingViewModel.swift | 142 ++++++++++++++ bitchat/Views/ContentView.swift | 143 +++----------- bitchat/Views/LocationChannelsSheet.swift | 21 +-- bitchat/Views/Media/VoiceNoteView.swift | 20 +- bitchatTests/ViewSmokeTests.swift | 3 +- 8 files changed, 301 insertions(+), 258 deletions(-) create mode 100644 bitchat/Utils/SystemSettings.swift create mode 100644 bitchat/ViewModels/VoiceRecordingViewModel.swift diff --git a/bitchat/Features/voice/VoiceNotePlaybackController.swift b/bitchat/Features/voice/VoiceNotePlaybackController.swift index 1bd43cee..79b9af6c 100644 --- a/bitchat/Features/voice/VoiceNotePlaybackController.swift +++ b/bitchat/Features/voice/VoiceNotePlaybackController.swift @@ -9,6 +9,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay @Published private(set) var duration: TimeInterval = 0 @Published private(set) var progress: Double = 0 + /// rounded so 4.9s shows "00:05" + var roundedDuration: Int { + guard duration.isFinite else { return 0 } + return Int(duration.rounded()) + } + + /// ceil so "00:01" stays visible until playback ends, capped to rounded duration + var remainingSeconds: Int { + let remaining = max(0, duration - currentTime) + return min(roundedDuration, Int(ceil(remaining))) + } + private var player: AVAudioPlayer? private var timer: Timer? private var url: URL diff --git a/bitchat/Features/voice/VoiceRecorder.swift b/bitchat/Features/voice/VoiceRecorder.swift index ad02d95d..b7b4b91f 100644 --- a/bitchat/Features/voice/VoiceRecorder.swift +++ b/bitchat/Features/voice/VoiceRecorder.swift @@ -2,8 +2,7 @@ import Foundation import AVFoundation /// Manages audio capture for mesh voice notes with predictable encoding settings. -/// Recording runs on an internal serial queue to avoid AVAudioSession contention. -final class VoiceRecorder: NSObject, AVAudioRecorderDelegate { +actor VoiceRecorder { enum RecorderError: Error { case microphoneAccessDenied case recorderInitializationFailed @@ -12,21 +11,16 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate { static let shared = VoiceRecorder() - private let queue = DispatchQueue(label: "com.bitchat.voice-recorder") private let paddingInterval: TimeInterval = 0.5 private let maxRecordingDuration: TimeInterval = 120 + static let minRecordingDuration: TimeInterval = 1 private var recorder: AVAudioRecorder? private var currentURL: URL? - private var stopWorkItem: DispatchWorkItem? - - private override init() { - super.init() - } // MARK: - Permissions - @discardableResult + nonisolated func requestPermission() async -> Bool { #if os(iOS) return await withCheckedContinuation { continuation in @@ -47,106 +41,88 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate { // MARK: - Recording Lifecycle + @discardableResult func startRecording() throws -> URL { - try queue.sync { - if recorder?.isRecording == true { - throw RecorderError.recordingInProgress - } - - #if os(iOS) - let session = AVAudioSession.sharedInstance() - guard session.recordPermission == .granted else { - throw RecorderError.microphoneAccessDenied - } - #if targetEnvironment(simulator) - // allowBluetoothHFP is not available on iOS Simulator - try session.setCategory( - .playAndRecord, - mode: .default, - options: [.defaultToSpeaker, .allowBluetoothA2DP] - ) - #else - try session.setCategory( - .playAndRecord, - mode: .default, - options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP] - ) - #endif - try session.setActive(true, options: .notifyOthersOnDeactivation) - #endif - #if os(macOS) - guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { - throw RecorderError.microphoneAccessDenied - } - #endif - - let outputURL = try makeOutputURL() - let settings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: 16_000, - AVNumberOfChannelsKey: 1, - AVEncoderBitRateKey: 16_000 - ] - - let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings) - audioRecorder.delegate = self - audioRecorder.isMeteringEnabled = true - audioRecorder.prepareToRecord() - audioRecorder.record(forDuration: maxRecordingDuration) - - recorder = audioRecorder - currentURL = outputURL - stopWorkItem?.cancel() - stopWorkItem = nil - return outputURL + if recorder?.isRecording == true { + throw RecorderError.recordingInProgress } + + #if os(iOS) + let session = AVAudioSession.sharedInstance() + guard session.recordPermission == .granted else { + throw RecorderError.microphoneAccessDenied + } + #if targetEnvironment(simulator) + // allowBluetoothHFP is not available on iOS Simulator + try session.setCategory( + .playAndRecord, + mode: .default, + options: [.defaultToSpeaker, .allowBluetoothA2DP] + ) + #else + try session.setCategory( + .playAndRecord, + mode: .default, + options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP] + ) + #endif + try session.setActive(true, options: .notifyOthersOnDeactivation) + #endif + #if os(macOS) + guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else { + throw RecorderError.microphoneAccessDenied + } + #endif + + let outputURL = try makeOutputURL() + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 16_000, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 16_000 + ] + + let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings) + audioRecorder.isMeteringEnabled = true + audioRecorder.prepareToRecord() + audioRecorder.record(forDuration: maxRecordingDuration) + + recorder = audioRecorder + currentURL = outputURL + return outputURL } - func stopRecording(completion: @escaping (URL?) -> Void) { - queue.async { [weak self] in - guard let self = self, let recorder = self.recorder, recorder.isRecording else { - completion(self?.currentURL) - return - } - - let item = DispatchWorkItem { [weak self] in - guard let self = self else { return } - recorder.stop() - self.cleanupSession() - let url = self.currentURL - self.recorder = nil - self.currentURL = url - completion(url) - } - self.stopWorkItem = item - self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item) + func stopRecording() async -> URL? { + guard let recorder, recorder.isRecording else { + return currentURL } + + let sessionURL = currentURL + + try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000)) + + recorder.stop() + + // A new session may have started during the sleep — don't touch its state + if self.recorder === recorder { + cleanupSession() + self.recorder = nil + currentURL = nil + } + + return sessionURL } func cancelRecording() { - queue.async { [weak self] in - guard let self = self else { return } - self.stopWorkItem?.cancel() - self.stopWorkItem = nil - if let recorder = self.recorder, recorder.isRecording { - recorder.stop() - } - self.cleanupSession() - if let url = self.currentURL { - try? FileManager.default.removeItem(at: url) - } - self.recorder = nil - self.currentURL = nil + if let recorder, recorder.isRecording { + recorder.stop() } - } - - // MARK: - Metering - - func currentAveragePower() -> Float { - queue.sync { - recorder?.updateMeters() - return recorder?.averagePower(forChannel: 0) ?? -160 + cleanupSession() + if let currentURL { + try? FileManager.default.removeItem(at: currentURL) } + recorder = nil + currentURL = nil } // MARK: - Helpers diff --git a/bitchat/Utils/SystemSettings.swift b/bitchat/Utils/SystemSettings.swift new file mode 100644 index 00000000..ab552e07 --- /dev/null +++ b/bitchat/Utils/SystemSettings.swift @@ -0,0 +1,44 @@ +// +// SystemSettings.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +enum SystemSettings { + case bluetooth + case location + case microphone + + #if os(macOS) + private static let baseURL = "x-apple.systempreferences:com.apple.preference.security" + + private var macPrivacyAnchor: String { + switch self { + case .bluetooth: "Privacy_Bluetooth" + case .location: "Privacy_LocationServices" + case .microphone: "Privacy_Microphone" + } + } + #endif + + func open() { + #if os(iOS) + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + #elseif os(macOS) + let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)" + if let url = URL(string: urlString) { + NSWorkspace.shared.open(url) + } + #endif + } +} diff --git a/bitchat/ViewModels/VoiceRecordingViewModel.swift b/bitchat/ViewModels/VoiceRecordingViewModel.swift new file mode 100644 index 00000000..92e70a02 --- /dev/null +++ b/bitchat/ViewModels/VoiceRecordingViewModel.swift @@ -0,0 +1,142 @@ +// +// VoiceRecordingViewModel.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitLogger +import Foundation + +@MainActor +final class VoiceRecordingViewModel: ObservableObject { + enum State: Equatable { + case idle + case requestingPermission + case permissionDenied + case preparing + case recording(startDate: Date) + case error(message: String) + + var isActive: Bool { + switch self { + case .preparing, .recording: true + case .idle, .requestingPermission, .permissionDenied, .error: false + } + } + + var alertMessage: String { + switch self { + case .error(let message): message + case .permissionDenied: "Microphone access is required to record voice notes." + case .idle, .requestingPermission, .preparing, .recording: "" + } + } + + fileprivate func duration(for date: Date) -> TimeInterval { + switch self { + case .idle, .requestingPermission, .preparing, .permissionDenied, .error: 0 + case .recording(let startDate): date.timeIntervalSince(startDate) + } + } + } + + var showAlert: Bool { + get { + switch state { + case .permissionDenied, .error: true + case .idle, .requestingPermission, .preparing, .recording: false + } + } + set { + if !newValue { state = .idle } + } + } + + @Published private(set) var state = State.idle + + func formattedDuration(for date: Date) -> String { + let clamped = max(0, state.duration(for: date)) + let totalMilliseconds = Int(clamped * 1000) + let minutes = totalMilliseconds / 60_000 + let seconds = (totalMilliseconds % 60_000) / 1_000 + let centiseconds = (totalMilliseconds % 1_000) / 10 + return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds) + } + + func start(shouldShow: Bool) { + guard shouldShow, state == .idle else { return } + state = .requestingPermission + Task { + let granted = await VoiceRecorder.shared.requestPermission() + guard state == .requestingPermission else { return } + guard granted else { + state = .permissionDenied + return + } + state = .preparing + do { + try await VoiceRecorder.shared.startRecording() + guard state == .preparing else { + cancel() + return + } + state = .recording(startDate: Date()) + } catch { + SecureLogger.error("Voice recording failed to start: \(error)", category: .session) + await VoiceRecorder.shared.cancelRecording() + guard state == .preparing else { return } + state = .error(message: "Could not start recording.") + } + } + } + + func finish(completion: ((URL) -> Void)?) { + let previousState = state + + switch previousState { + case .permissionDenied, .error: + return + case .idle, .requestingPermission, .preparing, .recording: + break + } + + state = .idle + + guard case .recording(let startDate) = previousState, let completion else { + Task { await VoiceRecorder.shared.cancelRecording() } + return + } + + Task { + let finalDuration = Date().timeIntervalSince(startDate) + if let url = await VoiceRecorder.shared.stopRecording(), + isValidRecording(at: url, duration: finalDuration) { + completion(url) + } else { + guard state == .idle else { return } + state = .error( + message: finalDuration < VoiceRecorder.minRecordingDuration + ? "Recording is too short." + : "Recording failed to save." + ) + } + } + } + + func cancel() { + finish(completion: nil) + } + + private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool { + if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + let fileSize = attributes[.size] as? NSNumber, + fileSize.intValue > 0, + duration >= VoiceRecorder.minRecordingDuration { + return true + } + try? FileManager.default.removeItem(at: url) + return false + } +} diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 32ebb8eb..fb2dc85e 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -49,6 +49,7 @@ struct ContentView: View { // MARK: - Properties @EnvironmentObject var viewModel: ChatViewModel + @StateObject private var voiceRecordingVM = VoiceRecordingViewModel() @ObservedObject private var locationManager = LocationChannelManager.shared @ObservedObject private var bookmarks = GeohashBookmarksStore.shared @State private var messageText = "" @@ -73,13 +74,6 @@ struct ContentView: View { @State private var showLocationNotes = false @State private var notesGeohash: String? = nil @State private var imagePreviewURL: URL? = nil - @State private var recordingAlertMessage: String = "" - @State private var showRecordingAlert = false - @State private var isRecordingVoiceNote = false - @State private var isPreparingVoiceNote = false - @State private var recordingDuration: TimeInterval = 0 - @State private var recordingTimer: Timer? - @State private var recordingStartDate: Date? #if os(iOS) @State private var showImagePicker = false @State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera @@ -282,10 +276,15 @@ struct ContentView: View { .environmentObject(viewModel) } } - .alert("Recording Error", isPresented: $showRecordingAlert, actions: { - Button("OK", role: .cancel) {} + .alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: { + Button("common.ok", role: .cancel) {} + if voiceRecordingVM.state == .permissionDenied { + Button("location_channels.action.open_settings") { + SystemSettings.microphone.open() + } + } }, message: { - Text(recordingAlertMessage) + Text(voiceRecordingVM.state.alertMessage) }) .confirmationDialog( selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"), @@ -342,11 +341,7 @@ struct ContentView: View { } .alert("content.alert.bluetooth_required.title", isPresented: $viewModel.showBluetoothAlert) { Button("content.alert.bluetooth_required.settings") { - #if os(iOS) - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - #endif + SystemSettings.bluetooth.open() } Button("common.ok", role: .cancel) {} } message: { @@ -629,8 +624,7 @@ struct ContentView: View { secondaryTextColor: secondaryTextColor ) - // Recording indicator - if isPreparingVoiceNote || isRecordingVoiceNote { + if voiceRecordingVM.state.isActive { recordingIndicator } @@ -1687,11 +1681,16 @@ private extension ContentView { Image(systemName: "waveform.circle.fill") .foregroundColor(.red) .font(.bitchatSystem(size: 20)) - Text("recording \(formattedRecordingDuration())", comment: "Voice note recording duration indicator") + TimelineView(.periodic(from: .now, by: 0.05)) { context in + Text( + "recording \(voiceRecordingVM.formattedDuration(for: context.date))", + comment: "Voice note recording duration indicator" + ) .font(.bitchatSystem(size: 13, design: .monospaced)) .foregroundColor(.red) + } Spacer() - Button(action: cancelVoiceRecording) { + Button(action: voiceRecordingVM.cancel) { Label("Cancel", systemImage: "xmark.circle") .labelStyle(.iconOnly) .font(.bitchatSystem(size: 18)) @@ -1788,11 +1787,9 @@ private extension ContentView { } private var micButtonView: some View { - let tint = (isRecordingVoiceNote || isPreparingVoiceNote) ? Color.red : composerAccentColor - - return Image(systemName: "mic.circle.fill") + Image(systemName: "mic.circle.fill") .font(.bitchatSystem(size: 24)) - .foregroundColor(tint) + .foregroundColor(voiceRecordingVM.state.isActive ? Color.red : composerAccentColor) .frame(width: 36, height: 36) .contentShape(Circle()) .overlay( @@ -1800,8 +1797,8 @@ private extension ContentView { .contentShape(Circle()) .gesture( DragGesture(minimumDistance: 0) - .onChanged { _ in startVoiceRecording() } - .onEnded { _ in finishVoiceRecording(send: true) } + .onChanged { _ in voiceRecordingVM.start(shouldShow: shouldShowVoiceControl) } + .onEnded { _ in voiceRecordingVM.finish(completion: viewModel.sendVoiceNote) } ) ) .accessibilityLabel("Hold to record a voice note") @@ -1827,102 +1824,6 @@ private extension ContentView { ) } - func formattedRecordingDuration() -> String { - let clamped = max(0, recordingDuration) - let totalMilliseconds = Int((clamped * 1000).rounded()) - let minutes = totalMilliseconds / 60_000 - let seconds = (totalMilliseconds % 60_000) / 1_000 - let centiseconds = (totalMilliseconds % 1_000) / 10 - return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds) - } - - func startVoiceRecording() { - guard shouldShowVoiceControl else { return } - guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return } - isPreparingVoiceNote = true - Task { @MainActor in - let granted = await VoiceRecorder.shared.requestPermission() - guard granted else { - isPreparingVoiceNote = false - recordingAlertMessage = "Microphone access is required to record voice notes." - showRecordingAlert = true - return - } - do { - _ = try VoiceRecorder.shared.startRecording() - recordingDuration = 0 - recordingStartDate = Date() - recordingTimer?.invalidate() - recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in - if let start = recordingStartDate { - recordingDuration = Date().timeIntervalSince(start) - } - } - if let timer = recordingTimer { - RunLoop.main.add(timer, forMode: .common) - } - isPreparingVoiceNote = false - isRecordingVoiceNote = true - } catch { - SecureLogger.error("Voice recording failed to start: \(error)", category: .session) - recordingAlertMessage = "Could not start recording." - showRecordingAlert = true - VoiceRecorder.shared.cancelRecording() - isPreparingVoiceNote = false - isRecordingVoiceNote = false - recordingStartDate = nil - } - } - } - - func finishVoiceRecording(send: Bool) { - if isPreparingVoiceNote { - isPreparingVoiceNote = false - VoiceRecorder.shared.cancelRecording() - return - } - guard isRecordingVoiceNote else { return } - isRecordingVoiceNote = false - recordingTimer?.invalidate() - recordingTimer = nil - if let start = recordingStartDate { - recordingDuration = Date().timeIntervalSince(start) - } - recordingStartDate = nil - if send { - let minimumDuration: TimeInterval = 1.0 - VoiceRecorder.shared.stopRecording { url in - DispatchQueue.main.async { - guard - let url = url, - let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), - let fileSize = attributes[.size] as? NSNumber, - fileSize.intValue > 0, - recordingDuration >= minimumDuration - else { - if let url = url { - try? FileManager.default.removeItem(at: url) - } - recordingAlertMessage = recordingDuration < minimumDuration - ? "Recording is too short." - : "Recording failed to save." - showRecordingAlert = true - return - } - viewModel.sendVoiceNote(at: url) - } - } - } else { - VoiceRecorder.shared.cancelRecording() - } - } - - func cancelVoiceRecording() { - if isPreparingVoiceNote || isRecordingVoiceNote { - finishVoiceRecording(send: false) - } - } - func applicationFilesDirectory() -> URL? { // Cache the directory lookup to avoid repeated FileManager calls during view rendering struct Cache { diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 01842d78..774dcd5c 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -125,7 +125,7 @@ struct LocationChannelsSheet: View { Text(Strings.permissionDenied) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(.secondary) - Button(Strings.openSettings) { openSystemLocationSettings() } + Button(Strings.openSettings, action: SystemSettings.location.open) .buttonStyle(.plain) } case LocationChannelManager.PermissionState.authorized: @@ -246,9 +246,7 @@ struct LocationChannelsSheet: View { sectionDivider torToggleSection .padding(.top, 12) - Button(action: { - openSystemLocationSettings() - }) { + Button(action: SystemSettings.location.open) { Text(Strings.removeAccess) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1)) @@ -622,18 +620,3 @@ extension LocationChannelsSheet { } } } - -// MARK: - Open Settings helper -private func openSystemLocationSettings() { - #if os(iOS) - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - #else - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") { - NSWorkspace.shared.open(url) - } else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") { - NSWorkspace.shared.open(url) - } - #endif -} diff --git a/bitchat/Views/Media/VoiceNoteView.swift b/bitchat/Views/Media/VoiceNoteView.swift index b5e28651..7547893e 100644 --- a/bitchat/Views/Media/VoiceNoteView.swift +++ b/bitchat/Views/Media/VoiceNoteView.swift @@ -34,24 +34,10 @@ struct VoiceNoteView: View { colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2) } - private var durationText: String { - let duration = playback.duration - guard duration.isFinite, duration > 0 else { return "--:--" } - let minutes = Int(duration) / 60 - let seconds = Int(duration) % 60 - return String(format: "%02d:%02d", minutes, seconds) - } - - private var currentText: String { - let current = playback.currentTime - guard current.isFinite, current > 0 else { return "00:00" } - let minutes = Int(current) / 60 - let seconds = Int(current) % 60 - return String(format: "%02d:%02d", minutes, seconds) - } - private var playbackLabel: String { - playback.isPlaying ? currentText + "/" + durationText : durationText + guard playback.duration.isFinite else { return "--:--" } + let seconds = playback.isPlaying ? playback.remainingSeconds : playback.roundedDuration + return String(format: "%02d:%02d", seconds / 60, seconds % 60) } var body: some View { diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 262db7ef..5c33b5e7 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -436,13 +436,12 @@ struct ViewSmokeTests { playback.stop() VoiceNotePlaybackCoordinator.shared.activate(playback) VoiceNotePlaybackCoordinator.shared.deactivate(playback) - VoiceRecorder.shared.cancelRecording() + await VoiceRecorder.shared.cancelRecording() #expect(bins.count == 16) #expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16) #expect(playback.duration > 0) #expect(playback.progress == 0) - #expect(VoiceRecorder.shared.currentAveragePower() <= 0) } #if os(iOS)