diff --git a/ElementX/Sources/Other/SwiftUI/Views/ContentScanningView.swift b/ElementX/Sources/Other/SwiftUI/Views/ContentScanningView.swift index b92077369..aecad0565 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/ContentScanningView.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/ContentScanningView.swift @@ -42,7 +42,7 @@ struct ContentScanningView SafeContent private let scanningContent: () -> ScanningContent @@ -55,6 +55,8 @@ struct ContentScanningView SafeContent, @ViewBuilder scanningContent: @escaping () -> ScanningContent, @ViewBuilder unsafeContent: @escaping (ContentScanningFailure) -> UnsafeContent) { self.contentScannerService = contentScannerService - self.mediaSource = mediaSource + mediaSources = [mediaSource, thumbnailSource].compactMap { $0 } self.containerShowsFailure = containerShowsFailure self.safeContent = safeContent self.scanningContent = scanningContent @@ -79,7 +82,7 @@ struct ContentScanningView Bool { guard let contentScannerService else { return true } + let sources = [source, mediaItem.thumbnailMediaSource].compactMap { $0 } + // Only reflect the scanning state when there's no cached verdict, so that // scanned items don't flash the scanning indicator when they're revisited. - if contentScannerService.scanResultFromSource(source) == nil { + if contentScannerService.scanResultFromSources(sources) == nil { setCurrentItem(.contentScan(.init(media: mediaItem, state: .scanning))) } - switch await contentScannerService.loadScanResultFromSource(source) { + switch await contentScannerService.loadScanResultFromSources(sources) { case .success(true): finishScan(with: .media(mediaItem), for: mediaItem) return true diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/FileMediaEventsTimelineView.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/FileMediaEventsTimelineView.swift index c8860db8f..ba683a17e 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/FileMediaEventsTimelineView.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/FileMediaEventsTimelineView.swift @@ -25,7 +25,8 @@ struct FileMediaEventsTimelineView: View { formattedCaption: timelineItem.content.formattedCaption, trailingReservedSize: timelineItem.trailingReservedSize, contentScannerService: context?.contentScannerService, - mediaSource: timelineItem.content.source) + mediaSource: timelineItem.content.source, + thumbnailSource: timelineItem.content.thumbnailSource) .accessibilityLabel(L10n.commonFile) .frame(maxWidth: .infinity, alignment: .leading) .bubbleBackground(isOutgoing: timelineItem.isOutgoing, diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/ImageMediaEventsTimelineView.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/ImageMediaEventsTimelineView.swift index e6a357e90..6fc2027b3 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/ImageMediaEventsTimelineView.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/ImageMediaEventsTimelineView.swift @@ -15,7 +15,8 @@ struct ImageMediaEventsTimelineView: View { var body: some View { ContentScanningView(contentScannerService: context?.contentScannerService, - mediaSource: timelineItem.content.imageInfo.source) { + mediaSource: timelineItem.content.imageInfo.source, + thumbnailSource: timelineItem.content.thumbnailInfo?.source) { Color.clear // Let the image aspect fill in place .aspectRatio(1, contentMode: .fill) .overlay { diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/VideoMediaEventsTimelineView.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/VideoMediaEventsTimelineView.swift index 0ed59afef..1fb5a9d31 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/VideoMediaEventsTimelineView.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/View/TimelineViews/VideoMediaEventsTimelineView.swift @@ -15,7 +15,8 @@ struct VideoMediaEventsTimelineView: View { var body: some View { ContentScanningView(contentScannerService: context?.contentScannerService, - mediaSource: timelineItem.content.videoInfo.source) { + mediaSource: timelineItem.content.videoInfo.source, + thumbnailSource: timelineItem.content.thumbnailInfo?.source) { Color.clear // Let the image aspect fill in place .aspectRatio(1, contentMode: .fill) .overlay { diff --git a/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift b/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift index 8983848ba..740574fef 100644 --- a/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Replies/TimelineReplyView.swift @@ -25,6 +25,7 @@ struct TimelineReplyView: View { var body: some View { ContentScanningView(contentScannerService: timelineContext?.contentScannerService, mediaSource: scannedMediaSource, + thumbnailSource: scannedThumbnailSource, containerShowsFailure: false) { content .roundedContainer(padding: 4, @@ -63,6 +64,21 @@ struct TimelineReplyView: View { } } + /// The thumbnail source validated alongside ``scannedMediaSource`` when the replied to message has one. + private var scannedThumbnailSource: MediaSourceProxy? { + guard case .loaded(_, _, let eventContent) = timelineItemReplyDetails, + case .message(let message) = eventContent else { + return nil + } + + switch message { + case .file(let content): return content.thumbnailSource + case .image(let content): return content.thumbnailInfo?.source + case .video(let content): return content.thumbnailInfo?.source + default: return nil + } + } + @ViewBuilder private var content: some View { if let timelineItemReplyDetails { diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FileRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FileRoomTimelineView.swift index c865a29cd..b290df37a 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FileRoomTimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/FileRoomTimelineView.swift @@ -22,7 +22,8 @@ struct FileRoomTimelineView: View { trailingReservedSize: timelineItem.trailingReservedSize, shouldBoost: timelineItem.shouldBoost, contentScannerService: context?.contentScannerService, - mediaSource: timelineItem.content.source) { + mediaSource: timelineItem.content.source, + thumbnailSource: timelineItem.content.thumbnailSource) { context?.send(viewAction: .mediaTapped(itemID: timelineItem.id)) } .accessibilityLabel(L10n.commonFile) @@ -42,6 +43,7 @@ struct MediaFileRoomTimelineContent: View { var isAudioFile = false var contentScannerService: ContentScannerServiceProtocol? var mediaSource: MediaSourceProxy? + var thumbnailSource: MediaSourceProxy? private var fileDescription: String { var fileDescription = "\(filename.validatedFileExtension.uppercased())" @@ -60,7 +62,8 @@ struct MediaFileRoomTimelineContent: View { var body: some View { VStack(alignment: .leading, spacing: 8) { ContentScanningView(contentScannerService: contentScannerService, - mediaSource: mediaSource) { + mediaSource: mediaSource, + thumbnailSource: thumbnailSource) { if let onMediaTap { filePreview(isScanning: false) .onTapGesture(perform: onMediaTap) diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/ImageRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/ImageRoomTimelineView.swift index eec6c4a2c..d738a29b0 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/ImageRoomTimelineView.swift @@ -24,7 +24,8 @@ struct ImageRoomTimelineView: View { // The caption sits 8pts below the content scanner failure placeholder, 4pts below the media. VStack(alignment: .leading, spacing: contentScanningFailure == nil ? 4 : 8) { ContentScanningView(contentScannerService: context?.contentScannerService, - mediaSource: timelineItem.content.imageInfo.source) { + mediaSource: timelineItem.content.imageInfo.source, + thumbnailSource: timelineItem.content.thumbnailInfo?.source) { loadableImage .accessibilityElement(children: .ignore) .accessibilityLabel(L10n.commonImage) diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/VideoRoomTimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/VideoRoomTimelineView.swift index e0bc96cda..1c2f3a202 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/VideoRoomTimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineItemViews/VideoRoomTimelineView.swift @@ -24,7 +24,8 @@ struct VideoRoomTimelineView: View { // The caption sits 8pts below the content scanner failure placeholder, 4pts below the media. VStack(alignment: .leading, spacing: contentScanningFailure == nil ? 4 : 8) { ContentScanningView(contentScannerService: context?.contentScannerService, - mediaSource: timelineItem.content.videoInfo.source) { + mediaSource: timelineItem.content.videoInfo.source, + thumbnailSource: timelineItem.content.thumbnailInfo?.source) { thumbnail .timelineMediaFrame(imageInfo: timelineItem.content.thumbnailInfo) .accessibilityElement(children: .ignore) diff --git a/ElementX/Sources/Services/ContentScanner/ContentScannerProxy.swift b/ElementX/Sources/Services/ContentScanner/ContentScannerProxy.swift index 315262799..7551d8323 100644 --- a/ElementX/Sources/Services/ContentScanner/ContentScannerProxy.swift +++ b/ElementX/Sources/Services/ContentScanner/ContentScannerProxy.swift @@ -26,6 +26,10 @@ nonisolated struct ContentScannerProxy: ContentScannerProxyProtocol { do { let response = try await contentScanner.scan(client: client, mediaSource: mediaSource.underlyingSource) return .success(response.clean) + } catch ClientError.ContentScanner(.mcsMimeTypeForbidden, _), ClientError.ContentScanner(.mcsMediaNotClean, _) { + // The server flagged the media as unsafe (dangerous content or a forbidden mime type), + // which we treat as an unsafe verdict rather than a scan failure. + return .success(false) } catch { MXLog.error("Failed scanning media content: \(error)") return .failure(.sdkError(error)) diff --git a/ElementX/Sources/Services/ContentScanner/ContentScannerServiceProtocol.swift b/ElementX/Sources/Services/ContentScanner/ContentScannerServiceProtocol.swift index 014ee9a51..580062e5e 100644 --- a/ElementX/Sources/Services/ContentScanner/ContentScannerServiceProtocol.swift +++ b/ElementX/Sources/Services/ContentScanner/ContentScannerServiceProtocol.swift @@ -25,3 +25,47 @@ nonisolated protocol ContentScannerServiceProtocol: Sendable { @discardableResult func loadScanResultFromSource(_ source: MediaSourceProxy) async -> Result } + +nonisolated extension ContentScannerServiceProtocol { + /// The combined cached verdict for several sources (e.g. a media source and its thumbnail, both + /// downloaded through the scanner): `false` if any is unsafe, `true` if all are safe, or `nil` + /// when any of them hasn't been scanned yet. + func scanResultFromSources(_ sources: [MediaSourceProxy]) -> Bool? { + let results = sources.map { scanResultFromSource($0) } + if results.contains(false) { + return false + } + if results.contains(where: { $0 == nil }) { + return nil + } + return true + } + + /// Scans several sources concurrently, resolving as soon as one comes back non-safe (unsafe or + /// failed) since a single failure is enough to flag the media - the remaining scans are then + /// ignored. Resolves as safe only when every source is safe. + @discardableResult + func loadScanResultFromSources(_ sources: [MediaSourceProxy]) async -> Result { + await withTaskGroup(of: Result.self) { group in + for source in sources { + group.addTask { await loadScanResultFromSource(source) } + } + + for await result in group where !result.isSafe { + group.cancelAll() + return result + } + + return .success(true) + } + } +} + +private nonisolated extension Result where Success == Bool { + var isSafe: Bool { + if case .success(true) = self { + return true + } + return false + } +} diff --git a/UnitTests/Sources/ContentScannerServiceTests.swift b/UnitTests/Sources/ContentScannerServiceTests.swift index 59fcd5656..3c227c0a9 100644 --- a/UnitTests/Sources/ContentScannerServiceTests.swift +++ b/UnitTests/Sources/ContentScannerServiceTests.swift @@ -101,6 +101,66 @@ struct ContentScannerServiceTests { #expect(proxy.scanMediaSourceCallsCount == 1, "Concurrent loads for the same source should share a single scan.") } + // MARK: - Multiple sources + + @Test + func multipleSourcesAreSafeWhenAllSafe() async throws { + let proxy = ContentScannerProxyMock() + proxy.scanMediaSourceReturnValue = .success(true) + let service = ContentScannerService(contentScannerProxy: proxy) + + let result = await service.loadScanResultFromSources([makeSource("media"), makeSource("thumbnail")]) + + #expect(try result.get() == true) + } + + @Test + func multipleSourcesAreUnsafeWhenAnySourceIsUnsafe() async throws { + let proxy = ContentScannerProxyMock() + let unsafeSource = makeSource("thumbnail") + proxy.scanMediaSourceClosure = { source in + source == unsafeSource ? .success(false) : .success(true) + } + let service = ContentScannerService(contentScannerProxy: proxy) + + // The media is safe but its thumbnail is unsafe, which is enough to flag the whole item. + let result = await service.loadScanResultFromSources([makeSource("media"), unsafeSource]) + + #expect(try result.get() == false) + } + + @Test + func multipleSourcesFailWhenAnySourceFails() async { + let proxy = ContentScannerProxyMock() + let failingSource = makeSource("thumbnail") + proxy.scanMediaSourceClosure = { source in + source == failingSource ? .failure(.missingClient) : .success(true) + } + let service = ContentScannerService(contentScannerProxy: proxy) + + let result = await service.loadScanResultFromSources([makeSource("media"), failingSource]) + + guard case .failure(.failedScanning) = result else { + Issue.record("A failure of any source should fail the combined scan.") + return + } + } + + @Test + func multipleSourcesSyncLookupCombinesVerdicts() 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.scanResultFromSources([makeSource("safe")]) == true) + #expect(service.scanResultFromSources([makeSource("safe"), makeSource("unsafe")]) == false) + #expect(service.scanResultFromSources([makeSource("safe"), makeSource("unscanned")]) == nil) + } + // MARK: - Helpers private func makeSource(_ id: String) -> MediaSourceProxy {