mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +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:
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user