diff --git a/ElementX/Sources/Other/Extensions/UTType.swift b/ElementX/Sources/Other/Extensions/UTType.swift new file mode 100644 index 000000000..b92e825e2 --- /dev/null +++ b/ElementX/Sources/Other/Extensions/UTType.swift @@ -0,0 +1,35 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UniformTypeIdentifiers + +extension UTType { + /// Creates a type based on an optional mime type, falling back to a filename when this type is missing or unknown. + init?(mimeType: String?, fallbackFilename: String) { + guard let mimeType, let type = UTType(mimeType: mimeType) else { + self.init(filename: fallbackFilename) + return + } + self = type + } + + /// Creates a type based on a filename. + private init?(filename: String) { + let components = filename.split(separator: ".") + guard components.count > 1, let filenameExtension = components.last else { return nil } + self.init(filenameExtension: String(filenameExtension)) + } +} diff --git a/ElementX/Sources/Other/SwiftUI/Views/LoadableImage.swift b/ElementX/Sources/Other/SwiftUI/Views/LoadableImage.swift index ee8fe8d1b..e3073347c 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/LoadableImage.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/LoadableImage.swift @@ -56,7 +56,7 @@ struct LoadableImage: View { imageProvider: ImageProviderProtocol?, transformer: @escaping (Image) -> TransformerView = { $0 }, placeholder: @escaping () -> PlaceholderView) { - let mediaSource = url.map(MediaSourceProxy.init) + let mediaSource = url.map { MediaSourceProxy(url: $0, mimeType: nil) } self.init(mediaSource: mediaSource, blurhash: blurhash, diff --git a/ElementX/Sources/Screens/FilePreview/FilePreviewCoordinator.swift b/ElementX/Sources/Screens/FilePreview/FilePreviewCoordinator.swift index 721e05c0b..16e5cd60c 100644 --- a/ElementX/Sources/Screens/FilePreview/FilePreviewCoordinator.swift +++ b/ElementX/Sources/Screens/FilePreview/FilePreviewCoordinator.swift @@ -17,7 +17,7 @@ import SwiftUI struct FilePreviewCoordinatorParameters { - let fileURL: URL + let mediaFile: MediaFileHandleProxy let title: String? } @@ -34,7 +34,7 @@ final class FilePreviewCoordinator: CoordinatorProtocol { init(parameters: FilePreviewCoordinatorParameters) { self.parameters = parameters - viewModel = FilePreviewViewModel(fileURL: parameters.fileURL, title: parameters.title) + viewModel = FilePreviewViewModel(mediaFile: parameters.mediaFile, title: parameters.title) } // MARK: - Public diff --git a/ElementX/Sources/Screens/FilePreview/FilePreviewModels.swift b/ElementX/Sources/Screens/FilePreview/FilePreviewModels.swift index 9305481ac..70cc0f4f1 100644 --- a/ElementX/Sources/Screens/FilePreview/FilePreviewModels.swift +++ b/ElementX/Sources/Screens/FilePreview/FilePreviewModels.swift @@ -21,7 +21,7 @@ enum FilePreviewViewModelAction { } struct FilePreviewViewState: BindableState { - let fileURL: URL + let mediaFile: MediaFileHandleProxy let title: String? } diff --git a/ElementX/Sources/Screens/FilePreview/FilePreviewViewModel.swift b/ElementX/Sources/Screens/FilePreview/FilePreviewViewModel.swift index 961951632..3e2b52ca4 100644 --- a/ElementX/Sources/Screens/FilePreview/FilePreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreview/FilePreviewViewModel.swift @@ -21,8 +21,8 @@ typealias FilePreviewViewModelType = StateStoreViewModel Void)? - init(fileURL: URL, title: String? = nil) { - super.init(initialViewState: FilePreviewViewState(fileURL: fileURL, title: title)) + init(mediaFile: MediaFileHandleProxy, title: String? = nil) { + super.init(initialViewState: FilePreviewViewState(mediaFile: mediaFile, title: title)) } override func process(viewAction: FilePreviewViewAction) async { diff --git a/ElementX/Sources/Screens/FilePreview/View/FilePreviewScreen.swift b/ElementX/Sources/Screens/FilePreview/View/FilePreviewScreen.swift index a4e36fbc0..dca43107b 100644 --- a/ElementX/Sources/Screens/FilePreview/View/FilePreviewScreen.swift +++ b/ElementX/Sources/Screens/FilePreview/View/FilePreviewScreen.swift @@ -24,7 +24,7 @@ struct FilePreviewScreen: View { var body: some View { ZStack(alignment: .leading) { PreviewView(context: context, - fileURL: context.viewState.fileURL, + fileURL: context.viewState.mediaFile.url, title: context.viewState.title) .ignoresSafeArea(edges: .bottom) @@ -93,10 +93,9 @@ private class PreviewItem: NSObject, QLPreviewItem { // MARK: - Previews struct FilePreview_Previews: PreviewProvider { + static let viewModel = FilePreviewViewModel(mediaFile: .unmanaged(url: URL(staticString: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"))) + static var previews: some View { - Group { - let upgradeViewModel = FilePreviewViewModel(fileURL: URL(staticString: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf")) - FilePreviewScreen(context: upgradeViewModel.context) - } + FilePreviewScreen(context: viewModel.context) } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 9ad0f8a10..5b6d2000e 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -56,8 +56,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { switch action { case .displayRoomDetails: self.displayRoomDetails() - case .displayVideo(let fileURL, let title), .displayFile(let fileURL, let title): - self.displayFile(for: fileURL, with: title) + case .displayMediaFile(let file, let title): + self.displayFilePreview(for: file, with: title) case .displayEmojiPicker(let itemId): self.displayEmojiPickerScreen(for: itemId) case .displayReportContent(let itemId): @@ -77,8 +77,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { // MARK: - Private - private func displayFile(for fileURL: URL, with title: String?) { - let params = FilePreviewCoordinatorParameters(fileURL: fileURL, title: title) + private func displayFilePreview(for file: MediaFileHandleProxy, with title: String?) { + let params = FilePreviewCoordinatorParameters(mediaFile: file, title: title) let coordinator = FilePreviewCoordinator(parameters: params) coordinator.callback = { [weak self] _ in self?.navigationStackCoordinator.pop() diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 5e3928789..cdaca0968 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -20,8 +20,7 @@ import UIKit enum RoomScreenViewModelAction { case displayRoomDetails - case displayVideo(videoURL: URL, title: String?) - case displayFile(fileURL: URL, title: String?) + case displayMediaFile(file: MediaFileHandleProxy, title: String?) case displayEmojiPicker(itemId: String) case displayReportContent(itemId: String) } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index a9566c44f..3127f6ef2 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -134,10 +134,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol let action = await timelineController.processItemTap(itemId) switch action { - case .displayVideo(let videoURL, let title): - callback?(.displayVideo(videoURL: videoURL, title: title)) - case .displayFile(let fileURL, let title): - callback?(.displayFile(fileURL: fileURL, title: title)) + case .displayMediaFile(let file, let title): + callback?(.displayMediaFile(file: file, title: title)) case .none: break } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift index 875b265a4..e9efc71a4 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift @@ -48,7 +48,7 @@ struct ImageRoomTimelineView: View { @ViewBuilder var overlay: some View { - if timelineItem.type == .gif { + if timelineItem.contentType == .gif { Text(ElementL10n.roomTimelineImageGif) .font(.element.bodyBold) .foregroundStyle(.primary) @@ -96,7 +96,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { source: nil, aspectRatio: 0.7, blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW", - type: .gif)) + contentType: .gif)) } } } diff --git a/ElementX/Sources/Services/Cache/FileCache.swift b/ElementX/Sources/Services/Cache/FileCache.swift deleted file mode 100644 index c76543457..000000000 --- a/ElementX/Sources/Services/Cache/FileCache.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation - -// MARK: - FileCacheProtocol - -protocol FileCacheProtocol { - func file(forKey key: String, fileExtension: String) -> URL? - func store(_ data: Data, with fileExtension: String, forKey key: String) throws -> URL - func remove(forKey key: String, fileExtension: String) throws - func removeAll() throws -} - -// MARK: - FileCache - -/// Implementation of `FileCacheProtocol` under `FileManager.default.temporaryDirectory`. -class FileCache { - private let fileManager = FileManager.default - private let folder: URL - - /// Default instance. Uses `Files` as the folder name. - static let `default` = FileCache(folderName: "Files") - - init(folderName: String) { - folder = URL.cacheBaseDirectory.appending(path: folderName, directoryHint: .isDirectory) - } - - // MARK: Private - - private func filePath(forKey key: String, fileExtension: String) -> URL { - folder.appending(path: key, directoryHint: .notDirectory).appendingPathExtension(fileExtension) - } -} - -// MARK: - FileCacheProtocol - -extension FileCache: FileCacheProtocol { - func file(forKey key: String, fileExtension: String) -> URL? { - let url = filePath(forKey: key, fileExtension: fileExtension) - return fileManager.isReadableFile(atPath: url.path()) ? url : nil - } - - func store(_ data: Data, with fileExtension: String, forKey key: String) throws -> URL { - try fileManager.createDirectoryIfNeeded(at: folder) - let url = filePath(forKey: key, fileExtension: fileExtension) - try data.write(to: url) - return url - } - - func remove(forKey key: String, fileExtension: String) throws { - try fileManager.removeItem(at: filePath(forKey: key, fileExtension: fileExtension)) - } - - func removeAll() throws { - guard fileManager.directoryExists(at: folder) else { - return - } - try fileManager.removeItem(at: folder) - } -} diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 038fd2199..f2242ed91 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -394,4 +394,8 @@ extension ClientProxy: MediaLoaderProtocol { func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data { try await mediaLoader.loadMediaThumbnailForSource(source, width: width, height: height) } + + func loadMediaFileForSource(_ source: MediaSourceProxy) async throws -> MediaFileHandleProxy { + try await mediaLoader.loadMediaFileForSource(source) + } } diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index fbd5ec870..7cf74d926 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -76,6 +76,10 @@ class MockClientProxy: ClientProxyProtocol { throw ClientProxyError.failedLoadingMedia } + func loadMediaFileForSource(_ source: MediaSourceProxy) async throws -> MediaFileHandleProxy { + throw ClientProxyError.failedLoadingMedia + } + var sessionVerificationControllerProxyResult: Result? func sessionVerificationControllerProxy() async -> Result { if let sessionVerificationControllerProxyResult { diff --git a/ElementX/Sources/Services/Media/ImageProviderProtocol.swift b/ElementX/Sources/Services/Media/ImageProviderProtocol.swift index f7b176146..eae26b377 100644 --- a/ElementX/Sources/Services/Media/ImageProviderProtocol.swift +++ b/ElementX/Sources/Services/Media/ImageProviderProtocol.swift @@ -19,7 +19,7 @@ import UIKit protocol ImageProviderProtocol { func imageFromSource(_ source: MediaSourceProxy?, size: CGSize?) -> UIImage? - @discardableResult func loadImageFromSource(_ source: MediaSourceProxy, size: CGSize?) async -> Result + func loadImageFromSource(_ source: MediaSourceProxy, size: CGSize?) async -> Result } extension ImageProviderProtocol { @@ -27,7 +27,7 @@ extension ImageProviderProtocol { imageFromSource(source, size: nil) } - @discardableResult func loadImageFromSource(_ source: MediaSourceProxy) async -> Result { + func loadImageFromSource(_ source: MediaSourceProxy) async -> Result { await loadImageFromSource(source, size: nil) } } diff --git a/ElementX/Sources/Services/Media/MediaFileHandleProxy.swift b/ElementX/Sources/Services/Media/MediaFileHandleProxy.swift new file mode 100644 index 000000000..592641370 --- /dev/null +++ b/ElementX/Sources/Services/Media/MediaFileHandleProxy.swift @@ -0,0 +1,68 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import MatrixRustSDK + +/// A wrapper around Rust's `MediaFileHandle` type that provides us with a +/// media file that is stored unencrypted in a temporary location for previewing. +class MediaFileHandleProxy { + /// The underlying handle for the file. + private let handle: MediaFileHandleProtocol + + /// Creates a new instance from the Rust type. + init(handle: MediaFileHandleProtocol) { + self.handle = handle + } + + /// Creates an unmanaged instance (for mocking etc), using a raw `URL` + /// + /// A media file created from a URL won't have the automatic clean-up mechanism + /// that is provided by the SDK's `MediaFileHandle`. + static func unmanaged(url: URL) -> MediaFileHandleProxy { + MediaFileHandleProxy(handle: UnmanagedMediaFileHandle(url: url)) + } + + /// The media file's location on disk. + var url: URL { + URL(filePath: handle.path()) + } +} + +// MARK: - Hashable + +extension MediaFileHandleProxy: Hashable { + static func == (lhs: MediaFileHandleProxy, rhs: MediaFileHandleProxy) -> Bool { + lhs.url == rhs.url + } + + func hash(into hasher: inout Hasher) { + hasher.combine(url) + } +} + +// MARK: - + +/// An unmanaged file handle that can be created direct from a URL. +/// +/// This type allows for mocking but doesn't provide the automatic clean-up mechanism provided by the SDK. +private struct UnmanagedMediaFileHandle: MediaFileHandleProtocol { + let url: URL + + func path() -> String { + url.path() + } +} diff --git a/ElementX/Sources/Services/Media/MediaLoader.swift b/ElementX/Sources/Services/Media/MediaLoader.swift index bbb43bb81..bb047264b 100644 --- a/ElementX/Sources/Services/Media/MediaLoader.swift +++ b/ElementX/Sources/Services/Media/MediaLoader.swift @@ -46,6 +46,14 @@ actor MediaLoader: MediaLoaderProtocol { } } + func loadMediaFileForSource(_ source: MediaSourceProxy) async throws -> MediaFileHandleProxy { + let result = try await Task.dispatch(on: clientQueue) { + try self.client.getMediaFile(source: source.underlyingSource, mimeType: source.mimeType ?? "application/octet-stream") + } + + return MediaFileHandleProxy(handle: result) + } + // MARK: - Private private func enqueueLoadMediaRequest(forSource source: MediaSourceProxy, operation: @escaping () throws -> [UInt8]) async throws -> Data { diff --git a/ElementX/Sources/Services/Media/MediaLoaderProtocol.swift b/ElementX/Sources/Services/Media/MediaLoaderProtocol.swift index 1a2d932f0..331806c32 100644 --- a/ElementX/Sources/Services/Media/MediaLoaderProtocol.swift +++ b/ElementX/Sources/Services/Media/MediaLoaderProtocol.swift @@ -20,4 +20,6 @@ protocol MediaLoaderProtocol { func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data + + func loadMediaFileForSource(_ source: MediaSourceProxy) async throws -> MediaFileHandleProxy } diff --git a/ElementX/Sources/Services/Media/MediaProvider.swift b/ElementX/Sources/Services/Media/MediaProvider.swift index 98d4fc2d1..679631301 100644 --- a/ElementX/Sources/Services/Media/MediaProvider.swift +++ b/ElementX/Sources/Services/Media/MediaProvider.swift @@ -20,16 +20,13 @@ import UIKit struct MediaProvider: MediaProviderProtocol { private let mediaLoader: MediaLoaderProtocol private let imageCache: Kingfisher.ImageCache - private let fileCache: FileCacheProtocol private let backgroundTaskService: BackgroundTaskServiceProtocol? init(mediaLoader: MediaLoaderProtocol, imageCache: Kingfisher.ImageCache, - fileCache: FileCacheProtocol, backgroundTaskService: BackgroundTaskServiceProtocol?) { self.mediaLoader = mediaLoader self.imageCache = imageCache - self.fileCache = fileCache self.backgroundTaskService = backgroundTaskService } @@ -83,35 +80,17 @@ struct MediaProvider: MediaProviderProtocol { } // MARK: Files - - func fileFromSource(_ source: MediaSourceProxy?, fileExtension: String) -> URL? { - guard let source else { - return nil - } - let cacheKey = fileCacheKeyForURL(source.url) - return fileCache.file(forKey: cacheKey, fileExtension: fileExtension) - } - - @discardableResult func loadFileFromSource(_ source: MediaSourceProxy, fileExtension: String) async -> Result { - if let url = fileFromSource(source, fileExtension: fileExtension) { - return .success(url) - } - + + func loadFileFromSource(_ source: MediaSourceProxy) async -> Result { let loadFileBgTask = await backgroundTaskService?.startBackgroundTask(withName: "LoadFile: \(source.url.hashValue)") - defer { - loadFileBgTask?.stop() - } - - let cacheKey = fileCacheKeyForURL(source.url) + defer { loadFileBgTask?.stop() } do { - let data = try await mediaLoader.loadMediaContentForSource(source) - - let url = try fileCache.store(data, with: fileExtension, forKey: cacheKey) - return .success(url) + let file = try await mediaLoader.loadMediaFileForSource(source) + return .success(file) } catch { MXLog.error("Failed retrieving file with error: \(error)") - return .failure(.failedRetrievingImage) + return .failure(.failedRetrievingFile) } } @@ -124,14 +103,6 @@ struct MediaProvider: MediaProviderProtocol { return url.absoluteString } } - - private func fileCacheKeyForURL(_ url: URL) -> String { - let component = url.lastPathComponent - guard !component.isEmpty else { - return url.absoluteString - } - return String(component) - } } private extension ImageCache { diff --git a/ElementX/Sources/Services/Media/MediaProviderProtocol.swift b/ElementX/Sources/Services/Media/MediaProviderProtocol.swift index 89e90874a..d5172aa7e 100644 --- a/ElementX/Sources/Services/Media/MediaProviderProtocol.swift +++ b/ElementX/Sources/Services/Media/MediaProviderProtocol.swift @@ -24,7 +24,5 @@ enum MediaProviderError: Error { } protocol MediaProviderProtocol: ImageProviderProtocol { - func fileFromSource(_ source: MediaSourceProxy?, fileExtension: String) -> URL? - - @discardableResult func loadFileFromSource(_ source: MediaSourceProxy, fileExtension: String) async -> Result + func loadFileFromSource(_ source: MediaSourceProxy) async -> Result } diff --git a/ElementX/Sources/Services/Media/MediaSourceProxy.swift b/ElementX/Sources/Services/Media/MediaSourceProxy.swift index ba9ca38c0..d0ecc5afb 100644 --- a/ElementX/Sources/Services/Media/MediaSourceProxy.swift +++ b/ElementX/Sources/Services/Media/MediaSourceProxy.swift @@ -18,14 +18,20 @@ import Foundation import MatrixRustSDK struct MediaSourceProxy: Hashable { + /// The media source provided by Rust. let underlyingSource: MediaSource + /// The media's mime type, used when loading the media's file. + /// This is optional when loading images and thumbnails in memory. + let mimeType: String? - init(source: MediaSource) { + init(source: MediaSource, mimeType: String?) { underlyingSource = source + self.mimeType = mimeType } - init(url: URL) { + init(url: URL, mimeType: String?) { underlyingSource = mediaSourceFromUrl(url: url.absoluteString) + self.mimeType = mimeType } var url: URL! { diff --git a/ElementX/Sources/Services/Media/MockMediaProvider.swift b/ElementX/Sources/Services/Media/MockMediaProvider.swift index 85d3d2d51..024c17e64 100644 --- a/ElementX/Sources/Services/Media/MockMediaProvider.swift +++ b/ElementX/Sources/Services/Media/MockMediaProvider.swift @@ -41,12 +41,8 @@ struct MockMediaProvider: MediaProviderProtocol { return .success(image) } - - func fileFromSource(_ source: MediaSourceProxy?, fileExtension: String) -> URL? { - nil - } - - @discardableResult func loadFileFromSource(_ source: MediaSourceProxy, fileExtension: String) async -> Result { + + func loadFileFromSource(_ source: MediaSourceProxy) async -> Result { .failure(.failedRetrievingFile) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index 59da99fad..e2a6f36e1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -17,7 +17,6 @@ import Combine import Foundation import UIKit -import UniformTypeIdentifiers class RoomTimelineController: RoomTimelineControllerProtocol { private let userId: String @@ -105,44 +104,34 @@ class RoomTimelineController: RoomTimelineControllerProtocol { guard let timelineItem = timelineItems.first(where: { $0.id == itemID }) else { return .none } - + + var source: MediaSourceProxy? + var title: String switch timelineItem { case let item as ImageRoomTimelineItem: - await loadFileForImageTimelineItem(item) - guard let index = timelineItems.firstIndex(where: { $0.id == itemID }), - let item = timelineItems[index] as? ImageRoomTimelineItem, - let fileURL = item.cachedFileURL else { - return .none - } - return .displayFile(fileURL: fileURL, title: item.body) + source = item.source + title = item.body case let item as VideoRoomTimelineItem: - await loadVideoForTimelineItem(item) - guard let index = timelineItems.firstIndex(where: { $0.id == itemID }), - let item = timelineItems[index] as? VideoRoomTimelineItem, - let videoURL = item.cachedVideoURL else { - return .none - } - return .displayVideo(videoURL: videoURL, title: item.body) + source = item.source + title = item.body case let item as FileRoomTimelineItem: - await loadFileForTimelineItem(item) - guard let index = timelineItems.firstIndex(where: { $0.id == itemID }), - let item = timelineItems[index] as? FileRoomTimelineItem, - let fileURL = item.cachedFileURL else { - return .none - } - return .displayFile(fileURL: fileURL, title: item.body) + source = item.source + title = item.body case let item as AudioRoomTimelineItem: - await loadAudioForTimelineItem(item) - guard let index = timelineItems.firstIndex(where: { $0.id == itemID }), - let item = timelineItems[index] as? AudioRoomTimelineItem, - let audioURL = item.cachedAudioURL else { - return .none - } // For now we are just displaying audio messages with the File preview until we create a timeline player for them. - return .displayFile(fileURL: audioURL, title: item.body) + source = item.source + title = item.body default: return .none } + + guard let source else { return .none } + switch await mediaProvider.loadFileFromSource(source) { + case .success(let file): + return .displayMediaFile(file: file, title: title) + case .failure: + return .none + } } func sendMessage(_ message: String, inReplyTo itemID: String?) async { @@ -305,134 +294,4 @@ class RoomTimelineController: RoomTimelineControllerProtocol { return false } - - private func loadVideoForTimelineItem(_ timelineItem: VideoRoomTimelineItem) async { - if timelineItem.cachedVideoURL != nil { - // already cached - return - } - - guard let source = timelineItem.source else { - return - } - - let fileExtension = movieFileExtension(for: timelineItem.body) - switch await mediaProvider.loadFileFromSource(source, fileExtension: fileExtension) { - case .success(let fileURL): - guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), - var item = timelineItems[index] as? VideoRoomTimelineItem else { - return - } - - item.cachedVideoURL = fileURL - timelineItems[index] = item - case .failure: - break - } - } - - /// Temporary method that generates a file extension for a video file name - /// using `UTType.movie` and falls back to .mp4 if anything goes wrong. - /// - /// Ideally Rust should be able to handle this for us, otherwise we should be - /// attempting to detect the file type from the data itself. - private func movieFileExtension(for text: String) -> String { - let fallbackExtension = "mp4" - - // This is not great. We could better estimate file extension from the mimetype. - guard let fileExtensionComponent = text.split(separator: ".").last else { return fallbackExtension } - let fileExtension = String(fileExtensionComponent) - - // We can't trust that the extension provided is an extension that AVFoundation will accept. - guard let fileType = UTType(filenameExtension: fileExtension), - fileType.isSubtype(of: .movie) - else { return fallbackExtension } - - return fileExtension - } - - private func loadFileForImageTimelineItem(_ timelineItem: ImageRoomTimelineItem) async { - if timelineItem.cachedFileURL != nil { - // already cached - return - } - - guard let source = timelineItem.source else { - return - } - - // This is not great. We could better estimate file extension from the mimetype. - guard let fileExtension = timelineItem.body.split(separator: ".").last else { - return - } - switch await mediaProvider.loadFileFromSource(source, fileExtension: String(fileExtension)) { - case .success(let fileURL): - guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), - var item = timelineItems[index] as? ImageRoomTimelineItem else { - return - } - - item.cachedFileURL = fileURL - timelineItems[index] = item - case .failure: - break - } - } - - private func loadFileForTimelineItem(_ timelineItem: FileRoomTimelineItem) async { - if timelineItem.cachedFileURL != nil { - // already cached - return - } - - guard let source = timelineItem.source else { - return - } - - // This is not great. We could better estimate file extension from the mimetype. - guard let fileExtension = timelineItem.body.split(separator: ".").last else { - return - } - switch await mediaProvider.loadFileFromSource(source, fileExtension: String(fileExtension)) { - case .success(let fileURL): - guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), - var item = timelineItems[index] as? FileRoomTimelineItem else { - return - } - - item.cachedFileURL = fileURL - timelineItems[index] = item - case .failure: - break - } - } - - private func loadAudioForTimelineItem(_ timelineItem: AudioRoomTimelineItem) async { - if timelineItem.cachedAudioURL != nil { - // already cached - return - } - - guard let source = timelineItem.source else { - return - } - - // This is not great. We could better estimate file extension from the mimetype. - guard let fileExtension = timelineItem.body.split(separator: ".").last else { - return - } - - switch await mediaProvider.loadFileFromSource(source, fileExtension: String(fileExtension)) { - case .success(let audioURL): - guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), - var item = timelineItems[index] as? AudioRoomTimelineItem else { - return - } - - item.cachedAudioURL = audioURL - timelineItems[index] = item - case .failure: - break - } - } } diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index 9173e37aa..1ad653ea8 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -25,8 +25,7 @@ enum RoomTimelineControllerCallback { } enum RoomTimelineControllerAction { - case displayVideo(videoURL: URL, title: String?) - case displayFile(fileURL: URL, title: String?) + case displayMediaFile(file: MediaFileHandleProxy, title: String?) case none } diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift index ceb033166..1ed450c54 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift @@ -27,7 +27,7 @@ protocol MessageContentProtocol: RoomMessageEventContentProtocol, CustomStringCo /// A timeline item that represents an `m.room.message` event. struct MessageTimelineItem { - let item: MatrixRustSDK.EventTimelineItem + let item: MatrixRustSDK.EventTimelineItemProtocol let content: Content var id: String { @@ -82,7 +82,7 @@ extension MatrixRustSDK.ImageMessageContent: MessageContentProtocol { } /// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.image`. extension MessageTimelineItem where Content == MatrixRustSDK.ImageMessageContent { var source: MediaSourceProxy { - .init(source: content.source) + .init(source: content.source, mimeType: content.info?.mimetype) } var width: CGFloat? { @@ -97,8 +97,8 @@ extension MessageTimelineItem where Content == MatrixRustSDK.ImageMessageContent content.info?.blurhash } - var type: UTType? { - content.info?.mimetype.flatMap { UTType(mimeType: $0) } + var contentType: UTType? { + UTType(mimeType: content.info?.mimetype, fallbackFilename: content.body) } } @@ -107,14 +107,14 @@ extension MatrixRustSDK.VideoMessageContent: MessageContentProtocol { } /// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.video`. extension MessageTimelineItem where Content == MatrixRustSDK.VideoMessageContent { var source: MediaSourceProxy { - .init(source: content.source) + .init(source: content.source, mimeType: content.info?.mimetype) } var thumbnailSource: MediaSourceProxy? { guard let src = content.info?.thumbnailSource else { return nil } - return .init(source: src) + return .init(source: src, mimeType: content.info?.thumbnailInfo?.mimetype) } var duration: UInt64 { @@ -132,6 +132,10 @@ extension MessageTimelineItem where Content == MatrixRustSDK.VideoMessageContent var blurhash: String? { content.info?.blurhash } + + var contentType: UTType? { + UTType(mimeType: content.info?.mimetype, fallbackFilename: content.body) + } } extension MatrixRustSDK.FileMessageContent: MessageContentProtocol { } @@ -139,14 +143,18 @@ extension MatrixRustSDK.FileMessageContent: MessageContentProtocol { } /// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.file`. extension MessageTimelineItem where Content == MatrixRustSDK.FileMessageContent { var source: MediaSourceProxy { - .init(source: content.source) + .init(source: content.source, mimeType: content.info?.mimetype) } var thumbnailSource: MediaSourceProxy? { guard let src = content.info?.thumbnailSource else { return nil } - return .init(source: src) + return .init(source: src, mimeType: content.info?.thumbnailInfo?.mimetype) + } + + var contentType: UTType? { + UTType(mimeType: content.info?.mimetype, fallbackFilename: content.body) } } @@ -155,10 +163,14 @@ extension MatrixRustSDK.AudioMessageContent: MessageContentProtocol { } /// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.audio`. extension MessageTimelineItem where Content == MatrixRustSDK.AudioMessageContent { var source: MediaSourceProxy { - .init(source: content.source) + .init(source: content.source, mimeType: content.info?.mimetype) } var duration: UInt64 { content.info?.duration ?? 0 } + + var contentType: UTType? { + UTType(mimeType: content.info?.mimetype, fallbackFilename: content.body) + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift index 1a7aac7bf..0e0d2efc9 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift @@ -15,6 +15,7 @@ // import Foundation +import UniformTypeIdentifiers struct AudioRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String @@ -26,7 +27,7 @@ struct AudioRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let duration: UInt64 let source: MediaSourceProxy? - var cachedAudioURL: URL? + var contentType: UTType? var properties = RoomTimelineItemProperties() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift index 139dd56ac..9d8a567f1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift @@ -15,6 +15,7 @@ // import UIKit +import UniformTypeIdentifiers struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String @@ -27,7 +28,7 @@ struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha let source: MediaSourceProxy? let thumbnailSource: MediaSourceProxy? - var cachedFileURL: URL? + var contentType: UTType? var properties = RoomTimelineItemProperties() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift index 75bd0c573..4ea028ed3 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift @@ -27,13 +27,12 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let sender: TimelineItemSender let source: MediaSourceProxy? - var cachedFileURL: URL? var width: CGFloat? var height: CGFloat? var aspectRatio: CGFloat? var blurhash: String? - var type: UTType? + var contentType: UTType? var properties = RoomTimelineItemProperties() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift index d7abedc2b..f135b56b0 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift @@ -15,6 +15,7 @@ // import UIKit +import UniformTypeIdentifiers struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { let id: String @@ -28,12 +29,12 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let duration: UInt64 let source: MediaSourceProxy? let thumbnailSource: MediaSourceProxy? - var cachedVideoURL: URL? var width: CGFloat? var height: CGFloat? var aspectRatio: CGFloat? var blurhash: String? + var contentType: UTType? var properties = RoomTimelineItemProperties() } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 94aadc325..cd8b713e3 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -222,7 +222,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { height: message.height, aspectRatio: aspectRatio, blurhash: message.blurhash, - type: message.type, + contentType: message.contentType, properties: RoomTimelineItemProperties(isEdited: message.isEdited, reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus)) @@ -250,6 +250,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { height: message.height, aspectRatio: aspectRatio, blurhash: message.blurhash, + contentType: message.contentType, properties: RoomTimelineItemProperties(isEdited: message.isEdited, reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus)) @@ -265,7 +266,11 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, duration: message.duration, - source: message.source) + source: message.source, + contentType: message.contentType, + properties: RoomTimelineItemProperties(isEdited: message.isEdited, + reactions: aggregateReactions(eventItemProxy.reactions), + deliveryStatus: eventItemProxy.deliveryStatus)) } private func buildFileTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, @@ -279,6 +284,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { sender: eventItemProxy.sender, source: message.source, thumbnailSource: message.thumbnailSource, + contentType: message.contentType, properties: RoomTimelineItemProperties(isEdited: message.isEdited, reactions: aggregateReactions(eventItemProxy.reactions), deliveryStatus: eventItemProxy.deliveryStatus)) diff --git a/ElementX/Sources/Services/UserSession/UserSessionStore.swift b/ElementX/Sources/Services/UserSession/UserSessionStore.swift index 8a1070bbc..1a6b00cc5 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionStore.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionStore.swift @@ -98,7 +98,6 @@ class UserSessionStore: UserSessionStoreProtocol { return UserSession(clientProxy: clientProxy, mediaProvider: MediaProvider(mediaLoader: clientProxy, imageCache: imageCache, - fileCache: FileCache.default, backgroundTaskService: backgroundTaskService)) } diff --git a/NSE/Sources/NotificationServiceExtension.swift b/NSE/Sources/NotificationServiceExtension.swift index d504abb95..510f05708 100644 --- a/NSE/Sources/NotificationServiceExtension.swift +++ b/NSE/Sources/NotificationServiceExtension.swift @@ -130,7 +130,6 @@ class NotificationServiceExtension: UNNotificationServiceExtension { return MediaProvider(mediaLoader: MediaLoader(client: client), imageCache: .onlyOnDisk, - fileCache: FileCache.default, backgroundTaskService: nil) } diff --git a/NSE/Sources/Other/NotificationItemProxy+NSE.swift b/NSE/Sources/Other/NotificationItemProxy+NSE.swift index 9567f6323..9b3438f22 100644 --- a/NSE/Sources/Other/NotificationItemProxy+NSE.swift +++ b/NSE/Sources/Other/NotificationItemProxy+NSE.swift @@ -171,7 +171,7 @@ extension NotificationItemProxy { notification.body = "📷 " + content.body notification = try await notification.addMediaAttachment(using: mediaProvider, - mediaSource: .init(source: content.source)) + mediaSource: .init(source: content.source, mimeType: content.info?.mimetype)) return notification } @@ -186,7 +186,7 @@ extension NotificationItemProxy { notification.body = "📹 " + content.body notification = try await notification.addMediaAttachment(using: mediaProvider, - mediaSource: .init(source: content.source)) + mediaSource: .init(source: content.source, mimeType: content.info?.mimetype)) return notification } diff --git a/NSE/Sources/Other/UNMutableNotificationContent.swift b/NSE/Sources/Other/UNMutableNotificationContent.swift index 0e11e2bb5..f96465c0f 100644 --- a/NSE/Sources/Other/UNMutableNotificationContent.swift +++ b/NSE/Sources/Other/UNMutableNotificationContent.swift @@ -24,11 +24,10 @@ extension UNMutableNotificationContent { guard let mediaProvider else { return self } - - switch await mediaProvider.loadFileFromSource(mediaSource, fileExtension: "") { - case .success(let url): + switch await mediaProvider.loadFileFromSource(mediaSource) { + case .success(let file): let attachment = try UNNotificationAttachment(identifier: ProcessInfo.processInfo.globallyUniqueString, - url: url, + url: file.url, // Needs testing: Does the file get copied before the media handle is be dropped? options: nil) attachments.append(attachment) case .failure(let error): @@ -47,14 +46,14 @@ extension UNMutableNotificationContent { return self } - switch await mediaProvider.loadFileFromSource(mediaSource, fileExtension: "jpg") { - case .success(let url): + switch await mediaProvider.loadFileFromSource(mediaSource) { + case .success(let mediaFile): // Initialize only the sender for a one-to-one message intent. let handle = INPersonHandle(value: senderId, type: .unknown) let sender = try INPerson(personHandle: handle, nameComponents: nil, displayName: senderName, - image: INImage(imageData: Data(contentsOf: url)), + image: INImage(imageData: Data(contentsOf: mediaFile.url)), contactIdentifier: nil, customIdentifier: nil) diff --git a/NSE/SupportingFiles/target.yml b/NSE/SupportingFiles/target.yml index 905d58784..e6878da88 100644 --- a/NSE/SupportingFiles/target.yml +++ b/NSE/SupportingFiles/target.yml @@ -79,7 +79,6 @@ targets: - path: ../../ElementX/Sources/Services/Media - path: ../../ElementX/Sources/Services/Background/BackgroundTaskServiceProtocol.swift - path: ../../ElementX/Sources/Services/Background/BackgroundTaskProtocol.swift - - path: ../../ElementX/Sources/Services/Cache/FileCache.swift - path: ../../ElementX/Sources/Other/Logging - path: ../../ElementX/Sources/Other/Extensions/Task.swift - path: ../../ElementX/Sources/Other/Extensions/FileManager.swift @@ -87,5 +86,6 @@ targets: - path: ../../ElementX/Sources/Other/Extensions/Bundle.swift - path: ../../ElementX/Sources/Other/Extensions/Date.swift - path: ../../ElementX/Sources/Other/Extensions/ImageCache.swift + - path: ../../ElementX/Sources/Other/Extensions/UTType.swift - path: ../../ElementX/Sources/Other/AvatarSize.swift - path: ../../ElementX/Sources/Other/InfoPlistReader.swift diff --git a/UnitTests/Sources/FileCacheTests.swift b/UnitTests/Sources/FileCacheTests.swift deleted file mode 100644 index 31b4bca27..000000000 --- a/UnitTests/Sources/FileCacheTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation -import XCTest - -@testable import ElementX - -class FileCacheTests: XCTestCase { - private var cache: FileCache! - - override func setUp() { - cache = .default - } - - override func tearDown() async throws { - try cache.removeAll() - } - - func testExistence() throws { - let data = Data(repeating: 1, count: 32) - let key = "some_key" - let fileExtension = "mp4" - - let url1 = try cache.store(data, with: fileExtension, forKey: key) - let url2 = cache.file(forKey: key, fileExtension: fileExtension) - - XCTAssertEqual(url1, url2) - } - - func testRemove() throws { - let data = Data(repeating: 1, count: 32) - let key = "some_key" - let fileExtension = "mp4" - - _ = try cache.store(data, with: fileExtension, forKey: key) - try cache.remove(forKey: key, fileExtension: fileExtension) - let url = cache.file(forKey: key, fileExtension: fileExtension) - - XCTAssertNil(url) - } - - func testRemoveAll() throws { - let data1 = Data(repeating: 1, count: 32) - let key1 = "some_key_1" - let fileExtension1 = "mp4" - - let data2 = Data(repeating: 1, count: 64) - let key2 = "some_key_2" - let fileExtension2 = "mp4" - - _ = try cache.store(data1, with: fileExtension1, forKey: key1) - _ = try cache.store(data2, with: fileExtension2, forKey: key2) - try cache.removeAll() - let url1 = cache.file(forKey: key1, fileExtension: fileExtension1) - let url2 = cache.file(forKey: key2, fileExtension: fileExtension2) - - XCTAssertNil(url1) - XCTAssertNil(url2) - } - - func testRemoveAllWhenEmpty() throws { - XCTAssertNoThrow(try cache.removeAll()) - } -} diff --git a/UnitTests/Sources/FilePreviewViewModelTests.swift b/UnitTests/Sources/FilePreviewViewModelTests.swift index 336f8dfdc..97c77d489 100644 --- a/UnitTests/Sources/FilePreviewViewModelTests.swift +++ b/UnitTests/Sources/FilePreviewViewModelTests.swift @@ -24,7 +24,7 @@ class FilePreviewScreenViewModelTests: XCTestCase { var context: FilePreviewViewModelType.Context! @MainActor override func setUpWithError() throws { - viewModel = FilePreviewViewModel(fileURL: URL(staticString: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf")) + viewModel = FilePreviewViewModel(mediaFile: .unmanaged(url: URL(staticString: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"))) context = viewModel.context } diff --git a/UnitTests/Sources/MediaProvider/MediaLoaderTests.swift b/UnitTests/Sources/MediaProvider/MediaLoaderTests.swift index 049dcfa04..d1ed23ae2 100644 --- a/UnitTests/Sources/MediaProvider/MediaLoaderTests.swift +++ b/UnitTests/Sources/MediaProvider/MediaLoaderTests.swift @@ -23,7 +23,7 @@ final class MediaLoaderTests: XCTestCase { let mediaLoadingClient = MockMediaLoadingClient() let mediaLoader = MediaLoader(client: mediaLoadingClient) - let mediaSource = MediaSourceProxy(url: URL.documentsDirectory) + let mediaSource = MediaSourceProxy(url: URL.documentsDirectory, mimeType: nil) do { for _ in 1...10 { @@ -40,7 +40,7 @@ final class MediaLoaderTests: XCTestCase { let mediaLoadingClient = MockMediaLoadingClient() let mediaLoader = MediaLoader(client: mediaLoadingClient) - let mediaSource = MediaSourceProxy(url: URL.documentsDirectory) + let mediaSource = MediaSourceProxy(url: URL.documentsDirectory, mimeType: nil) do { for _ in 1...10 { diff --git a/UnitTests/Sources/MediaProvider/MediaProviderTests.swift b/UnitTests/Sources/MediaProvider/MediaProviderTests.swift index 61e9aa028..1c561c839 100644 --- a/UnitTests/Sources/MediaProvider/MediaProviderTests.swift +++ b/UnitTests/Sources/MediaProvider/MediaProviderTests.swift @@ -21,7 +21,6 @@ import XCTest @MainActor final class MediaProviderTests: XCTestCase { private let mediaLoader = MockMediaLoader() - private let fileCache = MockFileCache() private var imageCache: MockImageCache! private var backgroundTaskService = MockBackgroundTaskService() @@ -31,7 +30,6 @@ final class MediaProviderTests: XCTestCase { imageCache = MockImageCache(name: "Test") mediaProvider = MediaProvider(mediaLoader: mediaLoader, imageCache: imageCache, - fileCache: fileCache, backgroundTaskService: backgroundTaskService) } @@ -46,12 +44,13 @@ final class MediaProviderTests: XCTestCase { let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" let imageForKey = UIImage() imageCache.retrievedImagesInMemory[key] = imageForKey - let image = mediaProvider.imageFromSource(MediaSourceProxy(url: url), size: avatarSize.scaledSize) + let image = mediaProvider.imageFromSource(MediaSourceProxy(url: url, mimeType: "image/jpeg"), + size: avatarSize.scaledSize) XCTAssertEqual(image, imageForKey) } func test_whenImageFromSourceWithSourceNotNilAndImageNotCached_nilReturned() throws { - let image = mediaProvider.imageFromSource(MediaSourceProxy(url: URL.picturesDirectory), + let image = mediaProvider.imageFromSource(MediaSourceProxy(url: URL.picturesDirectory, mimeType: "image/jpeg"), size: AvatarSize.room(on: .timeline).scaledSize) XCTAssertNil(image) } @@ -62,7 +61,8 @@ final class MediaProviderTests: XCTestCase { let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" let imageForKey = UIImage() imageCache.retrievedImagesInMemory[key] = imageForKey - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url), size: avatarSize.scaledSize) + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url, mimeType: "image/jpeg"), + size: avatarSize.scaledSize) XCTAssertEqual(Result.success(imageForKey), result) } @@ -72,7 +72,8 @@ final class MediaProviderTests: XCTestCase { let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" let imageForKey = UIImage() imageCache.retrievedImages[key] = imageForKey - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url), size: avatarSize.scaledSize) + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url, mimeType: "image/jpeg"), + size: avatarSize.scaledSize) XCTAssertEqual(Result.success(imageForKey), result) } @@ -80,7 +81,8 @@ final class MediaProviderTests: XCTestCase { let avatarSize = AvatarSize.room(on: .timeline) let expectedImage = try loadTestImage() mediaLoader.mediaThumbnailData = expectedImage.pngData() - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), size: avatarSize.scaledSize) + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory, mimeType: "image/jpeg"), + size: avatarSize.scaledSize) switch result { case .success(let image): XCTAssertEqual(image.pngData(), expectedImage.pngData()) @@ -95,7 +97,8 @@ final class MediaProviderTests: XCTestCase { let key = "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}" let expectedImage = try loadTestImage() mediaLoader.mediaThumbnailData = expectedImage.pngData() - _ = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url), size: avatarSize.scaledSize) + _ = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: url, mimeType: "image/jpeg"), + size: avatarSize.scaledSize) let storedImage = try XCTUnwrap(imageCache.storedImages[key]) XCTAssertEqual(expectedImage.pngData(), storedImage.pngData()) } @@ -103,7 +106,8 @@ final class MediaProviderTests: XCTestCase { func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndNoAvatarSize_imageContentIsLoaded() async throws { let expectedImage = try loadTestImage() mediaLoader.mediaContentData = expectedImage.pngData() - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), size: nil) + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory, mimeType: "image/jpeg"), + size: nil) switch result { case .success(let image): XCTAssertEqual(image.pngData(), expectedImage.pngData()) @@ -113,7 +117,7 @@ final class MediaProviderTests: XCTestCase { } func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndLoadImageThumbnailFails_errorIsThrown() async throws { - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory, mimeType: "image/jpeg"), size: AvatarSize.room(on: .timeline).scaledSize) switch result { case .success: @@ -124,7 +128,8 @@ final class MediaProviderTests: XCTestCase { } func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndNoAvatarSizeAndLoadImageContentFails_errorIsThrown() async throws { - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), size: nil) + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory, mimeType: "image/jpeg"), + size: nil) switch result { case .success: XCTFail("Should fail") @@ -135,7 +140,7 @@ final class MediaProviderTests: XCTestCase { func test_whenLoadImageFromSourceAndImageNotCachedAndRetrieveImageFailsAndImageThumbnailIsLoadedWithCorruptedData_errorIsThrown() async throws { mediaLoader.mediaThumbnailData = Data() - let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory), + let result = await mediaProvider.loadImageFromSource(MediaSourceProxy(url: URL.picturesDirectory, mimeType: "image/jpeg"), size: AvatarSize.room(on: .timeline).scaledSize) switch result { case .success: @@ -145,51 +150,18 @@ final class MediaProviderTests: XCTestCase { } } - func test_whenFileFromSourceWithSourceNil_nilIsReturned() throws { - let url = mediaProvider.fileFromSource(nil, fileExtension: "png") - XCTAssertNil(url) - } - - func test_whenFileFromSourceWithSource_correctValuesAreReturned() throws { - let expectedURL = URL(filePath: "/some/file/path") - fileCache.fileURLToReturn = expectedURL - let url = mediaProvider.fileFromSource(MediaSourceProxy(url: URL(staticString: "test/test1")), fileExtension: "png") - XCTAssertEqual(fileCache.fileKey, "test1") - XCTAssertEqual(fileCache.fileExtension, "png") - XCTAssertEqual(url?.absoluteString, expectedURL.absoluteString) - } - func test_whenLoadFileFromSourceAndFileFromSourceExists_urlIsReturned() async throws { let expectedURL = URL(filePath: "/some/file/path") - let expectedResult: Result = .success(expectedURL) - fileCache.fileURLToReturn = expectedURL - let result = await mediaProvider.loadFileFromSource(MediaSourceProxy(url: URL(staticString: "test/test1")), fileExtension: "png") + let expectedResult: Result = .success(.unmanaged(url: expectedURL)) + mediaLoader.mediaFileURL = expectedURL + let result = await mediaProvider.loadFileFromSource(MediaSourceProxy(url: URL(staticString: "test/test1"), mimeType: "video/mp4")) XCTAssertEqual(result, expectedResult) } - func test_whenLoadFileFromSourceAndNoFileFromSourceExists_mediaLoadedFromSource() async throws { - let expectedURL = URL(filePath: "/some/file/path") - let expectedResult: Result = .success(expectedURL) - mediaLoader.mediaContentData = try loadTestImage().pngData() - fileCache.storeURLToReturn = expectedURL - let result = await mediaProvider.loadFileFromSource(MediaSourceProxy(url: URL(staticString: "test/test1")), fileExtension: "png") - XCTAssertEqual(result, expectedResult) - XCTAssertEqual(mediaLoader.mediaContentData, fileCache.storedData) - XCTAssertEqual("test1", fileCache.storedFileKey) - XCTAssertEqual("png", fileCache.storedFileExtension) - } - func test_whenLoadFileFromSourceAndNoFileFromSourceExistsAndLoadContentSourceFails_failureIsReturned() async throws { - let expectedResult: Result = .failure(.failedRetrievingImage) - mediaLoader.mediaContentData = nil - let result = await mediaProvider.loadFileFromSource(MediaSourceProxy(url: URL(staticString: "test/test1")), fileExtension: "png") - XCTAssertEqual(result, expectedResult) - } - - func test_whenLoadFileFromSourceAndNoFileFromSourceExistsAndStoreDataFails_failureIsReturned() async throws { - let expectedResult: Result = .failure(.failedRetrievingImage) - mediaLoader.mediaContentData = try loadTestImage().pngData() - let result = await mediaProvider.loadFileFromSource(MediaSourceProxy(url: URL(staticString: "test/test1")), fileExtension: "png") + let expectedResult: Result = .failure(.failedRetrievingFile) + mediaLoader.mediaFileURL = nil + let result = await mediaProvider.loadFileFromSource(MediaSourceProxy(url: URL(staticString: "test/test1"), mimeType: "video/mp4")) XCTAssertEqual(result, expectedResult) } diff --git a/UnitTests/Sources/MediaProvider/MockFileCache.swift b/UnitTests/Sources/MediaProvider/MockFileCache.swift deleted file mode 100644 index 2943dc59f..000000000 --- a/UnitTests/Sources/MediaProvider/MockFileCache.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -@testable import ElementX -import Foundation - -enum MockFileCacheError: Error { - case someError -} - -class MockFileCache: FileCacheProtocol { - var fileKey: String? - var fileExtension: String? - var fileURLToReturn: URL? - var storedData: Data? - var storedFileExtension: String? - var storedFileKey: String? - var storeURLToReturn: URL? - - func file(forKey key: String, fileExtension: String) -> URL? { - fileKey = key - self.fileExtension = fileExtension - return fileURLToReturn - } - - func store(_ data: Data, with fileExtension: String, forKey key: String) throws -> URL { - storedData = data - storedFileExtension = fileExtension - storedFileKey = key - if let storeURLToReturn { - return storeURLToReturn - } else { - throw MockFileCacheError.someError - } - } - - func remove(forKey key: String, fileExtension: String) throws { } - - func removeAll() throws { } -} diff --git a/UnitTests/Sources/MediaProvider/MockMediaLoader.swift b/UnitTests/Sources/MediaProvider/MockMediaLoader.swift index 7a7b67545..4ad1732eb 100644 --- a/UnitTests/Sources/MediaProvider/MockMediaLoader.swift +++ b/UnitTests/Sources/MediaProvider/MockMediaLoader.swift @@ -23,6 +23,7 @@ enum MockMediaLoaderError: Error { class MockMediaLoader: MediaLoaderProtocol { var mediaContentData: Data? var mediaThumbnailData: Data? + var mediaFileURL: URL? func loadMediaContentForSource(_ source: ElementX.MediaSourceProxy) async throws -> Data { if let mediaContentData { @@ -39,4 +40,12 @@ class MockMediaLoader: MediaLoaderProtocol { throw MockMediaLoaderError.someError } } + + func loadMediaFileForSource(_ source: MediaSourceProxy) async throws -> MediaFileHandleProxy { + if let mediaFileURL { + return .unmanaged(url: mediaFileURL) + } else { + throw MockMediaLoaderError.someError + } + } } diff --git a/UnitTests/Sources/MessageTimelineItemTests.swift b/UnitTests/Sources/MessageTimelineItemTests.swift new file mode 100644 index 000000000..63641b975 --- /dev/null +++ b/UnitTests/Sources/MessageTimelineItemTests.swift @@ -0,0 +1,167 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@testable import ElementX +import MatrixRustSDK +import XCTest + +// swiftlint:disable force_unwrapping +class MessageTimelineItemTests: XCTestCase { + // MARK: Image + + func testImageContentType() { + let mimetype = "image/gif" + let imageContent = ImageMessageContent(body: "amazing.gif", + source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), + info: makeImageInfo(mimetype: mimetype)) + let messageItem = MockEventTimelineItem.message(from: imageContent) + XCTAssertEqual(messageItem.contentType, .gif) + } + + func testImageContentTypeWithoutMimetype() { + let imageContent = ImageMessageContent(body: "amazing.jpeg", + source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), + info: makeImageInfo(mimetype: nil)) + let messageItem = MockEventTimelineItem.message(from: imageContent) + XCTAssertEqual(messageItem.contentType, .jpeg) + } + + func makeImageInfo(mimetype: String?) -> ImageInfo { + ImageInfo(height: nil, + width: nil, + mimetype: mimetype, + size: nil, + thumbnailInfo: nil, + thumbnailSource: nil, + blurhash: nil) + } + + // MARK: Video + + func testVideoContentType() { + let mimetype = "video/x-msvideo" + let imageContent = VideoMessageContent(body: "amazing.avi", + source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), + info: makeVideoInfo(mimetype: mimetype)) + let messageItem = MockEventTimelineItem.message(from: imageContent) + XCTAssertEqual(messageItem.contentType, .avi) + } + + func testVideoContentTypeWithoutMimetype() { + let imageContent = VideoMessageContent(body: "amazing.mp4", + source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), + info: makeVideoInfo(mimetype: nil)) + let messageItem = MockEventTimelineItem.message(from: imageContent) + XCTAssertEqual(messageItem.contentType, .mpeg4Movie) + } + + func makeVideoInfo(mimetype: String?) -> VideoInfo { + VideoInfo(duration: nil, + height: nil, + width: nil, + mimetype: mimetype, + size: nil, + thumbnailInfo: nil, + thumbnailSource: nil, + blurhash: nil) + } + + // MARK: Audio + + func testAudioContentType() { + let mimetype = "audio/mp3" + let imageContent = AudioMessageContent(body: "amazing.mp3", + source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), + info: makeAudioInfo(mimetype: mimetype)) + let messageItem = MockEventTimelineItem.message(from: imageContent) + XCTAssertEqual(messageItem.contentType, .mp3) + } + + func testAudioContentTypeWithoutMimetype() { + let imageContent = AudioMessageContent(body: "amazing.m4a", + source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), + info: makeAudioInfo(mimetype: nil)) + let messageItem = MockEventTimelineItem.message(from: imageContent) + XCTAssertTrue(messageItem.contentType!.conforms(to: .mpeg4Audio)) + } + + func makeAudioInfo(mimetype: String?) -> AudioInfo { + AudioInfo(duration: nil, size: nil, mimetype: mimetype) + } + + // MARK: File + + func testFileContentType() { + let mimetype = "text" + let imageContent = FileMessageContent(body: "amazing.txt", + source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), + info: makeFileInfo(mimetype: mimetype)) + let messageItem = MockEventTimelineItem.message(from: imageContent) + XCTAssertEqual(messageItem.contentType, .plainText) + } + + func testFileContentTypeWithoutMimetype() { + let imageContent = FileMessageContent(body: "amazing.rtf", + source: mediaSourceFromUrl(url: "mxc://doesnt/matter"), + info: makeFileInfo(mimetype: nil)) + let messageItem = MockEventTimelineItem.message(from: imageContent) + XCTAssertEqual(messageItem.contentType, .rtf) + } + + func makeFileInfo(mimetype: String?) -> FileInfo { + FileInfo(mimetype: mimetype, size: nil, thumbnailInfo: nil, thumbnailSource: nil) + } +} + +// MARK: - Mocks + +// swiftlint:disable force_cast +private struct MockEventTimelineItem: EventTimelineItemProtocol { + static func message(from content: Content) -> MessageTimelineItem { + let item = MockEventTimelineItem(underlyingContent: content) + return MessageTimelineItem(item: item, content: content) + } + + let underlyingContent: MessageContentProtocol + + func content() -> MatrixRustSDK.TimelineItemContent { underlyingContent as! TimelineItemContent } + + func eventId() -> String? { UUID().uuidString } + + func fmtDebug() -> String { "MockEvent" } + + func isEditable() -> Bool { false } + + func isLocal() -> Bool { false } + + func isOwn() -> Bool { false } + + func isRemote() -> Bool { true } + + func localSendState() -> MatrixRustSDK.EventSendState? { nil } + + func raw() -> String? { nil } + + func reactions() -> [MatrixRustSDK.Reaction]? { nil } + + func sender() -> String { "@user:server.com" } + + func senderProfile() -> MatrixRustSDK.ProfileTimelineDetails { .unavailable } + + func timestamp() -> UInt64 { 0 } + + func uniqueIdentifier() -> String { eventId() ?? "" } +} diff --git a/changelog.d/316.change b/changelog.d/316.change new file mode 100644 index 000000000..dc458a0f1 --- /dev/null +++ b/changelog.d/316.change @@ -0,0 +1 @@ +Move media file loading logic to the SDK.