Transition voice messages to the new in thread sending mechanism

This commit is contained in:
Stefan Ceriu
2025-07-22 10:41:30 +03:00
committed by Stefan Ceriu
parent ed98f64a35
commit 3fcbc8c60a
8 changed files with 76 additions and 69 deletions
@@ -19610,15 +19610,15 @@ class VoiceMessageRecorderMock: VoiceMessageRecorderProtocol, @unchecked Sendabl
} }
//MARK: - sendVoiceMessage //MARK: - sendVoiceMessage
var sendVoiceMessageInRoomAudioConverterThreadRootEventIDUnderlyingCallsCount = 0 var sendVoiceMessageTimelineControllerAudioConverterUnderlyingCallsCount = 0
var sendVoiceMessageInRoomAudioConverterThreadRootEventIDCallsCount: Int { var sendVoiceMessageTimelineControllerAudioConverterCallsCount: Int {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return sendVoiceMessageInRoomAudioConverterThreadRootEventIDUnderlyingCallsCount return sendVoiceMessageTimelineControllerAudioConverterUnderlyingCallsCount
} else { } else {
var returnValue: Int? = nil var returnValue: Int? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = sendVoiceMessageInRoomAudioConverterThreadRootEventIDUnderlyingCallsCount returnValue = sendVoiceMessageTimelineControllerAudioConverterUnderlyingCallsCount
} }
return returnValue! return returnValue!
@@ -19626,29 +19626,29 @@ class VoiceMessageRecorderMock: VoiceMessageRecorderProtocol, @unchecked Sendabl
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
sendVoiceMessageInRoomAudioConverterThreadRootEventIDUnderlyingCallsCount = newValue sendVoiceMessageTimelineControllerAudioConverterUnderlyingCallsCount = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
sendVoiceMessageInRoomAudioConverterThreadRootEventIDUnderlyingCallsCount = newValue sendVoiceMessageTimelineControllerAudioConverterUnderlyingCallsCount = newValue
} }
} }
} }
} }
var sendVoiceMessageInRoomAudioConverterThreadRootEventIDCalled: Bool { var sendVoiceMessageTimelineControllerAudioConverterCalled: Bool {
return sendVoiceMessageInRoomAudioConverterThreadRootEventIDCallsCount > 0 return sendVoiceMessageTimelineControllerAudioConverterCallsCount > 0
} }
var sendVoiceMessageInRoomAudioConverterThreadRootEventIDReceivedArguments: (roomProxy: JoinedRoomProxyProtocol, audioConverter: AudioConverterProtocol, threadRootEventID: String?)? var sendVoiceMessageTimelineControllerAudioConverterReceivedArguments: (timelineController: TimelineControllerProtocol, audioConverter: AudioConverterProtocol)?
var sendVoiceMessageInRoomAudioConverterThreadRootEventIDReceivedInvocations: [(roomProxy: JoinedRoomProxyProtocol, audioConverter: AudioConverterProtocol, threadRootEventID: String?)] = [] var sendVoiceMessageTimelineControllerAudioConverterReceivedInvocations: [(timelineController: TimelineControllerProtocol, audioConverter: AudioConverterProtocol)] = []
var sendVoiceMessageInRoomAudioConverterThreadRootEventIDUnderlyingReturnValue: Result<Void, VoiceMessageRecorderError>! var sendVoiceMessageTimelineControllerAudioConverterUnderlyingReturnValue: Result<Void, VoiceMessageRecorderError>!
var sendVoiceMessageInRoomAudioConverterThreadRootEventIDReturnValue: Result<Void, VoiceMessageRecorderError>! { var sendVoiceMessageTimelineControllerAudioConverterReturnValue: Result<Void, VoiceMessageRecorderError>! {
get { get {
if Thread.isMainThread { if Thread.isMainThread {
return sendVoiceMessageInRoomAudioConverterThreadRootEventIDUnderlyingReturnValue return sendVoiceMessageTimelineControllerAudioConverterUnderlyingReturnValue
} else { } else {
var returnValue: Result<Void, VoiceMessageRecorderError>? = nil var returnValue: Result<Void, VoiceMessageRecorderError>? = nil
DispatchQueue.main.sync { DispatchQueue.main.sync {
returnValue = sendVoiceMessageInRoomAudioConverterThreadRootEventIDUnderlyingReturnValue returnValue = sendVoiceMessageTimelineControllerAudioConverterUnderlyingReturnValue
} }
return returnValue! return returnValue!
@@ -19656,26 +19656,26 @@ class VoiceMessageRecorderMock: VoiceMessageRecorderProtocol, @unchecked Sendabl
} }
set { set {
if Thread.isMainThread { if Thread.isMainThread {
sendVoiceMessageInRoomAudioConverterThreadRootEventIDUnderlyingReturnValue = newValue sendVoiceMessageTimelineControllerAudioConverterUnderlyingReturnValue = newValue
} else { } else {
DispatchQueue.main.sync { DispatchQueue.main.sync {
sendVoiceMessageInRoomAudioConverterThreadRootEventIDUnderlyingReturnValue = newValue sendVoiceMessageTimelineControllerAudioConverterUnderlyingReturnValue = newValue
} }
} }
} }
} }
var sendVoiceMessageInRoomAudioConverterThreadRootEventIDClosure: ((JoinedRoomProxyProtocol, AudioConverterProtocol, String?) async -> Result<Void, VoiceMessageRecorderError>)? var sendVoiceMessageTimelineControllerAudioConverterClosure: ((TimelineControllerProtocol, AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError>)?
func sendVoiceMessage(inRoom roomProxy: JoinedRoomProxyProtocol, audioConverter: AudioConverterProtocol, threadRootEventID: String?) async -> Result<Void, VoiceMessageRecorderError> { func sendVoiceMessage(timelineController: TimelineControllerProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError> {
sendVoiceMessageInRoomAudioConverterThreadRootEventIDCallsCount += 1 sendVoiceMessageTimelineControllerAudioConverterCallsCount += 1
sendVoiceMessageInRoomAudioConverterThreadRootEventIDReceivedArguments = (roomProxy: roomProxy, audioConverter: audioConverter, threadRootEventID: threadRootEventID) sendVoiceMessageTimelineControllerAudioConverterReceivedArguments = (timelineController: timelineController, audioConverter: audioConverter)
DispatchQueue.main.async { DispatchQueue.main.async {
self.sendVoiceMessageInRoomAudioConverterThreadRootEventIDReceivedInvocations.append((roomProxy: roomProxy, audioConverter: audioConverter, threadRootEventID: threadRootEventID)) self.sendVoiceMessageTimelineControllerAudioConverterReceivedInvocations.append((timelineController: timelineController, audioConverter: audioConverter))
} }
if let sendVoiceMessageInRoomAudioConverterThreadRootEventIDClosure = sendVoiceMessageInRoomAudioConverterThreadRootEventIDClosure { if let sendVoiceMessageTimelineControllerAudioConverterClosure = sendVoiceMessageTimelineControllerAudioConverterClosure {
return await sendVoiceMessageInRoomAudioConverterThreadRootEventIDClosure(roomProxy, audioConverter, threadRootEventID) return await sendVoiceMessageTimelineControllerAudioConverterClosure(timelineController, audioConverter)
} else { } else {
return sendVoiceMessageInRoomAudioConverterThreadRootEventIDReturnValue return sendVoiceMessageTimelineControllerAudioConverterReturnValue
} }
} }
} }
@@ -362,9 +362,8 @@ class TimelineInteractionHandler {
actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL), isUploading: true)))) actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL), isUploading: true))))
await voiceMessageRecorder.stopPlayback() await voiceMessageRecorder.stopPlayback()
switch await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy,
audioConverter: AudioConverter(), switch await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController, audioConverter: AudioConverter()) {
threadRootEventID: timelineController.timelineKind.threadRootEventID) {
case .success: case .success:
await deleteCurrentVoiceMessage() await deleteCurrentVoiceMessage()
case .failure(let error): case .failure(let error):
@@ -18,6 +18,8 @@ class MockTimelineController: TimelineControllerProtocol {
var incomingItems: [RoomTimelineItemProtocol] = [] var incomingItems: [RoomTimelineItemProtocol] = []
var roomProxy: JoinedRoomProxyProtocol? var roomProxy: JoinedRoomProxyProtocol?
var timelineProxy: TimelineProxyProtocol?
var roomID: String { roomProxy?.id ?? "MockRoomIdentifier" } var roomID: String { roomProxy?.id ?? "MockRoomIdentifier" }
var timelineKind: TimelineKind var timelineKind: TimelineKind
@@ -46,9 +48,13 @@ class MockTimelineController: TimelineControllerProtocol {
return mock return mock
} }
init(timelineKind: TimelineKind = .live, listenForSignals: Bool = false, timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default) { init(timelineKind: TimelineKind = .live,
listenForSignals: Bool = false,
timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default,
timelineProxy: TimelineProxyProtocol? = nil) {
self.timelineKind = timelineKind self.timelineKind = timelineKind
self.timelineItems = timelineItems self.timelineItems = timelineItems
self.timelineProxy = timelineProxy
callbacks.send(.paginationState(paginationState)) callbacks.send(.paginationState(paginationState))
callbacks.send(.isLive(true)) callbacks.send(.isLive(true))
@@ -107,6 +113,10 @@ class MockTimelineController: TimelineControllerProtocol {
html: String?, html: String?,
inReplyToEventID: String?, inReplyToEventID: String?,
intentionalMentions: IntentionalMentions) async { } intentionalMentions: IntentionalMentions) async { }
func sendVoiceMessage(url: URL, audioInfo: AudioInfo, waveform: [UInt16]) async -> Result<Void, TimelineProxyError> {
await timelineProxy?.sendVoiceMessage(url: url, audioInfo: audioInfo, waveform: waveform, requestHandle: { _ in }) ?? .success(())
}
func toggleReaction(_ reaction: String, to eventID: TimelineItemIdentifier.EventOrTransactionID) async { } func toggleReaction(_ reaction: String, to eventID: TimelineItemIdentifier.EventOrTransactionID) async { }
@@ -145,6 +145,8 @@ class TimelineController: TimelineControllerProtocol {
func processItemDisappearance(_ itemID: TimelineItemIdentifier) { } func processItemDisappearance(_ itemID: TimelineItemIdentifier) { }
// MARK: - Sending
func sendMessage(_ message: String, func sendMessage(_ message: String,
html: String?, html: String?,
inReplyToEventID: String?, inReplyToEventID: String?,
@@ -153,7 +155,6 @@ class TimelineController: TimelineControllerProtocol {
switch await activeTimeline.sendMessage(message, switch await activeTimeline.sendMessage(message,
html: html, html: html,
threadRootEventID: timelineKind.threadRootEventID,
inReplyToEventID: inReplyToEventID, inReplyToEventID: inReplyToEventID,
intentionalMentions: intentionalMentions) { intentionalMentions: intentionalMentions) {
case .success: case .success:
@@ -164,6 +165,12 @@ class TimelineController: TimelineControllerProtocol {
} }
} }
func sendVoiceMessage(url: URL, audioInfo: AudioInfo, waveform: [UInt16]) async -> Result<Void, TimelineProxyError> {
await activeTimeline.sendVoiceMessage(url: url,
audioInfo: audioInfo,
waveform: waveform) { _ in }
}
private func donateSendMessageIntent() async { private func donateSendMessageIntent() async {
guard let displayName = roomProxy.details.name ?? roomProxy.details.canonicalAlias, !displayName.isEmpty else { guard let displayName = roomProxy.details.name ?? roomProxy.details.canonicalAlias, !displayName.isEmpty else {
MXLog.error("Failed donating send message intent, room missing name or alias.") MXLog.error("Failed donating send message intent, room missing name or alias.")
@@ -68,6 +68,8 @@ protocol TimelineControllerProtocol {
inReplyToEventID: String?, inReplyToEventID: String?,
intentionalMentions: IntentionalMentions) async intentionalMentions: IntentionalMentions) async
func sendVoiceMessage(url: URL, audioInfo: AudioInfo, waveform: [UInt16]) async -> Result<Void, TimelineProxyError>
func edit(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID, func edit(_ eventOrTransactionID: TimelineItemIdentifier.EventOrTransactionID,
message: String, message: String,
html: String?, html: String?,
@@ -144,9 +144,8 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
return .success(waveformData) return .success(waveformData)
} }
func sendVoiceMessage(inRoom roomProxy: JoinedRoomProxyProtocol, func sendVoiceMessage(timelineController: TimelineControllerProtocol,
audioConverter: AudioConverterProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError> {
threadRootEventID: String?) async -> Result<Void, VoiceMessageRecorderError> {
guard let url = audioRecorder.audioFileURL else { guard let url = audioRecorder.audioFileURL else {
return .failure(VoiceMessageRecorderError.missingRecordingFile) return .failure(VoiceMessageRecorderError.missingRecordingFile)
} }
@@ -178,9 +177,9 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
return .failure(.failedSendingVoiceMessage) return .failure(.failedSendingVoiceMessage)
} }
let result = await roomProxy.timeline.sendVoiceMessage(url: oggFile, let result = await timelineController.sendVoiceMessage(url: oggFile,
audioInfo: audioInfo, audioInfo: audioInfo,
waveform: waveform) { _ in } waveform: waveform)
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)")
@@ -38,9 +38,8 @@ protocol VoiceMessageRecorderProtocol {
func seekPlayback(to progress: Double) async func seekPlayback(to progress: Double) async
func deleteRecording() async func deleteRecording() async
func sendVoiceMessage(inRoom roomProxy: JoinedRoomProxyProtocol, func sendVoiceMessage(timelineController: TimelineControllerProtocol,
audioConverter: AudioConverterProtocol, audioConverter: AudioConverterProtocol) async -> Result<Void, VoiceMessageRecorderError>
threadRootEventID: String?) async -> Result<Void, VoiceMessageRecorderError>
} }
// sourcery: AutoMockable // sourcery: AutoMockable
@@ -192,13 +192,12 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
func testSendVoiceMessage_NoRecordingFile() async throws { func testSendVoiceMessage_NoRecordingFile() async throws {
let roomProxy = JoinedRoomProxyMock() let timelineController = MockTimelineController()
// 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, guard case .failure(.missingRecordingFile) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
audioConverter: audioConverter, audioConverter: audioConverter) else {
threadRootEventID: nil) else {
XCTFail("An error is expected") XCTFail("An error is expected")
return return
} }
@@ -209,10 +208,9 @@ class VoiceMessageRecorderTests: XCTestCase {
// If the converter returns an error // If the converter returns an error
audioConverter.convertToOpusOggSourceURLDestinationURLThrowableError = AudioConverterError.conversionFailed(nil) audioConverter.convertToOpusOggSourceURLDestinationURLThrowableError = AudioConverterError.conversionFailed(nil)
let roomProxy = JoinedRoomProxyMock() let timelineController = MockTimelineController()
guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
audioConverter: audioConverter, audioConverter: audioConverter) else {
threadRootEventID: nil) else {
XCTFail("An error is expected") XCTFail("An error is expected")
return return
} }
@@ -229,12 +227,10 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
let timelineProxy = TimelineProxyMock() let timelineProxy = TimelineProxyMock()
let roomProxy = JoinedRoomProxyMock() let timelineController = MockTimelineController(timelineProxy: timelineProxy)
roomProxy.timeline = timelineProxy timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue = .failure(.sdkError(SDKError.generic))
timelineProxy.sendVoiceMessageUrlAudioInfoWaveformThreadRootEventIDRequestHandleReturnValue = .failure(.sdkError(SDKError.generic)) guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: audioConverter) else {
audioConverter: audioConverter,
threadRootEventID: nil) else {
XCTFail("An error is expected") XCTFail("An error is expected")
return return
} }
@@ -252,12 +248,10 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
let timelineProxy = TimelineProxyMock() let timelineProxy = TimelineProxyMock()
let roomProxy = JoinedRoomProxyMock() let timelineController = MockTimelineController(timelineProxy: timelineProxy)
roomProxy.timeline = timelineProxy timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue = .failure(.sdkError(SDKError.generic))
timelineProxy.sendVoiceMessageUrlAudioInfoWaveformThreadRootEventIDRequestHandleReturnValue = .failure(.sdkError(SDKError.generic)) guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: audioConverter) else {
audioConverter: audioConverter,
threadRootEventID: nil) else {
XCTFail("An error is expected") XCTFail("An error is expected")
return return
} }
@@ -277,12 +271,10 @@ class VoiceMessageRecorderTests: XCTestCase {
// If the media upload fails // If the media upload fails
let timelineProxy = TimelineProxyMock() let timelineProxy = TimelineProxyMock()
let roomProxy = JoinedRoomProxyMock() let timelineController = MockTimelineController(timelineProxy: timelineProxy)
roomProxy.timeline = timelineProxy timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleReturnValue = .failure(.sdkError(SDKError.generic))
timelineProxy.sendVoiceMessageUrlAudioInfoWaveformThreadRootEventIDRequestHandleReturnValue = .failure(.sdkError(SDKError.generic)) guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController,
guard case .failure(.failedSendingVoiceMessage) = await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: audioConverter) else {
audioConverter: audioConverter,
threadRootEventID: nil) else {
XCTFail("An error is expected") XCTFail("An error is expected")
return return
} }
@@ -295,8 +287,7 @@ class VoiceMessageRecorderTests: XCTestCase {
} }
let timelineProxy = TimelineProxyMock() let timelineProxy = TimelineProxyMock()
let roomProxy = JoinedRoomProxyMock() let timelineController = MockTimelineController(timelineProxy: timelineProxy)
roomProxy.timeline = timelineProxy
audioRecorder.currentTime = 42 audioRecorder.currentTime = 42
audioRecorder.audioFileURL = imageFileURL audioRecorder.audioFileURL = imageFileURL
_ = await voiceMessageRecorder.startRecording() _ = await voiceMessageRecorder.startRecording()
@@ -317,7 +308,7 @@ class VoiceMessageRecorderTests: XCTestCase {
XCTAssertEqual(destination.pathExtension, "ogg") XCTAssertEqual(destination.pathExtension, "ogg")
} }
timelineProxy.sendVoiceMessageUrlAudioInfoWaveformThreadRootEventIDRequestHandleClosure = { url, audioInfo, waveform, _, _ in timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleClosure = { 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)
@@ -327,13 +318,13 @@ class VoiceMessageRecorderTests: XCTestCase {
return .success(()) return .success(())
} }
guard case .success = await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: audioConverter, threadRootEventID: nil) else { guard case .success = await voiceMessageRecorder.sendVoiceMessage(timelineController: timelineController, audioConverter: audioConverter) else {
XCTFail("A success is expected") XCTFail("A success is expected")
return return
} }
XCTAssert(audioConverter.convertToOpusOggSourceURLDestinationURLCalled) XCTAssert(audioConverter.convertToOpusOggSourceURLDestinationURLCalled)
XCTAssert(timelineProxy.sendVoiceMessageUrlAudioInfoWaveformThreadRootEventIDRequestHandleCalled) XCTAssert(timelineProxy.sendVoiceMessageUrlAudioInfoWaveformRequestHandleCalled)
// the converted file must have been deleted // the converted file must have been deleted
if let convertedFileURL { if let convertedFileURL {