From da831f67259a750a7d60462f6ddf676c8d39c0f7 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Fri, 10 Nov 2023 16:32:22 +0100 Subject: [PATCH] New voice message recording mode (#2051) * Lock voice message recording * Use the VoiceMessageCache to store the recording file * Rework on some composer toolbar buttons * Update accessibility labels for voice message recording button * PreviewTests --- ElementX.xcodeproj/project.pbxproj | 4 - .../stop-recording.imageset/Contents.json | 16 +++ .../stop-recording.svg | 5 + .../en.lproj/Localizable.strings | 3 +- ElementX/Sources/Generated/Assets.swift | 1 + ElementX/Sources/Generated/Strings.swift | 4 +- .../Mocks/Generated/GeneratedMocks.swift | 58 +++++++-- .../View/ComposerToolbar.swift | 37 +++--- .../View/VoiceMessagePreviewComposer.swift | 2 - .../View/VoiceMessageRecordingButton.swift | 110 ++++++++---------- ...iceMessageRecordingButtonTooltipView.swift | 110 ------------------ .../View/VoiceMessageRecordingComposer.swift | 2 - .../RoomScreenInteractionHandler.swift | 14 ++- .../Services/Audio/Player/AudioPlayer.swift | 5 + .../Audio/Recorder/AudioRecorder.swift | 55 ++++++--- .../Recorder/AudioRecorderProtocol.swift | 17 +-- .../Audio/Recorder/AudioRecorderState.swift | 1 + .../MediaPlayer/MediaPlayerProtocol.swift | 1 + .../VoiceMessage/VoiceMessageCache.swift | 7 ++ .../VoiceMessageCacheProtocol.swift | 3 + .../VoiceMessage/VoiceMessageRecorder.swift | 30 +++-- .../VoiceMessageRecorderProtocol.swift | 1 + UnitTests/Sources/AudioRecorderTests.swift | 3 +- .../Sources/VoiceMessageRecorderTests.swift | 51 ++++---- .../PreviewTests/test_composerToolbar.1.png | 4 +- .../test_composerToolbar.Voice-Message.png | 4 +- .../test_composerToolbar.With-Suggestions.png | 4 +- .../PreviewTests/test_roomScreen.1.png | 4 +- .../PreviewTests/test_timelineView.1.png | 4 +- .../PreviewTests/test_uITimelineView.1.png | 4 +- .../test_voiceMessagePreviewComposer.1.png | 4 +- .../test_voiceMessageRecordingButton.1.png | 4 +- ...iceMessageRecordingButtonTooltipView.1.png | 3 - .../test_voiceMessageRecordingComposer.1.png | 4 +- 34 files changed, 283 insertions(+), 296 deletions(-) create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/stop-recording.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/composer/stop-recording.imageset/stop-recording.svg delete mode 100644 ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButtonTooltipView.swift delete mode 100644 UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButtonTooltipView.1.png diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index fd988bde9..bb8f7380d 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -527,7 +527,6 @@ 8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */; }; 8C050A8012E6078BEAEF5BC8 /* PillTextAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913C8E13B8B602C7B6C0C4AE /* PillTextAttachmentData.swift */; }; 8C1A5ECAF895D4CAF8C4D461 /* AppActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F21ED7205048668BEB44A38 /* AppActivityView.swift */; }; - 8C27BEB00B903D953F31F962 /* VoiceMessageRecordingButtonTooltipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF449205DF1E9817115245C4 /* VoiceMessageRecordingButtonTooltipView.swift */; }; 8C42B5B1642D189C362A5EDF /* SecureBackupScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91831D7042EADD0CC2B5EC36 /* SecureBackupScreenUITests.swift */; }; 8C454500B8073E1201F801A9 /* MXLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34A814CBD56230BC74FFCF4 /* MXLogger.swift */; }; 8C706DA7EAC0974CA2F8F1CD /* MentionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15748C254911E3654C93B0ED /* MentionBuilder.swift */; }; @@ -1918,7 +1917,6 @@ FE87C931165F5E201CACBB87 /* MediaPlayerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProtocol.swift; sourceTree = ""; }; FEC2E8E1B20BB2EA07B0B61E /* WelcomeScreenScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeScreenScreenViewModel.swift; sourceTree = ""; }; FEFEEE93B82937B2E86F92EB /* AnalyticsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsScreen.swift; sourceTree = ""; }; - FF449205DF1E9817115245C4 /* VoiceMessageRecordingButtonTooltipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingButtonTooltipView.swift; sourceTree = ""; }; FFECCE59967018204876D0A5 /* LocationMarkerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationMarkerView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2827,7 +2825,6 @@ 3E6A9B9DFEE964962C179DE3 /* RoomAttachmentPicker.swift */, BFC9F57320EC80C7CE34FE4A /* VoiceMessagePreviewComposer.swift */, D2E61DDB42C0DE429C0955D8 /* VoiceMessageRecordingButton.swift */, - FF449205DF1E9817115245C4 /* VoiceMessageRecordingButtonTooltipView.swift */, CCB6F36CCE44A29A06FCAF1C /* VoiceMessageRecordingComposer.swift */, 0A634D8DD1E10D858CF7995D /* VoiceMessageRecordingView.swift */, ); @@ -5909,7 +5906,6 @@ C2879369106A419A5071F1F8 /* VoiceMessageRecorder.swift in Sources */, 19DED23340D0855B59693ED2 /* VoiceMessageRecorderProtocol.swift in Sources */, 09EF4222EEBBA1A7B8F4071E /* VoiceMessageRecordingButton.swift in Sources */, - 8C27BEB00B903D953F31F962 /* VoiceMessageRecordingButtonTooltipView.swift in Sources */, CA5BFF0C2EF5A8EF40CA2D69 /* VoiceMessageRecordingComposer.swift in Sources */, EBDB339A7C127F068B6E52E5 /* VoiceMessageRecordingView.swift in Sources */, A9482B967FC85DA611514D35 /* VoiceMessageRoomPlaybackView.swift in Sources */, diff --git a/ElementX/Resources/Assets.xcassets/images/composer/stop-recording.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/composer/stop-recording.imageset/Contents.json new file mode 100644 index 000000000..e2ff1961b --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/stop-recording.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "stop-recording.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/composer/stop-recording.imageset/stop-recording.svg b/ElementX/Resources/Assets.xcassets/images/composer/stop-recording.imageset/stop-recording.svg new file mode 100644 index 000000000..4d0ff3f37 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/composer/stop-recording.imageset/stop-recording.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index ad923b512..7537a9f84 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -12,7 +12,8 @@ "a11y_show_password" = "Show password"; "a11y_start_call" = "Start a call"; "a11y_user_menu" = "User menu"; -"a11y_voice_message_record" = "Record voice message. Double tap and hold to record. Release to end recording."; +"a11y_voice_message_record" = "Record voice message."; +"a11y_voice_message_stop_recording" = "Stop recording"; "action_accept" = "Accept"; "action_add_to_timeline" = "Add to timeline"; "action_back" = "Back"; diff --git a/ElementX/Sources/Generated/Assets.swift b/ElementX/Sources/Generated/Assets.swift index 18fe14afb..091612281 100644 --- a/ElementX/Sources/Generated/Assets.swift +++ b/ElementX/Sources/Generated/Assets.swift @@ -52,6 +52,7 @@ internal enum Asset { internal static let numberedList = ImageAsset(name: "images/numbered-list") internal static let quote = ImageAsset(name: "images/quote") internal static let sendMessage = ImageAsset(name: "images/send-message") + internal static let stopRecording = ImageAsset(name: "images/stop-recording") internal static let strikethrough = ImageAsset(name: "images/strikethrough") internal static let textFormat = ImageAsset(name: "images/text-format") internal static let underline = ImageAsset(name: "images/underline") diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 62762c64f..c76b42298 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -40,8 +40,10 @@ public enum L10n { public static var a11yStartCall: String { return L10n.tr("Localizable", "a11y_start_call") } /// User menu public static var a11yUserMenu: String { return L10n.tr("Localizable", "a11y_user_menu") } - /// Record voice message. Double tap and hold to record. Release to end recording. + /// Record voice message. public static var a11yVoiceMessageRecord: String { return L10n.tr("Localizable", "a11y_voice_message_record") } + /// Stop recording + public static var a11yVoiceMessageStopRecording: String { return L10n.tr("Localizable", "a11y_voice_message_stop_recording") } /// Accept public static var actionAccept: String { return L10n.tr("Localizable", "action_accept") } /// Add to timeline diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 9a62c6ce7..19a709d25 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -466,6 +466,18 @@ class AudioPlayerMock: AudioPlayerProtocol { loadMediaSourceUsingAutoplayReceivedInvocations.append((mediaSource: mediaSource, url: url, autoplay: autoplay)) loadMediaSourceUsingAutoplayClosure?(mediaSource, url, autoplay) } + //MARK: - reset + + var resetCallsCount = 0 + var resetCalled: Bool { + return resetCallsCount > 0 + } + var resetClosure: (() -> Void)? + + func reset() { + resetCallsCount += 1 + resetClosure?() + } //MARK: - play var playCallsCount = 0 @@ -535,23 +547,23 @@ class AudioRecorderMock: AudioRecorderProtocol { set(value) { underlyingIsRecording = value } } var underlyingIsRecording: Bool! - var audioFileUrl: URL? + var audioFileURL: URL? //MARK: - record - var recordWithCallsCount = 0 - var recordWithCalled: Bool { - return recordWithCallsCount > 0 + var recordAudioFileURLCallsCount = 0 + var recordAudioFileURLCalled: Bool { + return recordAudioFileURLCallsCount > 0 } - var recordWithReceivedRecordID: AudioRecordingIdentifier? - var recordWithReceivedInvocations: [AudioRecordingIdentifier] = [] - var recordWithClosure: ((AudioRecordingIdentifier) async -> Void)? + var recordAudioFileURLReceivedAudioFileURL: URL? + var recordAudioFileURLReceivedInvocations: [URL] = [] + var recordAudioFileURLClosure: ((URL) async -> Void)? - func record(with recordID: AudioRecordingIdentifier) async { - recordWithCallsCount += 1 - recordWithReceivedRecordID = recordID - recordWithReceivedInvocations.append(recordID) - await recordWithClosure?(recordID) + func record(audioFileURL: URL) async { + recordAudioFileURLCallsCount += 1 + recordAudioFileURLReceivedAudioFileURL = audioFileURL + recordAudioFileURLReceivedInvocations.append(audioFileURL) + await recordAudioFileURLClosure?(audioFileURL) } //MARK: - stopRecording @@ -1098,6 +1110,18 @@ class MediaPlayerMock: MediaPlayerProtocol { loadMediaSourceUsingAutoplayReceivedInvocations.append((mediaSource: mediaSource, url: url, autoplay: autoplay)) loadMediaSourceUsingAutoplayClosure?(mediaSource, url, autoplay) } + //MARK: - reset + + var resetCallsCount = 0 + var resetCalled: Bool { + return resetCallsCount > 0 + } + var resetClosure: (() -> Void)? + + func reset() { + resetCallsCount += 1 + resetClosure?() + } //MARK: - play var playCallsCount = 0 @@ -3143,6 +3167,11 @@ class UserNotificationCenterMock: UserNotificationCenterProtocol { } } class VoiceMessageCacheMock: VoiceMessageCacheProtocol { + var urlForRecording: URL { + get { return underlyingUrlForRecording } + set(value) { underlyingUrlForRecording = value } + } + var underlyingUrlForRecording: URL! //MARK: - fileURL @@ -3234,6 +3263,11 @@ class VoiceMessageRecorderMock: VoiceMessageRecorderProtocol { } var underlyingAudioRecorder: AudioRecorderProtocol! var previewAudioPlayerState: AudioPlayerState? + var isRecording: Bool { + get { return underlyingIsRecording } + set(value) { underlyingIsRecording = value } + } + var underlyingIsRecording: Bool! var recordingURL: URL? var recordingDuration: TimeInterval { get { return underlyingRecordingDuration } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift index dd5774126..1b63d530e 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift @@ -24,9 +24,13 @@ struct ComposerToolbar: View { let keyCommandHandler: KeyCommandHandler @FocusState private var composerFocused: Bool - @ScaledMetric private var sendButtonIconSize = 16 + @ScaledMetric(relativeTo: .title) private var sendButtonIconSize = 16 + @ScaledMetric(relativeTo: .title) private var sendButtonIconPadding = 10 + @ScaledMetric(relativeTo: .title) private var sendButtonIconOffsetX = 1 + @ScaledMetric(relativeTo: .title) private var spinnerSize = 44 @ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30 + @ScaledMetric(relativeTo: .title) private var deleteRecordingButtonSize = 30 @State private var frame: CGRect = .zero @Environment(\.verticalSizeClass) private var verticalSizeClass @@ -77,8 +81,8 @@ struct ComposerToolbar: View { sendButton .padding(.leading, 3) } else { - voiceMessageRecordingButton - .padding(.leading, 4) + voiceMessageRecordingButton(mode: context.viewState.isVoiceMessageModeActivated ? .recording : .idle) + .padding(.leading, 3) } } } @@ -122,6 +126,7 @@ struct ComposerToolbar: View { if context.viewState.isVoiceMessageModeActivated { voiceMessageContent + .fixedSize(horizontal: false, vertical: true) } } } @@ -214,8 +219,9 @@ struct ComposerToolbar: View { .accessibilityHidden(!context.viewState.composerMode.isEdit) Image(asset: Asset.Images.sendMessage) .resizable() + .offset(x: sendButtonIconOffsetX) .frame(width: sendButtonIconSize, height: sendButtonIconSize) - .padding(EdgeInsets(top: 10, leading: 11, bottom: 10, trailing: 9)) + .padding(sendButtonIconPadding) .opacity(context.viewState.composerMode.isEdit ? 0 : 1) .accessibilityLabel(L10n.actionSend) .accessibilityHidden(context.viewState.composerMode.isEdit) @@ -235,8 +241,10 @@ struct ComposerToolbar: View { // Display the voice message composer above to keep the focus and keep the keyboard open if it's already open. switch context.viewState.composerMode { case .recordVoiceMessage(let state): - VoiceMessageRecordingComposer(recorderState: state) - .padding(.leading, 12) + topBarLayout { + voiceMessageTrashButton + VoiceMessageRecordingComposer(recorderState: state) + } case .previewVoiceMessage(let state, let waveform, let isUploading): topBarLayout { voiceMessageTrashButton @@ -248,17 +256,12 @@ struct ComposerToolbar: View { } } - private var voiceMessageRecordingButton: some View { - VoiceMessageRecordingButton { + private func voiceMessageRecordingButton(mode: VoiceMessageRecordingButtonMode) -> some View { + VoiceMessageRecordingButton(mode: mode) { context.send(viewAction: .voiceMessage(.startRecording)) - } stopRecording: { minimumRecordTimeReached in - if minimumRecordTimeReached { - context.send(viewAction: .voiceMessage(.stopRecording)) - } else { - context.send(viewAction: .voiceMessage(.cancelRecording)) - } + } stopRecording: { + context.send(viewAction: .voiceMessage(.stopRecording)) } - .padding(4) } private var voiceMessageTrashButton: some View { @@ -266,7 +269,9 @@ struct ComposerToolbar: View { context.send(viewAction: .voiceMessage(.deleteRecording)) } label: { CompoundIcon(\.delete) - .padding(EdgeInsets(top: 10, leading: 11, bottom: 10, trailing: 11)) + .scaledToFit() + .frame(width: deleteRecordingButtonSize, height: deleteRecordingButtonSize) + .padding(7) } .buttonStyle(.compound(.plain)) .accessibilityLabel(L10n.a11yDelete) diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift index f181019ea..411831b41 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift @@ -72,8 +72,6 @@ struct VoiceMessagePreviewComposer: View { .fill(Color.compound.bgSubtleSecondary) } } - .frame(minHeight: 42) - .fixedSize(horizontal: false, vertical: true) } @ViewBuilder diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift index 85b40f855..d64a05de2 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift @@ -17,79 +17,71 @@ import Compound import SwiftUI +enum VoiceMessageRecordingButtonMode { + case idle + case recording +} + struct VoiceMessageRecordingButton: View { + let mode: VoiceMessageRecordingButtonMode var startRecording: (() -> Void)? - var stopRecording: ((_ minimumRecordTimeReached: Bool) -> Void)? + var stopRecording: (() -> Void)? - @ScaledMetric private var tooltipPointerHeight = 6 - - @State private var buttonPressed = false - @State private var recordingStartTime: Date? - @State private var showTooltip = false - @State private var frame: CGRect = .zero - - private let minimumRecordingDuration = 1.0 - private let tooltipDuration = 1.0 private let impactFeedbackGenerator = UIImpactFeedbackGenerator() - private let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + @ScaledMetric(relativeTo: .title) private var idleImageSize = 16 + @ScaledMetric(relativeTo: .title) private var idleImagePadding = 10 + + @ScaledMetric(relativeTo: .title) private var recordingImageSize = 24 + @ScaledMetric(relativeTo: .title) private var recordingImagePadding = 6 var body: some View { - Button { } label: { - CompoundIcon(buttonPressed ? \.micOnSolid : \.micOnOutline) - .foregroundColor(.compound.iconSecondary) - .padding(EdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6)) - } - .readFrame($frame, in: .global) - .accessibilityLabel(L10n.a11yVoiceMessageRecord) - .onLongPressGesture { } onPressingChanged: { isPressing in - buttonPressed = isPressing - - if isPressing { - showTooltip = false - recordingStartTime = Date.now - impactFeedbackGenerator.impactOccurred() + Button { + impactFeedbackGenerator.impactOccurred() + switch mode { + case .idle: startRecording?() - } else { - if let recordingStartTime, Date.now.timeIntervalSince(recordingStartTime) < minimumRecordingDuration { - withElementAnimation { - showTooltip = true - } - notificationFeedbackGenerator.notificationOccurred(.error) - stopRecording?(false) - } else { - impactFeedbackGenerator.impactOccurred() - stopRecording?(true) - } - } - } - .overlay(alignment: .bottomTrailing) { - if showTooltip { - tooltipView - .offset(y: -frame.height - tooltipPointerHeight) + case .recording: + stopRecording?() + } + } label: { + switch mode { + case .idle: + CompoundIcon(\.micOnOutline, size: .medium, relativeTo: .title) + .foregroundColor(.compound.iconSecondary) + .frame(width: idleImageSize, height: idleImageSize) + .padding(idleImagePadding) + .padding(4) + case .recording: + Asset.Images.stopRecording.swiftUIImage + .resizable() + .foregroundColor(.compound.iconOnSolidPrimary) + .frame(width: recordingImageSize, height: recordingImageSize) + .padding(recordingImagePadding) + .background( + Circle() + .foregroundColor(.compound.bgActionPrimaryRest) + ) + .padding(4) } } + .buttonStyle(VoiceMessageRecordingButtonStyle()) + .accessibilityLabel(mode == .idle ? L10n.a11yVoiceMessageRecord : L10n.a11yVoiceMessageStopRecording) } - - private var tooltipView: some View { - VoiceMessageRecordingButtonTooltipView(text: L10n.screenRoomVoiceMessageTooltip, - pointerHeight: tooltipPointerHeight, - pointerLocation: frame.midX, - pointerLocationCoordinateSpace: .global) - .allowsHitTesting(false) - .fixedSize() - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + tooltipDuration) { - withElementAnimation { - showTooltip = false - } - } - } +} + +private struct VoiceMessageRecordingButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? 0.6 : 1) } } struct VoiceMessageRecordingButton_Previews: PreviewProvider, TestablePreview { static var previews: some View { - VoiceMessageRecordingButton() - .fixedSize(horizontal: true, vertical: true) + HStack { + VoiceMessageRecordingButton(mode: .idle) + + VoiceMessageRecordingButton(mode: .recording) + } } } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButtonTooltipView.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButtonTooltipView.swift deleted file mode 100644 index f93ebaac1..000000000 --- a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButtonTooltipView.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// Copyright 2023 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Compound -import Foundation -import SwiftUI - -struct VoiceMessageRecordingButtonTooltipView: View { - var text: String - var radius: CGFloat = 4 - var corners: UIRectCorner = .allCorners - @ScaledMetric var pointerHeight: CGFloat = 6 - @ScaledMetric var pointerWidth: CGFloat = 10 - var pointerLocation: CGFloat = 10 - var pointerLocationCoordinateSpace: CoordinateSpace = .local - - var body: some View { - Text(text) - .font(.compound.bodySMSemibold) - .foregroundColor(.compound.textOnSolidPrimary) - .padding(6) - .background( - GeometryReader { geometry in - TooltipShape(radius: radius, - corners: corners, - pointerHeight: pointerHeight, - pointerWidth: pointerWidth, - pointerLocation: localPointerLocation(using: geometry)) - .fill(.compound.bgActionPrimaryRest) - } - ) - } - - private func localPointerLocation(using geometry: GeometryProxy) -> CGFloat { - let frame = geometry.frame(in: pointerLocationCoordinateSpace) - let minX = radius + pointerWidth / 2 - let maxX = geometry.size.width - radius - pointerWidth / 2 - return min(max(minX, pointerLocation - frame.minX), maxX) - } -} - -private struct TooltipShape: Shape { - var radius: CGFloat - var corners: UIRectCorner - var pointerHeight: CGFloat - var pointerWidth: CGFloat - var pointerLocation: CGFloat - - func path(in rect: CGRect) -> Path { - var path = Path() - - let width = rect.size.width - let height = rect.size.height - - var topLeft: CGFloat = corners.contains(.topLeft) ? radius : 0.0 - var topRight: CGFloat = corners.contains(.topRight) ? radius : 0.0 - var bottomLeft: CGFloat = corners.contains(.bottomLeft) ? radius : 0.0 - var bottomRight: CGFloat = corners.contains(.bottomRight) ? radius : 0.0 - - // Make sure we do not exceed the size of the rectangle - topRight = min(min(topRight, height / 2), width / 2) - topLeft = min(min(topLeft, height / 2), width / 2) - bottomLeft = min(min(bottomLeft, height / 2), width / 2) - bottomRight = min(min(bottomRight, height / 2), width / 2) - - path.move(to: CGPoint(x: width / 2.0, y: 0)) - path.addLine(to: CGPoint(x: width - topRight, y: 0)) - path.addArc(center: CGPoint(x: width - topRight, y: topRight), radius: topRight, - startAngle: Angle(degrees: -90), endAngle: Angle(degrees: 0), clockwise: false) - - path.addLine(to: CGPoint(x: width, y: height - bottomRight)) - path.addArc(center: CGPoint(x: width - bottomRight, y: height - bottomRight), radius: bottomRight, - startAngle: Angle(degrees: 0), endAngle: Angle(degrees: 90), clockwise: false) - - path.addLine(to: CGPoint(x: pointerLocation + (pointerWidth / 2.0), y: height)) - path.addLine(to: CGPoint(x: pointerLocation, y: height + pointerHeight)) - path.addLine(to: CGPoint(x: pointerLocation - (pointerWidth / 2.0), y: height)) - - path.addLine(to: CGPoint(x: bottomLeft, y: height)) - path.addArc(center: CGPoint(x: bottomLeft, y: height - bottomLeft), radius: bottomLeft, - startAngle: Angle(degrees: 90), endAngle: Angle(degrees: 180), clockwise: false) - - path.addLine(to: CGPoint(x: 0, y: topLeft)) - path.addArc(center: CGPoint(x: topLeft, y: topLeft), radius: topLeft, - startAngle: Angle(degrees: 180), endAngle: Angle(degrees: 270), clockwise: false) - path.closeSubpath() - - return path - } -} - -struct VoiceMessageRecordingButtonTooltipView_Previews: PreviewProvider, TestablePreview { - static var previews: some View { - VoiceMessageRecordingButtonTooltipView(text: "Hold to record") - .fixedSize() - } -} diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingComposer.swift index e3227dccb..6414b3bba 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingComposer.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingComposer.swift @@ -32,8 +32,6 @@ struct VoiceMessageRecordingComposer: View { .fill(Color.compound.bgSubtleSecondary) } } - .frame(minHeight: 42) - .fixedSize(horizontal: false, vertical: true) } private func onPlaybackPlayPause() { } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift index 5f9bd3731..9768e0169 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenInteractionHandler.swift @@ -390,7 +390,12 @@ class RoomScreenInteractionHandler { } func deleteCurrentVoiceMessage() async { - await voiceMessageRecorder.deleteRecording() + if voiceMessageRecorder.isRecording { + await voiceMessageRecorder.cancelRecording() + } else { + await voiceMessageRecorder.deleteRecording() + } + voiceMessageRecorderObserver = nil actionsSubject.send(.composer(action: .setMode(mode: .default))) } @@ -473,7 +478,12 @@ class RoomScreenInteractionHandler { MXLog.error("Cannot play a voice message without an audio player") return } - + + // Stop any recording in progress + if voiceMessageRecorder.isRecording { + await voiceMessageRecorder.stopRecording() + } + let audioPlayerState = audioPlayerState(for: itemID) // Ensure this one is attached diff --git a/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift index f3341dda8..72150d4be 100644 --- a/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift +++ b/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift @@ -105,6 +105,11 @@ class AudioPlayer: NSObject, AudioPlayerProtocol { addObservers() } + func reset() { + stop() + unloadContent() + } + func play() { isStopped = false setupAudioSession() diff --git a/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift b/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift index c146dbca6..8d10f8026 100644 --- a/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift +++ b/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift @@ -40,10 +40,11 @@ class AudioRecorder: AudioRecorderProtocol { actionsSubject.eraseToAnyPublisher() } + private let maximumRecordingTime: TimeInterval = 1800 // 30 minutes private let silenceThreshold: Float = -50.0 private var meterLevel: Float = 0 - private(set) var audioFileUrl: URL? + private(set) var audioFileURL: URL? var currentTime: TimeInterval = .zero var isRecording: Bool { audioEngine?.isRunning ?? false @@ -56,13 +57,21 @@ class AudioRecorder: AudioRecorderProtocol { self.audioSession = audioSession } - func record(with recordID: AudioRecordingIdentifier) async { + deinit { + if isRecording { + // Cleanup + cleanupAudioEngine() + deleteRecordingFile() + } + } + + func record(audioFileURL: URL) async { stopped = false guard await requestRecordPermission() else { setInternalState(.error(.recordPermissionNotGranted)) return } - let result = await startRecording(with: recordID) + let result = await startRecording(audioFileURL: audioFileURL) switch result { case .success: setInternalState(.recording) @@ -124,25 +133,25 @@ class AudioRecorder: AudioRecorderProtocol { removeObservers() } - private func startRecording(with recordID: AudioRecordingIdentifier) async -> Result { + private func startRecording(audioFileURL: URL) async -> Result { await withCheckedContinuation { continuation in - startRecording(with: recordID) { result in + startRecording(audioFileURL: audioFileURL) { result in continuation.resume(returning: result) } } } - private func createAudioFile(with recordID: AudioRecordingIdentifier, sampleRate: Int) throws -> AVAudioFile { + private func createAudioFile(at recordingURL: URL, sampleRate: Int) throws -> AVAudioFile { let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC), AVSampleRateKey: sampleRate, AVNumberOfChannelsKey: 1, AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue] MXLog.info("creating audio file with format: \(settings)") - let outputURL = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a") - return try AVAudioFile(forWriting: outputURL, settings: settings) + try? FileManager.default.removeItem(at: recordingURL) + return try AVAudioFile(forWriting: recordingURL, settings: settings) } - private func startRecording(with recordID: AudioRecordingIdentifier, completion: @escaping (Result) -> Void) { + private func startRecording(audioFileURL: URL, completion: @escaping (Result) -> Void) { dispatchQueue.async { [weak self] in guard let self, !self.stopped else { completion(.failure(.recordingCancelled)) @@ -170,9 +179,9 @@ class AudioRecorder: AudioRecorderProtocol { currentTime = 0 let audioFile: AVAudioFile do { - audioFile = try createAudioFile(with: recordID, sampleRate: Int(sampleRate)) + audioFile = try createAudioFile(at: audioFileURL, sampleRate: Int(sampleRate)) self.audioFile = audioFile - audioFileUrl = audioFile.url + self.audioFileURL = audioFile.url } catch { MXLog.error("failed to create an audio file. \(error)") completion(.failure(.audioFileCreationFailure)) @@ -208,6 +217,7 @@ class AudioRecorder: AudioRecorderProtocol { } private func cleanupAudioEngine() { + MXLog.info("cleaning up the audio engine") if let audioEngine { audioEngine.stop() if let mixer { @@ -226,14 +236,22 @@ class AudioRecorder: AudioRecorderProtocol { completion() } guard let self else { return } - if let audioFileUrl { - try? FileManager.default.removeItem(at: audioFileUrl) - } - audioFileUrl = nil + deleteRecordingFile() + audioFileURL = nil currentTime = 0 } } + private func deleteRecordingFile() { + guard let audioFileURL else { return } + do { + try FileManager.default.removeItem(at: audioFileURL) + MXLog.info("recording file deleted.") + } catch { + MXLog.error("failed to delete recording file. \(error)") + } + } + // MARK: Audio Processing private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) { @@ -246,6 +264,12 @@ class AudioRecorder: AudioRecorderProtocol { // Update the recording duration only if we succeed to write the buffer currentTime += Double(buffer.frameLength) / buffer.format.sampleRate + + // Limit the recording time + if currentTime >= maximumRecordingTime { + MXLog.info("Maximum recording time reach (\(maximumRecordingTime))") + Task { await stopRecording() } + } } catch { MXLog.error("failed to write sample. \(error)") } @@ -331,7 +355,6 @@ class AudioRecorder: AudioRecorderProtocol { private func setInternalState(_ state: InternalAudioRecorderState) { dispatchQueue.async { [weak self] in guard let self else { return } - guard internalState != state else { return } MXLog.debug("internal state: \(internalState) -> \(state)") internalState = state diff --git a/ElementX/Sources/Services/Audio/Recorder/AudioRecorderProtocol.swift b/ElementX/Sources/Services/Audio/Recorder/AudioRecorderProtocol.swift index fa30677ce..6e0a1fb81 100644 --- a/ElementX/Sources/Services/Audio/Recorder/AudioRecorderProtocol.swift +++ b/ElementX/Sources/Services/Audio/Recorder/AudioRecorderProtocol.swift @@ -17,19 +17,6 @@ import Combine import Foundation -enum AudioRecordingIdentifier { - case uuid(UUID) -} - -extension AudioRecordingIdentifier { - var identifier: String { - switch self { - case .uuid(let uuid): - return uuid.uuidString - } - } -} - enum AudioRecorderError: Error, Equatable { case audioEngineFailure case audioFileCreationFailure @@ -49,9 +36,9 @@ protocol AudioRecorderProtocol: AnyObject { var actions: AnyPublisher { get } var currentTime: TimeInterval { get } var isRecording: Bool { get } - var audioFileUrl: URL? { get } + var audioFileURL: URL? { get } - func record(with recordID: AudioRecordingIdentifier) async + func record(audioFileURL: URL) async func stopRecording() async func deleteRecording() async func averagePower() -> Float diff --git a/ElementX/Sources/Services/Audio/Recorder/AudioRecorderState.swift b/ElementX/Sources/Services/Audio/Recorder/AudioRecorderState.swift index b874dcaba..c1d1fe5b1 100644 --- a/ElementX/Sources/Services/Audio/Recorder/AudioRecorderState.swift +++ b/ElementX/Sources/Services/Audio/Recorder/AudioRecorderState.swift @@ -41,6 +41,7 @@ class AudioRecorderState: ObservableObject, Identifiable { self.audioRecorder = audioRecorder subscribeToAudioRecorder(audioRecorder) if audioRecorder.isRecording { + recordingState = .recording startPublishUpdates() } } diff --git a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift index 02f3b6842..7af5e4e22 100644 --- a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift +++ b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift @@ -32,6 +32,7 @@ protocol MediaPlayerProtocol: AnyObject { var state: MediaPlayerState { get } func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool) + func reset() func play() func pause() func stop() diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift index ba47cda4e..8b5018b85 100644 --- a/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift @@ -22,6 +22,12 @@ class VoiceMessageCache: VoiceMessageCacheProtocol { FileManager.default.temporaryDirectory.appendingPathComponent("media/voice-message") } + var urlForRecording: URL { + // Make sure the directory exist + setupTemporaryFilesFolder() + return temporaryFilesFolderURL.appendingPathComponent("voice-message-recording").appendingPathExtension(preferredFileExtension) + } + func fileURL(for mediaSource: MediaSourceProxy) -> URL? { let url = cacheURL(for: mediaSource) return FileManager.default.fileExists(atPath: url.path()) ? url : nil @@ -69,6 +75,7 @@ class VoiceMessageCache: VoiceMessageCacheProtocol { } else { try FileManager.default.copyItem(at: source, to: destination) } + try (destination as NSURL).setResourceValue(URLFileProtection.complete, forKey: .fileProtectionKey) } private func cacheURL(for mediaSource: MediaSourceProxy) -> URL { diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageCacheProtocol.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCacheProtocol.swift index 505acdf73..9551b046e 100644 --- a/ElementX/Sources/Services/VoiceMessage/VoiceMessageCacheProtocol.swift +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCacheProtocol.swift @@ -22,6 +22,9 @@ enum VoiceMessageCacheError: Error { } protocol VoiceMessageCacheProtocol { + /// URL to use for recording + var urlForRecording: URL { get } + /// Returns the URL of the cached audio file for a given media source /// - Parameter mediaSource: the media source /// - Returns: the URL of the cached audio file or nil if the file doesn't exist diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift index b4f32f93c..a41ebbaa5 100644 --- a/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift @@ -33,8 +33,12 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol { private let mp4accMimeType = "audio/m4a" private let waveformSamplesCount = 100 + var isRecording: Bool { + audioRecorder.isRecording + } + var recordingURL: URL? { - audioRecorder.audioFileUrl + audioRecorder.audioFileURL } var recordingDuration: TimeInterval { @@ -67,9 +71,10 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol { func startRecording() async { await stopPlayback() + previewAudioPlayer?.reset() recordingCancelled = false - await audioRecorder.record(with: .uuid(UUID())) + await audioRecorder.record(audioFileURL: voiceMessageCache.urlForRecording) } func stopRecording() async { @@ -78,22 +83,26 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol { } func cancelRecording() async { + MXLog.info("Cancel recording.") recordingCancelled = true await audioRecorder.stopRecording() await audioRecorder.deleteRecording() previewAudioPlayerState = nil + previewAudioPlayer?.reset() } func deleteRecording() async { + MXLog.info("Delete recording.") await stopPlayback() await audioRecorder.deleteRecording() + previewAudioPlayer?.reset() previewAudioPlayerState = nil } // MARK: - Preview func startPlayback() async -> Result { - guard let previewAudioPlayerState, let url = audioRecorder.audioFileUrl else { + guard let previewAudioPlayerState, let url = audioRecorder.audioFileURL else { return .failure(.previewNotAvailable) } @@ -132,7 +141,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol { } func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError> { - guard let url = audioRecorder.audioFileUrl else { + guard let url = audioRecorder.audioFileURL else { return .failure(.missingRecordingFile) } // build the waveform @@ -150,13 +159,18 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol { } func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result { - guard let url = audioRecorder.audioFileUrl else { + guard let url = audioRecorder.audioFileURL else { return .failure(VoiceMessageRecorderError.missingRecordingFile) } // convert the file let sourceFilename = url.deletingPathExtension().lastPathComponent let oggFile = URL.temporaryDirectory.appendingPathComponent(sourceFilename).appendingPathExtension("ogg") + defer { + // delete the temporary file + try? FileManager.default.removeItem(at: oggFile) + } + do { try audioConverter.convertToOpusOgg(sourceURL: url, destinationURL: oggFile) } catch { @@ -180,8 +194,6 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol { audioInfo: audioInfo, waveform: waveform, progressSubject: nil) { _ in } - // delete the temporary file - try? FileManager.default.removeItem(at: oggFile) if case .failure(let error) = result { MXLog.error("Failed to send the voice message. \(error)") @@ -219,7 +231,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol { actionsSubject.send(.didFailWithError(error: VoiceMessageRecorderError.previewNotAvailable)) return } - guard let recordingURL = audioRecorder.audioFileUrl, let previewAudioPlayerState else { + guard let recordingURL = audioRecorder.audioFileURL, let previewAudioPlayerState else { actionsSubject.send(.didFailWithError(error: VoiceMessageRecorderError.previewNotAvailable)) return } @@ -235,7 +247,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol { private func finalizeRecording() async -> Result { MXLog.info("finalize audio recording") - guard let url = audioRecorder.audioFileUrl, audioRecorder.currentTime > 0 else { + guard let url = audioRecorder.audioFileURL, audioRecorder.currentTime > 0 else { return .failure(.previewNotAvailable) } diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorderProtocol.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorderProtocol.swift index 903178b8f..9eaa08b88 100644 --- a/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorderProtocol.swift +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorderProtocol.swift @@ -35,6 +35,7 @@ enum VoiceMessageRecorderAction { protocol VoiceMessageRecorderProtocol { var audioRecorder: AudioRecorderProtocol { get } var previewAudioPlayerState: AudioPlayerState? { get } + var isRecording: Bool { get } var recordingURL: URL? { get } var recordingDuration: TimeInterval { get } diff --git a/UnitTests/Sources/AudioRecorderTests.swift b/UnitTests/Sources/AudioRecorderTests.swift index ed16a2eff..6bc736c31 100644 --- a/UnitTests/Sources/AudioRecorderTests.swift +++ b/UnitTests/Sources/AudioRecorderTests.swift @@ -49,7 +49,8 @@ class AudioRecorderTests: XCTestCase { return false } } - await audioRecorder.record(with: .uuid(UUID())) + let url = URL.temporaryDirectory.appendingPathComponent("test-voice-message").appendingPathExtension("m4a") + await audioRecorder.record(audioFileURL: url) try await deferred.fulfill() XCTAssertFalse(audioRecorder.isRecording) } diff --git a/UnitTests/Sources/VoiceMessageRecorderTests.swift b/UnitTests/Sources/VoiceMessageRecorderTests.swift index e789d1d82..31285fbce 100644 --- a/UnitTests/Sources/VoiceMessageRecorderTests.swift +++ b/UnitTests/Sources/VoiceMessageRecorderTests.swift @@ -57,6 +57,7 @@ class VoiceMessageRecorderTests: XCTestCase { } audioConverter = AudioConverterMock() voiceMessageCache = VoiceMessageCacheMock() + voiceMessageCache.urlForRecording = FileManager.default.temporaryDirectory.appendingPathComponent("test-voice-message").appendingPathExtension("m4a") voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: audioRecorder, mediaPlayerProvider: mediaPlayerProvider, @@ -65,7 +66,7 @@ class VoiceMessageRecorderTests: XCTestCase { } private func setRecordingComplete() async throws { - audioRecorder.audioFileUrl = recordingURL + audioRecorder.audioFileURL = recordingURL audioRecorder.currentTime = 5 let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in @@ -81,7 +82,7 @@ class VoiceMessageRecorderTests: XCTestCase { } func testRecordingURL() async throws { - audioRecorder.audioFileUrl = recordingURL + audioRecorder.audioFileURL = recordingURL XCTAssertEqual(voiceMessageRecorder.recordingURL, recordingURL) } @@ -92,7 +93,7 @@ class VoiceMessageRecorderTests: XCTestCase { func testStartRecording() async throws { _ = await voiceMessageRecorder.startRecording() - XCTAssert(audioRecorder.recordWithCalled) + XCTAssert(audioRecorder.recordAudioFileURLCalled) } func testStopRecording() async throws { @@ -185,17 +186,17 @@ class VoiceMessageRecorderTests: XCTestCase { func testBuildRecordedWaveform() async throws { // If there is no recording file, an error is expected - audioRecorder.audioFileUrl = nil + audioRecorder.audioFileURL = nil guard case .failure(.missingRecordingFile) = await voiceMessageRecorder.buildRecordingWaveform() else { XCTFail("An error is expected") return } - guard let audioFileUrl = Bundle(for: Self.self).url(forResource: "test_audio", withExtension: "mp3") else { + guard let audioFileURL = Bundle(for: Self.self).url(forResource: "test_audio", withExtension: "mp3") else { XCTFail("Test audio file is missing") return } - audioRecorder.audioFileUrl = audioFileUrl + audioRecorder.audioFileURL = audioFileURL guard case .success(let data) = await voiceMessageRecorder.buildRecordingWaveform() else { XCTFail("A waveform is expected") return @@ -207,7 +208,7 @@ class VoiceMessageRecorderTests: XCTestCase { let roomProxy = RoomProxyMock() // If there is no recording file, an error is expected - audioRecorder.audioFileUrl = nil + audioRecorder.audioFileURL = nil guard case .failure(.missingRecordingFile) = await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: audioConverter) else { XCTFail("An error is expected") return @@ -215,7 +216,7 @@ class VoiceMessageRecorderTests: XCTestCase { } func testSendVoiceMessage_ConversionError() async throws { - audioRecorder.audioFileUrl = recordingURL + audioRecorder.audioFileURL = recordingURL // If the converter returns an error audioConverter.convertToOpusOggSourceURLDestinationURLThrowableError = AudioConverterError.conversionFailed(nil) @@ -227,11 +228,11 @@ class VoiceMessageRecorderTests: XCTestCase { } func testSendVoiceMessage_InvalidFile() async throws { - guard let audioFileUrl = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else { + guard let audioFileURL = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else { XCTFail("Test audio file is missing") return } - audioRecorder.audioFileUrl = audioFileUrl + audioRecorder.audioFileURL = audioFileURL audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { _, destination in try? FileManager.default.removeItem(at: destination) } @@ -245,14 +246,14 @@ class VoiceMessageRecorderTests: XCTestCase { } func testSendVoiceMessage_WaveformAnlyseFailed() async throws { - guard let imageFileUrl = Bundle(for: Self.self).url(forResource: "test_image", withExtension: "png") else { + guard let imageFileURL = Bundle(for: Self.self).url(forResource: "test_image", withExtension: "png") else { XCTFail("Test audio file is missing") return } - audioRecorder.audioFileUrl = imageFileUrl + audioRecorder.audioFileURL = imageFileURL audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { _, destination in try? FileManager.default.removeItem(at: destination) - try? FileManager.default.copyItem(at: imageFileUrl, to: destination) + try? FileManager.default.copyItem(at: imageFileURL, to: destination) } let roomProxy = RoomProxyMock() @@ -264,11 +265,11 @@ class VoiceMessageRecorderTests: XCTestCase { } func testSendVoiceMessage_SendError() async throws { - guard let audioFileUrl = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else { + guard let audioFileURL = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else { XCTFail("Test audio file is missing") return } - audioRecorder.audioFileUrl = audioFileUrl + audioRecorder.audioFileURL = audioFileURL audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { source, destination in try? FileManager.default.removeItem(at: destination) let internalConverter = AudioConverter() @@ -285,34 +286,34 @@ class VoiceMessageRecorderTests: XCTestCase { } func testSendVoiceMessage() async throws { - guard let audioFileUrl = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else { + guard let imageFileURL = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else { XCTFail("Test audio file is missing") return } let roomProxy = RoomProxyMock() audioRecorder.currentTime = 42 - audioRecorder.audioFileUrl = audioFileUrl + audioRecorder.audioFileURL = imageFileURL _ = await voiceMessageRecorder.startRecording() _ = await voiceMessageRecorder.stopRecording() - var convertedFileUrl: URL? + var convertedFileURL: URL? var convertedFileSize: UInt64? audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { source, destination in - convertedFileUrl = destination + convertedFileURL = destination try? FileManager.default.removeItem(at: destination) let internalConverter = AudioConverter() try internalConverter.convertToOpusOgg(sourceURL: source, destinationURL: destination) convertedFileSize = try? UInt64(FileManager.default.sizeForItem(at: destination)) // the source URL must be the recorded file - XCTAssertEqual(source, audioFileUrl) + XCTAssertEqual(source, imageFileURL) // check the converted file extension XCTAssertEqual(destination.pathExtension, "ogg") } roomProxy.sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleClosure = { url, audioInfo, waveform, _, _ in - XCTAssertEqual(url, convertedFileUrl) + XCTAssertEqual(url, convertedFileURL) XCTAssertEqual(audioInfo.duration, self.audioRecorder.currentTime) XCTAssertEqual(audioInfo.size, convertedFileSize) XCTAssertEqual(audioInfo.mimetype, "audio/ogg") @@ -330,8 +331,8 @@ class VoiceMessageRecorderTests: XCTestCase { XCTAssert(roomProxy.sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleCalled) // the converted file must have been deleted - if let convertedFileUrl { - XCTAssertFalse(FileManager.default.fileExists(atPath: convertedFileUrl.path())) + if let convertedFileURL { + XCTAssertFalse(FileManager.default.fileExists(atPath: convertedFileURL.path())) } else { XCTFail("converted file URL is missing") } @@ -351,7 +352,7 @@ class VoiceMessageRecorderTests: XCTestCase { } func testAudioRecorderActionHandling_didStopRecording() async throws { - audioRecorder.audioFileUrl = recordingURL + audioRecorder.audioFileURL = recordingURL audioRecorder.currentTime = 5 let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in @@ -367,7 +368,7 @@ class VoiceMessageRecorderTests: XCTestCase { } func testAudioRecorderActionHandling_didFailed() async throws { - audioRecorder.audioFileUrl = recordingURL + audioRecorder.audioFileURL = recordingURL let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in switch action { diff --git a/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.1.png b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.1.png index c763fbaeb..c56c66922 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1e6e18b515a424aa91b552323bb5cba4d01914b51dc47e8ec3b5e9f81a57225 -size 66446 +oid sha256:5dbc6341b15314bd83e81fc786e9a113d5c08080362bef5b2cfd44f947e447ee +size 67930 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png index fa71cf875..51cb117cc 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:866e24b28ef3ad776461af85ee96abaf83275d17c4e7c2047691479028fe0f06 -size 94382 +oid sha256:d2ac55d0cb116fdc1ea0c01dea819dd14162c4084d2e9bf66c42470fb1f41181 +size 95317 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.With-Suggestions.png b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.With-Suggestions.png index 3a097e528..fb11c6dd8 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.With-Suggestions.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.With-Suggestions.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:225ba3d11602f2bd92ba2bb50c01c855d134945f1bbb6abedf5ff0cd549220bd -size 95840 +oid sha256:77a370c81ffba8cdb1eb899deac3e81accb444872d659379e3ce16648880ea0a +size 97326 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_roomScreen.1.png b/UnitTests/__Snapshots__/PreviewTests/test_roomScreen.1.png index 516fb1cd6..a7230505d 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_roomScreen.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_roomScreen.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:12c2540041f1a2739e015b962f1490cb7d78201ab5c488d7b0edab06e304e127 -size 318511 +oid sha256:8128aece82c38468ca5d44dbe1602282ecdd8aa6db46ea8968aec359cf2ef0e9 +size 318628 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_timelineView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_timelineView.1.png index 2ba17a1af..c4e8adace 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_timelineView.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_timelineView.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:720eccf0e4e600da78abe5772d78e871866c3fc3b48da404079dd7285f34ecc6 -size 314996 +oid sha256:bb67c46c12b1593eea1f3bcf3c561daf1f4d208676a884f76b973735a81bc313 +size 315113 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_uITimelineView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_uITimelineView.1.png index 2ba17a1af..c4e8adace 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_uITimelineView.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_uITimelineView.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:720eccf0e4e600da78abe5772d78e871866c3fc3b48da404079dd7285f34ecc6 -size 314996 +oid sha256:bb67c46c12b1593eea1f3bcf3c561daf1f4d208676a884f76b973735a81bc313 +size 315113 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png index 1feeeddd9..d898961d8 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22f419d23e9c8df9325e84bc44e0d706a88b08335a6a5e9bbc8be3deef08d961 -size 61935 +oid sha256:e47226f5fbdd1032534b2f6a1a715d9eee94b25e5ec7e02592b7c51c10a33f70 +size 61828 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButton.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButton.1.png index 1c919113d..1c0569211 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButton.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButton.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:454f1594e4f0eee33f53dcc3d45653087fbf1254324396b65723918af8a44536 -size 56462 +oid sha256:0d29e54f5d54222a0532f073b3111294335b90e84b56b277e404ceb96c602b3d +size 58855 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButtonTooltipView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButtonTooltipView.1.png deleted file mode 100644 index 96bd54c22..000000000 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingButtonTooltipView.1.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ffcce0a3fe8e27798377372f73090b72a27c986d36f4936701317d60571f5b92 -size 60040 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingComposer.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingComposer.1.png index bf747f60d..b78c65aaf 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingComposer.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingComposer.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49ba019171a846ae54d2849ff4ae4f02194fceda407fd26077cbf16a5f8682f3 -size 58567 +oid sha256:ada032ec7d48cc981306ffaaa0be302a3edd5356d4d9c87257596ab0a9cce8d8 +size 58335