mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
1DF8F7A3AD83D04C08D75E01 /* RoomDetailsEditScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
1DFE0E493FB55E5A62E7852A /* ProposedViewSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProposedViewSize.swift; sourceTree = "<group>"; };
|
||||
1EDFCC7391761D4DB2CC0D97 /* ContentScannerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScannerService.swift; sourceTree = "<group>"; };
|
||||
1F2529D434C750ED78ADF1ED /* UserAgentBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAgentBuilder.swift; sourceTree = "<group>"; };
|
||||
1F7C6DDBB5D12F6EF6A3D6E1 /* CollapsibleReactionLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleReactionLayout.swift; sourceTree = "<group>"; };
|
||||
1F8C01DEEA83903D45069BBD /* CharacterSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterSet.swift; sourceTree = "<group>"; };
|
||||
@@ -1978,6 +1984,7 @@
|
||||
3A12D3D8138F1B71AFA7C858 /* CompletionSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSuggestionService.swift; sourceTree = "<group>"; };
|
||||
3A21027F05874B1BCC3E452B /* InvitedRoomProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitedRoomProxyMock.swift; sourceTree = "<group>"; };
|
||||
3A6B10AEA5983B4A2336387B /* SearchScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
3AA9E2B1B5038DCABADA7D92 /* ContentScannerServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScannerServiceProtocol.swift; sourceTree = "<group>"; };
|
||||
3AB34956C87731AB094DB33A /* URLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTests.swift; sourceTree = "<group>"; };
|
||||
3AD253E7EFF88F308D644272 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/SAS.strings"; sourceTree = "<group>"; };
|
||||
3B5E97E9615A158C76B2AB77 /* DateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateTests.swift; sourceTree = "<group>"; };
|
||||
@@ -2183,6 +2190,7 @@
|
||||
5E43D8784B0054C048060FEB /* LabsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreenModels.swift; sourceTree = "<group>"; };
|
||||
5E5A65B5A000E7237AC61C67 /* LeaveSpaceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceViewModel.swift; sourceTree = "<group>"; };
|
||||
5E68CA59F66CE43B66D129E9 /* CXProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CXProviderProtocol.swift; sourceTree = "<group>"; };
|
||||
5E6EB3C22BACF0A17FEDA4C4 /* ContentScannerProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScannerProxyProtocol.swift; sourceTree = "<group>"; };
|
||||
5E75948AA1FE1D1A7809931F /* AuthenticationServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceProtocol.swift; sourceTree = "<group>"; };
|
||||
5E9CBF577B9711CFBB4FA40D /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = "<group>"; };
|
||||
5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
@@ -2543,6 +2551,7 @@
|
||||
9FD40B92FCF20165658296AD /* TimelineMediaPreviewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewModifier.swift; sourceTree = "<group>"; };
|
||||
9FD7E851E2BA8C5A8D284B2A /* BannedRoomProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannedRoomProxyMock.swift; sourceTree = "<group>"; };
|
||||
9FE8A35B4AD5256F4B562274 /* SettingsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||
A0013D4F8EA288D589CFBF37 /* ContentScannerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScannerProxy.swift; sourceTree = "<group>"; };
|
||||
A00C7A331B72C0F05C00392F /* RoomScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
A010B8EAD1A9F6B4686DF2F4 /* BlockedUsersScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
A019A12C866D64CF072024B9 /* AppLockSetupPINScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupPINScreenViewModel.swift; sourceTree = "<group>"; };
|
||||
@@ -2771,6 +2780,7 @@
|
||||
C55CC239AE12339C565F6C9A /* AudioRecorderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderStateTests.swift; sourceTree = "<group>"; };
|
||||
C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationControllerProxy.swift; sourceTree = "<group>"; };
|
||||
C57DB49B8426AA721BF85D83 /* SpaceServiceProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceServiceProxyProtocol.swift; sourceTree = "<group>"; };
|
||||
C5AB83F3B94569DD1720EE97 /* ContentScannerServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScannerServiceTests.swift; sourceTree = "<group>"; };
|
||||
C5AEB5907E24092D741718AF /* ResolveVerifiedUserSendFailureScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolveVerifiedUserSendFailureScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
C5CB64E711E86270B722FAF7 /* LiveLocationManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLocationManagerTests.swift; sourceTree = "<group>"; };
|
||||
C5F06F2F09B2EDD067DC2174 /* NotificationSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsScreen.swift; sourceTree = "<group>"; };
|
||||
@@ -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 = "<group>";
|
||||
};
|
||||
3A9A60666ED97CF8D4858D80 /* ContentScanner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A0013D4F8EA288D589CFBF37 /* ContentScannerProxy.swift */,
|
||||
5E6EB3C22BACF0A17FEDA4C4 /* ContentScannerProxyProtocol.swift */,
|
||||
1EDFCC7391761D4DB2CC0D97 /* ContentScannerService.swift */,
|
||||
3AA9E2B1B5038DCABADA7D92 /* ContentScannerServiceProtocol.swift */,
|
||||
);
|
||||
path = ContentScanner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
||||
@@ -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<Bool, ContentScannerProxyError>!
|
||||
var scanMediaSourceReturnValue: Result<Bool, ContentScannerProxyError>! {
|
||||
get { scanMediaSourceReturnValueLock.withLock { scanMediaSourceUnderlyingReturnValue } }
|
||||
set { scanMediaSourceReturnValueLock.withLock { scanMediaSourceUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var scanMediaSourceClosure: ((MediaSourceProxy) async -> Result<Bool, ContentScannerProxyError>)?
|
||||
|
||||
@concurrent func scan(mediaSource: MediaSourceProxy) async -> Result<Bool, ContentScannerProxyError> {
|
||||
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<Bool, ContentScannerServiceError>!
|
||||
var loadScanResultFromSourceReturnValue: Result<Bool, ContentScannerServiceError>! {
|
||||
get { loadScanResultFromSourceReturnValueLock.withLock { loadScanResultFromSourceUnderlyingReturnValue } }
|
||||
set { loadScanResultFromSourceReturnValueLock.withLock { loadScanResultFromSourceUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var loadScanResultFromSourceClosure: ((MediaSourceProxy) async -> Result<Bool, ContentScannerServiceError>)?
|
||||
|
||||
@discardableResult
|
||||
@concurrent func loadScanResultFromSource(_ source: MediaSourceProxy) async -> Result<Bool, ContentScannerServiceError> {
|
||||
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<ElementCallServiceAction, Never> {
|
||||
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<SessionSecurityState, Never> {
|
||||
get { return underlyingSessionSecurityStatePublisher }
|
||||
set(value) { underlyingSessionSecurityStatePublisher = value }
|
||||
|
||||
@@ -30,8 +30,8 @@ class StateStoreViewModel<State: BindableState, ViewAction> {
|
||||
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<State: BindableState, ViewAction> {
|
||||
/// 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<T>(dynamicMember keyPath: WritableKeyPath<State.BindStateType, T>) -> T {
|
||||
get { viewState.bindings[keyPath: keyPath] }
|
||||
@@ -78,9 +83,10 @@ class StateStoreViewModel<State: BindableState, ViewAction> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ class StateStoreViewModelV2<State: BindableState, ViewAction> {
|
||||
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<State: BindableState, ViewAction> {
|
||||
/// 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<T>(dynamicMember keyPath: WritableKeyPath<State.BindStateType, T>) -> T {
|
||||
get { viewState.bindings[keyPath: keyPath] }
|
||||
@@ -80,9 +85,10 @@ class StateStoreViewModelV2<State: BindableState, ViewAction> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Bool, ContentScannerProxyError> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Bool, ContentScannerProxyError>
|
||||
}
|
||||
@@ -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<Result<Bool, ContentScannerServiceError>, 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<Bool, ContentScannerServiceError> {
|
||||
enum Scan {
|
||||
case cached(Bool)
|
||||
case ongoing(Task<Result<Bool, ContentScannerServiceError>, 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<Bool, ContentScannerServiceError> in
|
||||
let result: Result<Bool, ContentScannerServiceError>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Bool, ContentScannerServiceError>
|
||||
}
|
||||
@@ -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<UserSessionCallback, Never>()
|
||||
|
||||
let sessionSecurityStateSubject = CurrentValueSubject<SessionSecurityState, Never>(.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)
|
||||
|
||||
@@ -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<SessionSecurityState, Never> { get }
|
||||
|
||||
var callbacks: PassthroughSubject<UserSessionCallback, Never> { get }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user