mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-06 04:18:31 +00:00
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:
@@ -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 */,
|
||||||
|
|||||||
+16
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+5
@@ -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";
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-110
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user