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
This commit is contained in:
Nicolas Mauri
2023-11-10 16:32:22 +01:00
committed by GitHub
parent 5a0dab1ce8
commit da831f6725
34 changed files with 283 additions and 296 deletions
-4
View File
@@ -527,7 +527,6 @@
8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */; }; 8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */; };
8C050A8012E6078BEAEF5BC8 /* PillTextAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913C8E13B8B602C7B6C0C4AE /* PillTextAttachmentData.swift */; }; 8C050A8012E6078BEAEF5BC8 /* PillTextAttachmentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913C8E13B8B602C7B6C0C4AE /* PillTextAttachmentData.swift */; };
8C1A5ECAF895D4CAF8C4D461 /* AppActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F21ED7205048668BEB44A38 /* AppActivityView.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 */; }; 8C42B5B1642D189C362A5EDF /* SecureBackupScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91831D7042EADD0CC2B5EC36 /* SecureBackupScreenUITests.swift */; };
8C454500B8073E1201F801A9 /* MXLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34A814CBD56230BC74FFCF4 /* MXLogger.swift */; }; 8C454500B8073E1201F801A9 /* MXLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A34A814CBD56230BC74FFCF4 /* MXLogger.swift */; };
8C706DA7EAC0974CA2F8F1CD /* MentionBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15748C254911E3654C93B0ED /* MentionBuilder.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 = "<group>"; }; FE87C931165F5E201CACBB87 /* MediaPlayerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProtocol.swift; sourceTree = "<group>"; };
FEC2E8E1B20BB2EA07B0B61E /* WelcomeScreenScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeScreenScreenViewModel.swift; sourceTree = "<group>"; }; FEC2E8E1B20BB2EA07B0B61E /* WelcomeScreenScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeScreenScreenViewModel.swift; sourceTree = "<group>"; };
FEFEEE93B82937B2E86F92EB /* AnalyticsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsScreen.swift; sourceTree = "<group>"; }; FEFEEE93B82937B2E86F92EB /* AnalyticsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsScreen.swift; sourceTree = "<group>"; };
FF449205DF1E9817115245C4 /* VoiceMessageRecordingButtonTooltipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingButtonTooltipView.swift; sourceTree = "<group>"; };
FFECCE59967018204876D0A5 /* LocationMarkerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationMarkerView.swift; sourceTree = "<group>"; }; FFECCE59967018204876D0A5 /* LocationMarkerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationMarkerView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -2827,7 +2825,6 @@
3E6A9B9DFEE964962C179DE3 /* RoomAttachmentPicker.swift */, 3E6A9B9DFEE964962C179DE3 /* RoomAttachmentPicker.swift */,
BFC9F57320EC80C7CE34FE4A /* VoiceMessagePreviewComposer.swift */, BFC9F57320EC80C7CE34FE4A /* VoiceMessagePreviewComposer.swift */,
D2E61DDB42C0DE429C0955D8 /* VoiceMessageRecordingButton.swift */, D2E61DDB42C0DE429C0955D8 /* VoiceMessageRecordingButton.swift */,
FF449205DF1E9817115245C4 /* VoiceMessageRecordingButtonTooltipView.swift */,
CCB6F36CCE44A29A06FCAF1C /* VoiceMessageRecordingComposer.swift */, CCB6F36CCE44A29A06FCAF1C /* VoiceMessageRecordingComposer.swift */,
0A634D8DD1E10D858CF7995D /* VoiceMessageRecordingView.swift */, 0A634D8DD1E10D858CF7995D /* VoiceMessageRecordingView.swift */,
); );
@@ -5909,7 +5906,6 @@
C2879369106A419A5071F1F8 /* VoiceMessageRecorder.swift in Sources */, C2879369106A419A5071F1F8 /* VoiceMessageRecorder.swift in Sources */,
19DED23340D0855B59693ED2 /* VoiceMessageRecorderProtocol.swift in Sources */, 19DED23340D0855B59693ED2 /* VoiceMessageRecorderProtocol.swift in Sources */,
09EF4222EEBBA1A7B8F4071E /* VoiceMessageRecordingButton.swift in Sources */, 09EF4222EEBBA1A7B8F4071E /* VoiceMessageRecordingButton.swift in Sources */,
8C27BEB00B903D953F31F962 /* VoiceMessageRecordingButtonTooltipView.swift in Sources */,
CA5BFF0C2EF5A8EF40CA2D69 /* VoiceMessageRecordingComposer.swift in Sources */, CA5BFF0C2EF5A8EF40CA2D69 /* VoiceMessageRecordingComposer.swift in Sources */,
EBDB339A7C127F068B6E52E5 /* VoiceMessageRecordingView.swift in Sources */, EBDB339A7C127F068B6E52E5 /* VoiceMessageRecordingView.swift in Sources */,
A9482B967FC85DA611514D35 /* VoiceMessageRoomPlaybackView.swift in Sources */, A9482B967FC85DA611514D35 /* VoiceMessageRoomPlaybackView.swift in Sources */,
@@ -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"
}
}
@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="stop_FILL1_wght400_GRAD0_opsz24 1">
<path id="Vector" d="M6 16V8C6 7.45 6.19583 6.97917 6.5875 6.5875C6.97917 6.19583 7.45 6 8 6H16C16.55 6 17.0208 6.19583 17.4125 6.5875C17.8042 6.97917 18 7.45 18 8V16C18 16.55 17.8042 17.0208 17.4125 17.4125C17.0208 17.8042 16.55 18 16 18H8C7.45 18 6.97917 17.8042 6.5875 17.4125C6.19583 17.0208 6 16.55 6 16Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 477 B

