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:
Mauro
2026-07-02 17:12:39 +02:00
committed by GitHub
parent f2d08c23a3
commit 5ea4b46604
15 changed files with 480 additions and 7 deletions
@@ -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 }