From 5ea4b46604a1a6db461b79b0bf6a61d835c73a5c Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:12:39 +0200 Subject: [PATCH] Content Scanner Service (#5826) * implemented the content scanner service with caching and a way to use in the state store context * fix swiftformat comment enforcing * pr suggestions --- .swiftformat | 1 + ElementX.xcodeproj/project.pbxproj | 28 ++++ .../Mocks/Generated/GeneratedMocks.swift | 135 ++++++++++++++++++ .../ViewModel/StateStoreViewModel.swift | 12 +- .../ViewModel/StateStoreViewModelV2.swift | 12 +- .../Screens/Timeline/TimelineViewModel.swift | 3 +- .../Sources/Services/Client/ClientProxy.swift | 11 ++ .../Services/Client/ClientProxyProtocol.swift | 2 + .../ContentScanner/ContentScannerProxy.swift | 34 +++++ .../ContentScannerProxyProtocol.swift | 21 +++ .../ContentScannerService.swift | 83 +++++++++++ .../ContentScannerServiceProtocol.swift | 27 ++++ .../Services/Session/UserSession.swift | 5 + .../Session/UserSessionProtocol.swift | 3 + .../Sources/ContentScannerServiceTests.swift | 110 ++++++++++++++ 15 files changed, 480 insertions(+), 7 deletions(-) create mode 100644 ElementX/Sources/Services/ContentScanner/ContentScannerProxy.swift create mode 100644 ElementX/Sources/Services/ContentScanner/ContentScannerProxyProtocol.swift create mode 100644 ElementX/Sources/Services/ContentScanner/ContentScannerService.swift create mode 100644 ElementX/Sources/Services/ContentScanner/ContentScannerServiceProtocol.swift create mode 100644 UnitTests/Sources/ContentScannerServiceTests.swift diff --git a/.swiftformat b/.swiftformat index bcd35a4dc..b4fd81628 100644 --- a/.swiftformat +++ b/.swiftformat @@ -7,6 +7,7 @@ --disable preferForLoop --disable consistentSwitchCaseSpacing --disable docCommentsBeforeModifiers # throwing false positives in AuthenticationServiceProtocol on v0.58.0 +--disable docComments # converts regular comments into doc comments even when they're implementation notes --disable emptyExtensions --commas inline diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 05cfd088e..bbe50e857 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -312,6 +312,7 @@ 31CCF3159E5CE1C05AE5781B /* HTMLFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A5FBB74981BF8EFFDAE90D /* HTMLFixtures.swift */; }; 31DE6B7FD15E1C6E43885717 /* RoomRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578AF9CE60816069536C0953 /* RoomRole.swift */; }; 32B7891D937377A59606EDFC /* UserFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21DD8599815136EFF5B73F38 /* UserFlowTests.swift */; }; + 32BFB9B4D2EC46196537B72F /* ContentScannerProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E6EB3C22BACF0A17FEDA4C4 /* ContentScannerProxyProtocol.swift */; }; 32F47002A331817F0E6BD7EB /* RoomMembershipDetailsProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1434D5169F0EE319E226DA7F /* RoomMembershipDetailsProxyProtocol.swift */; }; 33544885F5DB8016736CC610 /* LinkNewDeviceScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1714DD78BFE27ADD40A6F89 /* LinkNewDeviceScreenViewModelTests.swift */; }; 339BC18777912E1989F2F17D /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 584A61D9C459FAFEF038A7C0 /* Section.swift */; }; @@ -972,6 +973,7 @@ 9DE801D278AC34737467F937 /* VoiceMessageMediaManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 889DEDD63C68ABDA8AD29812 /* VoiceMessageMediaManagerProtocol.swift */; }; 9E838A62918E47BC72D6640D /* UserIndicatorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB54B4F94686CCF0289B72F /* UserIndicatorPresenter.swift */; }; 9EBDC79CAC9B63A0D626E333 /* LegalInformationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */; }; + 9ED768A43A2A302F9812C5DF /* ContentScannerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0013D4F8EA288D589CFBF37 /* ContentScannerProxy.swift */; }; 9EE71509E6E7519A2B2388B3 /* KnockRequestsListScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD9C9A31D9AB3B6D8128E69 /* KnockRequestsListScreenModels.swift */; }; 9EF72884B74BC39B01640098 /* BugReportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27DF257F5D968E5DD719583C /* BugReportTests.swift */; }; 9F11B9F347F9E2D236799FB3 /* ElementCallServiceConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 406C90AF8C3E98DF5D4E5430 /* ElementCallServiceConstants.swift */; }; @@ -1197,6 +1199,7 @@ C5A07E2D88BE7D51DCECD166 /* LoginScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D0B159AFFBBD8ECFD0E37FA /* LoginScreenModels.swift */; }; C5E3A4A678B4F8900830B76A /* NavigationTabCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C39B1D3FC8CF41D6C3B054F /* NavigationTabCoordinator.swift */; }; C646CFE86CA46495D6BB7448 /* ManageAuthorizedSpacesScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AE4F02A507882743973214 /* ManageAuthorizedSpacesScreenViewModelProtocol.swift */; }; + C655EFDC8905CBD17A481801 /* ContentScannerServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5AB83F3B94569DD1720EE97 /* ContentScannerServiceTests.swift */; }; C67156445600FAE6430DE41E /* LocationSharingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F56E6E41C6DFE8054787D57 /* LocationSharingScreen.swift */; }; C67FCC854F3A6FC7A2EC04D0 /* MediaUploadPreviewScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70C86696AC9521F8ED88FBEB /* MediaUploadPreviewScreen.swift */; }; C6AC34B731F4F853E9D15146 /* AnalyticsConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA2397174D0DC3918A7A8A7B /* AnalyticsConfiguration.swift */; }; @@ -1373,6 +1376,7 @@ E49F74BD93230BDEFFE5EA51 /* RoomNotificationSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D295F0081084F38DB20893 /* RoomNotificationSettingsScreenViewModelTests.swift */; }; E4B07FF075C99D04D9AF792D /* AppLockSetupPINScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B410B32B72C90BF94E481F33 /* AppLockSetupPINScreenModels.swift */; }; E4D261E237D5D45E6DF2D0F1 /* ManageRoomMemberSheetModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 787E84119E626E2F0E0BFBE8 /* ManageRoomMemberSheetModels.swift */; }; + E52F1E00FBAABA703DA510F9 /* ContentScannerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EDFCC7391761D4DB2CC0D97 /* ContentScannerService.swift */; }; E587A513B3E1EE9B56E9AB9C /* Flow in Frameworks */ = {isa = PBXBuildFile; productRef = 4D7E6BFC89715FC3CF0349D0 /* Flow */; }; E58F1F3276E98A93F7D39219 /* RoomPollsHistoryScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D8479BB704B7EF696F8ABE /* RoomPollsHistoryScreenCoordinator.swift */; }; E591742E509A2A009BF25F9D /* RoomEventStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AE5800184E93CD5E02C6543 /* RoomEventStringBuilderTests.swift */; }; @@ -1400,6 +1404,7 @@ E8BBCFF3B1380F56C73690BC /* ClassicAppManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C78D5FF15343724902EB20 /* ClassicAppManager.swift */; }; E8C4D9F93F0DCED211D5F187 /* HTMLParserStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3877D3CFAC1D33823359BAF /* HTMLParserStyle.swift */; }; E8C65C19F7C40EE545172DD6 /* RoomScreenFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4137900E28201C314C835C11 /* RoomScreenFooterView.swift */; }; + E8F88136C8E69D9E69621E19 /* ContentScannerServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA9E2B1B5038DCABADA7D92 /* ContentScannerServiceProtocol.swift */; }; E9347F56CF0683208F4D9249 /* RoomNotificationSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A9B5225D0881CEFA2CF7C9 /* RoomNotificationSettingsScreenViewModel.swift */; }; E938F7A45D6A3DBBE6789A03 /* NSEUserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C9651CD1066F239C7739240 /* NSEUserSession.swift */; }; E9560744F7B0292E20ECE5F2 /* RoomDetailsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E8A1E8EE094F570573B6E8 /* RoomDetailsScreenViewModelProtocol.swift */; }; @@ -1831,6 +1836,7 @@ 1DE7969EBCAF078813E18EA1 /* RoomRolesAndPermissionsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomRolesAndPermissionsScreenModels.swift; sourceTree = ""; }; 1DF8F7A3AD83D04C08D75E01 /* RoomDetailsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelProtocol.swift; sourceTree = ""; }; 1DFE0E493FB55E5A62E7852A /* ProposedViewSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProposedViewSize.swift; sourceTree = ""; }; + 1EDFCC7391761D4DB2CC0D97 /* ContentScannerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScannerService.swift; sourceTree = ""; }; 1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentBuilder.swift; sourceTree = ""; }; 1F7C6DDBB5D12F6EF6A3D6E1 /* CollapsibleReactionLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleReactionLayout.swift; sourceTree = ""; }; 1F8C01DEEA83903D45069BBD /* CharacterSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterSet.swift; sourceTree = ""; }; @@ -1978,6 +1984,7 @@ 3A12D3D8138F1B71AFA7C858 /* CompletionSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionService.swift; sourceTree = ""; }; 3A21027F05874B1BCC3E452B /* InvitedRoomProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitedRoomProxyMock.swift; sourceTree = ""; }; 3A6B10AEA5983B4A2336387B /* SearchScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenCoordinator.swift; sourceTree = ""; }; + 3AA9E2B1B5038DCABADA7D92 /* ContentScannerServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScannerServiceProtocol.swift; sourceTree = ""; }; 3AB34956C87731AB094DB33A /* URLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTests.swift; sourceTree = ""; }; 3AD253E7EFF88F308D644272 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/SAS.strings"; sourceTree = ""; }; 3B5E97E9615A158C76B2AB77 /* DateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTests.swift; sourceTree = ""; }; @@ -2183,6 +2190,7 @@ 5E43D8784B0054C048060FEB /* LabsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreenModels.swift; sourceTree = ""; }; 5E5A65B5A000E7237AC61C67 /* LeaveSpaceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceViewModel.swift; sourceTree = ""; }; 5E68CA59F66CE43B66D129E9 /* CXProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CXProviderProtocol.swift; sourceTree = ""; }; + 5E6EB3C22BACF0A17FEDA4C4 /* ContentScannerProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScannerProxyProtocol.swift; sourceTree = ""; }; 5E75948AA1FE1D1A7809931F /* AuthenticationServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProtocol.swift; sourceTree = ""; }; 5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = ""; }; 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = ""; }; @@ -2543,6 +2551,7 @@ 9FD40B92FCF20165658296AD /* TimelineMediaPreviewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewModifier.swift; sourceTree = ""; }; 9FD7E851E2BA8C5A8D284B2A /* BannedRoomProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannedRoomProxyMock.swift; sourceTree = ""; }; 9FE8A35B4AD5256F4B562274 /* SettingsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenViewModelTests.swift; sourceTree = ""; }; + A0013D4F8EA288D589CFBF37 /* ContentScannerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScannerProxy.swift; sourceTree = ""; }; A00C7A331B72C0F05C00392F /* RoomScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenViewModelProtocol.swift; sourceTree = ""; }; A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreenViewModel.swift; sourceTree = ""; }; A019A12C866D64CF072024B9 /* AppLockSetupPINScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModel.swift; sourceTree = ""; }; @@ -2771,6 +2780,7 @@ C55CC239AE12339C565F6C9A /* AudioRecorderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderStateTests.swift; sourceTree = ""; }; C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxy.swift; sourceTree = ""; }; C57DB49B8426AA721BF85D83 /* SpaceServiceProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceServiceProxyProtocol.swift; sourceTree = ""; }; + C5AB83F3B94569DD1720EE97 /* ContentScannerServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScannerServiceTests.swift; sourceTree = ""; }; C5AEB5907E24092D741718AF /* ResolveVerifiedUserSendFailureScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveVerifiedUserSendFailureScreenCoordinator.swift; sourceTree = ""; }; C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManagerTests.swift; sourceTree = ""; }; C5F06F2F09B2EDD067DC2174 /* NotificationSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreen.swift; sourceTree = ""; }; @@ -3322,6 +3332,7 @@ 786C421000CA16305B0FC55C /* Capabilities */, 8039515BAA53B7C3275AC64A /* Client */, 8B5E91450E85A9689931B221 /* ComposerDraft */, + 3A9A60666ED97CF8D4858D80 /* ContentScanner */, 92E99C57D7F92ED16F73282C /* ElementCall */, 39557ADF21345E18F3865B9E /* Emojis */, CA555F7C7CA382ACACF0D82B /* Keychain */, @@ -4056,6 +4067,17 @@ path = Helpers; sourceTree = ""; }; + 3A9A60666ED97CF8D4858D80 /* ContentScanner */ = { + isa = PBXGroup; + children = ( + A0013D4F8EA288D589CFBF37 /* ContentScannerProxy.swift */, + 5E6EB3C22BACF0A17FEDA4C4 /* ContentScannerProxyProtocol.swift */, + 1EDFCC7391761D4DB2CC0D97 /* ContentScannerService.swift */, + 3AA9E2B1B5038DCABADA7D92 /* ContentScannerServiceProtocol.swift */, + ); + path = ContentScanner; + sourceTree = ""; + }; 3AD37D7DDF9904587601239D /* AppLockScreen */ = { isa = PBXGroup; children = ( @@ -4971,6 +4993,7 @@ 4AC3F28DECDF8665E8EBC76E /* ClassicAppMediaLoaderTests.swift */, D5EA0312A6262484AA393AC9 /* CompletionSuggestionServiceTests.swift */, CA29952595B804DA221A0C1D /* ComposerToolbarViewModelTests.swift */, + C5AB83F3B94569DD1720EE97 /* ContentScannerServiceTests.swift */, 69D42EE0102D2857933625DD /* CreateRoomViewModelTests.swift */, 3B5E97E9615A158C76B2AB77 /* DateTests.swift */, D77F75B3E9F99864048A422A /* DeactivateAccountScreenViewModelTests.swift */, @@ -7946,6 +7969,7 @@ B5321A1F5B26A0F3EC54909E /* CollapsibleFlowLayoutTests.swift in Sources */, 3A164187907DA43B7858F9EC /* CompletionSuggestionServiceTests.swift in Sources */, 0C932A5158C1D0604DFC5750 /* ComposerToolbarViewModelTests.swift in Sources */, + C655EFDC8905CBD17A481801 /* ContentScannerServiceTests.swift in Sources */, D3FD96913D2B1AAA3149DAC7 /* CreateRoomViewModelTests.swift in Sources */, CD0088B763CD970CF1CBF8CB /* DateTests.swift in Sources */, 80F6C8EFCA4564B67F0D34B0 /* DeactivateAccountScreenViewModelTests.swift in Sources */, @@ -8341,6 +8365,10 @@ A6B83EB78F025D21B6EBA90C /* CompoundIcon.swift in Sources */, EA6613B29BA671F39CE1B1D2 /* ConfirmationDialog.swift in Sources */, AC7AA215D60FBC307F984028 /* Consumable.swift in Sources */, + 9ED768A43A2A302F9812C5DF /* ContentScannerProxy.swift in Sources */, + 32BFB9B4D2EC46196537B72F /* ContentScannerProxyProtocol.swift in Sources */, + E52F1E00FBAABA703DA510F9 /* ContentScannerService.swift in Sources */, + E8F88136C8E69D9E69621E19 /* ContentScannerServiceProtocol.swift in Sources */, 73DBE886625AF56FF08D7F76 /* CoordinateAnimator.swift in Sources */, C3522917C0C367C403429EEC /* CoordinatorProtocol.swift in Sources */, 633501761094E09DFBEBFFAD /* CopyTextButton.swift in Sources */, diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 7cbd1f2b5..6adea9cec 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -2317,6 +2317,7 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { set(value) { underlyingMediaLoader = value } } nonisolated(unsafe) var underlyingMediaLoader: MediaLoaderProtocol! + nonisolated(unsafe) var contentScanner: ContentScannerProxyProtocol? var roomSummaryProvider: RoomSummaryProviderProtocol { get { return underlyingRoomSummaryProvider } set(value) { underlyingRoomSummaryProvider = value } @@ -4537,6 +4538,139 @@ nonisolated class ComposerDraftServiceMock: ComposerDraftServiceProtocol, @unche } } } +nonisolated class ContentScannerProxyMock: ContentScannerProxyProtocol, @unchecked Sendable { + + //MARK: - scan + + private let scanMediaSourceCallsCountLock = NSLock() + private nonisolated(unsafe) var scanMediaSourceUnderlyingCallsCount = 0 + var scanMediaSourceCallsCount: Int { + get { scanMediaSourceCallsCountLock.withLock { scanMediaSourceUnderlyingCallsCount } } + set { scanMediaSourceCallsCountLock.withLock { scanMediaSourceUnderlyingCallsCount = newValue } } + } + var scanMediaSourceCalled: Bool { + return scanMediaSourceCallsCount > 0 + } + private let scanMediaSourceReceivedMediaSourceLock = NSLock() + private nonisolated(unsafe) var scanMediaSourceUnderlyingReceivedMediaSource: MediaSourceProxy? + var scanMediaSourceReceivedMediaSource: MediaSourceProxy? { + get { scanMediaSourceReceivedMediaSourceLock.withLock { scanMediaSourceUnderlyingReceivedMediaSource } } + set { scanMediaSourceReceivedMediaSourceLock.withLock { scanMediaSourceUnderlyingReceivedMediaSource = newValue } } + } + private let scanMediaSourceReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var scanMediaSourceUnderlyingReceivedInvocations: [MediaSourceProxy] = [] + var scanMediaSourceReceivedInvocations: [MediaSourceProxy] { + get { scanMediaSourceReceivedInvocationsLock.withLock { scanMediaSourceUnderlyingReceivedInvocations } } + set { scanMediaSourceReceivedInvocationsLock.withLock { scanMediaSourceUnderlyingReceivedInvocations = newValue } } + } + + private let scanMediaSourceReturnValueLock = NSLock() + private nonisolated(unsafe) var scanMediaSourceUnderlyingReturnValue: Result! + var scanMediaSourceReturnValue: Result! { + get { scanMediaSourceReturnValueLock.withLock { scanMediaSourceUnderlyingReturnValue } } + set { scanMediaSourceReturnValueLock.withLock { scanMediaSourceUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var scanMediaSourceClosure: ((MediaSourceProxy) async -> Result)? + + @concurrent func scan(mediaSource: MediaSourceProxy) async -> Result { + scanMediaSourceCallsCountLock.withLock { scanMediaSourceUnderlyingCallsCount += 1 } + scanMediaSourceReceivedMediaSource = mediaSource + scanMediaSourceReceivedInvocationsLock.withLock { scanMediaSourceUnderlyingReceivedInvocations.append(mediaSource) } + if let scanMediaSourceClosure = scanMediaSourceClosure { + return await scanMediaSourceClosure(mediaSource) + } else { + return scanMediaSourceReturnValue + } + } +} +nonisolated class ContentScannerServiceMock: ContentScannerServiceProtocol, @unchecked Sendable { + + //MARK: - scanResultFromSource + + private let scanResultFromSourceCallsCountLock = NSLock() + private nonisolated(unsafe) var scanResultFromSourceUnderlyingCallsCount = 0 + var scanResultFromSourceCallsCount: Int { + get { scanResultFromSourceCallsCountLock.withLock { scanResultFromSourceUnderlyingCallsCount } } + set { scanResultFromSourceCallsCountLock.withLock { scanResultFromSourceUnderlyingCallsCount = newValue } } + } + var scanResultFromSourceCalled: Bool { + return scanResultFromSourceCallsCount > 0 + } + private let scanResultFromSourceReceivedSourceLock = NSLock() + private nonisolated(unsafe) var scanResultFromSourceUnderlyingReceivedSource: MediaSourceProxy? + var scanResultFromSourceReceivedSource: MediaSourceProxy? { + get { scanResultFromSourceReceivedSourceLock.withLock { scanResultFromSourceUnderlyingReceivedSource } } + set { scanResultFromSourceReceivedSourceLock.withLock { scanResultFromSourceUnderlyingReceivedSource = newValue } } + } + private let scanResultFromSourceReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var scanResultFromSourceUnderlyingReceivedInvocations: [MediaSourceProxy] = [] + var scanResultFromSourceReceivedInvocations: [MediaSourceProxy] { + get { scanResultFromSourceReceivedInvocationsLock.withLock { scanResultFromSourceUnderlyingReceivedInvocations } } + set { scanResultFromSourceReceivedInvocationsLock.withLock { scanResultFromSourceUnderlyingReceivedInvocations = newValue } } + } + + private let scanResultFromSourceReturnValueLock = NSLock() + private nonisolated(unsafe) var scanResultFromSourceUnderlyingReturnValue: Bool? + var scanResultFromSourceReturnValue: Bool? { + get { scanResultFromSourceReturnValueLock.withLock { scanResultFromSourceUnderlyingReturnValue } } + set { scanResultFromSourceReturnValueLock.withLock { scanResultFromSourceUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var scanResultFromSourceClosure: ((MediaSourceProxy) -> Bool?)? + + func scanResultFromSource(_ source: MediaSourceProxy) -> Bool? { + scanResultFromSourceCallsCountLock.withLock { scanResultFromSourceUnderlyingCallsCount += 1 } + scanResultFromSourceReceivedSource = source + scanResultFromSourceReceivedInvocationsLock.withLock { scanResultFromSourceUnderlyingReceivedInvocations.append(source) } + if let scanResultFromSourceClosure = scanResultFromSourceClosure { + return scanResultFromSourceClosure(source) + } else { + return scanResultFromSourceReturnValue + } + } + //MARK: - loadScanResultFromSource + + private let loadScanResultFromSourceCallsCountLock = NSLock() + private nonisolated(unsafe) var loadScanResultFromSourceUnderlyingCallsCount = 0 + var loadScanResultFromSourceCallsCount: Int { + get { loadScanResultFromSourceCallsCountLock.withLock { loadScanResultFromSourceUnderlyingCallsCount } } + set { loadScanResultFromSourceCallsCountLock.withLock { loadScanResultFromSourceUnderlyingCallsCount = newValue } } + } + var loadScanResultFromSourceCalled: Bool { + return loadScanResultFromSourceCallsCount > 0 + } + private let loadScanResultFromSourceReceivedSourceLock = NSLock() + private nonisolated(unsafe) var loadScanResultFromSourceUnderlyingReceivedSource: MediaSourceProxy? + var loadScanResultFromSourceReceivedSource: MediaSourceProxy? { + get { loadScanResultFromSourceReceivedSourceLock.withLock { loadScanResultFromSourceUnderlyingReceivedSource } } + set { loadScanResultFromSourceReceivedSourceLock.withLock { loadScanResultFromSourceUnderlyingReceivedSource = newValue } } + } + private let loadScanResultFromSourceReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var loadScanResultFromSourceUnderlyingReceivedInvocations: [MediaSourceProxy] = [] + var loadScanResultFromSourceReceivedInvocations: [MediaSourceProxy] { + get { loadScanResultFromSourceReceivedInvocationsLock.withLock { loadScanResultFromSourceUnderlyingReceivedInvocations } } + set { loadScanResultFromSourceReceivedInvocationsLock.withLock { loadScanResultFromSourceUnderlyingReceivedInvocations = newValue } } + } + + private let loadScanResultFromSourceReturnValueLock = NSLock() + private nonisolated(unsafe) var loadScanResultFromSourceUnderlyingReturnValue: Result! + var loadScanResultFromSourceReturnValue: Result! { + get { loadScanResultFromSourceReturnValueLock.withLock { loadScanResultFromSourceUnderlyingReturnValue } } + set { loadScanResultFromSourceReturnValueLock.withLock { loadScanResultFromSourceUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var loadScanResultFromSourceClosure: ((MediaSourceProxy) async -> Result)? + + @discardableResult + @concurrent func loadScanResultFromSource(_ source: MediaSourceProxy) async -> Result { + loadScanResultFromSourceCallsCountLock.withLock { loadScanResultFromSourceUnderlyingCallsCount += 1 } + loadScanResultFromSourceReceivedSource = source + loadScanResultFromSourceReceivedInvocationsLock.withLock { loadScanResultFromSourceUnderlyingReceivedInvocations.append(source) } + if let loadScanResultFromSourceClosure = loadScanResultFromSourceClosure { + return await loadScanResultFromSourceClosure(source) + } else { + return loadScanResultFromSourceReturnValue + } + } +} nonisolated class ElementCallServiceMock: ElementCallServiceProtocol, @unchecked Sendable { var actions: AnyPublisher { get { return underlyingActions } @@ -14446,6 +14580,7 @@ nonisolated class UserSessionMock: UserSessionProtocol, @unchecked Sendable { set(value) { underlyingLiveLocationManager = value } } nonisolated(unsafe) var underlyingLiveLocationManager: LiveLocationManagerProtocol! + nonisolated(unsafe) var contentScannerService: ContentScannerServiceProtocol? var sessionSecurityStatePublisher: CurrentValuePublisher { get { return underlyingSessionSecurityStatePublisher } set(value) { underlyingSessionSecurityStatePublisher = value } diff --git a/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift b/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift index d75471f68..9038d082d 100644 --- a/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift +++ b/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModel.swift @@ -30,8 +30,8 @@ class StateStoreViewModel { set { context.viewState = newValue } } - init(initialViewState: State, mediaProvider: MediaProviderProtocol? = nil) { - context = Context(initialViewState: initialViewState, mediaProvider: mediaProvider) + init(initialViewState: State, mediaProvider: MediaProviderProtocol? = nil, contentScannerService: ContentScannerServiceProtocol? = nil) { + context = Context(initialViewState: initialViewState, mediaProvider: mediaProvider, contentScannerService: contentScannerService) context.viewModel = self } @@ -66,6 +66,11 @@ class StateStoreViewModel { /// Intentionally non-generic so that it doesn't grow uncontrollably let mediaProvider: MediaProviderProtocol? + /// An optional content scanning service so that views can validate media themselves. + /// `nil` when no content scanner is configured for the server, or when the screen + /// intentionally doesn't provide one because it has no media to scan. + let contentScannerService: ContentScannerServiceProtocol? + /// Set-able/Bindable access to the bindable state. subscript(dynamicMember keyPath: WritableKeyPath) -> T { get { viewState.bindings[keyPath: keyPath] } @@ -78,9 +83,10 @@ class StateStoreViewModel { viewModel?.process(viewAction: viewAction) } - fileprivate init(initialViewState: State, mediaProvider: MediaProviderProtocol?) { + fileprivate init(initialViewState: State, mediaProvider: MediaProviderProtocol?, contentScannerService: ContentScannerServiceProtocol?) { self.viewState = initialViewState self.mediaProvider = mediaProvider + self.contentScannerService = contentScannerService } } } diff --git a/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModelV2.swift b/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModelV2.swift index 2d2adba5a..8e82092ea 100644 --- a/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModelV2.swift +++ b/ElementX/Sources/Other/SwiftUI/ViewModel/StateStoreViewModelV2.swift @@ -32,8 +32,8 @@ class StateStoreViewModelV2 { set { context.viewState = newValue } } - init(initialViewState: State, mediaProvider: MediaProviderProtocol? = nil) { - context = Context(initialViewState: initialViewState, mediaProvider: mediaProvider) + init(initialViewState: State, mediaProvider: MediaProviderProtocol? = nil, contentScannerService: ContentScannerServiceProtocol? = nil) { + context = Context(initialViewState: initialViewState, mediaProvider: mediaProvider, contentScannerService: contentScannerService) context.viewModel = self } @@ -68,6 +68,11 @@ class StateStoreViewModelV2 { /// Intentionally non-generic so that it doesn't grow uncontrollably let mediaProvider: MediaProviderProtocol? + /// An optional content scanning service so that views can validate media themselves. + /// `nil` when no content scanner is configured for the server, or when the screen + /// intentionally doesn't provide one because it has no media to scan. + let contentScannerService: ContentScannerServiceProtocol? + /// Set-able access to the bindable state. subscript(dynamicMember keyPath: WritableKeyPath) -> T { get { viewState.bindings[keyPath: keyPath] } @@ -80,9 +85,10 @@ class StateStoreViewModelV2 { viewModel?.process(viewAction: viewAction) } - fileprivate init(initialViewState: State, mediaProvider: MediaProviderProtocol?) { + fileprivate init(initialViewState: State, mediaProvider: MediaProviderProtocol?, contentScannerService: ContentScannerServiceProtocol?) { self.viewState = initialViewState self.mediaProvider = mediaProvider + self.contentScannerService = contentScannerService } } } diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 2a3c0ca17..8961bff9b 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -109,7 +109,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { linkMetadataProvider: hideTimelineMedia ? nil : linkMetadataProvider, mapTilerSettings: appSettings.mapTilerSettings.publisher.value, bindings: .init(reactionsCollapsed: [:])), - mediaProvider: userSession.mediaProvider) + mediaProvider: userSession.mediaProvider, + contentScannerService: userSession.contentScannerService) if focussedEventID != nil { // The timeline controller will start loading a detached timeline. diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index d7dc2d2cb..74d24e467 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -20,6 +20,7 @@ class ClientProxy: ClientProxyProtocol { private let analyticsService: AnalyticsServiceProtocol let mediaLoader: MediaLoaderProtocol + let contentScanner: ContentScannerProxyProtocol? private let clientQueue: DispatchQueue private var roomListService: RoomListService @@ -219,6 +220,16 @@ class ClientProxy: ClientProxyProtocol { mediaLoader = MediaLoader(client: client) + // Route media downloads through a content scanner when one has been configured for the server, + // and expose a proxy for the active scanning of content in the timeline. + if let contentScannerURL = appSettings.contentScannerURL.publisher.value, let client = client as? Client { + let scanner = ContentScanner(scannerUrl: contentScannerURL.absoluteString) + await client.setContentScanner(contentScanner: scanner) + contentScanner = ContentScannerProxy(contentScanner: scanner, client: client) + } else { + contentScanner = nil + } + notificationSettings = await NotificationSettingsProxy(notificationSettings: client.getNotificationSettings()) secureBackupController = SecureBackupController(encryption: client.encryption()) diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index f48a5ad4f..85703b811 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -133,6 +133,8 @@ protocol ClientProxyProtocol: AnyObject { var mediaLoader: MediaLoaderProtocol { get } + var contentScanner: ContentScannerProxyProtocol? { get } + var roomSummaryProvider: RoomSummaryProviderProtocol { get } /// Used for listing rooms that shouldn't be affected by the main `roomSummaryProvider` filtering diff --git a/ElementX/Sources/Services/ContentScanner/ContentScannerProxy.swift b/ElementX/Sources/Services/ContentScanner/ContentScannerProxy.swift new file mode 100644 index 000000000..315262799 --- /dev/null +++ b/ElementX/Sources/Services/ContentScanner/ContentScannerProxy.swift @@ -0,0 +1,34 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +@preconcurrency import MatrixRustSDK + +nonisolated struct ContentScannerProxy: ContentScannerProxyProtocol { + private let contentScanner: ContentScanner + /// The client is held weakly for the same reason as in `MediaLoader` - to avoid keeping the + /// underlying `MatrixRustSDK.Client` alive longer than the owning `ClientProxy`. + private weak var client: Client? + + init(contentScanner: ContentScanner, client: Client) { + self.contentScanner = contentScanner + self.client = client + } + + func scan(mediaSource: MediaSourceProxy) async -> Result { + guard let client else { return .failure(.missingClient) } + + do { + let response = try await contentScanner.scan(client: client, mediaSource: mediaSource.underlyingSource) + return .success(response.clean) + } catch { + MXLog.error("Failed scanning media content: \(error)") + return .failure(.sdkError(error)) + } + } +} diff --git a/ElementX/Sources/Services/ContentScanner/ContentScannerProxyProtocol.swift b/ElementX/Sources/Services/ContentScanner/ContentScannerProxyProtocol.swift new file mode 100644 index 000000000..fd628ac07 --- /dev/null +++ b/ElementX/Sources/Services/ContentScanner/ContentScannerProxyProtocol.swift @@ -0,0 +1,21 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +enum ContentScannerProxyError: Error { + case missingClient + case sdkError(Error) +} + +// sourcery: AutoMockable +/// A thin wrapper around the SDK's content scanner. Given a media source it asks the scanner server +/// whether the content is clean (safe). It holds no state and no product logic - see +/// ``ContentScannerServiceProtocol`` for caching, per-event tracking and validation state. +protocol ContentScannerProxyProtocol: Sendable { + func scan(mediaSource: MediaSourceProxy) async -> Result +} diff --git a/ElementX/Sources/Services/ContentScanner/ContentScannerService.swift b/ElementX/Sources/Services/ContentScanner/ContentScannerService.swift new file mode 100644 index 000000000..56f03db85 --- /dev/null +++ b/ElementX/Sources/Services/ContentScanner/ContentScannerService.swift @@ -0,0 +1,83 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Foundation +import Synchronization + +final nonisolated class ContentScannerService: ContentScannerServiceProtocol { + private let contentScannerProxy: ContentScannerProxyProtocol + + private struct State { + /// Scan verdicts keyed by the media source's URL. Only definitive verdicts are cached, + /// failed scans are left out so that they can be retried. + var scanResults = [String: Bool]() + /// In-flight scans keyed by the media source's URL, so that concurrent requests + /// for the same source await a single scan. + var ongoingScans = [String: Task, Never>]() + } + + private let state = Mutex(State()) + + init(contentScannerProxy: ContentScannerProxyProtocol) { + self.contentScannerProxy = contentScannerProxy + } + + func scanResultFromSource(_ source: MediaSourceProxy) -> Bool? { + state.withLock { $0.scanResults[source.url.absoluteString] } + } + + @discardableResult + func loadScanResultFromSource(_ source: MediaSourceProxy) async -> Result { + enum Scan { + case cached(Bool) + case ongoing(Task, Never>) + } + + let sourceURL = source.url.absoluteString + + // Atomically return the cached verdict, join an ongoing scan, or register a new one. + let scan = state.withLock { state -> Scan in + if let cachedResult = state.scanResults[sourceURL] { + return .cached(cachedResult) + } + + if let ongoingScan = state.ongoingScans[sourceURL] { + return .ongoing(ongoingScan) + } + + let ongoingScan = Task { [weak self, contentScannerProxy] () -> Result in + let result: Result + switch await contentScannerProxy.scan(mediaSource: source) { + case .success(let isSafe): + result = .success(isSafe) + case .failure(let error): + MXLog.error("Failed scanning media source: \(error)") + result = .failure(.failedScanning) + } + + self?.state.withLock { state in + state.ongoingScans[sourceURL] = nil + if case .success(let isSafe) = result { + state.scanResults[sourceURL] = isSafe + } + } + + return result + } + + state.ongoingScans[sourceURL] = ongoingScan + return .ongoing(ongoingScan) + } + + switch scan { + case .cached(let isSafe): + return .success(isSafe) + case .ongoing(let ongoingScan): + return await ongoingScan.value + } + } +} diff --git a/ElementX/Sources/Services/ContentScanner/ContentScannerServiceProtocol.swift b/ElementX/Sources/Services/ContentScanner/ContentScannerServiceProtocol.swift new file mode 100644 index 000000000..014ee9a51 --- /dev/null +++ b/ElementX/Sources/Services/ContentScanner/ContentScannerServiceProtocol.swift @@ -0,0 +1,27 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Foundation + +enum ContentScannerServiceError: Error { + case failedScanning +} + +// sourcery: AutoMockable +/// Scans media sources through the content scanner, caching the verdicts by URL so that each source +/// is only ever scanned once. Mirrors ``MediaProviderProtocol``'s shape: a synchronous cache lookup +/// for views that already have a verdict and an async method to perform the scan. +nonisolated protocol ContentScannerServiceProtocol: Sendable { + /// The cached scan verdict for the given source: `true` when the content is safe, `false` when + /// it is unsafe, or `nil` when the source hasn't been scanned yet. + func scanResultFromSource(_ source: MediaSourceProxy) -> Bool? + + /// Starts a scan for the given source unless there's already a cached verdict, in which case + /// that is returned. Failed scans aren't cached so that they can be retried. + @discardableResult + func loadScanResultFromSource(_ source: MediaSourceProxy) async -> Result +} diff --git a/ElementX/Sources/Services/Session/UserSession.swift b/ElementX/Sources/Services/Session/UserSession.swift index 158fc6336..c03604811 100644 --- a/ElementX/Sources/Services/Session/UserSession.swift +++ b/ElementX/Sources/Services/Session/UserSession.swift @@ -16,9 +16,13 @@ class UserSession: UserSessionProtocol { let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol + let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol let liveLocationManager: LiveLocationManagerProtocol + /// Scans media content, `nil` when no content scanner is configured for the server. + let contentScannerService: ContentScannerServiceProtocol? + let callbacks = PassthroughSubject() let sessionSecurityStateSubject = CurrentValueSubject(.init(verificationState: .unknown, recoveryState: .unknown)) @@ -31,6 +35,7 @@ class UserSession: UserSessionProtocol { self.mediaProvider = mediaProvider self.voiceMessageMediaManager = voiceMessageMediaManager self.liveLocationManager = liveLocationManager + contentScannerService = clientProxy.contentScanner.map(ContentScannerService.init) authErrorCancellable = clientProxy.actionsPublisher .receive(on: DispatchQueue.main) diff --git a/ElementX/Sources/Services/Session/UserSessionProtocol.swift b/ElementX/Sources/Services/Session/UserSessionProtocol.swift index a04cdbeb9..243163a13 100644 --- a/ElementX/Sources/Services/Session/UserSessionProtocol.swift +++ b/ElementX/Sources/Services/Session/UserSessionProtocol.swift @@ -25,6 +25,9 @@ protocol UserSessionProtocol: Sendable { var voiceMessageMediaManager: VoiceMessageMediaManagerProtocol { get } var liveLocationManager: LiveLocationManagerProtocol { get } + /// Scans media content, `nil` when no content scanner is configured for the server. + var contentScannerService: ContentScannerServiceProtocol? { get } + var sessionSecurityStatePublisher: CurrentValuePublisher { get } var callbacks: PassthroughSubject { get } diff --git a/UnitTests/Sources/ContentScannerServiceTests.swift b/UnitTests/Sources/ContentScannerServiceTests.swift new file mode 100644 index 000000000..d1147205b --- /dev/null +++ b/UnitTests/Sources/ContentScannerServiceTests.swift @@ -0,0 +1,110 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +@testable import ElementX +import Foundation +import Testing + +struct ContentScannerServiceTests { + @Test + func safeContentIsCached() async throws { + let proxy = ContentScannerProxyMock() + proxy.scanMediaSourceReturnValue = .success(true) + let service = ContentScannerService(contentScannerProxy: proxy) + let source = makeSource("1") + + #expect(service.scanResultFromSource(source) == nil, "A source should not have a verdict before being scanned.") + + let result = await service.loadScanResultFromSource(source) + #expect(try result.get() == true) // swiftlint:disable:this force_try + + #expect(service.scanResultFromSource(source) == true) + + // A second load returns the cached verdict without scanning again. + await service.loadScanResultFromSource(source) + #expect(proxy.scanMediaSourceCallsCount == 1) + } + + @Test + func unsafeContentIsCached() async throws { + let proxy = ContentScannerProxyMock() + proxy.scanMediaSourceReturnValue = .success(false) + let service = ContentScannerService(contentScannerProxy: proxy) + let source = makeSource("1") + + let result = await service.loadScanResultFromSource(source) + #expect(try result.get() == false) // swiftlint:disable:this force_try + + #expect(service.scanResultFromSource(source) == false) + + await service.loadScanResultFromSource(source) + #expect(proxy.scanMediaSourceCallsCount == 1) + } + + @Test + func sourcesAreCachedIndependently() async { + let proxy = ContentScannerProxyMock() + let service = ContentScannerService(contentScannerProxy: proxy) + + proxy.scanMediaSourceReturnValue = .success(true) + await service.loadScanResultFromSource(makeSource("safe")) + proxy.scanMediaSourceReturnValue = .success(false) + await service.loadScanResultFromSource(makeSource("unsafe")) + + #expect(service.scanResultFromSource(makeSource("safe")) == true) + #expect(service.scanResultFromSource(makeSource("unsafe")) == false) + #expect(service.scanResultFromSource(makeSource("unscanned")) == nil) + } + + @Test + func failedScanIsNotCachedAndCanBeRetried() async throws { + let proxy = ContentScannerProxyMock() + proxy.scanMediaSourceReturnValue = .failure(.missingClient) + let service = ContentScannerService(contentScannerProxy: proxy) + let source = makeSource("1") + + var result = await service.loadScanResultFromSource(source) + guard case .failure(.failedScanning) = result else { + Issue.record("The scan should fail when the proxy fails.") + return + } + + #expect(service.scanResultFromSource(source) == nil, "A failed scan should not be cached.") + + // The scan is retried and now succeeds. + proxy.scanMediaSourceReturnValue = .success(true) + result = await service.loadScanResultFromSource(source) + + #expect(try result.get() == true) // swiftlint:disable:this force_try + #expect(proxy.scanMediaSourceCallsCount == 2) + } + + @Test + func concurrentLoadsShareASingleScan() async { + let proxy = ContentScannerProxyMock() + proxy.scanMediaSourceClosure = { _ in + try? await Task.sleep(for: .milliseconds(50)) + return .success(true) + } + let service = ContentScannerService(contentScannerProxy: proxy) + let source = makeSource("1") + + async let first = service.loadScanResultFromSource(source) + async let second = service.loadScanResultFromSource(source) + let results = await [first, second] + + #expect(results.allSatisfy { (try? $0.get()) == true }) + #expect(proxy.scanMediaSourceCallsCount == 1, "Concurrent loads for the same source should share a single scan.") + } + + // MARK: - Helpers + + private func makeSource(_ id: String) -> MediaSourceProxy { + // swiftlint:disable:next force_unwrapping force_try + try! MediaSourceProxy(url: URL(string: "mxc://example.com/\(id)")!, mimeType: nil) + } +}