@@ -12,7 +12,8 @@
"a11y_show_password" = "Show password"; "a11y_show_password" = "Show password";
"a11y_start_call" = "Start a call"; "a11y_start_call" = "Start a call";
"a11y_user_menu" = "User menu"; "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_accept" = "Accept";
"action_add_to_timeline" = "Add to timeline"; "action_add_to_timeline" = "Add to timeline";
"action_back" = "Back"; "action_back" = "Back";
+1
View File
@@ -52,6 +52,7 @@ internal enum Asset {
internal static let numberedList = ImageAsset(name: "images/numbered-list") internal static let numberedList = ImageAsset(name: "images/numbered-list")
internal static let quote = ImageAsset(name: "images/quote") internal static let quote = ImageAsset(name: "images/quote")
internal static let sendMessage = ImageAsset(name: "images/send-message") 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 strikethrough = ImageAsset(name: "images/strikethrough")
internal static let textFormat = ImageAsset(name: "images/text-format") internal static let textFormat = ImageAsset(name: "images/text-format")
internal static let underline = ImageAsset(name: "images/underline") internal static let underline = ImageAsset(name: "images/underline")
+3 -1
View File
@@ -40,8 +40,10 @@ public enum L10n {
public static var a11yStartCall: String { return L10n.tr("Localizable", "a11y_start_call") } public static var a11yStartCall: String { return L10n.tr("Localizable", "a11y_start_call") }
/// User menu /// User menu
public static var a11yUserMenu: String { return L10n.tr("Localizable", "a11y_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") } 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 /// Accept
public static var actionAccept: String { return L10n.tr("Localizable", "action_accept") } public static var actionAccept: String { return L10n.tr("Localizable", "action_accept") }
/// Add to timeline /// Add to timeline
@@ -466,6 +466,18 @@ class AudioPlayerMock: AudioPlayerProtocol {
loadMediaSourceUsingAutoplayReceivedInvocations.append((mediaSource: mediaSource, url: url, autoplay: autoplay)) loadMediaSourceUsingAutoplayReceivedInvocations.append((mediaSource: mediaSource, url: url, autoplay: autoplay))
loadMediaSourceUsingAutoplayClosure?(mediaSource, url, 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 //MARK: - play
var playCallsCount = 0 var playCallsCount = 0
@@ -535,23 +547,23 @@ class AudioRecorderMock: AudioRecorderProtocol {
set(value) { underlyingIsRecording = value } set(value) { underlyingIsRecording = value }
} }
var underlyingIsRecording: Bool! var underlyingIsRecording: Bool!
var audioFileUrl: URL? var audioFileURL: URL?
//MARK: - record //MARK: - record
var recordWithCallsCount = 0 var recordAudioFileURLCallsCount = 0
var recordWithCalled: Bool { var recordAudioFileURLCalled: Bool {
return recordWithCallsCount > 0 return recordAudioFileURLCallsCount > 0
} }
var recordWithReceivedRecordID: AudioRecordingIdentifier? var recordAudioFileURLReceivedAudioFileURL: URL?
var recordWithReceivedInvocations: [AudioRecordingIdentifier] = [] var recordAudioFileURLReceivedInvocations: [URL] = []
var recordWithClosure: ((AudioRecordingIdentifier) async -> Void)? var recordAudioFileURLClosure: ((URL) async -> Void)?
func record(with recordID: AudioRecordingIdentifier) async { func record(audioFileURL: URL) async {
recordWithCallsCount += 1 recordAudioFileURLCallsCount += 1
recordWithReceivedRecordID = recordID recordAudioFileURLReceivedAudioFileURL = audioFileURL
recordWithReceivedInvocations.append(recordID) recordAudioFileURLReceivedInvocations.append(audioFileURL)
await recordWithClosure?(recordID) await recordAudioFileURLClosure?(audioFileURL)
} }
//MARK: - stopRecording //MARK: - stopRecording
@@ -1098,6 +1110,18 @@ class MediaPlayerMock: MediaPlayerProtocol {
loadMediaSourceUsingAutoplayReceivedInvocations.append((mediaSource: mediaSource, url: url, autoplay: autoplay)) loadMediaSourceUsingAutoplayReceivedInvocations.append((mediaSource: mediaSource, url: url, autoplay: autoplay))
loadMediaSourceUsingAutoplayClosure?(mediaSource, url, 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 //MARK: - play
var playCallsCount = 0 var playCallsCount = 0
@@ -3143,6 +3167,11 @@ class UserNotificationCenterMock: UserNotificationCenterProtocol {
} }
} }
class VoiceMessageCacheMock: VoiceMessageCacheProtocol { class VoiceMessageCacheMock: VoiceMessageCacheProtocol {
var urlForRecording: URL {
get { return underlyingUrlForRecording }
set(value) { underlyingUrlForRecording = value }
}
var underlyingUrlForRecording: URL!
//MARK: - fileURL //MARK: - fileURL
@@ -3234,6 +3263,11 @@ class VoiceMessageRecorderMock: VoiceMessageRecorderProtocol {
} }
var underlyingAudioRecorder: AudioRecorderProtocol! var underlyingAudioRecorder: AudioRecorderProtocol!
var previewAudioPlayerState: AudioPlayerState? var previewAudioPlayerState: AudioPlayerState?
var isRecording: Bool {
get { return underlyingIsRecording }
set(value) { underlyingIsRecording = value }
}
var underlyingIsRecording: Bool!
var recordingURL: URL? var recordingURL: URL?
var recordingDuration: TimeInterval { var recordingDuration: TimeInterval {
get { return underlyingRecordingDuration } get { return underlyingRecordingDuration }
@@ -24,9 +24,13 @@ struct ComposerToolbar: View {
let keyCommandHandler: KeyCommandHandler let keyCommandHandler: KeyCommandHandler
@FocusState private var composerFocused: Bool @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 spinnerSize = 44
@ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30 @ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30
@ScaledMetric(relativeTo: .title) private var deleteRecordingButtonSize = 30
@State private var frame: CGRect = .zero @State private var frame: CGRect = .zero
@Environment(\.verticalSizeClass) private var verticalSizeClass @Environment(\.verticalSizeClass) private var verticalSizeClass
@@ -77,8 +81,8 @@ struct ComposerToolbar: View {
sendButton sendButton
.padding(.leading, 3) .padding(.leading, 3)
} else { } else {
voiceMessageRecordingButton voiceMessageRecordingButton(mode: context.viewState.isVoiceMessageModeActivated ? .recording : .idle)
.padding(.leading, 4) .padding(.leading, 3)
} }
} }
} }
@@ -122,6 +126,7 @@ struct ComposerToolbar: View {
if context.viewState.isVoiceMessageModeActivated { if context.viewState.isVoiceMessageModeActivated {
voiceMessageContent voiceMessageContent
.fixedSize(horizontal: false, vertical: true)
} }
} }
} }
@@ -214,8 +219,9 @@ struct ComposerToolbar: View {
.accessibilityHidden(!context.viewState.composerMode.isEdit) .accessibilityHidden(!context.viewState.composerMode.isEdit)
Image(asset: Asset.Images.sendMessage) Image(asset: Asset.Images.sendMessage)
.resizable() .resizable()
.offset(x: sendButtonIconOffsetX)
.frame(width: sendButtonIconSize, height: sendButtonIconSize) .frame(width: sendButtonIconSize, height: sendButtonIconSize)
.padding(EdgeInsets(top: 10, leading: 11, bottom: 10, trailing: 9)) .padding(sendButtonIconPadding)
.opacity(context.viewState.composerMode.isEdit ? 0 : 1) .opacity(context.viewState.composerMode.isEdit ? 0 : 1)
.accessibilityLabel(L10n.actionSend) .accessibilityLabel(L10n.actionSend)
.accessibilityHidden(context.viewState.composerMode.isEdit) .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. // Display the voice message composer above to keep the focus and keep the keyboard open if it's already open.
switch context.viewState.composerMode { switch context.viewState.composerMode {
case .recordVoiceMessage(let state): case .recordVoiceMessage(let state):
VoiceMessageRecordingComposer(recorderState: state) topBarLayout {
.padding(.leading, 12) voiceMessageTrashButton
VoiceMessageRecordingComposer(recorderState: state)
}
case .previewVoiceMessage(let state, let waveform, let isUploading): case .previewVoiceMessage(let state, let waveform, let isUploading):
topBarLayout { topBarLayout {
voiceMessageTrashButton voiceMessageTrashButton
@@ -248,17 +256,12 @@ struct ComposerToolbar: View {
} }
} }
private var voiceMessageRecordingButton: some View { private func voiceMessageRecordingButton(mode: VoiceMessageRecordingButtonMode) -> some View {
VoiceMessageRecordingButton { VoiceMessageRecordingButton(mode: mode) {
context.send(viewAction: .voiceMessage(.startRecording)) context.send(viewAction: .voiceMessage(.startRecording))
} stopRecording: { minimumRecordTimeReached in } stopRecording: {
if minimumRecordTimeReached { context.send(viewAction: .voiceMessage(.stopRecording))
context.send(viewAction: .voiceMessage(.stopRecording))
} else {
context.send(viewAction: .voiceMessage(.cancelRecording))
}
} }
.padding(4)
} }
private var voiceMessageTrashButton: some View { private var voiceMessageTrashButton: some View {
@@ -266,7 +269,9 @@ struct ComposerToolbar: View {
context.send(viewAction: .voiceMessage(.deleteRecording)) context.send(viewAction: .voiceMessage(.deleteRecording))
} label: { } label: {
CompoundIcon(\.delete) CompoundIcon(\.delete)
.padding(EdgeInsets(top: 10, leading: 11, bottom: 10, trailing: 11)) .scaledToFit()
.frame(width: deleteRecordingButtonSize, height: deleteRecordingButtonSize)
.padding(7)
} }
.buttonStyle(.compound(.plain)) .buttonStyle(.compound(.plain))
.accessibilityLabel(L10n.a11yDelete) .accessibilityLabel(L10n.a11yDelete)
@@ -72,8 +72,6 @@ struct VoiceMessagePreviewComposer: View {
.fill(Color.compound.bgSubtleSecondary) .fill(Color.compound.bgSubtleSecondary)
} }
} }
.frame(minHeight: 42)
.fixedSize(horizontal: false, vertical: true)
} }
@ViewBuilder @ViewBuilder
@@ -17,79 +17,71 @@
import Compound import Compound
import SwiftUI import SwiftUI
enum VoiceMessageRecordingButtonMode {
case idle
case recording
}
struct VoiceMessageRecordingButton: View { struct VoiceMessageRecordingButton: View {
let mode: VoiceMessageRecordingButtonMode
var startRecording: (() -> Void)? 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 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 { var body: some View {
Button { } label: { Button {
CompoundIcon(buttonPressed ? \.micOnSolid : \.micOnOutline) impactFeedbackGenerator.impactOccurred()
.foregroundColor(.compound.iconSecondary) switch mode {
.padding(EdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6)) case .idle:
}
.readFrame($frame, in: .global)
.accessibilityLabel(L10n.a11yVoiceMessageRecord)
.onLongPressGesture { } onPressingChanged: { isPressing in
buttonPressed = isPressing
if isPressing {
showTooltip = false
recordingStartTime = Date.now
impactFeedbackGenerator.impactOccurred()
startRecording?() startRecording?()
} else { case .recording:
if let recordingStartTime, Date.now.timeIntervalSince(recordingStartTime) < minimumRecordingDuration { stopRecording?()
withElementAnimation { }
showTooltip = true } label: {
} switch mode {
notificationFeedbackGenerator.notificationOccurred(.error) case .idle:
stopRecording?(false) CompoundIcon(\.micOnOutline, size: .medium, relativeTo: .title)
} else { .foregroundColor(.compound.iconSecondary)
impactFeedbackGenerator.impactOccurred() .frame(width: idleImageSize, height: idleImageSize)
stopRecording?(true) .padding(idleImagePadding)
} .padding(4)
} case .recording:
} Asset.Images.stopRecording.swiftUIImage
.overlay(alignment: .bottomTrailing) { .resizable()
if showTooltip { .foregroundColor(.compound.iconOnSolidPrimary)
tooltipView .frame(width: recordingImageSize, height: recordingImageSize)
.offset(y: -frame.height - tooltipPointerHeight) .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, private struct VoiceMessageRecordingButtonStyle: ButtonStyle {
pointerHeight: tooltipPointerHeight, func makeBody(configuration: Configuration) -> some View {
pointerLocation: frame.midX, configuration.label
pointerLocationCoordinateSpace: .global) .opacity(configuration.isPressed ? 0.6 : 1)
.allowsHitTesting(false)
.fixedSize()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + tooltipDuration) {
withElementAnimation {
showTooltip = false
}
}
}
} }
} }
struct VoiceMessageRecordingButton_Previews: PreviewProvider, TestablePreview { struct VoiceMessageRecordingButton_Previews: PreviewProvider, TestablePreview {
static var previews: some View { static var previews: some View {
VoiceMessageRecordingButton() HStack {
.fixedSize(horizontal: true, vertical: true) VoiceMessageRecordingButton(mode: .idle)
VoiceMessageRecordingButton(mode: .recording)
}
} }
} }
@@ -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()
}
}
@@ -32,8 +32,6 @@ struct VoiceMessageRecordingComposer: View {
.fill(Color.compound.bgSubtleSecondary) .fill(Color.compound.bgSubtleSecondary)
} }
} }
.frame(minHeight: 42)
.fixedSize(horizontal: false, vertical: true)
} }
private func onPlaybackPlayPause() { } private func onPlaybackPlayPause() { }
@@ -390,7 +390,12 @@ class RoomScreenInteractionHandler {
} }
func deleteCurrentVoiceMessage() async { func deleteCurrentVoiceMessage() async {
await voiceMessageRecorder.deleteRecording() if voiceMessageRecorder.isRecording {
await voiceMessageRecorder.cancelRecording()
} else {
await voiceMessageRecorder.deleteRecording()
}
voiceMessageRecorderObserver = nil voiceMessageRecorderObserver = nil
actionsSubject.send(.composer(action: .setMode(mode: .default))) actionsSubject.send(.composer(action: .setMode(mode: .default)))
} }
@@ -473,7 +478,12 @@ class RoomScreenInteractionHandler {
MXLog.error("Cannot play a voice message without an audio player") MXLog.error("Cannot play a voice message without an audio player")
return return
} }
// Stop any recording in progress
if voiceMessageRecorder.isRecording {
await voiceMessageRecorder.stopRecording()
}
let audioPlayerState = audioPlayerState(for: itemID) let audioPlayerState = audioPlayerState(for: itemID)
// Ensure this one is attached // Ensure this one is attached
@@ -105,6 +105,11 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
addObservers() addObservers()
} }
func reset() {
stop()
unloadContent()
}
func play() { func play() {
isStopped = false isStopped = false
setupAudioSession() setupAudioSession()
@@ -40,10 +40,11 @@ class AudioRecorder: AudioRecorderProtocol {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
private let maximumRecordingTime: TimeInterval = 1800 // 30 minutes
private let silenceThreshold: Float = -50.0 private let silenceThreshold: Float = -50.0
private var meterLevel: Float = 0 private var meterLevel: Float = 0
private(set) var audioFileUrl: URL? private(set) var audioFileURL: URL?
var currentTime: TimeInterval = .zero var currentTime: TimeInterval = .zero
var isRecording: Bool { var isRecording: Bool {
audioEngine?.isRunning ?? false audioEngine?.isRunning ?? false
@@ -56,13 +57,21 @@ class AudioRecorder: AudioRecorderProtocol {
self.audioSession = audioSession self.audioSession = audioSession
} }
func record(with recordID: AudioRecordingIdentifier) async { deinit {
if isRecording {
// Cleanup
cleanupAudioEngine()
deleteRecordingFile()
}
}
func record(audioFileURL: URL) async {
stopped = false stopped = false
guard await requestRecordPermission() else { guard await requestRecordPermission() else {
setInternalState(.error(.recordPermissionNotGranted)) setInternalState(.error(.recordPermissionNotGranted))
return return
} }
let result = await startRecording(with: recordID) let result = await startRecording(audioFileURL: audioFileURL)
switch result { switch result {
case .success: case .success:
setInternalState(.recording) setInternalState(.recording)
@@ -124,25 +133,25 @@ class AudioRecorder: AudioRecorderProtocol {
removeObservers() removeObservers()
} }
private func startRecording(with recordID: AudioRecordingIdentifier) async -> Result<Void, AudioRecorderError> { private func startRecording(audioFileURL: URL) async -> Result<Void, AudioRecorderError> {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
startRecording(with: recordID) { result in startRecording(audioFileURL: audioFileURL) { result in
continuation.resume(returning: result) 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), let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: sampleRate, AVSampleRateKey: sampleRate,
AVNumberOfChannelsKey: 1, AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue] AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]
MXLog.info("creating audio file with format: \(settings)") MXLog.info("creating audio file with format: \(settings)")
let outputURL = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a") try? FileManager.default.removeItem(at: recordingURL)
return try AVAudioFile(forWriting: outputURL, settings: settings) return try AVAudioFile(forWriting: recordingURL, settings: settings)
} }
private func startRecording(with recordID: AudioRecordingIdentifier, completion: @escaping (Result<Void, AudioRecorderError>) -> Void) { private func startRecording(audioFileURL: URL, completion: @escaping (Result<Void, AudioRecorderError>) -> Void) {
dispatchQueue.async { [weak self] in dispatchQueue.async { [weak self] in
guard let self, !self.stopped else { guard let self, !self.stopped else {
completion(.failure(.recordingCancelled)) completion(.failure(.recordingCancelled))
@@ -170,9 +179,9 @@ class AudioRecorder: AudioRecorderProtocol {
currentTime = 0 currentTime = 0
let audioFile: AVAudioFile let audioFile: AVAudioFile
do { do {
audioFile = try createAudioFile(with: recordID, sampleRate: Int(sampleRate)) audioFile = try createAudioFile(at: audioFileURL, sampleRate: Int(sampleRate))
self.audioFile = audioFile self.audioFile = audioFile
audioFileUrl = audioFile.url self.audioFileURL = audioFile.url
} catch { } catch {
MXLog.error("failed to create an audio file. \(error)") MXLog.error("failed to create an audio file. \(error)")
completion(.failure(.audioFileCreationFailure)) completion(.failure(.audioFileCreationFailure))
@@ -208,6 +217,7 @@ class AudioRecorder: AudioRecorderProtocol {
} }
private func cleanupAudioEngine() { private func cleanupAudioEngine() {
MXLog.info("cleaning up the audio engine")
if let audioEngine { if let audioEngine {
audioEngine.stop() audioEngine.stop()
if let mixer { if let mixer {
@@ -226,14 +236,22 @@ class AudioRecorder: AudioRecorderProtocol {
completion() completion()
} }
guard let self else { return } guard let self else { return }
if let audioFileUrl { deleteRecordingFile()
try? FileManager.default.removeItem(at: audioFileUrl) audioFileURL = nil
}
audioFileUrl = nil
currentTime = 0 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 // MARK: Audio Processing
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) { private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
@@ -246,6 +264,12 @@ class AudioRecorder: AudioRecorderProtocol {
// Update the recording duration only if we succeed to write the buffer // Update the recording duration only if we succeed to write the buffer
currentTime += Double(buffer.frameLength) / buffer.format.sampleRate 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 { } catch {
MXLog.error("failed to write sample. \(error)") MXLog.error("failed to write sample. \(error)")
} }
@@ -331,7 +355,6 @@ class AudioRecorder: AudioRecorderProtocol {
private func setInternalState(_ state: InternalAudioRecorderState) { private func setInternalState(_ state: InternalAudioRecorderState) {
dispatchQueue.async { [weak self] in dispatchQueue.async { [weak self] in
guard let self else { return } guard let self else { return }
guard internalState != state else { return }
MXLog.debug("internal state: \(internalState) -> \(state)") MXLog.debug("internal state: \(internalState) -> \(state)")
internalState = state internalState = state
@@ -17,19 +17,6 @@
import Combine import Combine
import Foundation 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 { enum AudioRecorderError: Error, Equatable {
case audioEngineFailure case audioEngineFailure
case audioFileCreationFailure case audioFileCreationFailure
@@ -49,9 +36,9 @@ protocol AudioRecorderProtocol: AnyObject {
var actions: AnyPublisher<AudioRecorderAction, Never> { get } var actions: AnyPublisher<AudioRecorderAction, Never> { get }
var currentTime: TimeInterval { get } var currentTime: TimeInterval { get }
var isRecording: Bool { 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 stopRecording() async
func deleteRecording() async func deleteRecording() async
func averagePower() -> Float func averagePower() -> Float
@@ -41,6 +41,7 @@ class AudioRecorderState: ObservableObject, Identifiable {
self.audioRecorder = audioRecorder self.audioRecorder = audioRecorder
subscribeToAudioRecorder(audioRecorder) subscribeToAudioRecorder(audioRecorder)
if audioRecorder.isRecording { if audioRecorder.isRecording {
recordingState = .recording
startPublishUpdates() startPublishUpdates()
} }
} }
@@ -32,6 +32,7 @@ protocol MediaPlayerProtocol: AnyObject {
var state: MediaPlayerState { get } var state: MediaPlayerState { get }
func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool) func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool)
func reset()
func play() func play()
func pause() func pause()
func stop() func stop()
@@ -22,6 +22,12 @@ class VoiceMessageCache: VoiceMessageCacheProtocol {
FileManager.default.temporaryDirectory.appendingPathComponent("media/voice-message") 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? { func fileURL(for mediaSource: MediaSourceProxy) -> URL? {
let url = cacheURL(for: mediaSource) let url = cacheURL(for: mediaSource)
return FileManager.default.fileExists(atPath: url.path()) ? url : nil return FileManager.default.fileExists(atPath: url.path()) ? url : nil
@@ -69,6 +75,7 @@ class VoiceMessageCache: VoiceMessageCacheProtocol {
} else { } else {
try FileManager.default.copyItem(at: source, to: destination) try FileManager.default.copyItem(at: source, to: destination)
} }
try (destination as NSURL).setResourceValue(URLFileProtection.complete, forKey: .fileProtectionKey)
} }
private func cacheURL(for mediaSource: MediaSourceProxy) -> URL { private func cacheURL(for mediaSource: MediaSourceProxy) -> URL {
@@ -22,6 +22,9 @@ enum VoiceMessageCacheError: Error {
} }
protocol VoiceMessageCacheProtocol { protocol VoiceMessageCacheProtocol {
/// URL to use for recording
var urlForRecording: URL { get }
/// Returns the URL of the cached audio file for a given media source /// Returns the URL of the cached audio file for a given media source
/// - Parameter mediaSource: the media source /// - Parameter mediaSource: the media source
/// - Returns: the URL of the cached audio file or nil if the file doesn't exist /// - Returns: the URL of the cached audio file or nil if the file doesn't exist
@@ -33,8 +33,12 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
private let mp4accMimeType = "audio/m4a" private let mp4accMimeType = "audio/m4a"
private let waveformSamplesCount = 100 private let waveformSamplesCount = 100
var isRecording: Bool {
audioRecorder.isRecording
}
var recordingURL: URL? { var recordingURL: URL? {
audioRecorder.audioFileUrl audioRecorder.audioFileURL
} }
var recordingDuration: TimeInterval { var recordingDuration: TimeInterval {
@@ -67,9 +71,10 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
func startRecording() async { func startRecording() async {
await stopPlayback() await stopPlayback()
previewAudioPlayer?.reset()
recordingCancelled = false recordingCancelled = false
await audioRecorder.record(with: .uuid(UUID())) await audioRecorder.record(audioFileURL: voiceMessageCache.urlForRecording)
} }
func stopRecording() async { func stopRecording() async {
@@ -78,22 +83,26 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
} }
func cancelRecording() async { func cancelRecording() async {
MXLog.info("Cancel recording.")
recordingCancelled = true recordingCancelled = true
await audioRecorder.stopRecording() await audioRecorder.stopRecording()
await audioRecorder.deleteRecording() await audioRecorder.deleteRecording()
previewAudioPlayerState = nil previewAudioPlayerState = nil
previewAudioPlayer?.reset()
} }
func deleteRecording() async { func deleteRecording() async {
MXLog.info("Delete recording.")
await stopPlayback() await stopPlayback()
await audioRecorder.deleteRecording() await audioRecorder.deleteRecording()
previewAudioPlayer?.reset()
previewAudioPlayerState = nil previewAudioPlayerState = nil
} }
// MARK: - Preview // MARK: - Preview
func startPlayback() async -> Result<Void, VoiceMessageRecorderError> { func startPlayback() async -> Result<Void, VoiceMessageRecorderError> {
guard let previewAudioPlayerState, let url = audioRecorder.audioFileUrl else { guard let previewAudioPlayerState, let url = audioRecorder.audioFileURL else {
return .failure(.previewNotAvailable) return .failure(.previewNotAvailable)
} }
@@ -132,7 +141,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
} }
func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError> { func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError> {
guard let url = audioRecorder.audioFileUrl else { guard let url = audioRecorder.audioFileURL else {
return .failure(.missingRecordingFile) return .failure(.missingRecordingFile)
} }
// build the waveform // build the waveform
@@ -150,13 +159,18 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
} }
func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError> { func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError> {
guard let url = audioRecorder.audioFileUrl else { guard let url = audioRecorder.audioFileURL else {
return .failure(VoiceMessageRecorderError.missingRecordingFile) return .failure(VoiceMessageRecorderError.missingRecordingFile)
} }
// convert the file // convert the file
let sourceFilename = url.deletingPathExtension().lastPathComponent let sourceFilename = url.deletingPathExtension().lastPathComponent
let oggFile = URL.temporaryDirectory.appendingPathComponent(sourceFilename).appendingPathExtension("ogg") let oggFile = URL.temporaryDirectory.appendingPathComponent(sourceFilename).appendingPathExtension("ogg")
defer {
// delete the temporary file
try? FileManager.default.removeItem(at: oggFile)
}
do { do {
try audioConverter.convertToOpusOgg(sourceURL: url, destinationURL: oggFile) try audioConverter.convertToOpusOgg(sourceURL: url, destinationURL: oggFile)
} catch { } catch {
@@ -180,8 +194,6 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
audioInfo: audioInfo, audioInfo: audioInfo,
waveform: waveform, waveform: waveform,
progressSubject: nil) { _ in } progressSubject: nil) { _ in }
// delete the temporary file
try? FileManager.default.removeItem(at: oggFile)
if case .failure(let error) = result { if case .failure(let error) = result {
MXLog.error("Failed to send the voice message. \(error)") MXLog.error("Failed to send the voice message. \(error)")
@@ -219,7 +231,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
actionsSubject.send(.didFailWithError(error: VoiceMessageRecorderError.previewNotAvailable)) actionsSubject.send(.didFailWithError(error: VoiceMessageRecorderError.previewNotAvailable))
return return
} }
guard let recordingURL = audioRecorder.audioFileUrl, let previewAudioPlayerState else { guard let recordingURL = audioRecorder.audioFileURL, let previewAudioPlayerState else {
actionsSubject.send(.didFailWithError(error: VoiceMessageRecorderError.previewNotAvailable)) actionsSubject.send(.didFailWithError(error: VoiceMessageRecorderError.previewNotAvailable))
return return
} }
@@ -235,7 +247,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
private func finalizeRecording() async -> Result<Void, VoiceMessageRecorderError> { private func finalizeRecording() async -> Result<Void, VoiceMessageRecorderError> {
MXLog.info("finalize audio recording") 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) return .failure(.previewNotAvailable)
} }
@@ -35,6 +35,7 @@ enum VoiceMessageRecorderAction {
protocol VoiceMessageRecorderProtocol { protocol VoiceMessageRecorderProtocol {
var audioRecorder: AudioRecorderProtocol { get } var audioRecorder: AudioRecorderProtocol { get }
var previewAudioPlayerState: AudioPlayerState? { get } var previewAudioPlayerState: AudioPlayerState? { get }
var isRecording: Bool { get }
var recordingURL: URL? { get } var recordingURL: URL? { get }
var recordingDuration: TimeInterval { get } var recordingDuration: TimeInterval { get }
+2 -1
View File
@@ -49,7 +49,8 @@ class AudioRecorderTests: XCTestCase {
return false 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() try await deferred.fulfill()
XCTAssertFalse(audioRecorder.isRecording) XCTAssertFalse(audioRecorder.isRecording)
} }
@@ -57,6 +57,7 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
audioConverter = AudioConverterMock() audioConverter = AudioConverterMock()
voiceMessageCache = VoiceMessageCacheMock() voiceMessageCache = VoiceMessageCacheMock()
voiceMessageCache.urlForRecording = FileManager.default.temporaryDirectory.appendingPathComponent("test-voice-message").appendingPathExtension("m4a")
voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: audioRecorder, voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: audioRecorder,
mediaPlayerProvider: mediaPlayerProvider, mediaPlayerProvider: mediaPlayerProvider,
@@ -65,7 +66,7 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
private func setRecordingComplete() async throws { private func setRecordingComplete() async throws {
audioRecorder.audioFileUrl = recordingURL audioRecorder.audioFileURL = recordingURL
audioRecorder.currentTime = 5 audioRecorder.currentTime = 5
let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in
@@ -81,7 +82,7 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
func testRecordingURL() async throws { func testRecordingURL() async throws {
audioRecorder.audioFileUrl = recordingURL audioRecorder.audioFileURL = recordingURL
XCTAssertEqual(voiceMessageRecorder.recordingURL, recordingURL) XCTAssertEqual(voiceMessageRecorder.recordingURL, recordingURL)
} }
@@ -92,7 +93,7 @@ class VoiceMessageRecorderTests: XCTestCase {
func testStartRecording() async throws { func testStartRecording() async throws {
_ = await voiceMessageRecorder.startRecording() _ = await voiceMessageRecorder.startRecording()
XCTAssert(audioRecorder.recordWithCalled) XCTAssert(audioRecorder.recordAudioFileURLCalled)
} }
func testStopRecording() async throws { func testStopRecording() async throws {
@@ -185,17 +186,17 @@ class VoiceMessageRecorderTests: XCTestCase {
func testBuildRecordedWaveform() async throws { func testBuildRecordedWaveform() async throws {
// If there is no recording file, an error is expected // If there is no recording file, an error is expected
audioRecorder.audioFileUrl = nil audioRecorder.audioFileURL = nil
guard case .failure(.missingRecordingFile) = await voiceMessageRecorder.buildRecordingWaveform() else { guard case .failure(.missingRecordingFile) = await voiceMessageRecorder.buildRecordingWaveform() else {
XCTFail("An error is expected") XCTFail("An error is expected")
return 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") XCTFail("Test audio file is missing")
return return
} }
audioRecorder.audioFileUrl = audioFileUrl audioRecorder.audioFileURL = audioFileURL
guard case .success(let data) = await voiceMessageRecorder.buildRecordingWaveform() else { guard case .success(let data) = await voiceMessageRecorder.buildRecordingWaveform() else {
XCTFail("A waveform is expected") XCTFail("A waveform is expected")
return return
@@ -207,7 +208,7 @@ class VoiceMessageRecorderTests: XCTestCase {
let roomProxy = RoomProxyMock() let roomProxy = RoomProxyMock()
// If there is no recording file, an error is expected // 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 { guard case .failure(.missingRecordingFile) = await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: audioConverter) else {
XCTFail("An error is expected") XCTFail("An error is expected")
return return
@@ -215,7 +216,7 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
func testSendVoiceMessage_ConversionError() async throws { func testSendVoiceMessage_ConversionError() async throws {
audioRecorder.audioFileUrl = recordingURL audioRecorder.audioFileURL = recordingURL
// If the converter returns an error // If the converter returns an error
audioConverter.convertToOpusOggSourceURLDestinationURLThrowableError = AudioConverterError.conversionFailed(nil) audioConverter.convertToOpusOggSourceURLDestinationURLThrowableError = AudioConverterError.conversionFailed(nil)
@@ -227,11 +228,11 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
func testSendVoiceMessage_InvalidFile() async throws { 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") XCTFail("Test audio file is missing")
return return
} }
audioRecorder.audioFileUrl = audioFileUrl audioRecorder.audioFileURL = audioFileURL
audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { _, destination in audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { _, destination in
try? FileManager.default.removeItem(at: destination) try? FileManager.default.removeItem(at: destination)
} }
@@ -245,14 +246,14 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
func testSendVoiceMessage_WaveformAnlyseFailed() async throws { 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") XCTFail("Test audio file is missing")
return return
} }
audioRecorder.audioFileUrl = imageFileUrl audioRecorder.audioFileURL = imageFileURL
audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { _, destination in audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { _, destination in
try? FileManager.default.removeItem(at: destination) 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() let roomProxy = RoomProxyMock()
@@ -264,11 +265,11 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
func testSendVoiceMessage_SendError() async throws { 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") XCTFail("Test audio file is missing")
return return
} }
audioRecorder.audioFileUrl = audioFileUrl audioRecorder.audioFileURL = audioFileURL
audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { source, destination in audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { source, destination in
try? FileManager.default.removeItem(at: destination) try? FileManager.default.removeItem(at: destination)
let internalConverter = AudioConverter() let internalConverter = AudioConverter()
@@ -285,34 +286,34 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
func testSendVoiceMessage() async throws { 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") XCTFail("Test audio file is missing")
return return
} }
let roomProxy = RoomProxyMock() let roomProxy = RoomProxyMock()
audioRecorder.currentTime = 42 audioRecorder.currentTime = 42
audioRecorder.audioFileUrl = audioFileUrl audioRecorder.audioFileURL = imageFileURL
_ = await voiceMessageRecorder.startRecording() _ = await voiceMessageRecorder.startRecording()
_ = await voiceMessageRecorder.stopRecording() _ = await voiceMessageRecorder.stopRecording()
var convertedFileUrl: URL? var convertedFileURL: URL?
var convertedFileSize: UInt64? var convertedFileSize: UInt64?
audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { source, destination in audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { source, destination in
convertedFileUrl = destination convertedFileURL = destination
try? FileManager.default.removeItem(at: destination) try? FileManager.default.removeItem(at: destination)
let internalConverter = AudioConverter() let internalConverter = AudioConverter()
try internalConverter.convertToOpusOgg(sourceURL: source, destinationURL: destination) try internalConverter.convertToOpusOgg(sourceURL: source, destinationURL: destination)
convertedFileSize = try? UInt64(FileManager.default.sizeForItem(at: destination)) convertedFileSize = try? UInt64(FileManager.default.sizeForItem(at: destination))
// the source URL must be the recorded file // the source URL must be the recorded file
XCTAssertEqual(source, audioFileUrl) XCTAssertEqual(source, imageFileURL)
// check the converted file extension // check the converted file extension
XCTAssertEqual(destination.pathExtension, "ogg") XCTAssertEqual(destination.pathExtension, "ogg")
} }
roomProxy.sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleClosure = { url, audioInfo, waveform, _, _ in roomProxy.sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleClosure = { url, audioInfo, waveform, _, _ in
XCTAssertEqual(url, convertedFileUrl) XCTAssertEqual(url, convertedFileURL)
XCTAssertEqual(audioInfo.duration, self.audioRecorder.currentTime) XCTAssertEqual(audioInfo.duration, self.audioRecorder.currentTime)
XCTAssertEqual(audioInfo.size, convertedFileSize) XCTAssertEqual(audioInfo.size, convertedFileSize)
XCTAssertEqual(audioInfo.mimetype, "audio/ogg") XCTAssertEqual(audioInfo.mimetype, "audio/ogg")
@@ -330,8 +331,8 @@ class VoiceMessageRecorderTests: XCTestCase {
XCTAssert(roomProxy.sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleCalled) XCTAssert(roomProxy.sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleCalled)
// the converted file must have been deleted // the converted file must have been deleted
if let convertedFileUrl { if let convertedFileURL {
XCTAssertFalse(FileManager.default.fileExists(atPath: convertedFileUrl.path())) XCTAssertFalse(FileManager.default.fileExists(atPath: convertedFileURL.path()))
} else { } else {
XCTFail("converted file URL is missing") XCTFail("converted file URL is missing")
} }
@@ -351,7 +352,7 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
func testAudioRecorderActionHandling_didStopRecording() async throws { func testAudioRecorderActionHandling_didStopRecording() async throws {
audioRecorder.audioFileUrl = recordingURL audioRecorder.audioFileURL = recordingURL
audioRecorder.currentTime = 5 audioRecorder.currentTime = 5
let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in
@@ -367,7 +368,7 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
func testAudioRecorderActionHandling_didFailed() async throws { func testAudioRecorderActionHandling_didFailed() async throws {
audioRecorder.audioFileUrl = recordingURL audioRecorder.audioFileURL = recordingURL
let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in let deferred = deferFulfillment(voiceMessageRecorder.actions) { action in
switch action { switch action {
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c1e6e18b515a424aa91b552323bb5cba4d01914b51dc47e8ec3b5e9f81a57225 oid sha256:5dbc6341b15314bd83e81fc786e9a113d5c08080362bef5b2cfd44f947e447ee
size 66446 size 67930
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:866e24b28ef3ad776461af85ee96abaf83275d17c4e7c2047691479028fe0f06 oid sha256:d2ac55d0cb116fdc1ea0c01dea819dd14162c4084d2e9bf66c42470fb1f41181
size 94382 size 95317
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:225ba3d11602f2bd92ba2bb50c01c855d134945f1bbb6abedf5ff0cd549220bd oid sha256:77a370c81ffba8cdb1eb899deac3e81accb444872d659379e3ce16648880ea0a
size 95840 size 97326
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:12c2540041f1a2739e015b962f1490cb7d78201ab5c488d7b0edab06e304e127 oid sha256:8128aece82c38468ca5d44dbe1602282ecdd8aa6db46ea8968aec359cf2ef0e9
size 318511 size 318628
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:720eccf0e4e600da78abe5772d78e871866c3fc3b48da404079dd7285f34ecc6 oid sha256:bb67c46c12b1593eea1f3bcf3c561daf1f4d208676a884f76b973735a81bc313
size 314996 size 315113
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:720eccf0e4e600da78abe5772d78e871866c3fc3b48da404079dd7285f34ecc6 oid sha256:bb67c46c12b1593eea1f3bcf3c561daf1f4d208676a884f76b973735a81bc313
size 314996 size 315113
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:22f419d23e9c8df9325e84bc44e0d706a88b08335a6a5e9bbc8be3deef08d961 oid sha256:e47226f5fbdd1032534b2f6a1a715d9eee94b25e5ec7e02592b7c51c10a33f70
size 61935 size 61828
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:454f1594e4f0eee33f53dcc3d45653087fbf1254324396b65723918af8a44536 oid sha256:0d29e54f5d54222a0532f073b3111294335b90e84b56b277e404ceb96c602b3d
size 56462 size 58855
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ffcce0a3fe8e27798377372f73090b72a27c986d36f4936701317d60571f5b92
size 60040
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:49ba019171a846ae54d2849ff4ae4f02194fceda407fd26077cbf16a5f8682f3 oid sha256:ada032ec7d48cc981306ffaaa0be302a3edd5356d4d9c87257596ab0a9cce8d8
size 58567 size 58335