From 2f57fbc77de5cdcf170f7cbd32ba390afa0dd398 Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Mon, 23 Oct 2023 17:47:36 +0200 Subject: [PATCH] Allow to record a voice message (#1926) --- .swiftlint.yml | 2 +- ElementX.xcodeproj/project.pbxproj | 52 ++- .../RoomFlowCoordinator.swift | 3 +- .../Mocks/Generated/GeneratedMocks.swift | 381 +++++++++++++++--- .../VoiceMessage}/EstimatedWaveformView.swift | 0 .../VoiceMessage}/ProgressMaskModifier.swift | 0 .../Other/VoiceMessage/WaveformSource.swift | 24 ++ .../WaveformViewDragGestureModifier.swift | 98 +++++ .../ComposerToolbarModels.swift | 20 +- .../ComposerToolbarViewModel.swift | 24 +- .../View/ComposerToolbar.swift | 58 ++- .../View/VoiceMessagePreviewComposer.swift | 154 ++++++- .../View/VoiceMessageRecordingButton.swift | 39 +- .../View/VoiceMessageRecordingView.swift | 18 +- .../RoomScreen/RoomScreenCoordinator.swift | 2 + .../Screens/RoomScreen/RoomScreenModels.swift | 2 +- .../RoomScreen/RoomScreenViewModel.swift | 123 ++++-- .../RoomScreen/View/RoomHeaderView.swift | 2 + .../Screens/RoomScreen/View/RoomScreen.swift | 1 + .../Style/TimelineItemBubbledStylerView.swift | 2 +- .../Style/TimelineItemPlainStylerView.swift | 2 +- .../TimelineReadReceiptsView.swift | 1 + .../View/Timeline/UITimelineView.swift | 1 + .../RoomScreen/View/TimelineView.swift | 1 + .../Services/Audio/AudioConverter.swift | 1 - .../Services/Audio/Player/AudioPlayer.swift | 15 +- .../Audio/Player/AudioPlayerProtocol.swift | 5 + .../Audio/Player/AudioPlayerState.swift | 10 +- .../Audio/Recorder/AudioRecorder.swift | 59 ++- .../Recorder/AudioRecorderProtocol.swift | 23 +- .../Audio/Recorder/AudioRecorderState.swift | 21 +- .../MediaPlayer/MediaPlayerProtocol.swift | 2 +- .../MediaPlayer/MediaPlayerProvider.swift | 67 ++- .../MediaPlayerProviderProtocol.swift | 14 +- .../Sources/Services/Room/RoomProxy.swift | 25 ++ .../Services/Room/RoomProxyProtocol.swift | 6 + .../MockRoomTimelineController.swift | 3 +- .../RoomTimelineController.swift | 56 ++- .../RoomTimelineControllerProtocol.swift | 4 +- .../VoiceMessageRoomPlaybackView.swift | 34 +- .../VoiceMessageRoomTimelineView.swift | 7 +- .../TimelineItems/RoomTimelineItemView.swift | 2 +- .../VoiceMessage/VoiceMessageCache.swift | 37 +- .../VoiceMessageCacheProtocol.swift | 19 +- .../VoiceMessageMediaManager.swift | 8 +- .../VoiceMessage/VoiceMessageRecorder.swift | 198 +++++++++ .../VoiceMessageRecorderProtocol.swift | 47 +++ .../UITests/UITestsAppCoordinator.swift | 13 + .../Resources/Media/test_voice_message.m4a | 3 + UnitTests/Sources/AudioPlayerStateTests.swift | 2 +- .../Sources/AudioRecorderStateTests.swift | 7 +- .../Sources/MediaPlayerProviderTests.swift | 125 ++++++ UnitTests/Sources/PillContextTests.swift | 3 + .../Sources/RoomScreenViewModelTests.swift | 13 + .../Sources/VoiceMessageCacheTests.swift | 28 +- .../VoiceMessageMediaManagerTests.swift | 4 +- .../Sources/VoiceMessageRecorderTests.swift | 237 +++++++++++ .../test_composerToolbar.Voice-Message.png | 4 +- .../test_voiceMessagePreviewComposer.1.png | 4 +- .../test_voiceMessageRecordingComposer.1.png | 4 +- .../test_voiceMessageRecordingView.1.png | 4 +- .../PreviewTests/test_waveformView.1.png | 3 - 62 files changed, 1795 insertions(+), 332 deletions(-) rename ElementX/Sources/{Services/Timeline/TimelineItems/Items/Messages/VoiceMessages => Other/VoiceMessage}/EstimatedWaveformView.swift (100%) rename ElementX/Sources/{Services/Timeline/TimelineItems/Items/Messages/VoiceMessages => Other/VoiceMessage}/ProgressMaskModifier.swift (100%) create mode 100644 ElementX/Sources/Other/VoiceMessage/WaveformSource.swift create mode 100644 ElementX/Sources/Other/VoiceMessage/WaveformViewDragGestureModifier.swift create mode 100644 ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift create mode 100644 ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorderProtocol.swift create mode 100644 UnitTests/Resources/Media/test_voice_message.m4a create mode 100644 UnitTests/Sources/MediaPlayerProviderTests.swift create mode 100644 UnitTests/Sources/VoiceMessageRecorderTests.swift delete mode 100644 UnitTests/__Snapshots__/PreviewTests/test_waveformView.1.png diff --git a/.swiftlint.yml b/.swiftlint.yml index 1166ea1f5c..85a50662c5 100755 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -25,7 +25,7 @@ line_length: file_length: warning: 1000 - error: 1000 + error: 1200 type_name: min_length: 3 diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 858bc0d4d4..233fc09c93 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -95,6 +95,7 @@ 1830E5431DB426E2F3660D58 /* NotificationSettingsEditScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F52419AEEDA2C006CB7181 /* NotificationSettingsEditScreenUITests.swift */; }; 18867F4F1C8991EEC56EA932 /* UTType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897DF5E9A70CE05A632FC8AF /* UTType.swift */; }; 1950A80CD198BED283DFC2CE /* ClientProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */; }; + 19DED23340D0855B59693ED2 /* VoiceMessageRecorderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D45C9EAA86423D7D3126DE4F /* VoiceMessageRecorderProtocol.swift */; }; 19FE025AE9BA2959B6589B0D /* RoomMemberDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC575D1895FA62591451A93 /* RoomMemberDetailsScreen.swift */; }; 1A70A2199394B5EC660934A5 /* MatrixRustSDK in Frameworks */ = {isa = PBXBuildFile; productRef = A678E40E917620059695F067 /* MatrixRustSDK */; }; 1A83DD22F3E6F76B13B6E2F9 /* VideoRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C8616254EE40CA8BA5E9BC2 /* VideoRoomTimelineItemContent.swift */; }; @@ -129,6 +130,7 @@ 2352C541AF857241489756FF /* MockRoomSummaryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F7D42E66E939B709C1EC390 /* MockRoomSummaryProvider.swift */; }; 2355289BB0146231DD8AFFC0 /* AnalyticsMessageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2133A5FF0C14986E60326115 /* AnalyticsMessageType.swift */; }; 23701DE32ACD6FD40AA992C3 /* MediaUploadingPreprocessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE203026B9AD3DB412439866 /* MediaUploadingPreprocessorTests.swift */; }; + 2379B526D29F3AB2A369EB10 /* ProgressMaskModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1CDAF97F99E79CC432FB908 /* ProgressMaskModifier.swift */; }; 237FC70AA257B935F53316BA /* SessionVerificationControllerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */; }; 245F7FE5961BD10C145A26E0 /* UITimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EA689E792E679F5E3956F21 /* UITimelineView.swift */; }; 24A75F72EEB7561B82D726FD /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2141693488CE5446BB391964 /* Date.swift */; }; @@ -193,6 +195,7 @@ 34F1261CEF6D6A00D559B520 /* SettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CFD5EB0B0EEA4549FB49784 /* SettingsScreen.swift */; }; 352C439BE0F75E101EF11FB1 /* RoomScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */; }; 355B11D08CE0CEF97A813236 /* AppRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A9E3FBE8A66B5A17AD7F74 /* AppRoutes.swift */; }; + 3582056513A384F110EC8274 /* MediaPlayerProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7A2C4A3A74F0D2FFE9356A /* MediaPlayerProviderTests.swift */; }; 35E975CFDA60E05362A7CF79 /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = 1222DB76B917EB8A55365BA5 /* target.yml */; }; 3627DFEE96824E0E2EA69B88 /* AppLockSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6615CBDE154455007F456DBB /* AppLockSettingsScreen.swift */; }; 366D5BFE52CB79E804C7D095 /* CallScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD9547E47C58930E2CE8306 /* CallScreenViewModelTests.swift */; }; @@ -291,6 +294,7 @@ 4FFDC274824F7CC0BBDF581E /* BugReportScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C2BCE0BC1FC69C1B36E688 /* BugReportScreenModels.swift */; }; 500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 874A1842477895F199567BD7 /* TimelineView.swift */; }; 50381244BA280451771BE3ED /* PINTextFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF13BFD415CA84B1272E94F8 /* PINTextFieldTests.swift */; }; + 50539366B408780B232C1910 /* EstimatedWaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0FF64B0E6470F66F42E182 /* EstimatedWaveformView.swift */; }; 50C90117FE25390BFBD40173 /* RustTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542D4F49FABA056DEEEB3400 /* RustTracing.swift */; }; 5100F53E6884A15F9BA07CC3 /* AttributedStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CA26F55123E36B50DB0B3A /* AttributedStringTests.swift */; }; 516534FC5C893D57F169D5A8 /* MapTilerGeocoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33720F7AD25E85E4A84669E8 /* MapTilerGeocoding.swift */; }; @@ -389,7 +393,6 @@ 6B4BF4A6450F55939B49FAEF /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67779D9A1B797285A09B7720 /* PollOptionView.swift */; }; 6BAD956B909A6E29F6CC6E7C /* ButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC23C63849452BC86EA2852 /* ButtonStyle.swift */; }; 6BB6944443C421C722ED1E7D /* portrait_test_video.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = F2D513D2477B57F90E98EEC0 /* portrait_test_video.mp4 */; }; - 6BDD969EFFAF18120429084A /* EstimatedWaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D9A987EAA44E2E1BDCDAFDC /* EstimatedWaveformView.swift */; }; 6C34237AFB808E38FC8776B9 /* RoomStateEventStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D55702474F279D910D2D162 /* RoomStateEventStringBuilder.swift */; }; 6C5A2C454E6C198AB39ED760 /* SharedUserDefaultsKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA8DC95C079805B0B56E8A9 /* SharedUserDefaultsKeys.swift */; }; 6CD61FAF03E8986523C2ABB8 /* StartChatScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3005886F00029F058DB62BE /* StartChatScreenCoordinator.swift */; }; @@ -476,7 +479,6 @@ 8317E1314C00DCCC99D30DA8 /* TextBasedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9227F7495DA43324050A863 /* TextBasedRoomTimelineItem.swift */; }; 83A4DAB181C56987C3E804FF /* MapTilerStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B9F5BC4C80543DE7228B9D /* MapTilerStyle.swift */; }; 84215E902C62E9B8E8AB79F0 /* AppLockSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 267C0279BB8D907E2C40DDCA /* AppLockSettingsScreenCoordinator.swift */; }; - 8421FFCD5360A15D170922A8 /* ProgressMaskModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A1D75C7C52CD14A327CC90 /* ProgressMaskModifier.swift */; }; 84226AD2E1F1FBC965F3B09E /* UnitTestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8E19C4645D3F5F9FB02355 /* UnitTestsAppCoordinator.swift */; }; 8478992479B296C45150208F /* AppLockScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */; }; 847DE3A7EB9FCA2C429C6E85 /* PINTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D1D4A6D451F43A03CACD01D /* PINTextField.swift */; }; @@ -582,6 +584,7 @@ 9D2E03DB175A6AB14589076D /* AnalyticsEvents in Frameworks */ = {isa = PBXBuildFile; productRef = 2A3F7BCCB18C15B30CCA39A9 /* AnalyticsEvents */; }; 9D79B94493FB32249F7E472F /* PlaceholderAvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C705E605EF57C19DBE86FFA1 /* PlaceholderAvatarImage.swift */; }; 9D9690D2FD4CD26FF670620F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C75EF87651B00A176AB08E97 /* AppDelegate.swift */; }; + 9D9EF9DD484E58A2E8877187 /* WaveformViewDragGestureModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03BA7958A4BB9C22CA8884EF /* WaveformViewDragGestureModifier.swift */; }; 9DC5FB22B8F86C3B51E907C1 /* HomeScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6E4C37E9F0E53D3DF951AC /* HomeScreenUITests.swift */; }; 9DD5AA10E85137140FEA86A3 /* MediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17EFA1D3D09FC2F9C5E1CB2 /* MediaProvider.swift */; }; 9DD84E014ADFB2DD813022D5 /* RoomDetailsEditScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00E5B2CBEF8F96424F095508 /* RoomDetailsEditScreenViewModelTests.swift */; }; @@ -611,6 +614,7 @@ A33784831AD880A670CAA9F9 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DF593C3F7AF4B2FBAEB05D /* FileManager.swift */; }; A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287FC98AF2664EAD79C0D902 /* UIDevice.swift */; }; A3A7A05E8F9B7EB0E1A09A2A /* SoftLogoutScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05596E4A11A8C9346E9E54AE /* SoftLogoutScreenCoordinator.swift */; }; + A3D7110C1E75E7B4A73BE71C /* VoiceMessageRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93C94C30E3135BC9290DE13 /* VoiceMessageRecorderTests.swift */; }; A3E390675E9730C176B59E1B /* ImageProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E8A8047B50E3607ACD354E /* ImageProviderProtocol.swift */; }; A439B456D0761D6541745CC3 /* NSRegularExpresion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */; }; A440D4BC02088482EC633A88 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */; }; @@ -693,6 +697,7 @@ B6EC2148FA5443C9289BEEBA /* MediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17EFA1D3D09FC2F9C5E1CB2 /* MediaProvider.swift */; }; B717A820BE02C6FE2CB53F6E /* WaitlistScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B697816AF93DA06EC58C5D70 /* WaitlistScreenViewModelProtocol.swift */; }; B721125D17A0BA86794F29FB /* MockServerSelectionScreenState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E057FB1F07A5C201C89061 /* MockServerSelectionScreenState.swift */; }; + B773ACD8881DB18E876D950C /* WaveformSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94028A227645FA880B966211 /* WaveformSource.swift */; }; B7888FC1E1DEF816D175C8D6 /* SecureBackupKeyBackupScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD72A9B720D75DBE60AC299F /* SecureBackupKeyBackupScreenModels.swift */; }; B796A25F282C0A340D1B9C12 /* ImageRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B5EDCD05D50BA9B815C66C /* ImageRoomTimelineItemContent.swift */; }; B7C9E07F4F9CCC8DD7156A20 /* CallScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28146817C61423CACCF942F5 /* CallScreenModels.swift */; }; @@ -734,6 +739,7 @@ C1D0AB8222D7BAFC9AF9C8C0 /* MapLibreMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 622D09D4ECE759189009AEAF /* MapLibreMapView.swift */; }; C1F863E16BDBC87255D23B57 /* DeveloperOptionsScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F684BDD23ECEADB3053BA5A /* DeveloperOptionsScreenUITests.swift */; }; C26DB49C06C00B5DF1A991A5 /* InviteUsersScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1454CF3AABD242F55C8A2615 /* InviteUsersScreenModels.swift */; }; + C2879369106A419A5071F1F8 /* VoiceMessageRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B0A96B8FE4849227945067 /* VoiceMessageRecorder.swift */; }; C287BE1802AD432F3D848D8E /* InvitesScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF81D7F2A6BA9DE3F6F8D9D /* InvitesScreenViewModel.swift */; }; C32765D740C81AD4C42E8F50 /* CreateRoomFlowParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 935C2FB18EFB8EEE96B26330 /* CreateRoomFlowParameters.swift */; }; C3522917C0C367C403429EEC /* CoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B251F5B4511D1CA0BA8361FE /* CoordinatorProtocol.swift */; }; @@ -767,6 +773,7 @@ CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B16048D30F0438731C41F775 /* StateRoomTimelineItem.swift */; }; CB99B0FA38A4AC596F38CC13 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E94DCFEE803E5ABAE8ACCE /* KeychainControllerProtocol.swift */; }; CBA9EDF305036039166E76FF /* StartChatScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */; }; + CBB4F39A1309F7281AE7AA8E /* test_voice_message.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 0392E3FDE372C9B56FEEED8B /* test_voice_message.m4a */; }; CBD2ABE4C1A47ECD99E1488E /* NotificationSettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 421FA93BCC2840E66E4F306F /* NotificationSettingsScreenViewModelProtocol.swift */; }; CC0D088F505F33A20DC5590F /* RoomStateEventStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEEAFB646E583655652C3D04 /* RoomStateEventStringBuilderTests.swift */; }; CC961529F9F1854BEC3272C9 /* LayoutMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8AA23D4F37CC26564F63C5 /* LayoutMocks.swift */; }; @@ -1013,6 +1020,8 @@ 033DB41C51865A2E83174E87 /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; 035177BCD8E8308B098AC3C2 /* WindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = ""; }; 0376C429FAB1687C3D905F3E /* MockCoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCoder.swift; sourceTree = ""; }; + 0392E3FDE372C9B56FEEED8B /* test_voice_message.m4a */ = {isa = PBXFileReference; path = test_voice_message.m4a; sourceTree = ""; }; + 03BA7958A4BB9C22CA8884EF /* WaveformViewDragGestureModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformViewDragGestureModifier.swift; sourceTree = ""; }; 03DD998E523D4EC93C7ED703 /* RoomNotificationSettingsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenViewModelProtocol.swift; sourceTree = ""; }; 03FABD73FD8086EFAB699F42 /* MediaUploadPreviewScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenViewModelTests.swift; sourceTree = ""; }; 045253F9967A535EE5B16691 /* Label.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Label.swift; sourceTree = ""; }; @@ -1139,6 +1148,7 @@ 25F7FE40EF7490A7E09D7BE6 /* NotificationItemProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItemProxy.swift; sourceTree = ""; }; 260004737C573A56FA01E86E /* Encodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Encodable.swift; sourceTree = ""; }; 267C0279BB8D907E2C40DDCA /* AppLockSettingsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSettingsScreenCoordinator.swift; sourceTree = ""; }; + 26B0A96B8FE4849227945067 /* VoiceMessageRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecorder.swift; sourceTree = ""; }; 26EAAB54C6CE91D64B69A9F8 /* AppLockServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockServiceProtocol.swift; sourceTree = ""; }; 2757B1BE23DF8AA239937243 /* AudioConverterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverterProtocol.swift; sourceTree = ""; }; 277C20CDD5B64510401B6D0D /* ServerConfigurationScreenViewStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfigurationScreenViewStateTests.swift; sourceTree = ""; }; @@ -1163,6 +1173,7 @@ 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringTests.swift; sourceTree = ""; }; 2D0946F77B696176E062D037 /* RoomMembersListScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenModels.swift; sourceTree = ""; }; 2D505843AB66822EB91F0DF0 /* TimelineItemProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemProxy.swift; sourceTree = ""; }; + 2D7A2C4A3A74F0D2FFE9356A /* MediaPlayerProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProviderTests.swift; sourceTree = ""; }; 2E88534A39781D76487D59DF /* SecureBackupKeyBackupScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupKeyBackupScreenViewModelTests.swift; sourceTree = ""; }; 2EFE1922F39398ABFB36DF3F /* RoomDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsViewModelTests.swift; sourceTree = ""; }; 2F36C5D9B37E50915ECBD3EE /* RoomMemberProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxy.swift; sourceTree = ""; }; @@ -1329,7 +1340,6 @@ 5D26A086A8278D39B5756D6F /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = project.yml; sourceTree = ""; }; 5D2D0A6F1ABC99D29462FB84 /* AuthenticationCoordinatorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationCoordinatorUITests.swift; sourceTree = ""; }; 5D99730313BEBF08CDE81EE3 /* EmojiDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetection.swift; sourceTree = ""; }; - 5D9A987EAA44E2E1BDCDAFDC /* EstimatedWaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedWaveformView.swift; sourceTree = ""; }; 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProperties.swift; sourceTree = ""; }; 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = ""; }; 5F088B61525099A48909743B /* AppLockSettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSettingsScreenUITests.swift; sourceTree = ""; }; @@ -1413,7 +1423,6 @@ 78910787F967CBC6042A101E /* StartChatScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenViewModelProtocol.swift; sourceTree = ""; }; 78913D6E120D46138E97C107 /* NavigationSplitCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSplitCoordinatorTests.swift; sourceTree = ""; }; 7893780A1FD6E3F38B3E9049 /* UserIndicatorControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerMock.swift; sourceTree = ""; }; - 79A1D75C7C52CD14A327CC90 /* ProgressMaskModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressMaskModifier.swift; sourceTree = ""; }; 7A5D2323D7B6BF4913EB7EED /* landscape_test_image.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = landscape_test_image.jpg; sourceTree = ""; }; 7AB7ED3A898B07976F3AA90F /* BugReportViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportViewModelTests.swift; sourceTree = ""; }; 7AE094FCB6387D268C436161 /* SecureBackupScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupScreenViewModel.swift; sourceTree = ""; }; @@ -1504,6 +1513,7 @@ 935C2FB18EFB8EEE96B26330 /* CreateRoomFlowParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomFlowParameters.swift; sourceTree = ""; }; 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenViewModelTests.swift; sourceTree = ""; }; 93E7304F5ECB4CB11CB10E60 /* SecureBackupRecoveryKeyScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupRecoveryKeyScreenViewModelProtocol.swift; sourceTree = ""; }; + 94028A227645FA880B966211 /* WaveformSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformSource.swift; sourceTree = ""; }; 94BCC8A9C73C1F838122C645 /* TimelineItemPlainStylerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemPlainStylerView.swift; sourceTree = ""; }; 94D670124FC3E84F23A62CCF /* APNSPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSPayload.swift; sourceTree = ""; }; 9501D11B4258DFA33BA3B40F /* ServerSelectionScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenModels.swift; sourceTree = ""; }; @@ -1576,6 +1586,7 @@ AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFlowLayoutTests.swift; sourceTree = ""; }; AC9104846487244648D32C6D /* AudioPlayerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerProtocol.swift; sourceTree = ""; }; ACCC1874C122E2BBE648B8F5 /* LegalInformationScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenUITests.swift; sourceTree = ""; }; + AD0FF64B0E6470F66F42E182 /* EstimatedWaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedWaveformView.swift; sourceTree = ""; }; AD378D580A41E42560C60E9C /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; AD558A898847C179E4B7A237 /* SecureBackupKeyBackupScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupKeyBackupScreen.swift; sourceTree = ""; }; AD6B522BD637845AB9570B10 /* RoomNotificationSettingsProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsProxy.swift; sourceTree = ""; }; @@ -1598,6 +1609,7 @@ B0BA67B3E4EF9D29D14A78CE /* AppLockSettingsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSettingsScreenViewModelTests.swift; sourceTree = ""; }; B16048D30F0438731C41F775 /* StateRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRoomTimelineItem.swift; sourceTree = ""; }; B16CAF20C9AC874A210E2DCF /* SessionVerificationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenViewModelProtocol.swift; sourceTree = ""; }; + B1CDAF97F99E79CC432FB908 /* ProgressMaskModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressMaskModifier.swift; sourceTree = ""; }; B1E227F34BE43B08E098796E /* TestablePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestablePreview.swift; sourceTree = ""; }; B251F5B4511D1CA0BA8361FE /* CoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinatorProtocol.swift; sourceTree = ""; }; B2B5EDCD05D50BA9B815C66C /* ImageRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRoomTimelineItemContent.swift; sourceTree = ""; }; @@ -1736,6 +1748,7 @@ D3D455BC2423D911A62ACFB2 /* NSELogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSELogger.swift; sourceTree = ""; }; D3F219838588C62198E726E3 /* LABiometryType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LABiometryType.swift; sourceTree = ""; }; D3F275432954C8C6B1B7D966 /* AppLockSetupPINScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreen.swift; sourceTree = ""; }; + D45C9EAA86423D7D3126DE4F /* VoiceMessageRecorderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecorderProtocol.swift; sourceTree = ""; }; D49B9785E3AD7D1C15A29F2F /* MediaSourceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSourceProxy.swift; sourceTree = ""; }; D4DA544B2520BFA65D6DB4BB /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; D529B976F8B2AA654D923422 /* VoiceMessageRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRoomTimelineItem.swift; sourceTree = ""; }; @@ -1755,6 +1768,7 @@ D8E057FB1F07A5C201C89061 /* MockServerSelectionScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockServerSelectionScreenState.swift; sourceTree = ""; }; D8E60332509665C00179ACF6 /* MessageForwardingScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenViewModel.swift; sourceTree = ""; }; D8F5F9E02B1AB5350B1815E7 /* TimelineStartRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStartRoomTimelineItem.swift; sourceTree = ""; }; + D93C94C30E3135BC9290DE13 /* VoiceMessageRecorderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecorderTests.swift; sourceTree = ""; }; DA14564EE143F73F7E4D1F79 /* RoomNotificationSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenModels.swift; sourceTree = ""; }; DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenUITests.swift; sourceTree = ""; }; DAB8D7926A5684E18196B538 /* VoiceMessageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCache.swift; sourceTree = ""; }; @@ -1982,6 +1996,8 @@ 43A84EE187D0C772E18A4E39 /* VoiceMessageCacheProtocol.swift */, 40076C770A5FB83325252973 /* VoiceMessageMediaManager.swift */, 889DEDD63C68ABDA8AD29812 /* VoiceMessageMediaManagerProtocol.swift */, + 26B0A96B8FE4849227945067 /* VoiceMessageRecorder.swift */, + D45C9EAA86423D7D3126DE4F /* VoiceMessageRecorderProtocol.swift */, ); path = VoiceMessage; sourceTree = ""; @@ -2533,8 +2549,6 @@ 3A542DF1C3BB67D829DFDC40 /* VoiceMessages */ = { isa = PBXGroup; children = ( - 5D9A987EAA44E2E1BDCDAFDC /* EstimatedWaveformView.swift */, - 79A1D75C7C52CD14A327CC90 /* ProgressMaskModifier.swift */, 3CCD41CD67DB5DA0D436BFE9 /* VoiceMessageRoomPlaybackView.swift */, B70A50C41C5871B4DB905E7E /* VoiceMessageRoomTimelineView.swift */, ); @@ -3142,6 +3156,7 @@ A05707BF550D770168A406DB /* LoginViewModelTests.swift */, 376D941BF8BB294389C0DE24 /* MapTilerURLBuildersTests.swift */, F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */, + 2D7A2C4A3A74F0D2FFE9356A /* MediaPlayerProviderTests.swift */, AE203026B9AD3DB412439866 /* MediaUploadingPreprocessorTests.swift */, 03FABD73FD8086EFAB699F42 /* MediaUploadPreviewScreenViewModelTests.swift */, 6F6E6EDC4BBF962B2ED595A4 /* MessageForwardingScreenViewModelTests.swift */, @@ -3184,6 +3199,7 @@ BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */, 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */, AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */, + D93C94C30E3135BC9290DE13 /* VoiceMessageRecorderTests.swift */, C796FC1DFDBCDD5573D0360F /* WaitlistScreenViewModelTests.swift */, 851EF6258DF8B7EF129DC3AC /* WelcomeScreenScreenViewModelTests.swift */, 53280D2292E6C9C7821773FD /* UserSession */, @@ -3777,6 +3793,17 @@ path = Style; sourceTree = ""; }; + A33CE1B72A29E3931CBEC2A5 /* VoiceMessage */ = { + isa = PBXGroup; + children = ( + AD0FF64B0E6470F66F42E182 /* EstimatedWaveformView.swift */, + B1CDAF97F99E79CC432FB908 /* ProgressMaskModifier.swift */, + 94028A227645FA880B966211 /* WaveformSource.swift */, + 03BA7958A4BB9C22CA8884EF /* WaveformViewDragGestureModifier.swift */, + ); + path = VoiceMessage; + sourceTree = ""; + }; A448A3A8F764174C60CD0CA1 /* Other */ = { isa = PBXGroup; children = ( @@ -3881,6 +3908,7 @@ D5E26C54362206BBDD096D83 /* test_audio.mp3 */, C733D11B421CFE3A657EF230 /* test_image.png */, 3FFDA99C98BE05F43A92343B /* test_pdf.pdf */, + 0392E3FDE372C9B56FEEED8B /* test_voice_message.m4a */, ); path = Media; sourceTree = ""; @@ -4090,6 +4118,7 @@ 9C4193C4524B35FD6B94B5A9 /* Pills */, 052CC920F473C10B509F9FC1 /* SwiftUI */, B687E3E8C23415A06A3D5C65 /* UserIndicator */, + A33CE1B72A29E3931CBEC2A5 /* VoiceMessage */, ); path = Other; sourceTree = ""; @@ -4870,6 +4899,7 @@ 87CEDB8A0696F0D5AE2ABB28 /* test_audio.mp3 in Resources */, 21BF2B7CEDFE3CA67C5355AD /* test_image.png in Resources */, E77469C5CD7F7F58C0AC9752 /* test_pdf.pdf in Resources */, + CBB4F39A1309F7281AE7AA8E /* test_voice_message.m4a in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5075,6 +5105,7 @@ 77C1A2F49CD90D3EFDF376E5 /* MapTilerURLBuildersTests.swift in Sources */, 2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */, 4B978C09567387EF4366BD7A /* MediaLoaderTests.swift in Sources */, + 3582056513A384F110EC8274 /* MediaPlayerProviderTests.swift in Sources */, 167D00CAA13FAFB822298021 /* MediaProviderTests.swift in Sources */, B9A8C34A00D03094C0CF56F3 /* MediaUploadPreviewScreenViewModelTests.swift in Sources */, 23701DE32ACD6FD40AA992C3 /* MediaUploadingPreprocessorTests.swift in Sources */, @@ -5130,6 +5161,7 @@ 81A7C020CB5F6232242A8414 /* UserSessionTests.swift in Sources */, 21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */, 44BDD670FF9095ACE240A3A2 /* VoiceMessageMediaManagerTests.swift in Sources */, + A3D7110C1E75E7B4A73BE71C /* VoiceMessageRecorderTests.swift in Sources */, FB9A1DD83EF641A75ABBCE69 /* WaitlistScreenViewModelTests.swift in Sources */, 7F02063FB3D1C3E5601471A1 /* WelcomeScreenScreenViewModelTests.swift in Sources */, 3116693C5EB476E028990416 /* XCTestCase.swift in Sources */, @@ -5327,7 +5359,7 @@ 9965CB800CE6BC74ACA969FC /* EncryptedHistoryRoomTimelineView.swift in Sources */, 4C5A638DAA8AF64565BA4866 /* EncryptedRoomTimelineItem.swift in Sources */, B5903E48CF43259836BF2DBF /* EncryptedRoomTimelineView.swift in Sources */, - 6BDD969EFFAF18120429084A /* EstimatedWaveformView.swift in Sources */, + 50539366B408780B232C1910 /* EstimatedWaveformView.swift in Sources */, F78BAD28482A467287A9A5A3 /* EventBasedMessageTimelineItemProtocol.swift in Sources */, 02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */, 63E46D18B91D08E15FC04125 /* ExpiringTaskRunner.swift in Sources */, @@ -5526,7 +5558,7 @@ 153E22E8227F46545E5D681C /* PollRoomTimelineView.swift in Sources */, DF504B10A4918F971A57BEF2 /* PostHogAnalyticsClient.swift in Sources */, FD4DEC88210F35C35B2FB386 /* ProcessInfo.swift in Sources */, - 8421FFCD5360A15D170922A8 /* ProgressMaskModifier.swift in Sources */, + 2379B526D29F3AB2A369EB10 /* ProgressMaskModifier.swift in Sources */, 9B356742E035D90A8BB5CABE /* ProposedViewSize.swift in Sources */, 2835FD52F3F618D07F799B3D /* Publisher.swift in Sources */, 9095B9E40DB5CF8BA26CE0D8 /* ReactionsSummaryView.swift in Sources */, @@ -5779,6 +5811,8 @@ 386720B603F87D156DB01FB2 /* VoiceMessageMediaManager.swift in Sources */, 9DE801D278AC34737467F937 /* VoiceMessageMediaManagerProtocol.swift in Sources */, 33CA777C9DF263582D77A67F /* VoiceMessagePreviewComposer.swift in Sources */, + C2879369106A419A5071F1F8 /* VoiceMessageRecorder.swift in Sources */, + 19DED23340D0855B59693ED2 /* VoiceMessageRecorderProtocol.swift in Sources */, 09EF4222EEBBA1A7B8F4071E /* VoiceMessageRecordingButton.swift in Sources */, 8C27BEB00B903D953F31F962 /* VoiceMessageRecordingButtonTooltipView.swift in Sources */, CA5BFF0C2EF5A8EF40CA2D69 /* VoiceMessageRecordingComposer.swift in Sources */, @@ -5792,6 +5826,8 @@ 2F66701B15657A87B4AC3A0A /* WaitlistScreenModels.swift in Sources */, CF3827071B0BC9638BD44F5D /* WaitlistScreenViewModel.swift in Sources */, B717A820BE02C6FE2CB53F6E /* WaitlistScreenViewModelProtocol.swift in Sources */, + B773ACD8881DB18E876D950C /* WaveformSource.swift in Sources */, + 9D9EF9DD484E58A2E8877187 /* WaveformViewDragGestureModifier.swift in Sources */, D871C8CF46950F959C9A62C3 /* WelcomeScreen.swift in Sources */, 383055C6ABE5BE058CEE1DDB /* WelcomeScreenScreenCoordinator.swift in Sources */, BD2BF1EC73FFB0C01552ECDA /* WelcomeScreenScreenModels.swift in Sources */, diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 7bed7d3c83..ff4a5d35e5 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -334,7 +334,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let userID = userSession.clientProxy.userID - let mediaPlayerProvider = MediaPlayerProvider(mediaProvider: userSession.mediaProvider) + let mediaPlayerProvider = MediaPlayerProvider() let timelineItemFactory = RoomTimelineItemFactory(userID: userID, mediaProvider: userSession.mediaProvider, @@ -357,6 +357,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy, timelineController: timelineController, mediaProvider: userSession.mediaProvider, + mediaPlayerProvider: mediaPlayerProvider, emojiProvider: emojiProvider, completionSuggestionService: completionSuggestionService, appSettings: appSettings) diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index b7d4a8a738..d182898e08 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -323,19 +323,19 @@ class AudioPlayerMock: AudioPlayerProtocol { //MARK: - load - var loadMediaSourceUsingCallsCount = 0 - var loadMediaSourceUsingCalled: Bool { - return loadMediaSourceUsingCallsCount > 0 + var loadMediaSourceUsingAutoplayCallsCount = 0 + var loadMediaSourceUsingAutoplayCalled: Bool { + return loadMediaSourceUsingAutoplayCallsCount > 0 } - var loadMediaSourceUsingReceivedArguments: (mediaSource: MediaSourceProxy, url: URL)? - var loadMediaSourceUsingReceivedInvocations: [(mediaSource: MediaSourceProxy, url: URL)] = [] - var loadMediaSourceUsingClosure: ((MediaSourceProxy, URL) -> Void)? + var loadMediaSourceUsingAutoplayReceivedArguments: (mediaSource: MediaSourceProxy, url: URL, autoplay: Bool)? + var loadMediaSourceUsingAutoplayReceivedInvocations: [(mediaSource: MediaSourceProxy, url: URL, autoplay: Bool)] = [] + var loadMediaSourceUsingAutoplayClosure: ((MediaSourceProxy, URL, Bool) -> Void)? - func load(mediaSource: MediaSourceProxy, using url: URL) { - loadMediaSourceUsingCallsCount += 1 - loadMediaSourceUsingReceivedArguments = (mediaSource: mediaSource, url: url) - loadMediaSourceUsingReceivedInvocations.append((mediaSource: mediaSource, url: url)) - loadMediaSourceUsingClosure?(mediaSource, url) + func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool) { + loadMediaSourceUsingAutoplayCallsCount += 1 + loadMediaSourceUsingAutoplayReceivedArguments = (mediaSource: mediaSource, url: url, autoplay: autoplay) + loadMediaSourceUsingAutoplayReceivedInvocations.append((mediaSource: mediaSource, url: url, autoplay: autoplay)) + loadMediaSourceUsingAutoplayClosure?(mediaSource, url, autoplay) } //MARK: - play @@ -408,21 +408,26 @@ class AudioRecorderMock: AudioRecorderProtocol { var underlyingIsRecording: Bool! var url: URL? - //MARK: - recordWithOutputURL + //MARK: - record - var recordWithOutputURLCallsCount = 0 - var recordWithOutputURLCalled: Bool { - return recordWithOutputURLCallsCount > 0 + var recordWithCallsCount = 0 + var recordWithCalled: Bool { + return recordWithCallsCount > 0 } - var recordWithOutputURLReceivedUrl: URL? - var recordWithOutputURLReceivedInvocations: [URL] = [] - var recordWithOutputURLClosure: ((URL) -> Void)? + var recordWithReceivedRecordID: AudioRecordingIdentifier? + var recordWithReceivedInvocations: [AudioRecordingIdentifier] = [] + var recordWithReturnValue: Result! + var recordWithClosure: ((AudioRecordingIdentifier) async -> Result)? - func recordWithOutputURL(_ url: URL) { - recordWithOutputURLCallsCount += 1 - recordWithOutputURLReceivedUrl = url - recordWithOutputURLReceivedInvocations.append(url) - recordWithOutputURLClosure?(url) + func record(with recordID: AudioRecordingIdentifier) async -> Result { + recordWithCallsCount += 1 + recordWithReceivedRecordID = recordID + recordWithReceivedInvocations.append(recordID) + if let recordWithClosure = recordWithClosure { + return await recordWithClosure(recordID) + } else { + return recordWithReturnValue + } } //MARK: - stopRecording @@ -430,11 +435,23 @@ class AudioRecorderMock: AudioRecorderProtocol { var stopRecordingCalled: Bool { return stopRecordingCallsCount > 0 } - var stopRecordingClosure: (() -> Void)? + var stopRecordingClosure: (() async -> Void)? - func stopRecording() { + func stopRecording() async { stopRecordingCallsCount += 1 - stopRecordingClosure?() + await stopRecordingClosure?() + } + //MARK: - deleteRecording + + var deleteRecordingCallsCount = 0 + var deleteRecordingCalled: Bool { + return deleteRecordingCallsCount > 0 + } + var deleteRecordingClosure: (() -> Void)? + + func deleteRecording() { + deleteRecordingCallsCount += 1 + deleteRecordingClosure?() } //MARK: - averagePowerForChannelNumber @@ -807,19 +824,19 @@ class MediaPlayerMock: MediaPlayerProtocol { //MARK: - load - var loadMediaSourceUsingCallsCount = 0 - var loadMediaSourceUsingCalled: Bool { - return loadMediaSourceUsingCallsCount > 0 + var loadMediaSourceUsingAutoplayCallsCount = 0 + var loadMediaSourceUsingAutoplayCalled: Bool { + return loadMediaSourceUsingAutoplayCallsCount > 0 } - var loadMediaSourceUsingReceivedArguments: (mediaSource: MediaSourceProxy, url: URL)? - var loadMediaSourceUsingReceivedInvocations: [(mediaSource: MediaSourceProxy, url: URL)] = [] - var loadMediaSourceUsingClosure: ((MediaSourceProxy, URL) -> Void)? + var loadMediaSourceUsingAutoplayReceivedArguments: (mediaSource: MediaSourceProxy, url: URL, autoplay: Bool)? + var loadMediaSourceUsingAutoplayReceivedInvocations: [(mediaSource: MediaSourceProxy, url: URL, autoplay: Bool)] = [] + var loadMediaSourceUsingAutoplayClosure: ((MediaSourceProxy, URL, Bool) -> Void)? - func load(mediaSource: MediaSourceProxy, using url: URL) { - loadMediaSourceUsingCallsCount += 1 - loadMediaSourceUsingReceivedArguments = (mediaSource: mediaSource, url: url) - loadMediaSourceUsingReceivedInvocations.append((mediaSource: mediaSource, url: url)) - loadMediaSourceUsingClosure?(mediaSource, url) + func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool) { + loadMediaSourceUsingAutoplayCallsCount += 1 + loadMediaSourceUsingAutoplayReceivedArguments = (mediaSource: mediaSource, url: url, autoplay: autoplay) + loadMediaSourceUsingAutoplayReceivedInvocations.append((mediaSource: mediaSource, url: url, autoplay: autoplay)) + loadMediaSourceUsingAutoplayClosure?(mediaSource, url, autoplay) } //MARK: - play @@ -874,6 +891,99 @@ class MediaPlayerMock: MediaPlayerProtocol { await seekToClosure?(progress) } } +class MediaPlayerProviderMock: MediaPlayerProviderProtocol { + + //MARK: - player + + var playerForCallsCount = 0 + var playerForCalled: Bool { + return playerForCallsCount > 0 + } + var playerForReceivedMediaSource: MediaSourceProxy? + var playerForReceivedInvocations: [MediaSourceProxy] = [] + var playerForReturnValue: Result! + var playerForClosure: ((MediaSourceProxy) -> Result)? + + func player(for mediaSource: MediaSourceProxy) -> Result { + playerForCallsCount += 1 + playerForReceivedMediaSource = mediaSource + playerForReceivedInvocations.append(mediaSource) + if let playerForClosure = playerForClosure { + return playerForClosure(mediaSource) + } else { + return playerForReturnValue + } + } + //MARK: - playerState + + var playerStateForCallsCount = 0 + var playerStateForCalled: Bool { + return playerStateForCallsCount > 0 + } + var playerStateForReceivedId: AudioPlayerStateIdentifier? + var playerStateForReceivedInvocations: [AudioPlayerStateIdentifier] = [] + var playerStateForReturnValue: AudioPlayerState? + var playerStateForClosure: ((AudioPlayerStateIdentifier) -> AudioPlayerState?)? + + func playerState(for id: AudioPlayerStateIdentifier) -> AudioPlayerState? { + playerStateForCallsCount += 1 + playerStateForReceivedId = id + playerStateForReceivedInvocations.append(id) + if let playerStateForClosure = playerStateForClosure { + return playerStateForClosure(id) + } else { + return playerStateForReturnValue + } + } + //MARK: - register + + var registerAudioPlayerStateCallsCount = 0 + var registerAudioPlayerStateCalled: Bool { + return registerAudioPlayerStateCallsCount > 0 + } + var registerAudioPlayerStateReceivedAudioPlayerState: AudioPlayerState? + var registerAudioPlayerStateReceivedInvocations: [AudioPlayerState] = [] + var registerAudioPlayerStateClosure: ((AudioPlayerState) -> Void)? + + func register(audioPlayerState: AudioPlayerState) { + registerAudioPlayerStateCallsCount += 1 + registerAudioPlayerStateReceivedAudioPlayerState = audioPlayerState + registerAudioPlayerStateReceivedInvocations.append(audioPlayerState) + registerAudioPlayerStateClosure?(audioPlayerState) + } + //MARK: - unregister + + var unregisterAudioPlayerStateCallsCount = 0 + var unregisterAudioPlayerStateCalled: Bool { + return unregisterAudioPlayerStateCallsCount > 0 + } + var unregisterAudioPlayerStateReceivedAudioPlayerState: AudioPlayerState? + var unregisterAudioPlayerStateReceivedInvocations: [AudioPlayerState] = [] + var unregisterAudioPlayerStateClosure: ((AudioPlayerState) -> Void)? + + func unregister(audioPlayerState: AudioPlayerState) { + unregisterAudioPlayerStateCallsCount += 1 + unregisterAudioPlayerStateReceivedAudioPlayerState = audioPlayerState + unregisterAudioPlayerStateReceivedInvocations.append(audioPlayerState) + unregisterAudioPlayerStateClosure?(audioPlayerState) + } + //MARK: - detachAllStates + + var detachAllStatesExceptCallsCount = 0 + var detachAllStatesExceptCalled: Bool { + return detachAllStatesExceptCallsCount > 0 + } + var detachAllStatesExceptReceivedException: AudioPlayerState? + var detachAllStatesExceptReceivedInvocations: [AudioPlayerState?] = [] + var detachAllStatesExceptClosure: ((AudioPlayerState?) async -> Void)? + + func detachAllStates(except exception: AudioPlayerState?) async { + detachAllStatesExceptCallsCount += 1 + detachAllStatesExceptReceivedException = exception + detachAllStatesExceptReceivedInvocations.append(exception) + await detachAllStatesExceptClosure?(exception) + } +} class NotificationCenterMock: NotificationCenterProtocol { //MARK: - post @@ -1775,6 +1885,23 @@ class RoomProxyMock: RoomProxyProtocol { return sendLocationBodyGeoURIDescriptionZoomLevelAssetTypeReturnValue } } + //MARK: - sendVoiceMessage + + var sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleCallsCount = 0 + var sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleCalled: Bool { + return sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleCallsCount > 0 + } + var sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleReturnValue: Result! + var sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleClosure: ((URL, AudioInfo, [UInt16], CurrentValueSubject?, @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result)? + + func sendVoiceMessage(url: URL, audioInfo: AudioInfo, waveform: [UInt16], progressSubject: CurrentValueSubject?, requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result { + sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleCallsCount += 1 + if let sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleClosure = sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleClosure { + return await sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleClosure(url, audioInfo, waveform, progressSubject, requestHandle) + } else { + return sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleReturnValue + } + } //MARK: - retrySend var retrySendTransactionIDCallsCount = 0 @@ -2715,25 +2842,21 @@ class VoiceMessageCacheMock: VoiceMessageCacheProtocol { } //MARK: - cache - var cacheMediaSourceUsingMoveThrowableError: Error? var cacheMediaSourceUsingMoveCallsCount = 0 var cacheMediaSourceUsingMoveCalled: Bool { return cacheMediaSourceUsingMoveCallsCount > 0 } var cacheMediaSourceUsingMoveReceivedArguments: (mediaSource: MediaSourceProxy, fileURL: URL, move: Bool)? var cacheMediaSourceUsingMoveReceivedInvocations: [(mediaSource: MediaSourceProxy, fileURL: URL, move: Bool)] = [] - var cacheMediaSourceUsingMoveReturnValue: URL! - var cacheMediaSourceUsingMoveClosure: ((MediaSourceProxy, URL, Bool) throws -> URL)? + var cacheMediaSourceUsingMoveReturnValue: Result! + var cacheMediaSourceUsingMoveClosure: ((MediaSourceProxy, URL, Bool) -> Result)? - func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) throws -> URL { - if let error = cacheMediaSourceUsingMoveThrowableError { - throw error - } + func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) -> Result { cacheMediaSourceUsingMoveCallsCount += 1 cacheMediaSourceUsingMoveReceivedArguments = (mediaSource: mediaSource, fileURL: fileURL, move: move) cacheMediaSourceUsingMoveReceivedInvocations.append((mediaSource: mediaSource, fileURL: fileURL, move: move)) if let cacheMediaSourceUsingMoveClosure = cacheMediaSourceUsingMoveClosure { - return try cacheMediaSourceUsingMoveClosure(mediaSource, fileURL, move) + return cacheMediaSourceUsingMoveClosure(mediaSource, fileURL, move) } else { return cacheMediaSourceUsingMoveReturnValue } @@ -2779,4 +2902,172 @@ class VoiceMessageMediaManagerMock: VoiceMessageMediaManagerProtocol { } } } +class VoiceMessageRecorderMock: VoiceMessageRecorderProtocol { + var audioRecorder: AudioRecorderProtocol { + get { return underlyingAudioRecorder } + set(value) { underlyingAudioRecorder = value } + } + var underlyingAudioRecorder: AudioRecorderProtocol! + var previewAudioPlayerState: AudioPlayerState? + var recordingURL: URL? + var recordingDuration: TimeInterval { + get { return underlyingRecordingDuration } + set(value) { underlyingRecordingDuration = value } + } + var underlyingRecordingDuration: TimeInterval! + + //MARK: - startRecording + + var startRecordingCallsCount = 0 + var startRecordingCalled: Bool { + return startRecordingCallsCount > 0 + } + var startRecordingReturnValue: Result! + var startRecordingClosure: (() async -> Result)? + + func startRecording() async -> Result { + startRecordingCallsCount += 1 + if let startRecordingClosure = startRecordingClosure { + return await startRecordingClosure() + } else { + return startRecordingReturnValue + } + } + //MARK: - stopRecording + + var stopRecordingCallsCount = 0 + var stopRecordingCalled: Bool { + return stopRecordingCallsCount > 0 + } + var stopRecordingReturnValue: Result! + var stopRecordingClosure: (() async -> Result)? + + func stopRecording() async -> Result { + stopRecordingCallsCount += 1 + if let stopRecordingClosure = stopRecordingClosure { + return await stopRecordingClosure() + } else { + return stopRecordingReturnValue + } + } + //MARK: - cancelRecording + + var cancelRecordingCallsCount = 0 + var cancelRecordingCalled: Bool { + return cancelRecordingCallsCount > 0 + } + var cancelRecordingClosure: (() async -> Void)? + + func cancelRecording() async { + cancelRecordingCallsCount += 1 + await cancelRecordingClosure?() + } + //MARK: - startPlayback + + var startPlaybackCallsCount = 0 + var startPlaybackCalled: Bool { + return startPlaybackCallsCount > 0 + } + var startPlaybackReturnValue: Result! + var startPlaybackClosure: (() async -> Result)? + + func startPlayback() async -> Result { + startPlaybackCallsCount += 1 + if let startPlaybackClosure = startPlaybackClosure { + return await startPlaybackClosure() + } else { + return startPlaybackReturnValue + } + } + //MARK: - pausePlayback + + var pausePlaybackCallsCount = 0 + var pausePlaybackCalled: Bool { + return pausePlaybackCallsCount > 0 + } + var pausePlaybackClosure: (() -> Void)? + + func pausePlayback() { + pausePlaybackCallsCount += 1 + pausePlaybackClosure?() + } + //MARK: - stopPlayback + + var stopPlaybackCallsCount = 0 + var stopPlaybackCalled: Bool { + return stopPlaybackCallsCount > 0 + } + var stopPlaybackClosure: (() async -> Void)? + + func stopPlayback() async { + stopPlaybackCallsCount += 1 + await stopPlaybackClosure?() + } + //MARK: - seekPlayback + + var seekPlaybackToCallsCount = 0 + var seekPlaybackToCalled: Bool { + return seekPlaybackToCallsCount > 0 + } + var seekPlaybackToReceivedProgress: Double? + var seekPlaybackToReceivedInvocations: [Double] = [] + var seekPlaybackToClosure: ((Double) async -> Void)? + + func seekPlayback(to progress: Double) async { + seekPlaybackToCallsCount += 1 + seekPlaybackToReceivedProgress = progress + seekPlaybackToReceivedInvocations.append(progress) + await seekPlaybackToClosure?(progress) + } + //MARK: - deleteRecording + + var deleteRecordingCallsCount = 0 + var deleteRecordingCalled: Bool { + return deleteRecordingCallsCount > 0 + } + var deleteRecordingClosure: (() async -> Void)? + + func deleteRecording() async { + deleteRecordingCallsCount += 1 + await deleteRecordingClosure?() + } + //MARK: - buildRecordingWaveform + + var buildRecordingWaveformCallsCount = 0 + var buildRecordingWaveformCalled: Bool { + return buildRecordingWaveformCallsCount > 0 + } + var buildRecordingWaveformReturnValue: Result<[UInt16], VoiceMessageRecorderError>! + var buildRecordingWaveformClosure: (() async -> Result<[UInt16], VoiceMessageRecorderError>)? + + func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError> { + buildRecordingWaveformCallsCount += 1 + if let buildRecordingWaveformClosure = buildRecordingWaveformClosure { + return await buildRecordingWaveformClosure() + } else { + return buildRecordingWaveformReturnValue + } + } + //MARK: - sendVoiceMessage + + var sendVoiceMessageInRoomAudioConverterCallsCount = 0 + var sendVoiceMessageInRoomAudioConverterCalled: Bool { + return sendVoiceMessageInRoomAudioConverterCallsCount > 0 + } + var sendVoiceMessageInRoomAudioConverterReceivedArguments: (roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol)? + var sendVoiceMessageInRoomAudioConverterReceivedInvocations: [(roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol)] = [] + var sendVoiceMessageInRoomAudioConverterReturnValue: Result! + var sendVoiceMessageInRoomAudioConverterClosure: ((RoomProxyProtocol, AudioConverterProtocol) async -> Result)? + + func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result { + sendVoiceMessageInRoomAudioConverterCallsCount += 1 + sendVoiceMessageInRoomAudioConverterReceivedArguments = (roomProxy: roomProxy, audioConverter: audioConverter) + sendVoiceMessageInRoomAudioConverterReceivedInvocations.append((roomProxy: roomProxy, audioConverter: audioConverter)) + if let sendVoiceMessageInRoomAudioConverterClosure = sendVoiceMessageInRoomAudioConverterClosure { + return await sendVoiceMessageInRoomAudioConverterClosure(roomProxy, audioConverter) + } else { + return sendVoiceMessageInRoomAudioConverterReturnValue + } + } +} // swiftlint:enable all diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/EstimatedWaveformView.swift b/ElementX/Sources/Other/VoiceMessage/EstimatedWaveformView.swift similarity index 100% rename from ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/EstimatedWaveformView.swift rename to ElementX/Sources/Other/VoiceMessage/EstimatedWaveformView.swift diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/ProgressMaskModifier.swift b/ElementX/Sources/Other/VoiceMessage/ProgressMaskModifier.swift similarity index 100% rename from ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/ProgressMaskModifier.swift rename to ElementX/Sources/Other/VoiceMessage/ProgressMaskModifier.swift diff --git a/ElementX/Sources/Other/VoiceMessage/WaveformSource.swift b/ElementX/Sources/Other/VoiceMessage/WaveformSource.swift new file mode 100644 index 0000000000..b3dd547f1f --- /dev/null +++ b/ElementX/Sources/Other/VoiceMessage/WaveformSource.swift @@ -0,0 +1,24 @@ +// +// 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 Foundation + +enum WaveformSource: Equatable { + /// File URL of the source audio file + case url(URL) + /// Array of small number of pre-computed samples + case data([Float]) +} diff --git a/ElementX/Sources/Other/VoiceMessage/WaveformViewDragGestureModifier.swift b/ElementX/Sources/Other/VoiceMessage/WaveformViewDragGestureModifier.swift new file mode 100644 index 0000000000..734bb2be3b --- /dev/null +++ b/ElementX/Sources/Other/VoiceMessage/WaveformViewDragGestureModifier.swift @@ -0,0 +1,98 @@ +// +// 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 DSWaveformImageViews +import Foundation +import SwiftUI + +struct WaveformViewDragGestureModifier: ViewModifier { + @GestureState private var dragGestureState = WaveformViewDragState.inactive + @Binding var dragState: WaveformViewDragState + + let minimumDragDistance: Double + + func body(content: Content) -> some View { + GeometryReader { geometry in + content + .gesture(SpatialTapGesture() + .simultaneously(with: LongPressGesture()) + .sequenced(before: DragGesture(minimumDistance: minimumDragDistance, coordinateSpace: .local)) + .updating($dragGestureState) { value, state, _ in + switch value { + // (SpatialTap, LongPress) begins. + case .first(let spatialLongPress): + // Compute the progress with the spatialTap location + let progress = (spatialLongPress.first?.location ?? .zero).x / geometry.size.width + state = .pressing(progress: progress) + // Long press confirmed, dragging may begin. + case .second(let spatialLongPress, let drag) where spatialLongPress.second ?? false: + var progress: Double = dragState.progress + // Compute the progress with drag location + if let location = drag?.location { + progress = location.x / geometry.size.width + } + state = .dragging(progress: progress) + // Dragging ended or the long press cancelled. + default: + state = .inactive + } + }) + } + .onChange(of: dragGestureState) { value in + dragState = value + } + } +} + +extension View { + func waveformDragGesture(_ dragState: Binding, minimumDragDistance: Double = 0) -> some View { + modifier(WaveformViewDragGestureModifier(dragState: dragState, + minimumDragDistance: minimumDragDistance)) + } +} + +enum WaveformViewDragState: Equatable { + case inactive + case pressing(progress: Double) + case dragging(progress: Double) + + var progress: Double { + switch self { + case .inactive: + return .zero + case .pressing(let progress), .dragging(let progress): + return progress + } + } + + var isActive: Bool { + switch self { + case .inactive: + return false + case .pressing, .dragging: + return true + } + } + + var isDragging: Bool { + switch self { + case .inactive, .pressing: + return false + case .dragging: + return true + } + } +} diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift index 5815efb3a9..c5bd3e97a0 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarModels.swift @@ -31,9 +31,13 @@ enum ComposerToolbarViewModelAction { case composerModeChanged(mode: RoomScreenComposerMode) case composerFocusedChanged(isFocused: Bool) - case startRecordingVoiceMessage - case stopRecordingVoiceMessage - case deleteRecordedVoiceMessage + case startVoiceMessageRecording + case stopVoiceMessageRecording + case cancelVoiceMessageRecording + case deleteVoiceMessageRecording + case startVoiceMessagePlayback + case pauseVoiceMessagePlayback + case seekVoiceMessagePlayback(progress: Double) case sendVoiceMessage } @@ -51,9 +55,13 @@ enum ComposerToolbarViewAction { case enableTextFormatting case composerAction(action: ComposerAction) case selectedSuggestion(_ suggestion: SuggestionItem) - case startRecordingVoiceMessage - case stopRecordingVoiceMessage - case deleteRecordedVoiceMessage + case startVoiceMessageRecording + case stopVoiceMessageRecording + case cancelVoiceMessageRecording + case deleteVoiceMessageRecording + case startVoiceMessagePlayback + case pauseVoiceMessagePlayback + case seekVoiceMessagePlayback(progress: Double) } struct ComposerToolbarViewState: BindableState { diff --git a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift index f423353049..26309ca5fd 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/ComposerToolbarViewModel.swift @@ -47,7 +47,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool super.init(initialViewState: ComposerToolbarViewState(areSuggestionsEnabled: completionSuggestionService.areSuggestionsEnabled, enableVoiceMessageComposer: appSettings.voiceMessageEnabled, - audioPlayerState: .init(duration: 0), + audioPlayerState: .init(id: .recorderPreview, duration: 0), audioRecorderState: .init(), bindings: .init()), imageProvider: mediaProvider) @@ -144,13 +144,21 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool } case .selectedSuggestion(let suggestion): handleSuggestion(suggestion) - case .startRecordingVoiceMessage: + case .startVoiceMessageRecording: state.bindings.composerActionsEnabled = false - actionsSubject.send(.startRecordingVoiceMessage) - case .stopRecordingVoiceMessage: - actionsSubject.send(.stopRecordingVoiceMessage) - case .deleteRecordedVoiceMessage: - actionsSubject.send(.deleteRecordedVoiceMessage) + actionsSubject.send(.startVoiceMessageRecording) + case .stopVoiceMessageRecording: + actionsSubject.send(.stopVoiceMessageRecording) + case .cancelVoiceMessageRecording: + actionsSubject.send(.cancelVoiceMessageRecording) + case .deleteVoiceMessageRecording: + actionsSubject.send(.deleteVoiceMessageRecording) + case .startVoiceMessagePlayback: + actionsSubject.send(.startVoiceMessagePlayback) + case .pauseVoiceMessagePlayback: + actionsSubject.send(.pauseVoiceMessagePlayback) + case .seekVoiceMessagePlayback(let progress): + actionsSubject.send(.seekVoiceMessagePlayback(progress: progress)) } } @@ -224,7 +232,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool case .recordVoiceMessage(let audioRecorderState): state.bindings.composerFocused = false state.audioRecorderState = audioRecorderState - case .previewVoiceMessage(let audioPlayerState): + case .previewVoiceMessage(let audioPlayerState, _): state.audioPlayerState = audioPlayerState case .edit, .reply: // Focus composer when switching to reply/edit diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift index 84aa36c5d0..dcf3ea423a 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/ComposerToolbar.swift @@ -28,9 +28,13 @@ struct ComposerToolbar: View { @ScaledMetric private var trashButtonIconSize = 24 @ScaledMetric(relativeTo: .title) private var closeRTEButtonSize = 30 + @State private var voiceMessageRecordingStartTime: Date? @State private var showVoiceMessageRecordingTooltip = false @ScaledMetric private var voiceMessageTooltipPointerHeight = 6 + private let voiceMessageMinimumRecordingDuration = 1.0 + private let voiceMessageTooltipDuration = 1.0 + @State private var frame: CGRect = .zero var body: some View { @@ -51,8 +55,10 @@ struct ComposerToolbar: View { } } .overlay(alignment: .bottomTrailing) { - voiceMessageRecordingButtonTooltipView - .offset(y: -frame.height - voiceMessageTooltipPointerHeight) + if showVoiceMessageRecordingTooltip { + voiceMessageRecordingButtonTooltipView + .offset(y: -frame.height - voiceMessageTooltipPointerHeight) + } } .alert(item: $context.alertInfo) } @@ -69,9 +75,9 @@ struct ComposerToolbar: View { case .recordVoiceMessage(let state) where context.viewState.enableVoiceMessageComposer: VoiceMessageRecordingComposer(recorderState: state) .padding(.leading, 12) - case .previewVoiceMessage(let state) where context.viewState.enableVoiceMessageComposer: + case .previewVoiceMessage(let state, let waveform) where context.viewState.enableVoiceMessageComposer: voiceMessageTrashButton - VoiceMessagePreviewComposer(playerState: state) + voiceMessagePreviewComposer(audioPlayerState: state, waveform: waveform) default: if !context.composerActionsEnabled { RoomAttachmentPicker(context: context) @@ -96,6 +102,7 @@ struct ComposerToolbar: View { } } } + .animation(.elementDefault, value: context.viewState.composerMode) } private var bottomBar: some View { @@ -216,17 +223,26 @@ struct ComposerToolbar: View { // MARK: - Voice message private var voiceMessageRecordingButton: some View { - VoiceMessageRecordingButton(showRecordTooltip: $showVoiceMessageRecordingTooltip, startRecording: { - context.send(viewAction: .startRecordingVoiceMessage) - }, stopRecording: { - context.send(viewAction: .stopRecordingVoiceMessage) - }) + VoiceMessageRecordingButton { + showVoiceMessageRecordingTooltip = false + voiceMessageRecordingStartTime = Date.now + context.send(viewAction: .startVoiceMessageRecording) + } stopRecording: { + if let voiceMessageRecordingStartTime, Date.now.timeIntervalSince(voiceMessageRecordingStartTime) < voiceMessageMinimumRecordingDuration { + context.send(viewAction: .cancelVoiceMessageRecording) + withAnimation { + showVoiceMessageRecordingTooltip = true + } + } else { + context.send(viewAction: .stopVoiceMessageRecording) + } + } .padding(4) } private var voiceMessageTrashButton: some View { Button { - context.send(viewAction: .deleteRecordedVoiceMessage) + context.send(viewAction: .deleteVoiceMessageRecording) } label: { CompoundIcon(\.delete) .font(.compound.bodyLG) @@ -241,8 +257,23 @@ struct ComposerToolbar: View { private var voiceMessageRecordingButtonTooltipView: some View { VoiceMessageRecordingButtonTooltipView(text: L10n.screenRoomVoiceMessageTooltip, pointerHeight: voiceMessageTooltipPointerHeight) .allowsHitTesting(false) - .opacity(showVoiceMessageRecordingTooltip ? 1.0 : 0.0) - .animation(.elementDefault, value: showVoiceMessageRecordingTooltip) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + voiceMessageTooltipDuration) { + withAnimation { + showVoiceMessageRecordingTooltip = false + } + } + } + } + + private func voiceMessagePreviewComposer(audioPlayerState: AudioPlayerState, waveform: WaveformSource) -> some View { + VoiceMessagePreviewComposer(playerState: audioPlayerState, waveform: waveform) { + context.send(viewAction: .startVoiceMessagePlayback) + } onPause: { + context.send(viewAction: .pauseVoiceMessagePlayback) + } onSeek: { progress in + context.send(viewAction: .seekVoiceMessagePlayback(progress: progress)) + } } } @@ -333,13 +364,14 @@ extension ComposerToolbar { static func voiceMessagePreviewMock(recording: Bool) -> ComposerToolbar { let wysiwygViewModel = WysiwygComposerViewModel() + let waveformData: [Float] = Array(repeating: 1.0, count: 1000) var composerViewModel: ComposerToolbarViewModel { let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel, completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), mediaProvider: MockMediaProvider(), appSettings: ServiceLocator.shared.settings, mentionDisplayHelper: ComposerMentionDisplayHelper.mock) - model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(duration: 10.0)) + model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, duration: 10.0), waveform: .data(waveformData)) model.state.enableVoiceMessageComposer = true return model } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift index ba988ed9d0..f4e199ad7e 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessagePreviewComposer.swift @@ -14,49 +14,161 @@ // limitations under the License. // +import DSWaveformImage +import DSWaveformImageViews import Foundation import SwiftUI struct VoiceMessagePreviewComposer: View { @ObservedObject var playerState: AudioPlayerState - + let waveform: WaveformSource + @ScaledMetric private var waveformLineWidth = 2.0 + @ScaledMetric private var waveformLinePadding = 2.0 @State private var resumePlaybackAfterScrubbing = false + + let onPlay: () -> Void + let onPause: () -> Void + let onSeek: (Double) -> Void + + @ScaledMetric private var playPauseButtonSize = 32 + @ScaledMetric private var playPauseImagePadding = 8 + @State var dragState: WaveformViewDragState = .inactive + private static let elapsedTimeFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "mm:ss" + return dateFormatter + }() + + var timeLabelContent: String { + // Display the duration if progress is 0.0 + let percent = playerState.progress > 0.0 ? playerState.progress : 1.0 + // If the duration is greater or equal 10 minutes, use the long format + let elapsed = Date(timeIntervalSinceReferenceDate: playerState.duration * percent) + return Self.elapsedTimeFormatter.string(from: elapsed) + } + + var showWaveformCursor: Bool { + playerState.playbackState == .playing || dragState.isDragging + } + var body: some View { - VoiceMessageRoomPlaybackView(playerState: playerState, - onPlayPause: onPlaybackPlayPause, - onSeek: { onPlaybackSeek($0) }, - onScrubbing: { onPlaybackScrubbing($0) }) - .padding(.vertical, 4.0) - .padding(.horizontal, 6.0) - .background { - let roundedRectangle = RoundedRectangle(cornerRadius: 12) - ZStack { - roundedRectangle - .fill(Color.compound.bgSubtleSecondary) - roundedRectangle - .stroke(Color.compound._borderTextFieldFocused, lineWidth: 0.5) + HStack { + HStack { + playPauseButton + Text(timeLabelContent) + .lineLimit(1) + .font(.compound.bodySMSemibold) + .foregroundColor(.compound.textSecondary) + .monospacedDigit() + .fixedSize(horizontal: true, vertical: true) + } + waveformView + .waveformDragGesture($dragState) + .onChange(of: dragState) { dragState in + switch dragState { + case .inactive: + onScrubbing(false) + case .pressing(let progress): + onScrubbing(true) + onSeek(max(0, min(progress, 1.0))) + case .dragging(let progress): + onSeek(max(0, min(progress, 1.0))) + } + self.dragState = dragState } + } + .padding(.vertical, 4.0) + .padding(.horizontal, 6.0) + .background { + let roundedRectangle = RoundedRectangle(cornerRadius: 12) + ZStack { + roundedRectangle + .fill(Color.compound.bgSubtleSecondary) + roundedRectangle + .stroke(Color.compound._borderTextFieldFocused, lineWidth: 0.5) } - .frame(minHeight: 42) - .fixedSize(horizontal: false, vertical: true) + } + .frame(minHeight: 42) + .fixedSize(horizontal: false, vertical: true) } - private func onPlaybackPlayPause() { } + @ViewBuilder + private var waveformView: some View { + let configuration: Waveform.Configuration = .init(style: .striped(.init(color: .black, width: waveformLineWidth, spacing: waveformLinePadding)), + verticalScalingFactor: 1.0) + switch waveform { + case .url(let url): + WaveformView(audioURL: url, + configuration: configuration) + .progressMask(progress: playerState.progress) + case .data(let array): + WaveformLiveCanvas(samples: array, + configuration: configuration) + .progressMask(progress: playerState.progress) + } + } + + @ViewBuilder + private var playPauseButton: some View { + Button { + onPlayPause() + } label: { + ZStack { + Circle() + .foregroundColor(.compound.bgCanvasDefault) + if playerState.playbackState == .loading { + ProgressView() + } else { + Image(asset: playerState.playbackState == .playing ? Asset.Images.mediaPause : Asset.Images.mediaPlay) + .resizable() + .padding(playPauseImagePadding) + .offset(x: playerState.playbackState == .playing ? 0 : 2) + .aspectRatio(contentMode: .fit) + .foregroundColor(.compound.iconSecondary) + .accessibilityLabel(playerState.playbackState == .playing ? L10n.a11yPause : L10n.a11yPlay) + } + } + } + .disabled(playerState.playbackState == .loading) + .frame(width: playPauseButtonSize, + height: playPauseButtonSize) + } - private func onPlaybackSeek(_ progress: Double) { } + private func onPlayPause() { + if playerState.playbackState == .playing { + onPause() + } else { + onPlay() + } + } - private func onPlaybackScrubbing(_ dragging: Bool) { } + private func onScrubbing(_ scrubbing: Bool) { + if scrubbing { + if playerState.playbackState == .playing { + resumePlaybackAfterScrubbing = true + onPause() + } + } else { + if resumePlaybackAfterScrubbing { + onPlay() + resumePlaybackAfterScrubbing = false + } + } + } } struct VoiceMessagePreviewComposer_Previews: PreviewProvider, TestablePreview { - static let playerState = AudioPlayerState(duration: 10.0, + static let playerState = AudioPlayerState(id: .recorderPreview, + duration: 10.0, waveform: EstimatedWaveform.mockWaveform, progress: 0.4) + static let waveformData: [Float] = Array(repeating: 1.0, count: 1000) + static var previews: some View { VStack { - VoiceMessagePreviewComposer(playerState: playerState) + VoiceMessagePreviewComposer(playerState: playerState, waveform: .data(waveformData), onPlay: { }, onPause: { }, onSeek: { _ in }) .fixedSize(horizontal: false, vertical: true) } } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift index 28b399ba8f..ce901f859e 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingButton.swift @@ -19,14 +19,8 @@ import SwiftUI struct VoiceMessageRecordingButton: View { @ScaledMetric private var buttonIconSize = 24 - @State private var longPressConfirmed = false @State private var buttonPressed = false - @State private var longPressTask = VoiceMessageButtonTask() - private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) - private let delayBeforeRecording = 500 - - @Binding var showRecordTooltip: Bool var startRecording: (() -> Void)? var stopRecording: (() -> Void)? @@ -34,29 +28,16 @@ struct VoiceMessageRecordingButton: View { Button { } label: { voiceMessageButtonImage } - .onLongPressGesture(perform: { }, onPressingChanged: { pressing in - buttonPressed = pressing - if pressing { - showRecordTooltip = true - feedbackGenerator.prepare() - longPressTask.task = Task { - try? await Task.sleep(for: .milliseconds(delayBeforeRecording)) - guard !Task.isCancelled else { - return - } - feedbackGenerator.impactOccurred() - showRecordTooltip = false - longPressConfirmed = true - startRecording?() - } + .onLongPressGesture { } onPressingChanged: { isPressing in + buttonPressed = isPressing + if isPressing { + // Start recording + startRecording?() } else { - longPressTask.task?.cancel() - showRecordTooltip = false - guard longPressConfirmed else { return } - longPressConfirmed = false + // Stop recording stopRecording?() } - }) + } .fixedSize() } @@ -71,13 +52,9 @@ struct VoiceMessageRecordingButton: View { } } -private class VoiceMessageButtonTask { - @CancellableTask var task: Task? -} - struct VoiceMessageRecordingButton_Previews: PreviewProvider, TestablePreview { static var previews: some View { - VoiceMessageRecordingButton(showRecordTooltip: .constant(false)) + VoiceMessageRecordingButton() .fixedSize(horizontal: true, vertical: true) } } diff --git a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingView.swift b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingView.swift index 793b1d0449..b52e6e8ac7 100644 --- a/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingView.swift +++ b/ElementX/Sources/Screens/ComposerToolbar/View/VoiceMessageRecordingView.swift @@ -15,6 +15,8 @@ // import Compound +import DSWaveformImage +import DSWaveformImageViews import Foundation import SwiftUI @@ -30,10 +32,15 @@ struct VoiceMessageRecordingView: View { return dateFormatter }() - var timeLabelContent: String { + private var timeLabelContent: String { Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: recorderState.duration)) } + private var configuration: Waveform.Configuration { + .init(style: .striped(.init(color: .compound.iconSecondary, width: waveformLineWidth, spacing: waveformLinePadding)), + verticalScalingFactor: 1.0) + } + var body: some View { HStack { Circle() @@ -45,7 +52,9 @@ struct VoiceMessageRecordingView: View { .foregroundColor(.compound.textSecondary) .monospacedDigit() .fixedSize() - EstimatedWaveformView(lineWidth: waveformLineWidth, linePadding: waveformLinePadding, waveform: recorderState.waveform, progress: 0) + + WaveformLiveCanvas(samples: recorderState.waveformSamples, + configuration: configuration) } .padding(.leading, 2) .padding(.trailing, 8) @@ -53,11 +62,6 @@ struct VoiceMessageRecordingView: View { } struct VoiceMessageRecordingView_Previews: PreviewProvider, TestablePreview { - static let waveform = EstimatedWaveform(data: [3, 127, 400, 266, 126, 122, 373, 251, 45, 112, - 334, 205, 99, 138, 397, 354, 125, 361, 199, 51, - 294, 131, 19, 2, 3, 3, 1, 2, 0, 0, - 0, 0, 0, 0, 0, 3]) - static let recorderState = AudioRecorderState() static var previews: some View { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index f924aefdbc..49c68f81fb 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -23,6 +23,7 @@ struct RoomScreenCoordinatorParameters { let roomProxy: RoomProxyProtocol let timelineController: RoomTimelineControllerProtocol let mediaProvider: MediaProviderProtocol + let mediaPlayerProvider: MediaPlayerProviderProtocol let emojiProvider: EmojiProviderProtocol let completionSuggestionService: CompletionSuggestionServiceProtocol let appSettings: AppSettings @@ -60,6 +61,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol { viewModel = RoomScreenViewModel(timelineController: parameters.timelineController, mediaProvider: parameters.mediaProvider, + mediaPlayerProvider: parameters.mediaPlayerProvider, roomProxy: parameters.roomProxy, appSettings: parameters.appSettings, analytics: ServiceLocator.shared.analytics, diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 14f8b45af0..77a0f97b44 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -42,7 +42,7 @@ enum RoomScreenComposerMode: Equatable { case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool) case edit(originalItemId: TimelineItemIdentifier) case recordVoiceMessage(state: AudioRecorderState) - case previewVoiceMessage(state: AudioPlayerState) + case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource) var isEdit: Bool { switch self { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 6b2b9a338b..674b852e09 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -34,16 +34,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol private let analytics: AnalyticsService private unowned let userIndicatorController: UserIndicatorControllerProtocol private let notificationCenterProtocol: NotificationCenterProtocol + private let voiceMessageRecorder: VoiceMessageRecorderProtocol private let composerFocusedSubject = PassthroughSubject() - + private let mediaPlayerProvider: MediaPlayerProviderProtocol private let actionsSubject: PassthroughSubject = .init() - private var canCurrentUserRedact = false - private var paginateBackwardsTask: Task? init(timelineController: RoomTimelineControllerProtocol, mediaProvider: MediaProviderProtocol, + mediaPlayerProvider: MediaPlayerProviderProtocol, roomProxy: RoomProxyProtocol, appSettings: AppSettings, analytics: AnalyticsService, @@ -55,6 +55,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol self.analytics = analytics self.userIndicatorController = userIndicatorController self.notificationCenterProtocol = notificationCenterProtocol + self.mediaPlayerProvider = mediaPlayerProvider + voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider) super.init(initialViewState: RoomScreenViewState(roomID: timelineController.roomID, roomTitle: roomProxy.roomTitle, @@ -194,15 +196,28 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol trackComposerMode(mode) case .composerFocusedChanged(isFocused: let isFocused): composerFocusedSubject.send(isFocused) - case .startRecordingVoiceMessage: - timelineController.pauseAudio() - startRecordingVoiceMessage() - case .stopRecordingVoiceMessage: - stopRecordingVoiceMessage() - case .deleteRecordedVoiceMessage: - deleteCurrentVoiceMessage() + case .startVoiceMessageRecording: + Task { + await mediaPlayerProvider.detachAllStates(except: nil) + await startRecordingVoiceMessage() + } + case .stopVoiceMessageRecording: + Task { await stopRecordingVoiceMessage() } + case .cancelVoiceMessageRecording: + Task { await cancelRecordingVoiceMessage() } + case .deleteVoiceMessageRecording: + Task { await deleteCurrentVoiceMessage() } case .sendVoiceMessage: Task { await sendCurrentVoiceMessage() } + case .startVoiceMessagePlayback: + Task { + await mediaPlayerProvider.detachAllStates(except: voiceMessageRecorder.previewAudioPlayerState) + await startPlayingRecordedVoiceMessage() + } + case .pauseVoiceMessagePlayback: + pausePlayingRecordedVoiceMessage() + case .seekVoiceMessagePlayback(let progress): + Task { await seekRecordedVoiceMessage(to: progress) } } } @@ -922,31 +937,90 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol // MARK: - Voice message - private func startRecordingVoiceMessage() { - // Partially implemented - + private func stopVoiceMessageRecorder() async { + _ = await voiceMessageRecorder.stopRecording() + await voiceMessageRecorder.stopPlayback() + } + + private func startRecordingVoiceMessage() async { let audioRecordState = AudioRecorderState() - actionsSubject.send(.composer(action: .setMode(mode: .recordVoiceMessage(state: audioRecordState)))) + audioRecordState.attachAudioRecorder(voiceMessageRecorder.audioRecorder) + + switch await voiceMessageRecorder.startRecording() { + case .success: + actionsSubject.send(.composer(action: .setMode(mode: .recordVoiceMessage(state: audioRecordState)))) + case .failure(let error): + switch error { + case .audioRecorderError(.recordPermissionNotGranted): + state.bindings.confirmationAlertInfo = .init(id: .init(), + title: "", + message: L10n.dialogPermissionMicrophone, + primaryButton: .init(title: L10n.actionOpenSettings, action: { [weak self] in self?.openSystemSettings() }), + secondaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil)) + default: + MXLog.error("failed to start voice message recording: \(error)") + } + } } - private func stopRecordingVoiceMessage() { - // Partially implemented + private func stopRecordingVoiceMessage() async { + if case .failure(let error) = await voiceMessageRecorder.stopRecording() { + MXLog.error("failed to stop the recording", context: error) + return + } - let audioPlayerState = AudioPlayerState(duration: 0) - actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState)))) + guard let audioPlayerState = voiceMessageRecorder.previewAudioPlayerState else { + MXLog.error("the recorder preview is missing after the recording has been stopped") + return + } + + guard let recordingURL = voiceMessageRecorder.recordingURL else { + MXLog.error("the recording URL is missing after the recording has been stopped") + return + } + + mediaPlayerProvider.register(audioPlayerState: audioPlayerState) + actionsSubject.send(.composer(action: .setMode(mode: .previewVoiceMessage(state: audioPlayerState, waveform: .url(recordingURL))))) } - private func deleteCurrentVoiceMessage() { - // Partially implemented - + private func cancelRecordingVoiceMessage() async { + await voiceMessageRecorder.cancelRecording() actionsSubject.send(.composer(action: .setMode(mode: .default))) } - private func sendCurrentVoiceMessage() async { - // Partially implemented - + private func deleteCurrentVoiceMessage() async { + await voiceMessageRecorder.deleteRecording() actionsSubject.send(.composer(action: .setMode(mode: .default))) } + + private func sendCurrentVoiceMessage() async { + await voiceMessageRecorder.stopPlayback() + switch await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: AudioConverter()) { + case .success: + await deleteCurrentVoiceMessage() + case .failure(let error): + MXLog.error("failed to send the voice message", context: error) + } + } + + private func startPlayingRecordedVoiceMessage() async { + if case .failure(let error) = await voiceMessageRecorder.startPlayback() { + MXLog.error("failed to play recorded voice message", context: error) + } + } + + private func pausePlayingRecordedVoiceMessage() { + voiceMessageRecorder.pausePlayback() + } + + private func seekRecordedVoiceMessage(to progress: Double) async { + await voiceMessageRecorder.seekPlayback(to: progress) + } + + private func openSystemSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } } private extension RoomProxyProtocol { @@ -974,6 +1048,7 @@ extension RoomScreenViewModel.Context { extension RoomScreenViewModel { static let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift index 0935334196..ec1eaa5311 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift @@ -57,6 +57,7 @@ struct RoomHeaderView_Previews: PreviewProvider, TestablePreview { static var bodyPlain: some View { let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: RoomProxyMock(with: .init(displayName: "Some Room name", avatarURL: URL.picturesDirectory)), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -71,6 +72,7 @@ struct RoomHeaderView_Previews: PreviewProvider, TestablePreview { static var bodyEncrypted: some View { let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: RoomProxyMock(with: .init(displayName: "Some Room name")), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index b9df8cede9..c04235914e 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -193,6 +193,7 @@ struct RoomScreen: View { struct RoomScreen_Previews: PreviewProvider, TestablePreview { static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: RoomProxyMock(with: .init(displayName: "Preview room", isCallOngoing: true)), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index e0c5ef0f27..feebf5f3ef 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -505,7 +505,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview contentType: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), contentType: .text(.init(body: "Short")))), - playerState: AudioPlayerState(duration: 10, waveform: EstimatedWaveform.mockWaveform)) + playerState: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 10, waveform: EstimatedWaveform.mockWaveform)) } .environmentObject(viewModel.context) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index 0ff1a48c50..0cf444f6f8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -232,7 +232,7 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider, TestablePreview { contentType: nil), replyDetails: .loaded(sender: .init(id: "", displayName: "Alice"), contentType: .text(.init(body: "Short")))), - playerState: AudioPlayerState(duration: 10, waveform: EstimatedWaveform.mockWaveform)) + playerState: AudioPlayerState(id: .timelineItemIdentifier(.init(timelineID: "")), duration: 10, waveform: EstimatedWaveform.mockWaveform)) } .environmentObject(viewModel.context) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReadReceiptsView.swift b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReadReceiptsView.swift index 6664721094..020b250df6 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReadReceiptsView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Supplementary/TimelineReadReceiptsView.swift @@ -60,6 +60,7 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview { static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: RoomProxyMock(with: .init(displayName: "Test", members: members)), appSettings: ServiceLocator.shared.settings, diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift index 67790a9e71..4c041b269b 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UITimelineView.swift @@ -82,6 +82,7 @@ struct UITimelineView: UIViewControllerRepresentable { struct UITimelineView_Previews: PreviewProvider, TestablePreview { static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index 74427ff7b3..339f5e312a 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -170,6 +170,7 @@ struct TimelineView: View { struct TimelineView_Previews: PreviewProvider, TestablePreview { static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: RoomProxyMock(with: .init(displayName: "Preview room")), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, diff --git a/ElementX/Sources/Services/Audio/AudioConverter.swift b/ElementX/Sources/Services/Audio/AudioConverter.swift index 284325a66f..8b3273864c 100644 --- a/ElementX/Sources/Services/Audio/AudioConverter.swift +++ b/ElementX/Sources/Services/Audio/AudioConverter.swift @@ -30,7 +30,6 @@ enum AudioConverterPreferredFileExtension: String { struct AudioConverter: AudioConverterProtocol { func convertToOpusOgg(sourceURL: URL, destinationURL: URL) throws { do { - MXLog.debug("converting \(sourceURL) to \(destinationURL)") try OGGConverter.convertM4aFileToOpusOGG(src: sourceURL, dest: destinationURL) } catch { MXLog.error("failed to convert to OpusOgg: \(error)") diff --git a/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift index 2d3f82bb10..e14143b8a9 100644 --- a/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift +++ b/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift @@ -30,11 +30,6 @@ private enum InternalAudioPlayerState { case error(Error) } -enum AudioPlayerError: Error { - case genericError - case loadFileError -} - class AudioPlayer: NSObject, AudioPlayerProtocol { var mediaSource: MediaSourceProxy? @@ -53,6 +48,7 @@ class AudioPlayer: NSObject, AudioPlayerProtocol { private var rateObserver: NSKeyValueObservation? private var playToEndObserver: NSObjectProtocol? private var appBackgroundObserver: NSObjectProtocol? + private var autoplay = false private(set) var url: URL? @@ -91,11 +87,12 @@ class AudioPlayer: NSObject, AudioPlayerProtocol { unloadContent() } - func load(mediaSource: MediaSourceProxy, using url: URL) { + func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool) { unloadContent() setInternalState(.loading) self.mediaSource = mediaSource self.url = url + self.autoplay = autoplay playerItem = AVPlayerItem(url: url) internalAudioPlayer = AVQueuePlayer(playerItem: playerItem) addObservers() @@ -122,6 +119,7 @@ class AudioPlayer: NSObject, AudioPlayerProtocol { isStopped = true internalAudioPlayer?.pause() internalAudioPlayer?.seek(to: .zero) + try? AVAudioSession.sharedInstance().setActive(false) } func seek(to progress: Double) async { @@ -205,7 +203,10 @@ class AudioPlayer: NSObject, AudioPlayerProtocol { actionsSubject.send(.didStartLoading) case .readyToPlay: actionsSubject.send(.didFinishLoading) - play() + if autoplay { + autoplay = false + play() + } case .playing: actionsSubject.send(.didStartPlaying) case .paused: diff --git a/ElementX/Sources/Services/Audio/Player/AudioPlayerProtocol.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayerProtocol.swift index e8d0b06ae5..26cd2806e3 100644 --- a/ElementX/Sources/Services/Audio/Player/AudioPlayerProtocol.swift +++ b/ElementX/Sources/Services/Audio/Player/AudioPlayerProtocol.swift @@ -17,6 +17,11 @@ import Combine import Foundation +enum AudioPlayerError: Error { + case genericError + case loadFileError +} + enum AudioPlayerAction { case didStartLoading case didFinishLoading diff --git a/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift index a2b7b4ac83..81ce772eb8 100644 --- a/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift +++ b/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift @@ -26,9 +26,14 @@ enum AudioPlayerPlaybackState { case error } +enum AudioPlayerStateIdentifier { + case timelineItemIdentifier(TimelineItemIdentifier) + case recorderPreview +} + @MainActor class AudioPlayerState: ObservableObject, Identifiable { - let id = UUID() + let id: AudioPlayerStateIdentifier let duration: Double let waveform: EstimatedWaveform @Published private(set) var playbackState: AudioPlayerPlaybackState @@ -50,7 +55,8 @@ class AudioPlayerState: ObservableObject, Identifiable { displayLink != nil } - init(duration: Double, waveform: EstimatedWaveform? = nil, progress: Double = 0.0) { + init(id: AudioPlayerStateIdentifier, duration: Double, waveform: EstimatedWaveform? = nil, progress: Double = 0.0) { + self.id = id self.duration = duration self.waveform = waveform ?? EstimatedWaveform(data: []) self.progress = progress diff --git a/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift b/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift index f34ec79c5d..6f34fafe74 100644 --- a/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift +++ b/ElementX/Sources/Services/Audio/Recorder/AudioRecorder.swift @@ -17,16 +17,14 @@ import AVFoundation import Combine import Foundation - -enum AudioRecorderError: Error { - case genericError -} +import UIKit class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate { private let silenceThreshold: Float = -50.0 private var audioRecorder: AVAudioRecorder? + private var cancellables = Set() private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() @@ -44,7 +42,11 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate { audioRecorder?.isRecording ?? false } - func recordWithOutputURL(_ url: URL) { + func record(with recordID: AudioRecordingIdentifier) async -> Result { + guard await requestRecordPermission() else { + return .failure(.recordPermissionNotGranted) + } + let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC), AVSampleRateKey: 48000, AVEncoderBitRateKey: 128_000, @@ -54,6 +56,7 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate { do { try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default) try AVAudioSession.sharedInstance().setActive(true) + let url = URL.temporaryDirectory.appendingPathComponent("voice-message-\(recordID.identifier).m4a") audioRecorder = try AVAudioRecorder(url: url, settings: settings) audioRecorder?.delegate = self audioRecorder?.isMeteringEnabled = true @@ -63,25 +66,26 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate { MXLog.error("audio recording failed: \(error)") actionsSubject.send(.didFailWithError(error: error)) } + return .success(()) } - - func stopRecording() { - audioRecorder?.stop() - - do { - try AVAudioSession.sharedInstance().setActive(false) - } catch { - actionsSubject.send(.didFailWithError(error: error)) + + func stopRecording() async { + guard let audioRecorder, audioRecorder.isRecording else { + return } + audioRecorder.stop() } + func deleteRecording() { + audioRecorder?.deleteRecording() + } + func peakPowerForChannelNumber(_ channelNumber: Int) -> Float { guard isRecording, let audioRecorder else { return 0.0 } audioRecorder.updateMeters() - return normalizedPowerLevelFromDecibels(audioRecorder.peakPower(forChannel: channelNumber)) } @@ -91,13 +95,37 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate { } audioRecorder.updateMeters() - return normalizedPowerLevelFromDecibels(audioRecorder.averagePower(forChannel: channelNumber)) } + + // MARK: - Private + + private func addObservers() { + // Stop recording uppon UIApplication.didEnterBackgroundNotification notification + NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) + .sink { [weak self] _ in + guard let self else { return } + Task { await self.stopRecording() } + } + .store(in: &cancellables) + } + + private func removeObservers() { + cancellables.removeAll() + } + + private func requestRecordPermission() async -> Bool { + await withCheckedContinuation { continuation in + AVAudioSession.sharedInstance().requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + } // MARK: - AVAudioRecorderDelegate func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) { + try? AVAudioSession.sharedInstance().setActive(false) if success { actionsSubject.send(.didStopRecording) } else { @@ -106,6 +134,7 @@ class AudioRecorder: NSObject, AudioRecorderProtocol, AVAudioRecorderDelegate { } func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { + try? AVAudioSession.sharedInstance().setActive(false) actionsSubject.send(.didFailWithError(error: error ?? AudioRecorderError.genericError)) } diff --git a/ElementX/Sources/Services/Audio/Recorder/AudioRecorderProtocol.swift b/ElementX/Sources/Services/Audio/Recorder/AudioRecorderProtocol.swift index b1b47b84a4..291d8371cc 100644 --- a/ElementX/Sources/Services/Audio/Recorder/AudioRecorderProtocol.swift +++ b/ElementX/Sources/Services/Audio/Recorder/AudioRecorderProtocol.swift @@ -17,6 +17,24 @@ import Combine import Foundation +enum AudioRecordingIdentifier { + case uuid(UUID) +} + +extension AudioRecordingIdentifier { + var identifier: String { + switch self { + case .uuid(let uuid): + return uuid.uuidString + } + } +} + +enum AudioRecorderError: Error { + case genericError + case recordPermissionNotGranted +} + enum AudioRecorderAction { case didStartRecording case didStopRecording @@ -29,8 +47,9 @@ protocol AudioRecorderProtocol: AnyObject { var isRecording: Bool { get } var url: URL? { get } - func recordWithOutputURL(_ url: URL) - func stopRecording() + func record(with recordID: AudioRecordingIdentifier) async -> Result + func stopRecording() async + func deleteRecording() func averagePowerForChannelNumber(_ channelNumber: Int) -> Float } diff --git a/ElementX/Sources/Services/Audio/Recorder/AudioRecorderState.swift b/ElementX/Sources/Services/Audio/Recorder/AudioRecorderState.swift index 3e08d09751..9a8a334c6b 100644 --- a/ElementX/Sources/Services/Audio/Recorder/AudioRecorderState.swift +++ b/ElementX/Sources/Services/Audio/Recorder/AudioRecorderState.swift @@ -30,25 +30,27 @@ class AudioRecorderState: ObservableObject, Identifiable { @Published private(set) var recordingState: AudioRecorderRecordingState = .stopped @Published private(set) var duration = 0.0 - @Published private(set) var waveform = EstimatedWaveform(data: Array(repeating: 0, count: 100)) + @Published private(set) var waveformSamples: [Float] = [] private weak var audioRecorder: AudioRecorderProtocol? private var cancellables: Set = [] private var displayLink: CADisplayLink? func attachAudioRecorder(_ audioRecorder: AudioRecorderProtocol) { - if self.audioRecorder != nil { - detachAudioRecorder() - } recordingState = .stopped self.audioRecorder = audioRecorder subscribeToAudioRecorder(audioRecorder) + if audioRecorder.isRecording { + startPublishUpdates() + } } - func detachAudioRecorder() { - guard audioRecorder != nil else { return } - audioRecorder?.stopRecording() + func detachAudioRecorder() async { + if let audioRecorder, audioRecorder.isRecording { + await audioRecorder.stopRecording() + } stopPublishUpdates() + waveformSamples = [] cancellables = [] audioRecorder = nil recordingState = .stopped @@ -91,7 +93,7 @@ class AudioRecorderState: ObservableObject, Identifiable { stopPublishUpdates() } displayLink = CADisplayLink(target: self, selector: #selector(publishUpdate)) - displayLink?.preferredFrameRateRange = .init(minimum: 10, maximum: 20) + displayLink?.preferredFrameRateRange = .init(minimum: 30, maximum: 60) displayLink?.add(to: .current, forMode: .common) } @@ -99,6 +101,9 @@ class AudioRecorderState: ObservableObject, Identifiable { if let currentTime = audioRecorder?.currentTime { duration = currentTime } + if let sample = audioRecorder?.averagePowerForChannelNumber(0) { + waveformSamples.append(sample) + } } private func stopPublishUpdates() { diff --git a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift index 66fc03b0e5..02f3b68420 100644 --- a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift +++ b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProtocol.swift @@ -31,7 +31,7 @@ protocol MediaPlayerProtocol: AnyObject { var url: URL? { get } var state: MediaPlayerState { get } - func load(mediaSource: MediaSourceProxy, using url: URL) + func load(mediaSource: MediaSourceProxy, using url: URL, autoplay: Bool) func play() func pause() func stop() diff --git a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProvider.swift b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProvider.swift index 0474cd57b9..45258fd4f9 100644 --- a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProvider.swift +++ b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProvider.swift @@ -17,31 +17,72 @@ import Foundation class MediaPlayerProvider: MediaPlayerProviderProtocol { - private let mediaProvider: MediaProviderProtocol - private var audioPlayer: AudioPlayerProtocol? - - init(mediaProvider: MediaProviderProtocol) { - self.mediaProvider = mediaProvider - } + private lazy var audioPlayer = AudioPlayer() + private var audioPlayerStates: [String: AudioPlayerState] = [:] deinit { - audioPlayer = nil + audioPlayerStates = [:] } - func player(for mediaSource: MediaSourceProxy) -> MediaPlayerProtocol? { + func player(for mediaSource: MediaSourceProxy) -> Result { guard let mimeType = mediaSource.mimeType else { MXLog.error("Unknown mime type") - return nil + return .failure(.unsupportedMediaType) } if mimeType.starts(with: "audio/") { - if audioPlayer == nil { - audioPlayer = AudioPlayer() - } - return audioPlayer + return .success(audioPlayer) } else { MXLog.error("Unsupported media type: \(mediaSource.mimeType ?? "unknown")") + return .failure(.unsupportedMediaType) + } + } + + // MARK: - AudioPlayer + + func playerState(for id: AudioPlayerStateIdentifier) -> AudioPlayerState? { + guard let audioPlayerStateID = audioPlayerStateID(for: id) else { + MXLog.error("Failed to build an ID using: \(id)") return nil } + return audioPlayerStates[audioPlayerStateID] + } + + @MainActor + func register(audioPlayerState: AudioPlayerState) { + guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else { + MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)") + return + } + audioPlayerStates[audioPlayerStateID] = audioPlayerState + } + + @MainActor + func unregister(audioPlayerState: AudioPlayerState) { + guard let audioPlayerStateID = audioPlayerStateID(for: audioPlayerState.id) else { + MXLog.error("Failed to build a key to register this audioPlayerState: \(audioPlayerState)") + return + } + audioPlayerStates[audioPlayerStateID] = nil + } + + func detachAllStates(except exception: AudioPlayerState?) async { + for key in audioPlayerStates.keys { + if let exception, key == audioPlayerStateID(for: exception.id) { + continue + } + await audioPlayerStates[key]?.detachAudioPlayer() + } + } + + // MARK: - Private + + private func audioPlayerStateID(for identifier: AudioPlayerStateIdentifier) -> String? { + switch identifier { + case .timelineItemIdentifier(let timelineItemIdentifier): + return timelineItemIdentifier.eventID + case .recorderPreview: + return "recorderPreviewAudioPlayerState" + } } } diff --git a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProviderProtocol.swift b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProviderProtocol.swift index b4c6487b8a..6966dc5a02 100644 --- a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProviderProtocol.swift +++ b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProviderProtocol.swift @@ -16,6 +16,18 @@ import Foundation +enum MediaPlayerProviderError: Error { + case unsupportedMediaType +} + protocol MediaPlayerProviderProtocol { - func player(for mediaSource: MediaSourceProxy) async -> MediaPlayerProtocol? + func player(for mediaSource: MediaSourceProxy) -> Result + + func playerState(for id: AudioPlayerStateIdentifier) -> AudioPlayerState? + func register(audioPlayerState: AudioPlayerState) + func unregister(audioPlayerState: AudioPlayerState) + func detachAllStates(except exception: AudioPlayerState?) async } + +// sourcery: AutoMockable +extension MediaPlayerProviderProtocol { } diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 20aa6c8c49..f8da99d46f 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -375,6 +375,31 @@ class RoomProxy: RoomProxyProtocol { return .success(()) } + func sendVoiceMessage(url: URL, + audioInfo: AudioInfo, + waveform: [UInt16], + progressSubject: CurrentValueSubject?, + requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result { + sendMessageBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) + defer { + sendMessageBackgroundTask?.stop() + } + + let handle = room.sendVoiceMessage(url: url.path(percentEncoded: false), audioInfo: audioInfo, waveform: waveform, progressWatcher: UploadProgressListener { progress in + progressSubject?.send(progress) + }) + + await requestHandle(handle) + + do { + try await handle.join() + } catch { + return .failure(.failedSendingMedia) + } + + return .success(()) + } + func sendFile(url: URL, fileInfo: FileInfo, progressSubject: CurrentValueSubject?, diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index d1df024a97..58b190b2d8 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -131,6 +131,12 @@ protocol RoomProxyProtocol { zoomLevel: UInt8?, assetType: AssetType?) async -> Result + func sendVoiceMessage(url: URL, + audioInfo: AudioInfo, + waveform: [UInt16], + progressSubject: CurrentValueSubject?, + requestHandle: @MainActor (SendAttachmentJoinHandleProtocol) -> Void) async -> Result + /// Retries sending a failed message given its transaction ID func retrySend(transactionID: String) async diff --git a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift index 3d97e1db20..dccc53395c 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/MockRoomTimelineController.swift @@ -89,7 +89,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func retryDecryption(for sessionID: String) async { } func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState { - AudioPlayerState(duration: 10.0, + AudioPlayerState(id: .timelineItemIdentifier(itemID), + duration: 10.0, waveform: nil, progress: 0.0) } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index 4712fac05b..8f030da4ae 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -38,7 +38,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol { let callbacks = PassthroughSubject() private(set) var timelineItems = [RoomTimelineItemProtocol]() - private var timelineAudioPlayerStates = [TimelineItemIdentifier: AudioPlayerState]() var roomID: String { roomProxy.id @@ -249,13 +248,14 @@ class RoomTimelineController: RoomTimelineControllerProtocol { fatalError("Invalid TimelineItem type (expecting `VoiceMessageRoomTimelineItem` but found \(type(of: timelineItem)) instead") } - if let playerState = timelineAudioPlayerStates[itemID] { + if let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) { return playerState } - let playerState = AudioPlayerState(duration: voiceMessageRoomTimelineItem.content.duration, + let playerState = AudioPlayerState(id: .timelineItemIdentifier(itemID), + duration: voiceMessageRoomTimelineItem.content.duration, waveform: voiceMessageRoomTimelineItem.content.waveform) - timelineAudioPlayerStates[itemID] = playerState + mediaPlayerProvider.register(audioPlayerState: playerState) return playerState } @@ -273,52 +273,48 @@ class RoomTimelineController: RoomTimelineControllerProtocol { return } - guard let player = await mediaPlayerProvider.player(for: source) as? AudioPlayerProtocol else { + guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: source), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else { MXLog.error("Cannot play a voice message without an audio player") return } - let playerState = audioPlayerState(for: itemID) + let audioPlayerState = audioPlayerState(for: itemID) - guard player.mediaSource == source, player.state != .error else { - timelineAudioPlayerStates.forEach { itemID, playerState in - if itemID != timelineItem.id { - playerState.detachAudioPlayer() - } - } - playerState.attachAudioPlayer(player) + guard audioPlayer.mediaSource == source, audioPlayer.state != .error else { + audioPlayer.stop() + + await mediaPlayerProvider.detachAllStates(except: audioPlayerState) + audioPlayerState.attachAudioPlayer(audioPlayer) + // Load content do { let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(source, body: nil) // Make sure that the player is still attached, as it may have been detached while waiting for the voice message to be loaded. - if playerState.isAttached { - player.load(mediaSource: source, using: url) + if audioPlayerState.isAttached { + audioPlayer.load(mediaSource: source, using: url, autoplay: true) } } catch { MXLog.error("Failed to load voice message: \(error)") - playerState.reportError(error) + audioPlayerState.reportError(error) } return } - if player.state == .playing { - player.pause() + if audioPlayer.state == .playing { + audioPlayer.pause() } else { - player.play() + audioPlayer.play() } } - - func pauseAudio() { - timelineAudioPlayerStates.forEach { _, playerState in - playerState.detachAudioPlayer() - } - } - + func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async { - await timelineAudioPlayerStates[itemID]?.updateState(progress: progress) + guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) else { + return + } + await playerState.updateState(progress: progress) } // MARK: - Private @@ -402,11 +398,11 @@ class RoomTimelineController: RoomTimelineControllerProtocol { // Stops the audio player when a voice message is redacted. if timelineItem is RedactedRoomTimelineItem { - guard let audioState = timelineAudioPlayerStates[timelineItem.id] else { + guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else { continue } - audioState.detachAudioPlayer() - timelineAudioPlayerStates.removeValue(forKey: timelineItem.id) + playerState.detachAudioPlayer() + mediaPlayerProvider.unregister(audioPlayerState: playerState) } newTimelineItems.append(timelineItem) diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index a96f39a08b..27f2cf2f79 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -74,9 +74,7 @@ protocol RoomTimelineControllerProtocol { func audioPlayerState(for itemID: TimelineItemIdentifier) -> AudioPlayerState func playPauseAudio(for itemID: TimelineItemIdentifier) async - - func pauseAudio() - + func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift index 317f949383..d7bb966d9f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomPlaybackView.swift @@ -45,8 +45,7 @@ struct VoiceMessageRoomPlaybackView: View { return dateFormatter }() - @GestureState private var dragState = DragState.inactive - @State private var tapProgress: Double = .zero + @State var dragState: WaveformViewDragState = .inactive var timeLabelContent: String { // Display the duration if progress is 0.0 @@ -85,38 +84,14 @@ struct VoiceMessageRoomPlaybackView: View { .frame(width: waveformLineWidth, height: geometry.size.height) .opacity(showWaveformCursor ? 1 : 0) } - // Add a gesture to drag the waveform - .gesture(SpatialTapGesture() - .simultaneously(with: LongPressGesture()) - .sequenced(before: DragGesture(minimumDistance: waveformLinePadding, coordinateSpace: .local)) - .updating($dragState) { value, state, _ in - switch value { - // (SpatialTap, LongPress) begins. - case .first(let spatialLongPress) where spatialLongPress.second ?? false: - // Compute the progress with the spatialTap location - let progress = (spatialLongPress.first?.location ?? .zero).x / geometry.size.width - state = .pressing(progress: progress) - // Long press confirmed, dragging may begin. - case .second(let spatialLongPress, let drag) where spatialLongPress.second ?? false: - var progress: Double = tapProgress - // Compute the progress with drag location - if let loc = drag?.location { - progress = loc.x / geometry.size.width - } - state = .dragging(progress: progress) - // Dragging ended or the long press cancelled. - default: - state = .inactive - } - }) + .waveformDragGesture($dragState) } } .onChange(of: dragState) { newDragState in switch newDragState { case .inactive: onScrubbing(false) - case .pressing(let progress): - tapProgress = progress + case .pressing: onScrubbing(true) feedbackGenerator.prepare() sendFeedback = true @@ -218,7 +193,8 @@ struct VoiceMessageRoomPlaybackView_Previews: PreviewProvider, TestablePreview { 294, 131, 19, 2, 3, 3, 1, 2, 0, 0, 0, 0, 0, 0, 0, 3]) - static var playerState = AudioPlayerState(duration: 10.0, + static var playerState = AudioPlayerState(id: .timelineItemIdentifier(.random), + duration: 10.0, waveform: waveform, progress: 0.3) diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomTimelineView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomTimelineView.swift index 54a511507f..61a702be75 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomTimelineView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VoiceMessages/VoiceMessageRoomTimelineView.swift @@ -65,8 +65,8 @@ struct VoiceMessageRoomTimelineView: View { struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview { static let viewModel = RoomScreenViewModel.mock - - static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: .random, + static let timelineItemIdentifier = TimelineItemIdentifier.random + static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: timelineItemIdentifier, timestamp: "Now", isOutgoing: false, isEditable: false, @@ -79,7 +79,8 @@ struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview { source: nil, contentType: nil)) - static let playerState = AudioPlayerState(duration: 10.0, + static let playerState = AudioPlayerState(id: .timelineItemIdentifier(timelineItemIdentifier), + duration: 10.0, waveform: EstimatedWaveform.mockWaveform, progress: 0.4) diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift index 3b99d2fa68..8fe009060a 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift @@ -75,7 +75,7 @@ struct RoomTimelineItemView: View { case .poll(let item): PollRoomTimelineView(timelineItem: item) case .voice(let item): - VoiceMessageRoomTimelineView(timelineItem: item, playerState: context.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(duration: 0)) + VoiceMessageRoomTimelineView(timelineItem: item, playerState: context.viewState.audioPlayerStateProvider?(item.id) ?? AudioPlayerState(id: .timelineItemIdentifier(item.id), duration: 0)) } } diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift index 26c182b4c8..75fbbca4e3 100644 --- a/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift @@ -16,36 +16,31 @@ import Foundation -enum VoiceMessageCacheError: Error { - case invalidFileExtension -} - class VoiceMessageCache: VoiceMessageCacheProtocol { private let preferredFileExtension = "m4a" private var temporaryFilesFolderURL: URL { FileManager.default.temporaryDirectory.appendingPathComponent("media/voice-message") } - + func fileURL(for mediaSource: MediaSourceProxy) -> URL? { let url = cacheURL(for: mediaSource) return FileManager.default.fileExists(atPath: url.path()) ? url : nil } - - func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool = false) throws -> URL { + + func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool = false) -> Result { guard fileURL.pathExtension == preferredFileExtension else { - throw VoiceMessageCacheError.invalidFileExtension + return .failure(.invalidFileExtension) } - setupTemporaryFilesFolder() let url = cacheURL(for: mediaSource) - try? FileManager.default.removeItem(at: url) - if move { - try FileManager.default.moveItem(at: fileURL, to: url) - } else { - try FileManager.default.copyItem(at: fileURL, to: url) + do { + try cacheFile(source: fileURL, destination: url, move: move) + } catch { + MXLog.error("Failed storing file in cache", context: error) + return .failure(.failedStoringFileInCache) } - return url + return .success(url) } - + func clearCache() { if FileManager.default.fileExists(atPath: temporaryFilesFolderURL.path) { do { @@ -66,6 +61,16 @@ class VoiceMessageCache: VoiceMessageCacheProtocol { } } + private func cacheFile(source: URL, destination: URL, move: Bool) throws { + setupTemporaryFilesFolder() + try? FileManager.default.removeItem(at: destination) + if move { + try FileManager.default.moveItem(at: source, to: destination) + } else { + try FileManager.default.copyItem(at: source, to: destination) + } + } + private func cacheURL(for mediaSource: MediaSourceProxy) -> URL { temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent).appendingPathExtension(preferredFileExtension) } diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageCacheProtocol.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCacheProtocol.swift index 6776daa427..505acdf73c 100644 --- a/ElementX/Sources/Services/VoiceMessage/VoiceMessageCacheProtocol.swift +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCacheProtocol.swift @@ -16,9 +16,26 @@ import Foundation +enum VoiceMessageCacheError: Error { + case invalidFileExtension + case failedStoringFileInCache +} + protocol VoiceMessageCacheProtocol { + /// Returns the URL of the cached audio file for a given media source + /// - Parameter mediaSource: the media source + /// - Returns: the URL of the cached audio file or nil if the file doesn't exist func fileURL(for mediaSource: MediaSourceProxy) -> URL? - func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) throws -> URL + + /// Adds a file in the cache + /// - Parameters: + /// - mediaSource: the media source + /// - fileURL: the source file + /// - move: wheter to move or copy the source file + /// - Returns: the cached URL + func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) -> Result + + /// Clears the cache func clearCache() } diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift index 6596a76a2d..3c6932d7ea 100644 --- a/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift @@ -80,7 +80,13 @@ class VoiceMessageMediaManager: VoiceMessageMediaManagerProtocol { try audioConverter.convertToMPEG4AAC(sourceURL: fileHandle.url, destinationURL: convertedFileURL) // Cache the file and return the url - return try voiceMessageCache.cache(mediaSource: source, using: convertedFileURL, move: true) + let result = voiceMessageCache.cache(mediaSource: source, using: convertedFileURL, move: true) + switch result { + case .success(let url): + return url + case .failure(let error): + throw error + } } } diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift new file mode 100644 index 0000000000..a20b9fc330 --- /dev/null +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorder.swift @@ -0,0 +1,198 @@ +// +// 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 DSWaveformImage +import Foundation +import MatrixRustSDK + +class VoiceMessageRecorder: VoiceMessageRecorderProtocol { + let audioRecorder: AudioRecorderProtocol + private let audioConverter: AudioConverterProtocol + private let voiceMessageCache: VoiceMessageCacheProtocol + private let mediaPlayerProvider: MediaPlayerProviderProtocol + + private let mp4accMimeType = "audio/m4a" + private let waveformSamplesCount = 100 + + private(set) var recordingURL: URL? + private(set) var recordingDuration: TimeInterval = 0.0 + + private(set) var previewAudioPlayerState: AudioPlayerState? + private(set) var previewAudioPlayer: AudioPlayerProtocol? + + init(audioRecorder: AudioRecorderProtocol = AudioRecorder(), + mediaPlayerProvider: MediaPlayerProviderProtocol, + audioConverter: AudioConverterProtocol = AudioConverter(), + voiceMessageCache: VoiceMessageCacheProtocol = VoiceMessageCache()) { + self.audioRecorder = audioRecorder + self.mediaPlayerProvider = mediaPlayerProvider + self.audioConverter = audioConverter + self.voiceMessageCache = voiceMessageCache + } + + // MARK: - Recording + + func startRecording() async -> Result { + await stopPlayback() + recordingURL = nil + switch await audioRecorder.record(with: .uuid(UUID())) { + case .failure(let error): + return .failure(.audioRecorderError(error)) + case .success: + recordingURL = audioRecorder.url + return .success(()) + } + } + + func stopRecording() async -> Result { + recordingDuration = audioRecorder.currentTime + await audioRecorder.stopRecording() + guard case .success = await finalizeRecording() else { + return .failure(.previewNotAvailable) + } + return .success(()) + } + + func cancelRecording() async { + await audioRecorder.stopRecording() + audioRecorder.deleteRecording() + recordingURL = nil + previewAudioPlayerState = nil + } + + func deleteRecording() async { + await stopPlayback() + audioRecorder.deleteRecording() + previewAudioPlayerState = nil + recordingURL = nil + } + + // MARK: - Preview + + func startPlayback() async -> Result { + guard let previewAudioPlayerState, let url = recordingURL else { + return .failure(.previewNotAvailable) + } + + guard let audioPlayer = previewAudioPlayer else { + return .failure(.previewNotAvailable) + } + + if audioPlayer.url == url { + audioPlayer.play() + return .success(()) + } + + await previewAudioPlayerState.attachAudioPlayer(audioPlayer) + let pendingMediaSource = MediaSourceProxy(url: url, mimeType: mp4accMimeType) + audioPlayer.load(mediaSource: pendingMediaSource, using: url, autoplay: true) + return .success(()) + } + + func pausePlayback() { + previewAudioPlayer?.pause() + } + + func stopPlayback() async { + guard let previewAudioPlayerState else { + return + } + await previewAudioPlayerState.detachAudioPlayer() + previewAudioPlayer?.stop() + } + + func seekPlayback(to progress: Double) async { + await previewAudioPlayerState?.updateState(progress: progress) + } + + func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError> { + guard let url = recordingURL else { + return .failure(.missingRecordingFile) + } + // build the waveform + var waveformData: [UInt16] = [] + let analyzer = WaveformAnalyzer() + do { + let samples = try await analyzer.samples(fromAudioAt: url, count: 100) + // linearly normalized to [0, 1] (1 -> -50 dB) + waveformData = samples.map { UInt16(max(0, (1 - $0) * 1024)) } + } catch { + MXLog.error("Waveform analysis failed: \(error)") + } + return .success(waveformData) + } + + func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result { + guard let url = recordingURL else { + return .failure(VoiceMessageRecorderError.missingRecordingFile) + } + + // convert the file + let sourceFilename = url.deletingPathExtension().lastPathComponent + let oggFile = URL.temporaryDirectory.appendingPathComponent(sourceFilename).appendingPathExtension("ogg") + do { + try audioConverter.convertToOpusOgg(sourceURL: url, destinationURL: oggFile) + } catch { + return .failure(.failedSendingVoiceMessage) + } + + // send it + let size: UInt64 + do { + size = try UInt64(FileManager.default.sizeForItem(at: oggFile)) + } catch { + MXLog.error("Failed to get the recording file size", context: error) + return .failure(.failedSendingVoiceMessage) + } + let audioInfo = AudioInfo(duration: recordingDuration, size: size, mimetype: "audio/ogg") + guard case .success(let waveform) = await buildRecordingWaveform() else { + return .failure(.failedSendingVoiceMessage) + } + + let result = await roomProxy.sendVoiceMessage(url: oggFile, + audioInfo: audioInfo, + waveform: waveform, + progressSubject: nil) { _ in } + // delete the temporary file + try? FileManager.default.removeItem(at: oggFile) + + if case .failure(let error) = result { + MXLog.error("Failed to send the voice message.", context: error) + return .failure(.failedSendingVoiceMessage) + } + + return .success(()) + } + + // MARK: - Private + + private func finalizeRecording() async -> Result { + guard let url = recordingURL else { + return .failure(.previewNotAvailable) + } + + // Build the preview audio player state + previewAudioPlayerState = await AudioPlayerState(id: .recorderPreview, duration: recordingDuration, waveform: EstimatedWaveform(data: [])) + + // Build the preview audio player + let mediaSource = MediaSourceProxy(url: url, mimeType: mp4accMimeType) + guard case .success(let mediaPlayer) = mediaPlayerProvider.player(for: mediaSource), let audioPlayer = mediaPlayer as? AudioPlayerProtocol else { + return .failure(.previewNotAvailable) + } + previewAudioPlayer = audioPlayer + return .success(()) + } +} diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorderProtocol.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorderProtocol.swift new file mode 100644 index 0000000000..16dd52a066 --- /dev/null +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageRecorderProtocol.swift @@ -0,0 +1,47 @@ +// +// 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 Foundation + +enum VoiceMessageRecorderError: Error { + case genericError + case missingRecordingFile + case previewNotAvailable + case audioRecorderError(AudioRecorderError) + case failedSendingVoiceMessage +} + +protocol VoiceMessageRecorderProtocol { + var audioRecorder: AudioRecorderProtocol { get } + var previewAudioPlayerState: AudioPlayerState? { get } + var recordingURL: URL? { get } + var recordingDuration: TimeInterval { get } + + func startRecording() async -> Result + func stopRecording() async -> Result + func cancelRecording() async + func startPlayback() async -> Result + func pausePlayback() + func stopPlayback() async + func seekPlayback(to progress: Double) async + func deleteRecording() async + + func buildRecordingWaveform() async -> Result<[UInt16], VoiceMessageRecorderError> + func sendVoiceMessage(inRoom roomProxy: RoomProxyProtocol, audioConverter: AudioConverterProtocol) async -> Result +} + +// sourcery: AutoMockable +extension VoiceMessageRecorderProtocol { } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index db5f79de7c..3e1efbc8a4 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -260,6 +260,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: nil)), timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -271,6 +272,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: URL.picturesDirectory)), timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -284,6 +286,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -297,6 +300,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -311,6 +315,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -327,6 +332,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -343,6 +349,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline, paginating", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -359,6 +366,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -376,6 +384,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -392,6 +401,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -408,6 +418,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -424,6 +435,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) @@ -440,6 +452,7 @@ class MockScreen: Identifiable { let parameters = RoomScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: .init(displayName: "Polls timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), emojiProvider: EmojiProvider(), completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()), appSettings: ServiceLocator.shared.settings) diff --git a/UnitTests/Resources/Media/test_voice_message.m4a b/UnitTests/Resources/Media/test_voice_message.m4a new file mode 100644 index 0000000000..ffdd652c1a --- /dev/null +++ b/UnitTests/Resources/Media/test_voice_message.m4a @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:050b2abdd29e0dea7b8aa3db65eb34491334675de3bffe8695d2b0b6cce51c15 +size 244042 diff --git a/UnitTests/Sources/AudioPlayerStateTests.swift b/UnitTests/Sources/AudioPlayerStateTests.swift index 131bde817c..06b2ece2d4 100644 --- a/UnitTests/Sources/AudioPlayerStateTests.swift +++ b/UnitTests/Sources/AudioPlayerStateTests.swift @@ -47,7 +47,7 @@ class AudioPlayerStateTests: XCTestCase { override func setUp() async throws { audioPlayerActionsSubject = .init() audioPlayerSeekCallsSubject = .init() - audioPlayerState = AudioPlayerState(duration: 10.0) + audioPlayerState = AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 10.0) audioPlayerMock = buildAudioPlayerMock() } diff --git a/UnitTests/Sources/AudioRecorderStateTests.swift b/UnitTests/Sources/AudioRecorderStateTests.swift index 1943a20c5d..59ef8484ac 100644 --- a/UnitTests/Sources/AudioRecorderStateTests.swift +++ b/UnitTests/Sources/AudioRecorderStateTests.swift @@ -31,8 +31,10 @@ class AudioRecorderStateTests: XCTestCase { private func buildAudioRecorderMock() -> AudioRecorderMock { let audioRecorderMock = AudioRecorderMock() + audioRecorderMock.isRecording = false audioRecorderMock.underlyingActions = audioRecorderActions audioRecorderMock.currentTime = 0.0 + audioRecorderMock.averagePowerForChannelNumberReturnValue = 0 return audioRecorderMock } @@ -44,14 +46,13 @@ class AudioRecorderStateTests: XCTestCase { func testAttach() async throws { audioRecorderState.attachAudioRecorder(audioRecorderMock) - XCTAssertEqual(audioRecorderState.recordingState, .stopped) } func testDetach() async throws { audioRecorderState.attachAudioRecorder(audioRecorderMock) - - audioRecorderState.detachAudioRecorder() + audioRecorderMock.isRecording = true + await audioRecorderState.detachAudioRecorder() XCTAssert(audioRecorderMock.stopRecordingCalled) XCTAssertEqual(audioRecorderState.recordingState, .stopped) } diff --git a/UnitTests/Sources/MediaPlayerProviderTests.swift b/UnitTests/Sources/MediaPlayerProviderTests.swift new file mode 100644 index 0000000000..eb0eaaebd6 --- /dev/null +++ b/UnitTests/Sources/MediaPlayerProviderTests.swift @@ -0,0 +1,125 @@ +// +// 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 Combine +@testable import ElementX +import Foundation +import XCTest + +class MediaPlayerProviderTests: XCTestCase { + private var mediaPlayerProvider: MediaPlayerProvider! + + private let oggMimeType = "audio/ogg" + private let someURL = URL("/some/url") + private let someOtherURL = URL("/some/other/url") + + override func setUp() async throws { + mediaPlayerProvider = MediaPlayerProvider() + } + + func testPlayerForWrongMediaType() async throws { + let mediaSourceWithoutMimeType = MediaSourceProxy(url: someURL, mimeType: nil) + switch mediaPlayerProvider.player(for: mediaSourceWithoutMimeType) { + case .failure(.unsupportedMediaType): + // Ok + break + default: + XCTFail("An error is expected") + } + + let mediaSourceVideo = MediaSourceProxy(url: someURL, mimeType: "video/mp4") + switch mediaPlayerProvider.player(for: mediaSourceWithoutMimeType) { + case .failure(.unsupportedMediaType): + // Ok + break + default: + XCTFail("An error is expected") + } + } + + func testPlayerFor() async throws { + let mediaSource = MediaSourceProxy(url: someURL, mimeType: oggMimeType) + guard case .success(let playerA) = mediaPlayerProvider.player(for: mediaSource) else { + XCTFail("A valid player is expected") + return + } + + // calling it again with another mediasource must returns the same player + let otherMediaSource = MediaSourceProxy(url: someOtherURL, mimeType: oggMimeType) + guard case .success(let playerB) = mediaPlayerProvider.player(for: otherMediaSource) else { + XCTFail("A valid player is expected") + return + } + + XCTAssert(playerA === playerB) + } + + func testPlayerStates() async throws { + let audioPlayerStateId = AudioPlayerStateIdentifier.timelineItemIdentifier(.random) + // By default, there should be no player state + XCTAssertNil(mediaPlayerProvider.playerState(for: audioPlayerStateId)) + + let audioPlayerState = await AudioPlayerState(id: audioPlayerStateId, duration: 10.0) + await mediaPlayerProvider.register(audioPlayerState: audioPlayerState) + XCTAssertEqual(audioPlayerState, mediaPlayerProvider.playerState(for: audioPlayerStateId)) + + await mediaPlayerProvider.unregister(audioPlayerState: audioPlayerState) + XCTAssertNil(mediaPlayerProvider.playerState(for: audioPlayerStateId)) + } + + func testDetachAllStates() async throws { + let audioPlayer = AudioPlayerMock() + audioPlayer.actions = PassthroughSubject().eraseToAnyPublisher() + + let audioPlayerStates = await Array(repeating: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 0), count: 10) + for audioPlayerState in audioPlayerStates { + await mediaPlayerProvider.register(audioPlayerState: audioPlayerState) + await audioPlayerState.attachAudioPlayer(audioPlayer) + let isAttached = await audioPlayerState.isAttached + XCTAssertTrue(isAttached) + } + + await mediaPlayerProvider.detachAllStates(except: nil) + for audioPlayerState in audioPlayerStates { + let isAttached = await audioPlayerState.isAttached + XCTAssertFalse(isAttached) + } + } + + func testDetachAllStatesWithException() async throws { + let audioPlayer = AudioPlayerMock() + audioPlayer.actions = PassthroughSubject().eraseToAnyPublisher() + + let audioPlayerStates = await Array(repeating: AudioPlayerState(id: .timelineItemIdentifier(.random), duration: 0), count: 10) + for audioPlayerState in audioPlayerStates { + await mediaPlayerProvider.register(audioPlayerState: audioPlayerState) + await audioPlayerState.attachAudioPlayer(audioPlayer) + let isAttached = await audioPlayerState.isAttached + XCTAssertTrue(isAttached) + } + + let exception = audioPlayerStates[1] + await mediaPlayerProvider.detachAllStates(except: exception) + for audioPlayerState in audioPlayerStates { + let isAttached = await audioPlayerState.isAttached + if audioPlayerState == exception { + XCTAssertTrue(isAttached) + } else { + XCTAssertFalse(isAttached) + } + } + } +} diff --git a/UnitTests/Sources/PillContextTests.swift b/UnitTests/Sources/PillContextTests.swift index 53b34da5c6..ab973b13b3 100644 --- a/UnitTests/Sources/PillContextTests.swift +++ b/UnitTests/Sources/PillContextTests.swift @@ -28,6 +28,7 @@ class PillContextTests: XCTestCase { proxyMock.members = subject.asCurrentValuePublisher() let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: proxyMock, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -59,6 +60,7 @@ class PillContextTests: XCTestCase { proxyMock.members = subject.asCurrentValuePublisher() let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: proxyMock, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -77,6 +79,7 @@ class PillContextTests: XCTestCase { mockController.roomProxy = proxyMock let mock = RoomScreenViewModel(timelineController: mockController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: proxyMock, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index 5c1b0c18d2..c27c2668dc 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -51,6 +51,7 @@ class RoomScreenViewModelTests: XCTestCase { timelineController.timelineItems = items let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: RoomProxyMock(with: .init(displayName: "")), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -84,6 +85,7 @@ class RoomScreenViewModelTests: XCTestCase { timelineController.timelineItems = items let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: RoomProxyMock(with: .init(displayName: "")), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -115,6 +117,7 @@ class RoomScreenViewModelTests: XCTestCase { timelineController.timelineItems = items let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: RoomProxyMock(with: .init(displayName: "")), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -143,6 +146,7 @@ class RoomScreenViewModelTests: XCTestCase { timelineController.timelineItems = items let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: RoomProxyMock(with: .init(displayName: "")), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -171,6 +175,7 @@ class RoomScreenViewModelTests: XCTestCase { timelineController.timelineItems = items let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: RoomProxyMock(with: .init(displayName: "")), appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -195,6 +200,7 @@ class RoomScreenViewModelTests: XCTestCase { let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: roomProxyMock, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -233,6 +239,7 @@ class RoomScreenViewModelTests: XCTestCase { let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: roomProxyMock, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -272,6 +279,7 @@ class RoomScreenViewModelTests: XCTestCase { let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: roomProxyMock, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -303,6 +311,7 @@ class RoomScreenViewModelTests: XCTestCase { let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: roomProxyMock, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -322,6 +331,7 @@ class RoomScreenViewModelTests: XCTestCase { let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: roomProxyMock, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -340,6 +350,7 @@ class RoomScreenViewModelTests: XCTestCase { let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: roomProxyMock, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -359,6 +370,7 @@ class RoomScreenViewModelTests: XCTestCase { let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: roomProxyMock, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -495,6 +507,7 @@ class RoomScreenViewModelTests: XCTestCase { let viewModel = RoomScreenViewModel(timelineController: timelineController, mediaProvider: MockMediaProvider(), + mediaPlayerProvider: MediaPlayerProviderMock(), roomProxy: roomProxy, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, diff --git a/UnitTests/Sources/VoiceMessageCacheTests.swift b/UnitTests/Sources/VoiceMessageCacheTests.swift index 7a6ec76eeb..34af723fa5 100644 --- a/UnitTests/Sources/VoiceMessageCacheTests.swift +++ b/UnitTests/Sources/VoiceMessageCacheTests.swift @@ -56,7 +56,10 @@ class VoiceMessageCacheTests: XCTestCase { // If the file is present in the cache, its URL must be returned let temporaryFileURL = try createTemporaryFile(named: testFilename, withExtension: mpeg4aacFileExtension) - let cachedURL = try voiceMessageCache.cache(mediaSource: mediaSource, using: temporaryFileURL, move: true) + guard case .success(let cachedURL) = voiceMessageCache.cache(mediaSource: mediaSource, using: temporaryFileURL, move: true) else { + XCTFail("A success is expected") + return + } XCTAssertEqual(cachedURL, voiceMessageCache.fileURL(for: mediaSource)) } @@ -64,22 +67,20 @@ class VoiceMessageCacheTests: XCTestCase { func testCacheInvalidFileExtension() async throws { // An error should be raised if the file extension is not "m4a" let mpegFileURL = try createTemporaryFile(named: testFilename, withExtension: "mpg") - do { - _ = try voiceMessageCache.cache(mediaSource: mediaSource, using: mpegFileURL, move: true) + guard case .failure(let error) = voiceMessageCache.cache(mediaSource: mediaSource, using: mpegFileURL, move: true) else { XCTFail("An error is expected") - } catch { - switch error as? VoiceMessageCacheError { - case .invalidFileExtension: - break - default: - XCTFail("A VoiceMessageCacheError.invalidFileExtension is expected") - } + return } + + XCTAssertEqual(error, .invalidFileExtension) } func testCacheCopy() async throws { let fileURL = try createTemporaryFile(named: testFilename, withExtension: mpeg4aacFileExtension) - let cacheURL = try voiceMessageCache.cache(mediaSource: mediaSource, using: fileURL, move: false) + guard case .success(let cacheURL) = voiceMessageCache.cache(mediaSource: mediaSource, using: fileURL, move: false) else { + XCTFail("A success is expected") + return + } // The source file must remain in its original location XCTAssertTrue(fileManager.fileExists(atPath: fileURL.path())) @@ -89,7 +90,10 @@ class VoiceMessageCacheTests: XCTestCase { func testCacheMove() async throws { let fileURL = try createTemporaryFile(named: testFilename, withExtension: mpeg4aacFileExtension) - let cacheURL = try voiceMessageCache.cache(mediaSource: mediaSource, using: fileURL, move: true) + guard case .success(let cacheURL) = voiceMessageCache.cache(mediaSource: mediaSource, using: fileURL, move: true) else { + XCTFail("A success is expected") + return + } // The file must have been moved XCTAssertFalse(fileManager.fileExists(atPath: fileURL.path())) diff --git a/UnitTests/Sources/VoiceMessageMediaManagerTests.swift b/UnitTests/Sources/VoiceMessageMediaManagerTests.swift index 502d81db1a..8c20942b52 100644 --- a/UnitTests/Sources/VoiceMessageMediaManagerTests.swift +++ b/UnitTests/Sources/VoiceMessageMediaManagerTests.swift @@ -93,7 +93,7 @@ class VoiceMessageMediaManagerTests: XCTestCase { let mediaSource = MediaSourceProxy(url: someURL, mimeType: audioOGGMimeType) mediaProvider.loadFileFromSourceReturnValue = MediaFileHandleProxy.unmanaged(url: loadedFile) let audioConverter = AudioConverterMock() - voiceMessageCache.cacheMediaSourceUsingMoveReturnValue = cachedConvertedFileURL + voiceMessageCache.cacheMediaSourceUsingMoveReturnValue = .success(cachedConvertedFileURL) voiceMessageMediaManager = VoiceMessageMediaManager(mediaProvider: mediaProvider, voiceMessageCache: voiceMessageCache, audioConverter: audioConverter, @@ -124,7 +124,7 @@ class VoiceMessageMediaManagerTests: XCTestCase { } voiceMessageCache.cacheMediaSourceUsingMoveClosure = { _, _, _ in cachedURL = cachedConvertedFileURL - return cachedConvertedFileURL + return .success(cachedConvertedFileURL) } let audioConverter = AudioConverterMock() diff --git a/UnitTests/Sources/VoiceMessageRecorderTests.swift b/UnitTests/Sources/VoiceMessageRecorderTests.swift new file mode 100644 index 0000000000..797ed018ab --- /dev/null +++ b/UnitTests/Sources/VoiceMessageRecorderTests.swift @@ -0,0 +1,237 @@ +// +// 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 Combine +@testable import ElementX +import Foundation +import XCTest + +@MainActor +class VoiceMessageRecorderTests: XCTestCase { + private var voiceMessageRecorder: VoiceMessageRecorder! + + private var audioRecorder: AudioRecorderMock! + private var mediaPlayerProvider: MediaPlayerProviderMock! + private var audioConverter: AudioConverterMock! + private var voiceMessageCache: VoiceMessageCacheMock! + + private var audioPlayer: AudioPlayerMock! + private var audioPlayerActionsSubject: PassthroughSubject = .init() + private var audioPlayerActions: AnyPublisher { + audioPlayerActionsSubject.eraseToAnyPublisher() + } + + private let recordingURL = URL("/some/url") + + override func setUp() async throws { + audioRecorder = AudioRecorderMock() + audioRecorder.recordWithReturnValue = .success(()) + audioRecorder.underlyingCurrentTime = 0 + audioRecorder.averagePowerForChannelNumberReturnValue = 0 + audioPlayer = AudioPlayerMock() + audioPlayer.actions = audioPlayerActions + + mediaPlayerProvider = MediaPlayerProviderMock() + mediaPlayerProvider.playerForClosure = { _ in + .success(self.audioPlayer) + } + audioConverter = AudioConverterMock() + voiceMessageCache = VoiceMessageCacheMock() + + voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: audioRecorder, + mediaPlayerProvider: mediaPlayerProvider, + audioConverter: audioConverter, + voiceMessageCache: voiceMessageCache) + } + + func testStartRecording() async throws { + audioRecorder.url = recordingURL + _ = await voiceMessageRecorder.startRecording() + XCTAssert(audioRecorder.recordWithCalled) + XCTAssertEqual(voiceMessageRecorder.recordingURL, audioRecorder.url) + } + + func testStopRecording() async throws { + audioRecorder.isRecording = true + audioRecorder.currentTime = 14.0 + audioRecorder.url = recordingURL + + _ = await voiceMessageRecorder.startRecording() + _ = await voiceMessageRecorder.stopRecording() + + // Internal audio recorder must have been stopped + XCTAssert(audioRecorder.stopRecordingCalled) + + // A preview player state must be available + let previewPlayerState = voiceMessageRecorder.previewAudioPlayerState + XCTAssertNotNil(previewPlayerState) + XCTAssertEqual(previewPlayerState?.duration, audioRecorder.currentTime) + } + + func testCancelRecording() async throws { + audioRecorder.isRecording = true + + await voiceMessageRecorder.cancelRecording() + + // The recording audio file must have been deleted + XCTAssert(audioRecorder.deleteRecordingCalled) + } + + func testDeleteRecording() async throws { + await voiceMessageRecorder.deleteRecording() + XCTAssert(audioRecorder.deleteRecordingCalled) + } + + func testStartPlayback() async throws { + audioRecorder.url = recordingURL + _ = await voiceMessageRecorder.startRecording() + _ = await voiceMessageRecorder.stopRecording() + + // if the player url doesn't match the recording url + guard case .success = await voiceMessageRecorder.startPlayback() else { + XCTFail("Playback should start") + return + } + + XCTAssert(audioPlayer.loadMediaSourceUsingAutoplayCalled) + XCTAssertEqual(audioPlayer.loadMediaSourceUsingAutoplayReceivedArguments?.url, recordingURL) + XCTAssertEqual(audioPlayer.loadMediaSourceUsingAutoplayReceivedArguments?.mediaSource.mimeType, "audio/m4a") + XCTAssertEqual(audioPlayer.loadMediaSourceUsingAutoplayReceivedArguments?.mediaSource.url, recordingURL) + XCTAssertEqual(audioPlayer.loadMediaSourceUsingAutoplayReceivedArguments?.autoplay, true) + XCTAssertFalse(audioPlayer.playCalled) + } + + func testResumePlayback() async throws { + audioRecorder.url = recordingURL + _ = await voiceMessageRecorder.startRecording() + _ = await voiceMessageRecorder.stopRecording() + + // if the player url matches the recording url + audioPlayer.url = recordingURL + guard case .success = await voiceMessageRecorder.startPlayback() else { + XCTFail("Playback should start") + return + } + + XCTAssertFalse(audioPlayer.loadMediaSourceUsingAutoplayCalled) + XCTAssert(audioPlayer.playCalled) + } + + func testPausePlayback() async throws { + audioRecorder.url = recordingURL + switch await voiceMessageRecorder.startRecording() { + case .failure(let error): + XCTFail("Recording should start. \(error)") + case .success: + break + } + _ = await voiceMessageRecorder.stopRecording() + + voiceMessageRecorder.pausePlayback() + XCTAssert(audioPlayer.pauseCalled) + } + + func testStopPlayback() async throws { + audioRecorder.url = recordingURL + _ = await voiceMessageRecorder.startRecording() + _ = await voiceMessageRecorder.stopRecording() + + await voiceMessageRecorder.stopPlayback() + XCTAssertEqual(voiceMessageRecorder.previewAudioPlayerState?.isAttached, false) + XCTAssert(audioPlayer.stopCalled) + } + + func testSeekPlayback() async throws { + audioRecorder.url = recordingURL + // Calling stop will generate the preview player state needed to have an audio player + _ = await voiceMessageRecorder.startRecording() + _ = await voiceMessageRecorder.stopRecording() + voiceMessageRecorder.previewAudioPlayerState?.attachAudioPlayer(audioPlayer) + + await voiceMessageRecorder.seekPlayback(to: 0.4) + XCTAssert(audioPlayer.seekToCalled) + XCTAssertEqual(audioPlayer.seekToReceivedProgress, 0.4) + } + + func testBuildRecordedWaveform() async throws { + guard let audioFileUrl = Bundle(for: Self.self).url(forResource: "test_audio", withExtension: "mp3") else { + XCTFail("Test audio file is missing") + return + } + audioRecorder.url = audioFileUrl + _ = await voiceMessageRecorder.startRecording() + _ = await voiceMessageRecorder.stopRecording() + + guard case .success(let data) = await voiceMessageRecorder.buildRecordingWaveform() else { + XCTFail("A waveform is expected") + return + } + XCTAssert(!data.isEmpty) + } + + func testSendVoiceMessage() async throws { + guard let audioFileUrl = Bundle(for: Self.self).url(forResource: "test_voice_message", withExtension: "m4a") else { + XCTFail("Test audio file is missing") + return + } + audioRecorder.currentTime = 42 + audioRecorder.url = audioFileUrl + _ = await voiceMessageRecorder.startRecording() + _ = await voiceMessageRecorder.stopRecording() + + let roomProxy = RoomProxyMock() + let audioConverter = AudioConverterMock() + var convertedFileUrl: URL? + var convertedFileSize: UInt64? + + audioConverter.convertToOpusOggSourceURLDestinationURLClosure = { source, destination in + convertedFileUrl = destination + try? FileManager.default.removeItem(at: destination) + let internalConverter = AudioConverter() + try internalConverter.convertToOpusOgg(sourceURL: source, destinationURL: destination) + convertedFileSize = try? UInt64(FileManager.default.sizeForItem(at: destination)) + // the source URL must be the recorded file + XCTAssertEqual(source, audioFileUrl) + // check the converted file extension + XCTAssertEqual(destination.pathExtension, "ogg") + } + + roomProxy.sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleClosure = { url, audioInfo, waveform, _, _ in + XCTAssertEqual(url, convertedFileUrl) + XCTAssertEqual(audioInfo.duration, self.audioRecorder.currentTime) + XCTAssertEqual(audioInfo.size, convertedFileSize) + XCTAssertEqual(audioInfo.mimetype, "audio/ogg") + XCTAssertFalse(waveform.isEmpty) + + return .success(()) + } + + guard case .success = await voiceMessageRecorder.sendVoiceMessage(inRoom: roomProxy, audioConverter: audioConverter) else { + XCTFail("A success is expected") + return + } + + XCTAssert(audioConverter.convertToOpusOggSourceURLDestinationURLCalled) + XCTAssert(roomProxy.sendVoiceMessageUrlAudioInfoWaveformProgressSubjectRequestHandleCalled) + + // the converted file must have been deleted + if let convertedFileUrl { + XCTAssertFalse(FileManager.default.fileExists(atPath: convertedFileUrl.path())) + } else { + XCTFail("converted file URL is missing") + } + } +} diff --git a/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png index 9d7155f8a5..5730edb98b 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_composerToolbar.Voice-Message.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26fdd567b146251130beb6f662ec227ad48d734561c759b072a0e37d8f804789 -size 96929 +oid sha256:4847895e8d01132c5fbef0cd2a5b3ac8c394ae197a15b37b7d9fff53cfa0bab9 +size 95668 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png index 3205ca2bf2..79bcb7060a 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessagePreviewComposer.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a14094723fbe08e8ebf64f6a7f567e57e8835533fcb3c2f03f806acded2af54 -size 65817 +oid sha256:efc7fa1ab541e5dd8bb1757ed782065e7c7e84d7354fe90702f1091134a24467 +size 63847 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingComposer.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingComposer.1.png index cb5e294274..3e63ffbabc 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingComposer.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingComposer.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ee69c3ef27115aeee58aacd362b3d382480b47bf1e53ffd1ce72880319d11e67 -size 60199 +oid sha256:af1d1d6fcf418f97cc9aae336b349b3630a1e2995c26ab8565421c97a823935a +size 59818 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingView.1.png index 0accbb7dd3..c89523464e 100644 --- a/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingView.1.png +++ b/UnitTests/__Snapshots__/PreviewTests/test_voiceMessageRecordingView.1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76d5359eca0ee480fc6197652baf9b282d342cfd1e84893afb4a469c572f24a9 -size 57410 +oid sha256:73435a7b6a9a06ee58de540c559cbe5a2492bb9c9617260b303957d54dcf195c +size 56863 diff --git a/UnitTests/__Snapshots__/PreviewTests/test_waveformView.1.png b/UnitTests/__Snapshots__/PreviewTests/test_waveformView.1.png deleted file mode 100644 index 3be71291ad..0000000000 --- a/UnitTests/__Snapshots__/PreviewTests/test_waveformView.1.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8dfd0301b8fd911e44b1f5f1caf9b74f575eb57e74d39a7765bde7966e5872d0 -size 63481