From b191f80dea0aab82dcf01a3277d783e29ccd0fdd Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Fri, 13 Oct 2023 11:48:11 +0200 Subject: [PATCH] Prevent multiple conversion of the same voice message audio file. (#1887) --- ElementX.xcodeproj/project.pbxproj | 16 ++ .../RoomFlowCoordinator.swift | 6 +- .../Mocks/Generated/GeneratedMocks.swift | 104 ++++++++++++ .../SwiftUI/Animation/ShimmerModifier.swift | 3 +- .../WaitlistScreen/View/WaitlistScreen.swift | 3 +- .../CreateRoom/View/CreateRoomScreen.swift | 6 +- .../Screens/HomeScreen/View/HomeScreen.swift | 3 +- .../View/HomeScreenEmptyStateView.swift | 3 +- .../HomeScreen/View/HomeScreenRoomCell.swift | 3 +- .../InvitesScreen/View/InvitesScreen.swift | 6 +- .../View/NotificationSettingsEditScreen.swift | 8 +- ...tificationSettingsEditScreenRoomCell.swift | 3 +- .../View/NotificationSettingsScreen.swift | 5 +- .../SettingsScreen/View/SettingsScreen.swift | 3 +- .../View/StartChatScreen.swift | 3 +- .../Services/AudioPlayer/AudioConverter.swift | 7 +- .../AudioPlayer/AudioConverterProtocol.swift | 25 +++ .../MockAuthenticationServiceProxy.swift | 3 +- .../Media/Provider/MockMediaProvider.swift | 6 +- .../Services/Session/MockUserSession.swift | 1 + .../Services/Session/UserSession.swift | 4 +- .../Session/UserSessionProtocol.swift | 1 + .../UserSession/UserSessionStore.swift | 12 +- .../VoiceMessage/VoiceMessageCache.swift | 41 ++--- .../VoiceMessageCacheProtocol.swift | 26 +++ .../VoiceMessageMediaManager.swift | 94 ++++++++--- .../UITests/UITestsAppCoordinator.swift | 27 ++-- .../Sources/CreateRoomViewModelTests.swift | 4 +- .../Sources/HomeScreenViewModelTests.swift | 3 +- .../Sources/InvitesScreenViewModelTests.swift | 4 +- .../NotificationManagerTests.swift | 4 +- ...tionSettingsEditScreenViewModelTests.swift | 4 +- ...ficationSettingsScreenViewModelTests.swift | 4 +- .../Sources/RoomFlowCoordinatorTests.swift | 5 +- .../Sources/SettingsViewModelTests.swift | 3 +- .../Sources/StartChatViewModelTests.swift | 4 +- .../UserSession/UserSessionTests.swift | 4 +- .../Sources/VoiceMessageCacheTests.swift | 104 ++++++++++++ .../VoiceMessageMediaManagerTests.swift | 150 ++++++++++++++++++ .../WaitlistScreenViewModelTests.swift | 3 +- 40 files changed, 627 insertions(+), 91 deletions(-) create mode 100644 ElementX/Sources/Services/AudioPlayer/AudioConverterProtocol.swift create mode 100644 ElementX/Sources/Services/VoiceMessage/VoiceMessageCacheProtocol.swift create mode 100644 UnitTests/Sources/VoiceMessageCacheTests.swift create mode 100644 UnitTests/Sources/VoiceMessageMediaManagerTests.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index de20840ed..1bb478435 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ 208C19811613F9A10F8A7B75 /* MediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AFCE895ECFFA53FEE64D62B /* MediaLoader.swift */; }; 20C16A3F718802B0E4A19C83 /* URLComponentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76310030C831D4610A705603 /* URLComponentsTests.swift */; }; 2185C1F6724C78FFF355D6FA /* WelcomeScreenScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB10FA6570DD08B3966C159 /* WelcomeScreenScreenUITests.swift */; }; + 21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */; }; 21BF2B7CEDFE3CA67C5355AD /* test_image.png in Resources */ = {isa = PBXBuildFile; fileRef = C733D11B421CFE3A657EF230 /* test_image.png */; }; 22882C710BC99EC34A5024A0 /* UITestsScreenIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CEBE5EA91E8691EDF364EC2 /* UITestsScreenIdentifier.swift */; }; 234E2C782981003971ABE96E /* PermalinkBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F754E66A8970963B15B2A41E /* PermalinkBuilder.swift */; }; @@ -221,6 +222,7 @@ 43F35A7E5703D64DB0519C59 /* ServerSelectionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD469F7513574341181F7EAA /* ServerSelectionScreen.swift */; }; 440123E29E2F9B001A775BBE /* TimelineItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D505843AB66822EB91F0DF0 /* TimelineItemProxy.swift */; }; 44121202B4A260C98BF615A7 /* RoomMembersListScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B7A755E985FA14469E86B2 /* RoomMembersListScreenUITests.swift */; }; + 44BDD670FF9095ACE240A3A2 /* VoiceMessageMediaManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */; }; 44F0E1B576C7599DF8022071 /* SwiftOGG in Frameworks */ = {isa = PBXBuildFile; productRef = 391D11F92DFC91666AA1503F /* SwiftOGG */; }; 4557192F5B15A8D9BB920232 /* AdvancedSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E492690C8B27A892C194CC4 /* AdvancedSettingsScreenCoordinator.swift */; }; 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */; }; @@ -251,6 +253,7 @@ 4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */; }; 4EA1CE0E88EA68E862FF0EA2 /* NotificationSettingsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B564D748B67A156F413CD97 /* NotificationSettingsEditScreenModels.swift */; }; 4EB1B717C1EFE3A7ABFBC0A8 /* CreatePollScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A22A3A4109959414EBC6113 /* CreatePollScreenViewModelProtocol.swift */; }; + 4F2DF6138E87A4B8C2488CA3 /* VoiceMessageCacheProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A84EE187D0C772E18A4E39 /* VoiceMessageCacheProtocol.swift */; }; 4FC085B1E5D1EB804495E2F4 /* MockMediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD6E621CC5E6D4830D96D2D /* MockMediaProvider.swift */; }; 4FC1EFE4968A259CBBACFAFB /* RoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */; }; 4FDC8A9764CFDA90CE035725 /* Duration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB2253D36E81E045E1CB432 /* Duration.swift */; }; @@ -429,6 +432,7 @@ 829062DD3C3F7016FE1A6476 /* RoomDetailsScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BFDAF6918BB096C44788FC9 /* RoomDetailsScreenUITests.swift */; }; 8317E1314C00DCCC99D30DA8 /* TextBasedRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9227F7495DA43324050A863 /* TextBasedRoomTimelineItem.swift */; }; 83A4DAB181C56987C3E804FF /* MapTilerStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B9F5BC4C80543DE7228B9D /* MapTilerStyle.swift */; }; + 8418688282763F4B9DDC42FB /* AudioConverterProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB4D7B3FBCA261927536FE64 /* AudioConverterProtocol.swift */; }; 84226AD2E1F1FBC965F3B09E /* UnitTestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8E19C4645D3F5F9FB02355 /* UnitTestsAppCoordinator.swift */; }; 84EFCB95F9DA2979C8042B26 /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; }; 8544657DEEE717ED2E22E382 /* RoomNotificationSettingsProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D1BAA90F3A073D91B4F16B /* RoomNotificationSettingsProxyMock.swift */; }; @@ -1055,6 +1059,7 @@ 27B8315A340B46F98B9C5AF0 /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = ""; }; 27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDetailsEditScreenModels.swift; sourceTree = ""; }; 27EA0F71A3A400A202E15318 /* CreatePollScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatePollScreen.swift; sourceTree = ""; }; + 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCacheTests.swift; sourceTree = ""; }; 287FC98AF2664EAD79C0D902 /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = ""; }; 28C19F54A0C4FC9AB7ABD583 /* TextRoomTimelineItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomTimelineItemContent.swift; sourceTree = ""; }; 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilder.swift; sourceTree = ""; }; @@ -1134,6 +1139,7 @@ 42ADEA322D2089391E049535 /* InvitesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreen.swift; sourceTree = ""; }; 42C64A14EE89928207E3B42B /* AnalyticsSettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsSettingsScreenModels.swift; sourceTree = ""; }; 42EEA67A6796BDC2761619C5 /* PaginationIndicatorRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIndicatorRoomTimelineView.swift; sourceTree = ""; }; + 43A84EE187D0C772E18A4E39 /* VoiceMessageCacheProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCacheProtocol.swift; sourceTree = ""; }; 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareToMapsAppActivity.swift; sourceTree = ""; }; 450E04B2A976CC4C8CC1807C /* EmoteRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoteRoomTimelineItem.swift; sourceTree = ""; }; 4549FCB53F43DB0B278374BC /* TemplateScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreen.swift; sourceTree = ""; }; @@ -1440,6 +1446,7 @@ ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfirmationScreenViewModelProtocol.swift; sourceTree = ""; }; AC1DA29A5A041CC0BACA7CB0 /* MockImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageCache.swift; sourceTree = ""; }; AC3F82523D6F48B926D6AF68 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageMediaManagerTests.swift; sourceTree = ""; }; AC5F5209279A752D98AAC4B2 /* CollapsibleFlowLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleFlowLayoutTests.swift; sourceTree = ""; }; ACCC1874C122E2BBE648B8F5 /* LegalInformationScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenUITests.swift; sourceTree = ""; }; AD378D580A41E42560C60E9C /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; @@ -1702,6 +1709,7 @@ FA7BB497B2F539C17E88F6B7 /* NotificationSettingsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreenViewModelProtocol.swift; sourceTree = ""; }; FA88C615D8BFCCEF0D2FEAC9 /* AudioPlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerState.swift; sourceTree = ""; }; FB0D6CB491777E7FC6B5BA12 /* CreateRoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateRoomScreen.swift; sourceTree = ""; }; + FB4D7B3FBCA261927536FE64 /* AudioConverterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioConverterProtocol.swift; sourceTree = ""; }; FB7BAD55A4E2B8E5828CD64C /* SoftLogoutScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenViewModel.swift; sourceTree = ""; }; FBB0328F2887BF0A65BC5D49 /* NotificationSettingsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsEditScreen.swift; sourceTree = ""; }; FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorProtocol.swift; sourceTree = ""; }; @@ -1821,6 +1829,7 @@ isa = PBXGroup; children = ( DAB8D7926A5684E18196B538 /* VoiceMessageCache.swift */, + 43A84EE187D0C772E18A4E39 /* VoiceMessageCacheProtocol.swift */, 40076C770A5FB83325252973 /* VoiceMessageMediaManager.swift */, 889DEDD63C68ABDA8AD29812 /* VoiceMessageMediaManagerProtocol.swift */, ); @@ -2870,6 +2879,8 @@ EB3B237387B8288A5A938F1B /* UserAgentBuilderTests.swift */, 2429224EB0EEA34D35CE9249 /* UserIndicatorControllerTests.swift */, BA241DEEF7C8A7181C0AEDC9 /* UserPreferenceTests.swift */, + 283974987DA7EC61D2AB57D9 /* VoiceMessageCacheTests.swift */, + AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */, C796FC1DFDBCDD5573D0360F /* WaitlistScreenViewModelTests.swift */, 851EF6258DF8B7EF129DC3AC /* WelcomeScreenScreenViewModelTests.swift */, 53280D2292E6C9C7821773FD /* UserSession */, @@ -3328,6 +3339,7 @@ isa = PBXGroup; children = ( CE1CD5EC6265A09315772DB7 /* AudioConverter.swift */, + FB4D7B3FBCA261927536FE64 /* AudioConverterProtocol.swift */, 2EDDE503C9BAC82B661E4164 /* AudioPlayer.swift */, 048A3590E8379BCED4D30D5C /* AudioPlayerProtocol.swift */, FA88C615D8BFCCEF0D2FEAC9 /* AudioPlayerState.swift */, @@ -4695,6 +4707,8 @@ A1DF0E1E526A981ED6D5DF44 /* UserIndicatorControllerTests.swift in Sources */, 04F17DE71A50206336749BAC /* UserPreferenceTests.swift in Sources */, 81A7C020CB5F6232242A8414 /* UserSessionTests.swift in Sources */, + 21AFEFB8CEFE56A3811A1F5B /* VoiceMessageCacheTests.swift in Sources */, + 44BDD670FF9095ACE240A3A2 /* VoiceMessageMediaManagerTests.swift in Sources */, FB9A1DD83EF641A75ABBCE69 /* WaitlistScreenViewModelTests.swift in Sources */, 7F02063FB3D1C3E5601471A1 /* WelcomeScreenScreenViewModelTests.swift in Sources */, 3116693C5EB476E028990416 /* XCTestCase.swift in Sources */, @@ -4764,6 +4778,7 @@ 3ED2725734568F6B8CC87544 /* AttributedStringBuilder.swift in Sources */, A6DEC1ADEC8FEEC206A0FA37 /* AttributedStringBuilderProtocol.swift in Sources */, 5F8E96263497FFB7D3254EB2 /* AudioConverter.swift in Sources */, + 8418688282763F4B9DDC42FB /* AudioConverterProtocol.swift in Sources */, CD1C6943F42F29079E5E7511 /* AudioPlayer.swift in Sources */, F0B196905CD23E3B4505CB7B /* AudioPlayerProtocol.swift in Sources */, C19085A284D54A166A64A86C /* AudioPlayerState.swift in Sources */, @@ -5286,6 +5301,7 @@ 64F43D7390DA2A0AFD6BA911 /* VideoRoomTimelineView.swift in Sources */, 6FC10A00D268FCD48B631E37 /* ViewFrameReader.swift in Sources */, 4681820102DAC8BA586357D4 /* VoiceMessageCache.swift in Sources */, + 4F2DF6138E87A4B8C2488CA3 /* VoiceMessageCacheProtocol.swift in Sources */, 386720B603F87D156DB01FB2 /* VoiceMessageMediaManager.swift in Sources */, 9DE801D278AC34737467F937 /* VoiceMessageMediaManagerProtocol.swift in Sources */, A9482B967FC85DA611514D35 /* VoiceMessageRoomPlaybackView.swift in Sources */, diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 4cf7bfb8c..4775798ad 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -328,14 +328,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { mentionBuilder: MentionBuilder(mentionsEnabled: appSettings.mentionsEnabled)), stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID), appSettings: appSettings) - - let voiceMesssageMediaManager = VoiceMessageMediaManager(mediaProvider: userSession.mediaProvider) - + let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: userSession.mediaProvider, mediaPlayerProvider: mediaPlayerProvider, - voiceMessageMediaManager: voiceMesssageMediaManager) + voiceMessageMediaManager: userSession.voiceMessageMediaManager) self.timelineController = timelineController analytics.trackViewRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace) diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 337c01453..be6a1ca7c 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -115,6 +115,49 @@ class AnalyticsClientMock: AnalyticsClientProtocol { updateUserPropertiesClosure?(userProperties) } } +class AudioConverterMock: AudioConverterProtocol { + + //MARK: - convertToOpusOgg + + var convertToOpusOggSourceURLDestinationURLThrowableError: Error? + var convertToOpusOggSourceURLDestinationURLCallsCount = 0 + var convertToOpusOggSourceURLDestinationURLCalled: Bool { + return convertToOpusOggSourceURLDestinationURLCallsCount > 0 + } + var convertToOpusOggSourceURLDestinationURLReceivedArguments: (sourceURL: URL, destinationURL: URL)? + var convertToOpusOggSourceURLDestinationURLReceivedInvocations: [(sourceURL: URL, destinationURL: URL)] = [] + var convertToOpusOggSourceURLDestinationURLClosure: ((URL, URL) throws -> Void)? + + func convertToOpusOgg(sourceURL: URL, destinationURL: URL) throws { + if let error = convertToOpusOggSourceURLDestinationURLThrowableError { + throw error + } + convertToOpusOggSourceURLDestinationURLCallsCount += 1 + convertToOpusOggSourceURLDestinationURLReceivedArguments = (sourceURL: sourceURL, destinationURL: destinationURL) + convertToOpusOggSourceURLDestinationURLReceivedInvocations.append((sourceURL: sourceURL, destinationURL: destinationURL)) + try convertToOpusOggSourceURLDestinationURLClosure?(sourceURL, destinationURL) + } + //MARK: - convertToMPEG4AAC + + var convertToMPEG4AACSourceURLDestinationURLThrowableError: Error? + var convertToMPEG4AACSourceURLDestinationURLCallsCount = 0 + var convertToMPEG4AACSourceURLDestinationURLCalled: Bool { + return convertToMPEG4AACSourceURLDestinationURLCallsCount > 0 + } + var convertToMPEG4AACSourceURLDestinationURLReceivedArguments: (sourceURL: URL, destinationURL: URL)? + var convertToMPEG4AACSourceURLDestinationURLReceivedInvocations: [(sourceURL: URL, destinationURL: URL)] = [] + var convertToMPEG4AACSourceURLDestinationURLClosure: ((URL, URL) throws -> Void)? + + func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL) throws { + if let error = convertToMPEG4AACSourceURLDestinationURLThrowableError { + throw error + } + convertToMPEG4AACSourceURLDestinationURLCallsCount += 1 + convertToMPEG4AACSourceURLDestinationURLReceivedArguments = (sourceURL: sourceURL, destinationURL: destinationURL) + convertToMPEG4AACSourceURLDestinationURLReceivedInvocations.append((sourceURL: sourceURL, destinationURL: destinationURL)) + try convertToMPEG4AACSourceURLDestinationURLClosure?(sourceURL, destinationURL) + } +} class AudioPlayerMock: AudioPlayerProtocol { var actions: AnyPublisher { get { return underlyingActions } @@ -2191,6 +2234,67 @@ class UserNotificationCenterMock: UserNotificationCenterProtocol { } } } +class VoiceMessageCacheMock: VoiceMessageCacheProtocol { + + //MARK: - fileURL + + var fileURLForCallsCount = 0 + var fileURLForCalled: Bool { + return fileURLForCallsCount > 0 + } + var fileURLForReceivedMediaSource: MediaSourceProxy? + var fileURLForReceivedInvocations: [MediaSourceProxy] = [] + var fileURLForReturnValue: URL? + var fileURLForClosure: ((MediaSourceProxy) -> URL?)? + + func fileURL(for mediaSource: MediaSourceProxy) -> URL? { + fileURLForCallsCount += 1 + fileURLForReceivedMediaSource = mediaSource + fileURLForReceivedInvocations.append(mediaSource) + if let fileURLForClosure = fileURLForClosure { + return fileURLForClosure(mediaSource) + } else { + return fileURLForReturnValue + } + } + //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)? + + func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) throws -> URL { + if let error = cacheMediaSourceUsingMoveThrowableError { + throw error + } + 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) + } else { + return cacheMediaSourceUsingMoveReturnValue + } + } + //MARK: - clearCache + + var clearCacheCallsCount = 0 + var clearCacheCalled: Bool { + return clearCacheCallsCount > 0 + } + var clearCacheClosure: (() -> Void)? + + func clearCache() { + clearCacheCallsCount += 1 + clearCacheClosure?() + } +} class VoiceMessageMediaManagerMock: VoiceMessageMediaManagerProtocol { //MARK: - loadVoiceMessageFromSource diff --git a/ElementX/Sources/Other/SwiftUI/Animation/ShimmerModifier.swift b/ElementX/Sources/Other/SwiftUI/Animation/ShimmerModifier.swift index e5b943b53..3bd3767c0 100644 --- a/ElementX/Sources/Other/SwiftUI/Animation/ShimmerModifier.swift +++ b/ElementX/Sources/Other/SwiftUI/Animation/ShimmerModifier.swift @@ -77,7 +77,8 @@ extension View { struct ShimmerOverlay_Previews: PreviewProvider, TestablePreview { static let viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: MockClientProxy(userID: ""), - mediaProvider: MockMediaProvider()), + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()), attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled)), selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), diff --git a/ElementX/Sources/Screens/Authentication/WaitlistScreen/View/WaitlistScreen.swift b/ElementX/Sources/Screens/Authentication/WaitlistScreen/View/WaitlistScreen.swift index 192973c12..c92232b8f 100644 --- a/ElementX/Sources/Screens/Authentication/WaitlistScreen/View/WaitlistScreen.swift +++ b/ElementX/Sources/Screens/Authentication/WaitlistScreen/View/WaitlistScreen.swift @@ -75,7 +75,8 @@ struct WaitlistScreen_Previews: PreviewProvider, TestablePreview { static let successViewModel = { let viewModel = WaitlistScreenViewModel(homeserver: .mockMatrixDotOrg) viewModel.update(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@alice:matrix.org"), - mediaProvider: MockMediaProvider())) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock())) return viewModel }() diff --git a/ElementX/Sources/Screens/CreateRoom/View/CreateRoomScreen.swift b/ElementX/Sources/Screens/CreateRoom/View/CreateRoomScreen.swift index 16f79284d..6ab9faeb5 100644 --- a/ElementX/Sources/Screens/CreateRoom/View/CreateRoomScreen.swift +++ b/ElementX/Sources/Screens/CreateRoom/View/CreateRoomScreen.swift @@ -207,7 +207,8 @@ struct CreateRoomScreen: View { struct CreateRoom_Previews: PreviewProvider, TestablePreview { static let viewModel = { let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let parameters = CreateRoomFlowParameters() let selectedUsers: [UserProfileProxy] = [.mockAlice, .mockBob, .mockCharlie] @@ -220,7 +221,8 @@ struct CreateRoom_Previews: PreviewProvider, TestablePreview { static let emtpyViewModel = { let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let parameters = CreateRoomFlowParameters() return CreateRoomViewModel(userSession: userSession, createRoomParameters: .init(parameters), diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift index ab9dab000..855ff1e35 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift @@ -335,7 +335,8 @@ struct HomeScreen_Previews: PreviewProvider, TestablePreview { static func viewModel(_ state: MockRoomSummaryProviderState) -> HomeScreenViewModel { let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@alice:example.com", roomSummaryProvider: MockRoomSummaryProvider(state: state)), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) return HomeScreenViewModel(userSession: userSession, attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled)), diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift index dc84cb8bb..a32747a3e 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenEmptyStateView.swift @@ -151,7 +151,8 @@ struct HomeScreenEmptyStateView_Previews: PreviewProvider, TestablePreview { static let viewModel = { let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@user:example.com", roomSummaryProvider: MockRoomSummaryProvider(state: .loaded([]))), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) return HomeScreenViewModel(userSession: userSession, attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled)), diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift index 5653ba1e1..848f6fea0 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift @@ -185,7 +185,8 @@ struct HomeScreenRoomCell_Previews: PreviewProvider, TestablePreview { let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockRooms)) let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe", roomSummaryProvider: summaryProvider), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let viewModel = HomeScreenViewModel(userSession: userSession, attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, diff --git a/ElementX/Sources/Screens/InvitesScreen/View/InvitesScreen.swift b/ElementX/Sources/Screens/InvitesScreen/View/InvitesScreen.swift index 95208bd8d..e9c799428 100644 --- a/ElementX/Sources/Screens/InvitesScreen/View/InvitesScreen.swift +++ b/ElementX/Sources/Screens/InvitesScreen/View/InvitesScreen.swift @@ -80,7 +80,8 @@ struct InvitesScreen_Previews: PreviewProvider, TestablePreview { private extension InvitesScreenViewModel { static let noInvites: InvitesScreenViewModel = { let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let regularViewModel = InvitesScreenViewModel(userSession: userSession, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, @@ -93,7 +94,8 @@ private extension InvitesScreenViewModel { clientProxy.inviteSummaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites)) clientProxy.roomSummaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites)) let userSession = MockUserSession(clientProxy: clientProxy, - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let regularViewModel = InvitesScreenViewModel(userSession: userSession, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/View/NotificationSettingsEditScreen.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/View/NotificationSettingsEditScreen.swift index 6fd757cbf..b9962eaf4 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/View/NotificationSettingsEditScreen.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/View/NotificationSettingsEditScreen.swift @@ -76,7 +76,8 @@ struct NotificationSettingsEditScreen_Previews: PreviewProvider, TestablePreview notificationSettingsProxy.getRoomsWithUserDefinedRulesReturnValue = [RoomSummary].mockRooms.compactMap(\.id) let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@alice:example.com", roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms))), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) var viewModel = NotificationSettingsEditScreenViewModel(chatType: .groupChat, userSession: userSession, notificationSettingsProxy: notificationSettingsProxy) @@ -90,7 +91,8 @@ struct NotificationSettingsEditScreen_Previews: PreviewProvider, TestablePreview notificationSettingsProxy.getRoomsWithUserDefinedRulesReturnValue = [] let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@alice:example.com", roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms))), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) var viewModel = NotificationSettingsEditScreenViewModel(chatType: .oneToOneChat, userSession: userSession, notificationSettingsProxy: notificationSettingsProxy) @@ -102,7 +104,7 @@ struct NotificationSettingsEditScreen_Previews: PreviewProvider, TestablePreview let notificationSettingsProxy = NotificationSettingsProxyMock(with: .init()) notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .mentionsAndKeywordsOnly notificationSettingsProxy.getRoomsWithUserDefinedRulesReturnValue = [] - let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe"), mediaProvider: MockMediaProvider()) + let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe"), mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()) var viewModel = NotificationSettingsEditScreenViewModel(chatType: .oneToOneChat, userSession: userSession, diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/View/NotificationSettingsEditScreenRoomCell.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/View/NotificationSettingsEditScreenRoomCell.swift index 67d8c83f0..19a601f8b 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/View/NotificationSettingsEditScreenRoomCell.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsEditScreen/View/NotificationSettingsEditScreenRoomCell.swift @@ -62,7 +62,8 @@ struct NotificationSettingsEditScreenRoomCell_Previews: PreviewProvider, Testabl let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockRooms)) let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe", roomSummaryProvider: summaryProvider), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let notificationSettingsProxy = NotificationSettingsProxyMock(with: .init()) notificationSettingsProxy.getRoomsWithUserDefinedRulesReturnValue = [] diff --git a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift index 443bde52a..8ce9a79d6 100644 --- a/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/NotificationSettingsScreen/View/NotificationSettingsScreen.swift @@ -199,7 +199,8 @@ struct NotificationSettingsScreen_Previews: PreviewProvider, TestablePreview { notificationSettingsProxy.isRoomMentionEnabledReturnValue = true notificationSettingsProxy.isCallEnabledReturnValue = false - let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe"), mediaProvider: MockMediaProvider()) + let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe"), mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) var viewModel = NotificationSettingsScreenViewModel(userSession: userSession, appSettings: appSettings, @@ -228,7 +229,7 @@ struct NotificationSettingsScreen_Previews: PreviewProvider, TestablePreview { notificationSettingsProxy.isRoomMentionEnabledReturnValue = true notificationSettingsProxy.isCallEnabledReturnValue = false - let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe"), mediaProvider: MockMediaProvider()) + let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe"), mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()) var viewModel = NotificationSettingsScreenViewModel(userSession: userSession, appSettings: appSettings, diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift index 9012f5763..a4836da77 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift @@ -235,7 +235,8 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview { let userSession = MockUserSession(sessionVerificationController: verificationController, clientProxy: MockClientProxy(userID: "@userid:example.com", deviceID: "AAAAAAAAAAA"), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) return SettingsScreenViewModel(userSession: userSession, appSettings: ServiceLocator.shared.settings) }() diff --git a/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift b/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift index 174885e40..e7bf0a153 100644 --- a/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift +++ b/ElementX/Sources/Screens/StartChatScreen/View/StartChatScreen.swift @@ -137,7 +137,8 @@ struct StartChatScreen: View { struct StartChatScreen_Previews: PreviewProvider, TestablePreview { static let viewModel = { let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let userDiscoveryService = UserDiscoveryServiceMock() userDiscoveryService.fetchSuggestionsReturnValue = .success([.mockAlice]) userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice]) diff --git a/ElementX/Sources/Services/AudioPlayer/AudioConverter.swift b/ElementX/Sources/Services/AudioPlayer/AudioConverter.swift index 23f1282fc..8b3273864 100644 --- a/ElementX/Sources/Services/AudioPlayer/AudioConverter.swift +++ b/ElementX/Sources/Services/AudioPlayer/AudioConverter.swift @@ -22,7 +22,12 @@ enum AudioConverterError: Error { case conversionFailed(Error?) } -struct AudioConverter { +enum AudioConverterPreferredFileExtension: String { + case mpeg4aac = "m4a" + case ogg +} + +struct AudioConverter: AudioConverterProtocol { func convertToOpusOgg(sourceURL: URL, destinationURL: URL) throws { do { try OGGConverter.convertM4aFileToOpusOGG(src: sourceURL, dest: destinationURL) diff --git a/ElementX/Sources/Services/AudioPlayer/AudioConverterProtocol.swift b/ElementX/Sources/Services/AudioPlayer/AudioConverterProtocol.swift new file mode 100644 index 000000000..c108a82ad --- /dev/null +++ b/ElementX/Sources/Services/AudioPlayer/AudioConverterProtocol.swift @@ -0,0 +1,25 @@ +// +// 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 + +protocol AudioConverterProtocol { + func convertToOpusOgg(sourceURL: URL, destinationURL: URL) throws + func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL) throws +} + +// sourcery: AutoMockable +extension AudioConverterProtocol { } diff --git a/ElementX/Sources/Services/Authentication/MockAuthenticationServiceProxy.swift b/ElementX/Sources/Services/Authentication/MockAuthenticationServiceProxy.swift index f3acd718b..45ce2d75d 100644 --- a/ElementX/Sources/Services/Authentication/MockAuthenticationServiceProxy.swift +++ b/ElementX/Sources/Services/Authentication/MockAuthenticationServiceProxy.swift @@ -63,7 +63,8 @@ class MockAuthenticationServiceProxy: AuthenticationServiceProxyProtocol { } let userSession = MockUserSession(clientProxy: MockClientProxy(userID: username), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) return .success(userSession) } } diff --git a/ElementX/Sources/Services/Media/Provider/MockMediaProvider.swift b/ElementX/Sources/Services/Media/Provider/MockMediaProvider.swift index fb5778e83..7e4d3a58d 100644 --- a/ElementX/Sources/Services/Media/Provider/MockMediaProvider.swift +++ b/ElementX/Sources/Services/Media/Provider/MockMediaProvider.swift @@ -47,7 +47,11 @@ struct MockMediaProvider: MediaProviderProtocol { return .success(data) } + var loadFileFromSourceReturnValue: MediaFileHandleProxy? func loadFileFromSource(_ source: MediaSourceProxy, body: String?) async -> Result { - .failure(.failedRetrievingFile) + if let loadFileFromSourceReturnValue { + return .success(loadFileFromSourceReturnValue) + } + return .failure(.failedRetrievingFile) } } diff --git a/ElementX/Sources/Services/Session/MockUserSession.swift b/ElementX/Sources/Services/Session/MockUserSession.swift index c5f52a5b3..e98256e91 100644 --- a/ElementX/Sources/Services/Session/MockUserSession.swift +++ b/ElementX/Sources/Services/Session/MockUserSession.swift @@ -24,4 +24,5 @@ struct MockUserSession: UserSessionProtocol { var homeserver: String { clientProxy.homeserver } let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol + let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol } diff --git a/ElementX/Sources/Services/Session/UserSession.swift b/ElementX/Sources/Services/Session/UserSession.swift index b5b45661e..91c290091 100644 --- a/ElementX/Sources/Services/Session/UserSession.swift +++ b/ElementX/Sources/Services/Session/UserSession.swift @@ -28,12 +28,14 @@ class UserSession: UserSessionProtocol { let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol + let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol let callbacks = PassthroughSubject() private(set) var sessionVerificationController: SessionVerificationControllerProxyProtocol? - init(clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol) { + init(clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) { self.clientProxy = clientProxy self.mediaProvider = mediaProvider + self.voiceMessageMediaManager = voiceMessageMediaManager setupSessionVerificationWatchdog() setupAuthErrorWatchdog() diff --git a/ElementX/Sources/Services/Session/UserSessionProtocol.swift b/ElementX/Sources/Services/Session/UserSessionProtocol.swift index b8da4cd65..df2bc1cb0 100644 --- a/ElementX/Sources/Services/Session/UserSessionProtocol.swift +++ b/ElementX/Sources/Services/Session/UserSessionProtocol.swift @@ -30,6 +30,7 @@ protocol UserSessionProtocol { var clientProxy: ClientProxyProtocol { get } var mediaProvider: MediaProviderProtocol { get } + var voiceMessageMediaManager: VoiceMessageMediaManagerProtocol { get } var sessionVerificationController: SessionVerificationControllerProxyProtocol? { get } diff --git a/ElementX/Sources/Services/UserSession/UserSessionStore.swift b/ElementX/Sources/Services/UserSession/UserSessionStore.swift index f68c31aa1..cd0da31a6 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionStore.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionStore.swift @@ -94,10 +94,16 @@ class UserSessionStore: UserSessionStoreProtocol { let imageCache = ImageCache.onlyInMemory imageCache.memoryStorage.config.keepWhenEnteringBackground = true + let mediaProvider = MediaProvider(mediaLoader: clientProxy, + imageCache: imageCache, + backgroundTaskService: backgroundTaskService) + + let voiceMessageMediaManager = VoiceMessageMediaManager(mediaProvider: mediaProvider, + backgroundTaskService: backgroundTaskService) + return UserSession(clientProxy: clientProxy, - mediaProvider: MediaProvider(mediaLoader: clientProxy, - imageCache: imageCache, - backgroundTaskService: backgroundTaskService)) + mediaProvider: mediaProvider, + voiceMessageMediaManager: voiceMessageMediaManager) } private func restorePreviousLogin(_ credentials: KeychainCredentials) async -> Result { diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift index dad799583..26c182b4c 100644 --- a/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCache.swift @@ -16,32 +16,33 @@ import Foundation -class VoiceMessageCache { - var temporaryFilesFolderURL: URL { +enum VoiceMessageCacheError: Error { + case invalidFileExtension +} + +class VoiceMessageCache: VoiceMessageCacheProtocol { + private let preferredFileExtension = "m4a" + private var temporaryFilesFolderURL: URL { FileManager.default.temporaryDirectory.appendingPathComponent("media/voice-message") } - - func cacheURL(for mediaSource: MediaSourceProxy, replacingExtension newExtension: String? = nil) -> URL { - var newURL = temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent) - if let newExtension { - newURL = newURL.deletingPathExtension().appendingPathExtension(newExtension) - } - return newURL - } - - func fileURL(for mediaSource: MediaSourceProxy, withExtension fileExtension: String? = nil) -> URL? { - var url = temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent) - if let fileExtension { - url = url.deletingPathExtension().appendingPathExtension(fileExtension) - } + + 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) throws -> URL { + func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool = false) throws -> URL { + guard fileURL.pathExtension == preferredFileExtension else { + throw VoiceMessageCacheError.invalidFileExtension + } setupTemporaryFilesFolder() let url = cacheURL(for: mediaSource) try? FileManager.default.removeItem(at: url) - try FileManager.default.copyItem(at: fileURL, to: url) + if move { + try FileManager.default.moveItem(at: fileURL, to: url) + } else { + try FileManager.default.copyItem(at: fileURL, to: url) + } return url } @@ -64,4 +65,8 @@ class VoiceMessageCache { MXLog.error("Failed to setup audio cache manager.") } } + + 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 new file mode 100644 index 000000000..6776daa42 --- /dev/null +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageCacheProtocol.swift @@ -0,0 +1,26 @@ +// +// 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 + +protocol VoiceMessageCacheProtocol { + func fileURL(for mediaSource: MediaSourceProxy) -> URL? + func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) throws -> URL + func clearCache() +} + +// sourcery: AutoMockable +extension VoiceMessageCacheProtocol { } diff --git a/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift b/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift index e9c80bba0..bdbe0e47c 100644 --- a/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift +++ b/ElementX/Sources/Services/VoiceMessage/VoiceMessageMediaManager.swift @@ -20,31 +20,47 @@ enum VoiceMessageMediaManagerError: Error { case unsupportedMimeTye } +private final class VoiceMessageConversionRequest { + var continuations: [CheckedContinuation] = [] +} + class VoiceMessageMediaManager: VoiceMessageMediaManagerProtocol { private let mediaProvider: MediaProviderProtocol - private let cache: VoiceMessageCache - - private let supportedVoiceMessageMimeType = "audio/ogg" - - /// Preferred audio file extension after conversion - private let preferredAudioExtension = "m4a" - - init(mediaProvider: MediaProviderProtocol) { - self.mediaProvider = mediaProvider - cache = VoiceMessageCache() - } + private let voiceMessageCache: VoiceMessageCacheProtocol + private let audioConverter: AudioConverterProtocol + private let backgroundTaskService: BackgroundTaskServiceProtocol? + private let processingQueue: DispatchQueue + private var conversionRequests = [MediaSourceProxy: VoiceMessageConversionRequest]() + + private let supportedVoiceMessageMimeType = "audio/ogg" + + init(mediaProvider: MediaProviderProtocol, + voiceMessageCache: VoiceMessageCacheProtocol = VoiceMessageCache(), + audioConverter: AudioConverterProtocol = AudioConverter(), + processingQueue: DispatchQueue = .global(), + backgroundTaskService: BackgroundTaskServiceProtocol?) { + self.mediaProvider = mediaProvider + self.voiceMessageCache = voiceMessageCache + self.audioConverter = audioConverter + self.processingQueue = processingQueue + self.backgroundTaskService = backgroundTaskService + } + deinit { - cache.clearCache() + voiceMessageCache.clearCache() } func loadVoiceMessageFromSource(_ source: MediaSourceProxy, body: String?) async throws -> URL { + let loadFileBgTask = await backgroundTaskService?.startBackgroundTask(withName: "LoadFile: \(source.url.hashValue)") + defer { loadFileBgTask?.stop() } + guard let mimeType = source.mimeType, mimeType == supportedVoiceMessageMimeType else { throw VoiceMessageMediaManagerError.unsupportedMimeTye } // Do we already have a converted version? - if let fileURL = cache.fileURL(for: source, withExtension: preferredAudioExtension) { + if let fileURL = voiceMessageCache.fileURL(for: source) { return fileURL } @@ -52,16 +68,50 @@ class VoiceMessageMediaManager: VoiceMessageMediaManagerProtocol { guard case .success(let fileHandle) = await mediaProvider.loadFileFromSource(source, body: body) else { throw MediaProviderError.failedRetrievingFile } - let fileURL = try cache.cache(mediaSource: source, using: fileHandle.url) - - // Convert from ogg - let audioConverter = AudioConverter() - let convertedFileURL = cache.cacheURL(for: source, replacingExtension: preferredAudioExtension) - try audioConverter.convertToMPEG4AAC(sourceURL: fileURL, destinationURL: convertedFileURL) - // we don't need the original file anymore - try? FileManager.default.removeItem(at: fileURL) + return try await enqueueVoiceMessageConversionRequest(forSource: source) { [audioConverter, voiceMessageCache] in + // Do we already have a converted version? + if let fileURL = voiceMessageCache.fileURL(for: source) { + return fileURL + } + + // Convert from ogg + let convertedFileURL = URL.temporaryDirectory.appendingPathComponent(fileHandle.url.deletingPathExtension().lastPathComponent).appendingPathExtension(AudioConverterPreferredFileExtension.mpeg4aac.rawValue) + 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) + } + } + + // MARK: - Private + + private func enqueueVoiceMessageConversionRequest(forSource source: MediaSourceProxy, operation: @escaping () throws -> URL) async throws -> URL { + if let conversionRequests = conversionRequests[source] { + return try await withCheckedThrowingContinuation { continuation in + conversionRequests.continuations.append(continuation) + } + } - return convertedFileURL + let conversionRequest = VoiceMessageConversionRequest() + conversionRequests[source] = conversionRequest + + defer { + conversionRequests[source] = nil + } + + do { + let result = try await Task.dispatch(on: processingQueue) { + try operation() + } + + conversionRequest.continuations.forEach { $0.resume(returning: result) } + + return result + + } catch { + conversionRequest.continuations.forEach { $0.resume(throwing: error) } + throw error + } } } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 67ab0fdf5..54b7d55c5 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -164,7 +164,8 @@ class MockScreen: Identifiable { case .home: let navigationStackCoordinator = NavigationStackCoordinator() let session = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:matrix.org"), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let coordinator = HomeScreenCoordinator(parameters: .init(userSession: session, attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: true)), bugReportService: BugReportServiceMock(), @@ -177,7 +178,7 @@ class MockScreen: Identifiable { let clientProxy = MockClientProxy(userID: "@mock:client.com") let coordinator = SettingsScreenCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator, userIndicatorController: nil, - userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()), + userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()), bugReportService: BugReportServiceMock(), notificationSettings: NotificationSettingsProxyMock(with: .init()), appSettings: ServiceLocator.shared.settings)) @@ -207,7 +208,8 @@ class MockScreen: Identifiable { let userNotificationCenter = UserNotificationCenterMock() userNotificationCenter.authorizationStatusReturnValue = .denied let session = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:matrix.org"), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let parameters = NotificationSettingsScreenCoordinatorParameters(userSession: session, userNotificationCenter: userNotificationCenter, notificationSettings: NotificationSettingsProxyMock(with: .init()), @@ -217,7 +219,8 @@ class MockScreen: Identifiable { let userNotificationCenter = UserNotificationCenterMock() userNotificationCenter.authorizationStatusReturnValue = .denied let session = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:matrix.org"), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let notificationSettings = NotificationSettingsProxyMock(with: .init()) notificationSettings.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in switch (isEncrypted, isOneToOne) { @@ -439,7 +442,7 @@ class MockScreen: Identifiable { ServiceLocator.shared.settings.migratedAccounts[clientProxy.userID] = true ServiceLocator.shared.settings.hasShownWelcomeScreen = true - let coordinator = UserSessionFlowCoordinator(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()), + let coordinator = UserSessionFlowCoordinator(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()), navigationSplitCoordinator: navigationSplitCoordinator, bugReportService: BugReportServiceMock(), roomTimelineControllerFactory: MockRoomTimelineControllerFactory(), @@ -614,7 +617,7 @@ class MockScreen: Identifiable { let userDiscoveryMock = UserDiscoveryServiceMock() userDiscoveryMock.fetchSuggestionsReturnValue = .success([.mockAlice, .mockBob, .mockCharlie]) userDiscoveryMock.searchProfilesWithReturnValue = .success([]) - let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()) + let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let parameters: StartChatScreenCoordinatorParameters = .init(userSession: userSession, navigationStackCoordinator: navigationStackCoordinator, userDiscoveryService: userDiscoveryMock) let coordinator = StartChatScreenCoordinator(parameters: parameters) navigationStackCoordinator.setRootCoordinator(coordinator) @@ -625,7 +628,7 @@ class MockScreen: Identifiable { let userDiscoveryMock = UserDiscoveryServiceMock() userDiscoveryMock.fetchSuggestionsReturnValue = .success([]) userDiscoveryMock.searchProfilesWithReturnValue = .success([.mockBob, .mockBobby]) - let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let coordinator = StartChatScreenCoordinator(parameters: .init(userSession: userSession, navigationStackCoordinator: navigationStackCoordinator, userDiscoveryService: userDiscoveryMock)) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator @@ -662,7 +665,7 @@ class MockScreen: Identifiable { let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites)) clientProxy.inviteSummaryProvider = summaryProvider - let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()))) + let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .invites: @@ -674,13 +677,13 @@ class MockScreen: Identifiable { let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites)) clientProxy.inviteSummaryProvider = summaryProvider - let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()))) + let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .invitesNoInvites: let navigationStackCoordinator = NavigationStackCoordinator() let clientProxy = MockClientProxy(userID: "@mock:client.com") - let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()))) + let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .inviteUsers, .inviteUsersInRoom, .inviteUsersInRoomExistingMembers: @@ -715,7 +718,7 @@ class MockScreen: Identifiable { case .createRoom: let navigationStackCoordinator = NavigationStackCoordinator() let clientProxy = MockClientProxy(userID: "@mock:client.com") - let mockUserSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + let mockUserSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let createRoomParameters = CreateRoomFlowParameters() let selectedUsers: [UserProfileProxy] = [.mockAlice, .mockBob, .mockCharlie] let parameters = CreateRoomCoordinatorParameters(userSession: mockUserSession, userIndicatorController: nil, createRoomParameters: .init(createRoomParameters), selectedUsers: .init(selectedUsers)) @@ -725,7 +728,7 @@ class MockScreen: Identifiable { case .createRoomNoUsers: let navigationStackCoordinator = NavigationStackCoordinator() let clientProxy = MockClientProxy(userID: "@mock:client.com") - let mockUserSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + let mockUserSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let createRoomParameters = CreateRoomFlowParameters() let parameters = CreateRoomCoordinatorParameters(userSession: mockUserSession, userIndicatorController: nil, createRoomParameters: .init(createRoomParameters), selectedUsers: .init([])) let coordinator = CreateRoomCoordinator(parameters: parameters) diff --git a/UnitTests/Sources/CreateRoomViewModelTests.swift b/UnitTests/Sources/CreateRoomViewModelTests.swift index 5316f2018..f0c6de76f 100644 --- a/UnitTests/Sources/CreateRoomViewModelTests.swift +++ b/UnitTests/Sources/CreateRoomViewModelTests.swift @@ -35,7 +35,9 @@ class CreateRoomScreenViewModelTests: XCTestCase { override func setUpWithError() throws { cancellables.removeAll() clientProxy = MockClientProxy(userID: "@a:b.com") - userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + userSession = MockUserSession(clientProxy: clientProxy, + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) let parameters = CreateRoomFlowParameters() usersSubject.send([.mockAlice, .mockBob, .mockCharlie]) let viewModel = CreateRoomViewModel(userSession: userSession, diff --git a/UnitTests/Sources/HomeScreenViewModelTests.swift b/UnitTests/Sources/HomeScreenViewModelTests.swift index 962eec4c9..edd0c36e8 100644 --- a/UnitTests/Sources/HomeScreenViewModelTests.swift +++ b/UnitTests/Sources/HomeScreenViewModelTests.swift @@ -30,7 +30,8 @@ class HomeScreenViewModelTests: XCTestCase { cancellables.removeAll() clientProxy = MockClientProxy(userID: "@mock:client.com") viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: clientProxy, - mediaProvider: MockMediaProvider()), + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()), attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: true)), selectedRoomPublisher: CurrentValueSubject(nil).asCurrentValuePublisher(), appSettings: ServiceLocator.shared.settings, diff --git a/UnitTests/Sources/InvitesScreenViewModelTests.swift b/UnitTests/Sources/InvitesScreenViewModelTests.swift index f47babf6e..8a7a71c8b 100644 --- a/UnitTests/Sources/InvitesScreenViewModelTests.swift +++ b/UnitTests/Sources/InvitesScreenViewModelTests.swift @@ -30,7 +30,9 @@ class InvitesScreenViewModelTests: XCTestCase { override func setUpWithError() throws { clientProxy = MockClientProxy(userID: "@a:b.com") - userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + userSession = MockUserSession(clientProxy: clientProxy, + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) mockNotificationCenter = NotificationCenterMock() } diff --git a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift index b570cb2e3..740a145e7 100644 --- a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift +++ b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift @@ -24,7 +24,9 @@ import XCTest final class NotificationManagerTests: XCTestCase { var notificationManager: NotificationManager! private let clientProxy = MockClientProxy(userID: "@test:user.net") - private lazy var mockUserSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + private lazy var mockUserSession = MockUserSession(clientProxy: clientProxy, + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) private var notificationCenter: UserNotificationCenterMock! private var authorizationStatusWasGranted = false private var shouldDisplayInAppNotificationReturnValue = false diff --git a/UnitTests/Sources/NotificationSettingsEditScreenViewModelTests.swift b/UnitTests/Sources/NotificationSettingsEditScreenViewModelTests.swift index 8ea6e616a..ed875344c 100644 --- a/UnitTests/Sources/NotificationSettingsEditScreenViewModelTests.swift +++ b/UnitTests/Sources/NotificationSettingsEditScreenViewModelTests.swift @@ -31,7 +31,9 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase { @MainActor override func setUpWithError() throws { let clientProxy = MockClientProxy(userID: "@a:b.com") - userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + userSession = MockUserSession(clientProxy: clientProxy, + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) notificationSettingsProxy = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration()) notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .allMessages } diff --git a/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift b/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift index 74e2e7008..8cee59381 100644 --- a/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/NotificationSettingsScreenViewModelTests.swift @@ -40,7 +40,9 @@ class NotificationSettingsScreenViewModelTests: XCTestCase { notificationSettingsProxy.isCallEnabledReturnValue = true let clientProxy = MockClientProxy(userID: "@a:b.com") - userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + userSession = MockUserSession(clientProxy: clientProxy, + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) viewModel = NotificationSettingsScreenViewModel(userSession: userSession, appSettings: appSettings, diff --git a/UnitTests/Sources/RoomFlowCoordinatorTests.swift b/UnitTests/Sources/RoomFlowCoordinatorTests.swift index c15d5e940..1d092179c 100644 --- a/UnitTests/Sources/RoomFlowCoordinatorTests.swift +++ b/UnitTests/Sources/RoomFlowCoordinatorTests.swift @@ -29,7 +29,10 @@ class RoomFlowCoordinatorTests: XCTestCase { cancellables.removeAll() let clientProxy = MockClientProxy(userID: "hi@bob", roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms))) let mediaProvider = MockMediaProvider() - let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: mediaProvider) + let voiceMessageMediaManager = VoiceMessageMediaManagerMock() + let userSession = MockUserSession(clientProxy: clientProxy, + mediaProvider: mediaProvider, + voiceMessageMediaManager: voiceMessageMediaManager) let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator()) navigationStackCoordinator = NavigationStackCoordinator() diff --git a/UnitTests/Sources/SettingsViewModelTests.swift b/UnitTests/Sources/SettingsViewModelTests.swift index 5707f40f0..cd1f9ff26 100644 --- a/UnitTests/Sources/SettingsViewModelTests.swift +++ b/UnitTests/Sources/SettingsViewModelTests.swift @@ -28,7 +28,8 @@ class SettingsScreenViewModelTests: XCTestCase { @MainActor override func setUpWithError() throws { cancellables.removeAll() let userSession = MockUserSession(clientProxy: MockClientProxy(userID: ""), - mediaProvider: MockMediaProvider()) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) viewModel = SettingsScreenViewModel(userSession: userSession, appSettings: ServiceLocator.shared.settings) context = viewModel.context } diff --git a/UnitTests/Sources/StartChatViewModelTests.swift b/UnitTests/Sources/StartChatViewModelTests.swift index f97cb52e0..d2dff12fd 100644 --- a/UnitTests/Sources/StartChatViewModelTests.swift +++ b/UnitTests/Sources/StartChatViewModelTests.swift @@ -33,7 +33,9 @@ class StartChatScreenViewModelTests: XCTestCase { userDiscoveryService = UserDiscoveryServiceMock() userDiscoveryService.fetchSuggestionsReturnValue = .success([]) userDiscoveryService.searchProfilesWithReturnValue = .success([]) - let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + let userSession = MockUserSession(clientProxy: clientProxy, + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) viewModel = StartChatScreenViewModel(userSession: userSession, appSettings: ServiceLocator.shared.settings, analytics: ServiceLocator.shared.analytics, diff --git a/UnitTests/Sources/UserSession/UserSessionTests.swift b/UnitTests/Sources/UserSession/UserSessionTests.swift index 74afe3a33..ad4369538 100644 --- a/UnitTests/Sources/UserSession/UserSessionTests.swift +++ b/UnitTests/Sources/UserSession/UserSessionTests.swift @@ -25,7 +25,9 @@ final class UserSessionTests: XCTestCase { override func setUpWithError() throws { cancellables.removeAll() - userSession = UserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + userSession = UserSession(clientProxy: clientProxy, + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock()) } func test_whenUserSessionReceivesSyncUpdateAndSessionControllerRetrievedAndSessionNotVerified_sessionVerificationNeededEventReceived() throws { diff --git a/UnitTests/Sources/VoiceMessageCacheTests.swift b/UnitTests/Sources/VoiceMessageCacheTests.swift new file mode 100644 index 000000000..7a6ec76ee --- /dev/null +++ b/UnitTests/Sources/VoiceMessageCacheTests.swift @@ -0,0 +1,104 @@ +// +// 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 VoiceMessageCacheTests: XCTestCase { + private var voiceMessageCache: VoiceMessageCache! + private var mediaSource: MediaSourceProxy! + private var fileManager: FileManager! + + private let someURL = URL("/some/url") + private let cachedFileURL = URL("/cache/file/url") + private let audioOGGMimeType = "audio/ogg" + private let testFilename = "test-file" + private let mpeg4aacFileExtension = "m4a" + private let testTemporaryDirectory = URL.temporaryDirectory.appendingPathComponent("test-voice-messsage-cache") + + override func setUp() async throws { + voiceMessageCache = VoiceMessageCache() + voiceMessageCache.clearCache() + + fileManager = FileManager.default + mediaSource = MediaSourceProxy(url: someURL, mimeType: "audio/ogg") + + // Create the temporary directory we will use + try fileManager.createDirectory(at: testTemporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDown() async throws { + voiceMessageCache.clearCache() + + // clear the test temporary directory + try fileManager.removeItem(at: testTemporaryDirectory) + } + + func testFileURL() async throws { + // If the file is not already in the cache, no URL is expected + XCTAssertNil(voiceMessageCache.fileURL(for: mediaSource)) + + // 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) + + XCTAssertEqual(cachedURL, voiceMessageCache.fileURL(for: mediaSource)) + } + + 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) + XCTFail("An error is expected") + } catch { + switch error as? VoiceMessageCacheError { + case .invalidFileExtension: + break + default: + XCTFail("A VoiceMessageCacheError.invalidFileExtension is expected") + } + } + } + + func testCacheCopy() async throws { + let fileURL = try createTemporaryFile(named: testFilename, withExtension: mpeg4aacFileExtension) + let cacheURL = try voiceMessageCache.cache(mediaSource: mediaSource, using: fileURL, move: false) + + // The source file must remain in its original location + XCTAssertTrue(fileManager.fileExists(atPath: fileURL.path())) + // A copy must be present in the cache + XCTAssertTrue(fileManager.fileExists(atPath: cacheURL.path())) + } + + func testCacheMove() async throws { + let fileURL = try createTemporaryFile(named: testFilename, withExtension: mpeg4aacFileExtension) + let cacheURL = try voiceMessageCache.cache(mediaSource: mediaSource, using: fileURL, move: true) + + // The file must have been moved + XCTAssertFalse(fileManager.fileExists(atPath: fileURL.path())) + XCTAssertTrue(fileManager.fileExists(atPath: cacheURL.path())) + } + + private func createTemporaryFile(named filename: String, withExtension fileExtension: String) throws -> URL { + let temporaryFileURL = testTemporaryDirectory.appendingPathComponent(filename).appendingPathExtension(fileExtension) + try Data().write(to: temporaryFileURL) + return temporaryFileURL + } +} diff --git a/UnitTests/Sources/VoiceMessageMediaManagerTests.swift b/UnitTests/Sources/VoiceMessageMediaManagerTests.swift new file mode 100644 index 000000000..502d81db1 --- /dev/null +++ b/UnitTests/Sources/VoiceMessageMediaManagerTests.swift @@ -0,0 +1,150 @@ +// +// 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 VoiceMessageMediaManagerTests: XCTestCase { + private var voiceMessageMediaManager: VoiceMessageMediaManager! + private var voiceMessageCache: VoiceMessageCacheMock! + private var mediaProvider: MockMediaProvider! + + private let someURL = URL("/some/url") + private let audioOGGMimeType = "audio/ogg" + + override func setUp() async throws { + voiceMessageCache = VoiceMessageCacheMock() + mediaProvider = MockMediaProvider() + voiceMessageMediaManager = VoiceMessageMediaManager(mediaProvider: mediaProvider, + voiceMessageCache: voiceMessageCache, + backgroundTaskService: MockBackgroundTaskService()) + } + + func testLoadVoiceMessageFromSourceUnsupportedMedia() async throws { + // Only "audio/ogg" file are supported + let unsupportedMediaSource = MediaSourceProxy(url: someURL, mimeType: "audio/wav") + do { + _ = try await voiceMessageMediaManager.loadVoiceMessageFromSource(unsupportedMediaSource, body: nil) + XCTFail("A `VoiceMessageMediaManagerError.unsupportedMimeTye` error is expected") + } catch { + switch error as? VoiceMessageMediaManagerError { + case .unsupportedMimeTye: + break + default: + XCTFail("A `VoiceMessageMediaManagerError.unsupportedMimeTye` error is expected") + } + } + } + + func testLoadVoiceMessageFromSourceAlreadyCached() async throws { + // Check if the file is already present in cache + voiceMessageCache.fileURLForReturnValue = URL("/converted_file/url") + let mediaSource = MediaSourceProxy(url: someURL, mimeType: audioOGGMimeType) + let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(mediaSource, body: nil) + XCTAssertEqual(url, URL("/converted_file/url")) + // The file must have be search in the cache + XCTAssertTrue(voiceMessageCache.fileURLForCalled) + XCTAssertEqual(voiceMessageCache.fileURLForReceivedMediaSource, mediaSource) + // The file must not have been cached again + XCTAssertFalse(voiceMessageCache.cacheMediaSourceUsingMoveCalled) + } + + func testLoadVoiceMessageFromSourceMediaProviderError() async throws { + // An error must be reported if the file cannot be retrieved + do { + voiceMessageCache.fileURLForReturnValue = nil + let mediaSource = MediaSourceProxy(url: someURL, mimeType: audioOGGMimeType) + _ = try await voiceMessageMediaManager.loadVoiceMessageFromSource(mediaSource, body: nil) + XCTFail("A `MediaProviderError.failedRetrievingFile` error is expected") + } catch { + switch error as? MediaProviderError { + case .failedRetrievingFile: + break + default: + XCTFail("A `MediaProviderError.failedRetrievingFile` error is expected") + } + } + } + + func testLoadVoiceMessageFromSourceSingleCall() async throws { + // URL representing the file loaded by the media provider + let loadedFile = URL("/some/url/loaded_file") + // URL representing the final cached file + let cachedConvertedFileURL = URL("/some/url/cached_converted_file") + + // Check if the file is not already present in cache + voiceMessageCache.fileURLForReturnValue = nil + let mediaSource = MediaSourceProxy(url: someURL, mimeType: audioOGGMimeType) + mediaProvider.loadFileFromSourceReturnValue = MediaFileHandleProxy.unmanaged(url: loadedFile) + let audioConverter = AudioConverterMock() + voiceMessageCache.cacheMediaSourceUsingMoveReturnValue = cachedConvertedFileURL + voiceMessageMediaManager = VoiceMessageMediaManager(mediaProvider: mediaProvider, + voiceMessageCache: voiceMessageCache, + audioConverter: audioConverter, + backgroundTaskService: MockBackgroundTaskService()) + let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(mediaSource, body: nil) + + // The file must have been converted + XCTAssertTrue(audioConverter.convertToMPEG4AACSourceURLDestinationURLCalled) + // The converted file must have been cached + XCTAssert(voiceMessageCache.cacheMediaSourceUsingMoveCalled) + XCTAssertEqual(voiceMessageCache.cacheMediaSourceUsingMoveReceivedArguments?.mediaSource, mediaSource) + XCTAssertEqual(voiceMessageCache.cacheMediaSourceUsingMoveReceivedArguments?.fileURL.pathExtension, "m4a") + XCTAssertTrue(voiceMessageCache.cacheMediaSourceUsingMoveReceivedArguments?.move ?? false) + // The returned URL must point to the cached converted file + XCTAssertEqual(url, cachedConvertedFileURL) + } + + func testLoadVoiceMessageFromSourceMultipleCalls() async throws { + // URL representing the file loaded by the media provider + let loadedFile = URL("/some/url/loaded_file") + // URL representing the final cached file + let cachedConvertedFileURL = URL("/some/url/cached_converted_file") + + // Multiple calls + var cachedURL: URL? + voiceMessageCache.fileURLForClosure = { _ in + cachedURL + } + voiceMessageCache.cacheMediaSourceUsingMoveClosure = { _, _, _ in + cachedURL = cachedConvertedFileURL + return cachedConvertedFileURL + } + + let audioConverter = AudioConverterMock() + mediaProvider.loadFileFromSourceReturnValue = MediaFileHandleProxy.unmanaged(url: loadedFile) + + voiceMessageMediaManager = VoiceMessageMediaManager(mediaProvider: mediaProvider, + voiceMessageCache: voiceMessageCache, + audioConverter: audioConverter, + backgroundTaskService: MockBackgroundTaskService()) + + let mediaSource = MediaSourceProxy(url: someURL, mimeType: audioOGGMimeType) + for _ in 0..<10 { + let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(mediaSource, body: nil) + XCTAssertEqual(url, cachedConvertedFileURL) + } + + // The file must have been converted only once + XCTAssertEqual(audioConverter.convertToMPEG4AACSourceURLDestinationURLCallsCount, 1) + + // The converted file must have been cached only once + XCTAssertEqual(voiceMessageCache.cacheMediaSourceUsingMoveCallsCount, 1) + } +} diff --git a/UnitTests/Sources/WaitlistScreenViewModelTests.swift b/UnitTests/Sources/WaitlistScreenViewModelTests.swift index 22d5a03da..2f348c8b0 100644 --- a/UnitTests/Sources/WaitlistScreenViewModelTests.swift +++ b/UnitTests/Sources/WaitlistScreenViewModelTests.swift @@ -32,7 +32,8 @@ class WaitlistScreenViewModelTests: XCTestCase { XCTAssertTrue(context.viewState.isWaiting, "The view should start off in the waiting state.") viewModel.update(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@alice:matrix.org"), - mediaProvider: MockMediaProvider())) + mediaProvider: MockMediaProvider(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock())) XCTAssertNotNil(context.viewState.userSession, "The user session should have been updated.") XCTAssertFalse(context.viewState.isWaiting, "The view should not be in the waiting state after setting a user session.")