// // Copyright 2023, 2024 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. // import UniformTypeIdentifiers import XCTest @testable import ElementX final class MediaUploadingPreprocessorTests: XCTestCase { var appSettings: AppSettings! var mediaUploadingPreprocessor: MediaUploadingPreprocessor! override func setUp() { AppSettings.resetAllSettings() appSettings = AppSettings() appSettings.optimizeMediaUploads = false ServiceLocator.shared.register(appSettings: appSettings) mediaUploadingPreprocessor = MediaUploadingPreprocessor(appSettings: appSettings) } override func tearDown() { AppSettings.resetAllSettings() } func testAudioFileProcessing() async { guard let url = Bundle(for: Self.self).url(forResource: "test_audio.mp3", withExtension: nil) else { XCTFail("Failed retrieving test asset") return } guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url), case let .audio(audioURL, audioInfo) = result else { XCTFail("Failed processing asset") return } // Check that the file name is preserved XCTAssertEqual(audioURL.lastPathComponent, "test_audio.mp3") XCTAssertEqual(audioInfo.mimetype, "audio/mpeg") XCTAssertEqual(audioInfo.duration ?? 0, 27, accuracy: 100) XCTAssertEqual(audioInfo.size ?? 0, 194_811, accuracy: 100) } func testLandscapeMovVideoProcessing() async { // Allow an increased execution time as we encode the video twice now. executionTimeAllowance = 180 guard let url = Bundle(for: Self.self).url(forResource: "landscape_test_video.mov", withExtension: nil) else { XCTFail("Failed retrieving test asset") return } guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url), case let .video(videoURL, thumbnailURL, videoInfo) = result else { XCTFail("Failed processing asset") return } // Check that the file name is preserved XCTAssertEqual(videoURL.lastPathComponent, "landscape_test_video.mp4") XCTAssertEqual(videoURL.pathExtension, "mp4", "The file extension should match the container we use.") // Check that the thumbnail is generated correctly guard let thumbnailData = try? Data(contentsOf: thumbnailURL), let thumbnail = UIImage(data: thumbnailData) else { XCTFail("Invalid thumbnail") return } XCTAssert(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width) XCTAssert(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height) // Check resulting video info XCTAssertEqual(videoInfo.mimetype, "video/mp4") XCTAssertEqual(videoInfo.blurhash, "K9F$LJZ9,+8yA9-:yT,@%1") XCTAssertEqual(videoInfo.size ?? 0, 4_016_620, accuracy: 100) XCTAssertEqual(videoInfo.width, 1280) XCTAssertEqual(videoInfo.height, 720) XCTAssertEqual(videoInfo.duration ?? 0, 30, accuracy: 100) XCTAssertNotNil(videoInfo.thumbnailInfo) XCTAssertEqual(videoInfo.thumbnailInfo?.mimetype, "image/jpeg") XCTAssertEqual(videoInfo.thumbnailInfo?.size ?? 0, 183_093, accuracy: 100) XCTAssertEqual(videoInfo.thumbnailInfo?.width, 800) XCTAssertEqual(videoInfo.thumbnailInfo?.height, 450) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else { XCTFail("Failed processing asset") return } XCTAssertEqual(optimizedVideoURL.pathExtension, "mp4", "The file extension should match the container we use.") // Check optimised video info XCTAssertEqual(optimizedVideoInfo.mimetype, "video/mp4") XCTAssertEqual(optimizedVideoInfo.blurhash, "K9F$LJZ9,+8yA9-:yT,@%1") XCTAssertEqual(optimizedVideoInfo.size ?? 0, 4_016_620, accuracy: 100) // Note: The video is already 720p so it doesn't change size. XCTAssertEqual(optimizedVideoInfo.width, 1280) XCTAssertEqual(optimizedVideoInfo.height, 720) XCTAssertEqual(optimizedVideoInfo.duration ?? 0, 30, accuracy: 100) } func testPortraitMp4VideoProcessing() async { // Allow an increased execution time as we encode the video twice now. executionTimeAllowance = 180 guard let url = Bundle(for: Self.self).url(forResource: "portrait_test_video.mp4", withExtension: nil) else { XCTFail("Failed retrieving test asset") return } guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url), case let .video(videoURL, thumbnailURL, videoInfo) = result else { XCTFail("Failed processing asset") return } // Check that the file name is preserved XCTAssertEqual(videoURL.lastPathComponent, "portrait_test_video.mp4") XCTAssertEqual(videoURL.pathExtension, "mp4", "The file extension should match the container we use.") // Check that the thumbnail is generated correctly guard let thumbnailData = try? Data(contentsOf: thumbnailURL), let thumbnail = UIImage(data: thumbnailData) else { XCTFail("Invalid thumbnail") return } XCTAssert(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width) XCTAssert(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height) // Check resulting video info XCTAssertEqual(videoInfo.mimetype, "video/mp4") XCTAssertEqual(videoInfo.blurhash, "KSB{UFO]MuwQS4oJvcaIt8") XCTAssertEqual(videoInfo.size ?? 0, 5_824_946, accuracy: 100) XCTAssertEqual(videoInfo.width, 1080) XCTAssertEqual(videoInfo.height, 1920) XCTAssertEqual(videoInfo.duration ?? 0, 21, accuracy: 100) XCTAssertNotNil(videoInfo.thumbnailInfo) XCTAssertEqual(videoInfo.thumbnailInfo?.mimetype, "image/jpeg") XCTAssertEqual(videoInfo.thumbnailInfo?.size ?? 0, 41444, accuracy: 100) XCTAssertEqual(videoInfo.thumbnailInfo?.width, 337) XCTAssertEqual(videoInfo.thumbnailInfo?.height, 600) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else { XCTFail("Failed processing asset") return } XCTAssertEqual(optimizedVideoURL.pathExtension, "mp4", "The file extension should match the container we use.") // Check optimised video info XCTAssertEqual(optimizedVideoInfo.mimetype, "video/mp4") XCTAssertEqual(optimizedVideoInfo.blurhash, "KSB{UFO]MuwQS4oJvcaIt8") XCTAssertEqual(optimizedVideoInfo.size ?? 0, 12_169_117, accuracy: 100) // Note: This is slightly stupid because it is larger now 🤦‍♂️ XCTAssertEqual(optimizedVideoInfo.width, 720) XCTAssertEqual(optimizedVideoInfo.height, 1280) XCTAssertEqual(optimizedVideoInfo.duration ?? 0, 30, accuracy: 100) } func testLandscapeImageProcessing() async { guard let url = Bundle(for: Self.self).url(forResource: "landscape_test_image.jpg", withExtension: nil) else { XCTFail("Failed retrieving test asset") return } guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url), case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else { XCTFail("Failed processing asset") return } compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) // Check resulting image info XCTAssertEqual(imageInfo.mimetype, "image/jpeg") XCTAssertEqual(imageInfo.blurhash, "K%I#.NofkC_4ayayxujsWB") XCTAssertEqual(imageInfo.size ?? 0, 3_305_795, accuracy: 100) XCTAssertEqual(imageInfo.width, 6103) XCTAssertEqual(imageInfo.height, 2621) XCTAssertNotNil(imageInfo.thumbnailInfo) XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 87733, accuracy: 100) XCTAssertEqual(imageInfo.thumbnailInfo?.width, 800) XCTAssertEqual(imageInfo.thumbnailInfo?.height, 344) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { XCTFail("Failed processing asset") return } compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) // Check optimised image info XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg") XCTAssertEqual(optimizedImageInfo.blurhash, "K%I#.NofkC_4ayaxxujsWB") XCTAssertEqual(optimizedImageInfo.size ?? 0, 524_226, accuracy: 100) XCTAssertEqual(optimizedImageInfo.width, 2048) XCTAssertEqual(optimizedImageInfo.height, 879) } func testPortraitImageProcessing() async { guard let url = Bundle(for: Self.self).url(forResource: "portrait_test_image.jpg", withExtension: nil) else { XCTFail("Failed retrieving test asset") return } guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url), case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else { XCTFail("Failed processing asset") return } compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) // Check resulting image info XCTAssertEqual(imageInfo.mimetype, "image/jpeg") XCTAssertEqual(imageInfo.blurhash, "KdE|0Ls+RP^-n*RP%OWAV@") XCTAssertEqual(imageInfo.size ?? 0, 4_414_666, accuracy: 100) XCTAssertEqual(imageInfo.width, 3024) XCTAssertEqual(imageInfo.height, 4032) XCTAssertNotNil(imageInfo.thumbnailInfo) XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 258_914, accuracy: 100) XCTAssertEqual(imageInfo.thumbnailInfo?.width, 600) XCTAssertEqual(imageInfo.thumbnailInfo?.height, 800) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { XCTFail("Failed processing asset") return } compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) // Check optimised image info XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg") XCTAssertEqual(optimizedImageInfo.blurhash, "KdE|0Ls+RP^-n*RP%OWAV@") XCTAssertEqual(optimizedImageInfo.size ?? 0, 1_462_937, accuracy: 100) XCTAssertEqual(optimizedImageInfo.width, 1536) XCTAssertEqual(optimizedImageInfo.height, 2048) } func testPNGImageProcessing() async { guard let url = Bundle(for: Self.self).url(forResource: "test_image.png", withExtension: nil) else { XCTFail("Failed retrieving test asset") return } guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url), case let .image(convertedImageURL, _, imageInfo) = result else { XCTFail("Failed processing asset") return } // Make sure the output file matches the image info. XCTAssertEqual(mimeType(from: convertedImageURL), "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.") XCTAssertEqual(convertedImageURL.pathExtension, "png", "The file extension should match the MIME type.") // Check resulting image info XCTAssertEqual(imageInfo.mimetype, "image/png") XCTAssertEqual(imageInfo.blurhash, "K0TSUA~qfQ~qj[fQfQfQfQ") XCTAssertEqual(imageInfo.size ?? 0, 4868, accuracy: 100) XCTAssertEqual(imageInfo.width, 240) XCTAssertEqual(imageInfo.height, 240) XCTAssertNotNil(imageInfo.thumbnailInfo) XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 1725, accuracy: 100) XCTAssertEqual(imageInfo.thumbnailInfo?.width, 240) XCTAssertEqual(imageInfo.thumbnailInfo?.height, 240) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else { XCTFail("Failed processing asset") return } // Make sure the output file matches the image info. XCTAssertEqual(mimeType(from: optimizedImageURL), "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.") XCTAssertEqual(optimizedImageURL.pathExtension, "png", "The file extension should match the MIME type.") // Check optimised image info XCTAssertEqual(optimizedImageInfo.mimetype, "image/png") XCTAssertEqual(optimizedImageInfo.blurhash, "K0TSUA~qfQ~qj[fQfQfQfQ") XCTAssertEqual(optimizedImageInfo.size ?? 0, 8199, accuracy: 100) // Assert that resizing didn't upscale to the maxPixelSize. XCTAssertEqual(optimizedImageInfo.width, 240) XCTAssertEqual(optimizedImageInfo.height, 240) } func testHEICImageProcessing() async { guard let url = Bundle(for: Self.self).url(forResource: "test_apple_image.heic", withExtension: nil) else { XCTFail("Failed retrieving test asset") return } guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url), case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else { XCTFail("Failed processing asset") return } compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) // Make sure the output file matches the image info. XCTAssertEqual(mimeType(from: convertedImageURL), "image/heic", "Unoptimised HEICs should always be sent as is.") XCTAssertEqual(convertedImageURL.pathExtension, "heic", "The file extension should match the MIME type.") // Check resulting image info XCTAssertEqual(imageInfo.mimetype, "image/heic") XCTAssertEqual(imageInfo.blurhash, "KGD]3ns:T00$kWxFXmt6xv") XCTAssertEqual(imageInfo.size ?? 0, 1_850_479, accuracy: 100) XCTAssertEqual(imageInfo.width, 3024) XCTAssertEqual(imageInfo.height, 4032) XCTAssertNotNil(imageInfo.thumbnailInfo) XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 218_108, accuracy: 100) XCTAssertEqual(imageInfo.thumbnailInfo?.width, 600) XCTAssertEqual(imageInfo.thumbnailInfo?.height, 800) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { XCTFail("Failed processing asset") return } compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) // Make sure the output file matches the image info. XCTAssertEqual(mimeType(from: optimizedImageURL), "image/jpeg", "Optimised HEICs should always be converted to JPEG for compatibility.") XCTAssertEqual(optimizedImageURL.pathExtension, "jpeg", "The file extension should match the MIME type.") // Check optimised image info XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg") XCTAssertEqual(optimizedImageInfo.blurhash, "KGD]3ns:T00#kWxFb^s:xv") XCTAssertEqual(optimizedImageInfo.size ?? 0, 1_049_393, accuracy: 100) XCTAssertEqual(optimizedImageInfo.width, 1536) XCTAssertEqual(optimizedImageInfo.height, 2048) } func testGIFImageProcessing() async { guard let url = Bundle(for: Self.self).url(forResource: "test_animated_image.gif", withExtension: nil) else { XCTFail("Failed retrieving test asset") return } guard let originalSize = try? FileManager.default.sizeForItem(at: url), originalSize > 0 else { XCTFail("Failed fetching test asset's original size") return } guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url), case let .image(convertedImageURL, _, imageInfo) = result else { XCTFail("Failed processing asset") return } // Make sure the output file matches the image info. XCTAssertEqual(mimeType(from: convertedImageURL), "image/gif", "GIFs should always be sent as GIF to preserve the animation.") XCTAssertEqual(convertedImageURL.pathExtension, "gif", "The file extension should match the MIME type.") // Check resulting image info XCTAssertEqual(imageInfo.mimetype, "image/gif") XCTAssertEqual(imageInfo.blurhash, "KpRMPTj[_NxuaeRj%MofMx") XCTAssertEqual(imageInfo.size ?? 0, UInt64(originalSize), accuracy: 100) XCTAssertEqual(imageInfo.width, 331) XCTAssertEqual(imageInfo.height, 472) XCTAssertNotNil(imageInfo.thumbnailInfo) XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 34215, accuracy: 100) XCTAssertEqual(imageInfo.thumbnailInfo?.width, 331) XCTAssertEqual(imageInfo.thumbnailInfo?.height, 472) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else { XCTFail("Failed processing asset") return } // Make sure the output file matches the image info. XCTAssertEqual(mimeType(from: optimizedImageURL), "image/gif", "GIFs should always be sent as GIF to preserve the animation.") XCTAssertEqual(optimizedImageURL.pathExtension, "gif", "The file extension should match the MIME type.") // Ensure optimised image is still the same as the original image. XCTAssertEqual(optimizedImageInfo.mimetype, "image/gif") XCTAssertEqual(optimizedImageInfo.blurhash, "KpRMPTj[_NxuaeRj%MofMx") XCTAssertEqual(optimizedImageInfo.size ?? 0, UInt64(originalSize), accuracy: 100) XCTAssertEqual(optimizedImageInfo.width, 331) XCTAssertEqual(optimizedImageInfo.height, 472) } func testRotatedImageProcessing() async { guard let url = Bundle(for: Self.self).url(forResource: "test_rotated_image.jpg", withExtension: nil) else { XCTFail("Failed retrieving test asset") return } guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url), case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else { XCTFail("Failed processing asset") return } compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) // Check resulting image info XCTAssertEqual(imageInfo.mimetype, "image/jpeg") XCTAssertEqual(imageInfo.width, 2848) XCTAssertEqual(imageInfo.height, 4272) XCTAssertNotNil(imageInfo.thumbnailInfo) XCTAssertEqual(imageInfo.thumbnailInfo?.width, 533) XCTAssertEqual(imageInfo.thumbnailInfo?.height, 800) // Repeat with optimised media setting appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { XCTFail("Failed processing asset") return } compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) // Check optimised image info XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg") XCTAssertEqual(optimizedImageInfo.width, 1365) XCTAssertEqual(optimizedImageInfo.height, 2048) } // MARK: - Private private func compare(originalImageAt originalImageURL: URL, toConvertedImageAt convertedImageURL: URL, withThumbnailAt thumbnailURL: URL) { guard let originalImageData = try? Data(contentsOf: originalImageURL), let originalImage = UIImage(data: originalImageData), let convertedImageData = try? Data(contentsOf: convertedImageURL), let convertedImage = UIImage(data: convertedImageData) else { fatalError() } if appSettings.optimizeMediaUploads { // Check that new image has been scaled within the requirements for an optimised image XCTAssert(convertedImage.size.width <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize) XCTAssert(convertedImage.size.height <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize) } else { // Check that the file name is preserved XCTAssertEqual(originalImageURL.lastPathComponent, convertedImageURL.lastPathComponent) // Check that new image is the same size as the original one XCTAssertEqual(originalImage.size, convertedImage.size) } // Check that the GPS data has been stripped let originalMetadata = metadata(from: originalImageData) XCTAssertNotNil(originalMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)")) let convertedMetadata = metadata(from: convertedImageData) XCTAssertNil(convertedMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)")) // Check that the thumbnail is generated correctly guard let thumbnailData = try? Data(contentsOf: thumbnailURL), let thumbnail = UIImage(data: thumbnailData) else { XCTFail("Invalid thumbnail") return } if thumbnail.size.width > thumbnail.size.height { XCTAssert(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width) XCTAssert(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height) } else { XCTAssert(thumbnail.size.width <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.height) XCTAssert(thumbnail.size.height <= MediaUploadingPreprocessor.Constants.maximumThumbnailSize.width) } let thumbnailMetadata = metadata(from: thumbnailData) XCTAssertNil(thumbnailMetadata.value(forKeyPath: "\(kCGImagePropertyGPSDictionary)")) } private func metadata(from imageData: Data) -> NSDictionary { guard let imageSource = CGImageSourceCreateWithData(imageData as NSData, nil) else { XCTFail("Invalid asset") return [:] } guard let convertedMetadata: NSDictionary = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) else { XCTFail("Test asset is expected to contain metadata") return [:] } return convertedMetadata } private func mimeType(from url: URL) -> String? { guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil), let typeIdentifier = CGImageSourceGetType(imageSource), let type = UTType(typeIdentifier as String), let mimeType = type.preferredMIMEType else { XCTFail("Failed to get mimetype from URL.") return nil } return mimeType } }