diff --git a/WordPress/Classes/Extensions/Math.swift b/WordPress/Classes/Extensions/Math.swift index 59ba550f740c..c19f51797659 100644 --- a/WordPress/Classes/Extensions/Math.swift +++ b/WordPress/Classes/Extensions/Math.swift @@ -49,6 +49,14 @@ extension CGSize { func clamp(min minValue: Int, max maxValue: Int) -> CGSize { return clamp(min: CGFloat(minValue), max: CGFloat(maxValue)) } + + func scaled(by scale: CGFloat) -> CGSize { + CGSize(width: width * scale, height: height * scale) + } + + func rounded() -> CGSize { + CGSize(width: width.rounded(), height: height.rounded()) + } } extension CGFloat { diff --git a/WordPress/Classes/Models/Media.h b/WordPress/Classes/Models/Media.h index 43dd0cd17d97..0f7703fea6c1 100644 --- a/WordPress/Classes/Models/Media.h +++ b/WordPress/Classes/Models/Media.h @@ -68,10 +68,8 @@ typedef NS_ENUM(NSUInteger, MediaType) { @property (nonatomic, strong, nullable) NSURL *absoluteLocalURL; /** - Local file URL for a preprocessed thumbnail of the Media's asset. This may be nil if the - thumbnail has been deleted from the cache directory. - - Note: it is recommended to instead use MediaService to generate thumbnails with a preferred size. + Local file URL for a preprocessed **large** thumbnail that can be used for + a full-screen presentation. */ @property (nonatomic, strong, nullable) NSURL *absoluteThumbnailLocalURL; diff --git a/WordPress/Classes/Services/MediaImageService.swift b/WordPress/Classes/Services/MediaImageService.swift index c0eb3f5d6859..c51e193eae29 100644 --- a/WordPress/Classes/Services/MediaImageService.swift +++ b/WordPress/Classes/Services/MediaImageService.swift @@ -1,16 +1,19 @@ -import Foundation +import UIKit +import CoreData -/// A service for handling the process of retrieving and generating thumbnail images -/// for existing Media objects, whether remote or locally available. +/// A service for retrieval and caching of thumbnails for Media objects. final class MediaImageService: NSObject { static let shared = MediaImageService() private let session: URLSession private let coreDataStack: CoreDataStackSwift + private let mediaFileManager: MediaFileManager private let ioQueue = DispatchQueue(label: "org.automattic.MediaImageService") - init(coreDataStack: CoreDataStackSwift = ContextManager.shared) { + init(coreDataStack: CoreDataStackSwift = ContextManager.shared, + mediaFileManager: MediaFileManager = MediaFileManager(directory: .cache)) { self.coreDataStack = coreDataStack + self.mediaFileManager = mediaFileManager let configuration = URLSessionConfiguration.default // `MediaImageService` has its own disk cache, so it's important to @@ -19,65 +22,118 @@ final class MediaImageService: NSObject { self.session = URLSession(configuration: configuration) } + static func migrateCacheIfNeeded() { + let didMigrateKey = "MediaImageService-didMigrateCacheKey" + guard Feature.enabled(.mediaModernization) && !UserDefaults.standard.bool(forKey: didMigrateKey) else { + return + } + UserDefaults.standard.set(true, forKey: didMigrateKey) + DispatchQueue.global(qos: .utility).async { + MediaFileManager.clearAllMediaCacheFiles(onCompletion: nil, onError: nil) + } + } + // MARK: - Thumbnails - /// Returns a preferred thumbnail size (in pixels) optimized for the device. - /// - /// - important: It makes sure the app uses the same thumbnails across - /// different screens and presentation modes to avoid fetching and caching - /// more than one version of the same image. - static let preferredThumbnailSize: CGSize = { - let scale = UIScreen.main.scale - let targetSize = preferredThumbnailPointSize - return CGSize(width: targetSize.width * scale, height: targetSize.height * scale) - }() - - static let preferredThumbnailPointSize: CGSize = { - let screenSide = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) - let itemPerRow = UIDevice.current.userInterfaceIdiom == .pad ? 5 : 4 - let availableWidth = screenSide - MediaViewController.spacing * CGFloat(itemPerRow - 1) - let targetSize = (availableWidth / CGFloat(itemPerRow)).rounded(.down) - return CGSize(width: targetSize, height: targetSize) - }() - - /// Returns a decompressed thumbnail optimized for the device. - /// - /// For local images added to the library, it expects the `absoluteThumbnailLocalURL` - /// to be set by `MediaImportService`. + /// Returns a thumbnail for the given media asset. The images are decompressed + /// (or bitmapped) and are ready to be displayed. @MainActor - func thumbnail(for media: Media) async throws -> UIImage { + func thumbnail(for media: Media, size: ThumbnailSize = .small) async throws -> UIImage { guard media.remoteStatus != .stub else { let media = try await fetchStubMedia(for: media) - // This should never happen, but adding it just in case to avoid recusion - guard media.remoteStatus != .stub else { - assertionFailure("The fetched media still has a .stub status") - throw MediaThumbnailExporter.ThumbnailExportError.failedToGenerateThumbnailFileURL - } - return try await _thumbnail(for: media) + return try await _thumbnail(for: media, size: size) } - - return try await _thumbnail(for: media) + return try await _thumbnail(for: media, size: size) } @MainActor - private func _thumbnail(for media: Media) async throws -> UIImage { - if let fileURL = media.absoluteThumbnailLocalURL, - let image = try? await decompressedImage(forFileURL: fileURL) { + private func _thumbnail(for media: Media, size: ThumbnailSize) async throws -> UIImage { + if let image = await cachedThumbnail(for: media.objectID, size: size) { return image } - let targetSize = MediaImageService.preferredThumbnailSize - return try await remoteThumbnail(for: media, targetSize: targetSize) + if let image = await localThumbnail(for: media, size: size) { + return image + } + return try await remoteThumbnail(for: media, size: size) + } + + // MARK: - Cached Thumbnail + + /// Returns a local thumbnail for the given media object (if available). + private func cachedThumbnail(for mediaID: NSManagedObjectID, size: ThumbnailSize) async -> UIImage? { + return try? await Task.detached { + let imageURL = try self.getCachedThumbnailURL(for: mediaID, size: size) + let data = try Data(contentsOf: imageURL) + return try decompressedImage(from: data) + }.value + } + + // The save is performed asynchronously to eliminate any delays. It's + // exceedingly unlikely it will result in any duplicated work thanks to the + // memore caches. + private func saveThumbnail(for mediaID: NSManagedObjectID, size: ThumbnailSize, _ closure: @escaping (URL) throws -> Void) { + ioQueue.async { + if let targetURL = try? self.getCachedThumbnailURL(for: mediaID, size: size) { + try? closure(targetURL) + } + } + } + + private func getCachedThumbnailURL(for mediaID: NSManagedObjectID, size: ThumbnailSize) throws -> URL { + let mediaID = mediaID.uriRepresentation().lastPathComponent + return try mediaFileManager.makeLocalMediaURL( + withFilename: "\(mediaID)-\(size.rawValue)-thumbnail", + fileExtension: nil, // We don't know ahead of time + incremented: false + ) } - /// Downloads thumbnail for the given media object and saves it locally. Returns - /// a file URL for the downloaded thumbnail. + /// Flushes all pending I/O changes to disk. /// - /// - Parameters: - /// - media: The Media object. - /// - targetSize: An ideal size of the thumbnail in pixels. + /// - warning: For testing purposes only. + func flush() { + ioQueue.sync {} + } + + // MARK: - Local Thumbnail + + /// Generates a thumbnail from a local asset and saves it in cache. + @MainActor + private func localThumbnail(for media: Media, size: ThumbnailSize) async -> UIImage? { + let exporter = MediaThumbnailExporter() + exporter.mediaDirectoryType = .cache + exporter.options.preferredSize = MediaImageService.getThumbnailSize(for: media, size: size) + exporter.options.scale = 1 // In pixels + + guard let sourceURL = media.absoluteLocalURL, + exporter.supportsThumbnailExport(forFile: sourceURL) else { + return nil + } + + guard let (_, export) = try? await exporter.exportThumbnail(forFileURL: sourceURL) else { + return nil + } + + let image = try? await Task.detached { + let data = try Data(contentsOf: export.url) + return try decompressedImage(from: data) + }.value + + // The order is important to ensure `export.url` still exists when creating an image + saveThumbnail(for: media.objectID, size: size) { targetURL in + try FileManager.default.moveItem(at: export.url, to: targetURL) + } + + return image + } + + // MARK: - Remote Thumbnail + + /// Downloads a remote thumbnail and saves it in cache. @MainActor - private func remoteThumbnail(for media: Media, targetSize: CGSize) async throws -> UIImage { - guard let imageURL = remoteThumbnailURL(for: media, targetSize: targetSize) else { + private func remoteThumbnail(for media: Media, size: ThumbnailSize) async throws -> UIImage { + let targetSize = MediaImageService.getThumbnailSize(for: media, size: size) + guard let imageURL = media.getRemoteThumbnailURL(targetSize: targetSize) else { throw URLError(.badURL) } @@ -89,102 +145,167 @@ final class MediaImageService: NSObject { } let (data, _) = try await session.data(for: request) - // Saves the thumbnail and records `absoluteThumbnailLocalURL` asynchronously. - // The service doesn't wait for the completion to eliminate any delays - // for image display. This includes writing data to disk, which is relatively - // fast, and updating `absoluteThumbnailLocalURL` on the media object. - // The latter can be slow because there is only one background context - // and it's often busy with long operations that could delay the image - // display by seconds. - let mediaID = TaggedManagedObjectID(media) - ioQueue.async { - if let fileURL = try? self.saveThumbnail(data, for: imageURL) { - self.setLocalThumbnailURL(fileURL, for: mediaID) - } + saveThumbnail(for: media.objectID, size: size) { targetURL in + try data.write(to: targetURL) } - return try await Task.detached(priority: .userInitiated) { - try decompressedImage(from: data, fileExtension: imageURL.pathExtension) + return try await Task.detached { + try decompressedImage(from: data) }.value } - private func saveThumbnail(_ data: Data, for imageURL: URL) throws -> URL { - let fileURL = try MediaFileManager.cache.directoryURL() - .appendingPathComponent(UUID().uuidString, isDirectory: false) - .appendingPathExtension(imageURL.pathExtension) - try data.write(to: fileURL) - return fileURL + // MARK: - Stubs + + @MainActor + private func fetchStubMedia(for media: Media) async throws -> Media { + guard let mediaID = media.mediaID else { + throw MediaThumbnailExporter.ThumbnailExportError.failedToGenerateThumbnailFileURL + } + let mediaRepository = MediaRepository(coreDataStack: coreDataStack) + let objectID = try await mediaRepository.getMedia(withID: mediaID, in: .init(media.blog)) + return try coreDataStack.mainContext.existingObject(with: objectID) + } +} + +// MARK: - MediaImageService (ThumbnailSize) + +extension MediaImageService { + + enum ThumbnailSize: String { + /// The small thumbnail that can be used in collection view cells and + /// similar situations. + case small } - private func setLocalThumbnailURL(_ fileURL: URL, for mediaID: TaggedManagedObjectID) { - coreDataStack.performAndSave({ context in - let media = try context.existingObject(with: mediaID) - if media.absoluteThumbnailLocalURL != fileURL { - media.absoluteThumbnailLocalURL = fileURL - } - }, completion: nil, on: .main) + /// Returns an optimal target size in pixels for a thumbnail of the given + /// size for the given media asset. + static func getThumbnailSize(for media: Media, size: ThumbnailSize) -> CGSize { + let mediaSize = CGSize( + width: CGFloat(media.width?.floatValue ?? 0), + height: CGFloat(media.height?.floatValue ?? 0) + ) + let targetSize = MediaImageService.getPreferredThumbnailSize(for: size) + return MediaImageService.targetSize(forMediaSize: mediaSize, targetSize: targetSize) } - @MainActor - private func remoteThumbnailURL(for media: Media, targetSize: CGSize) -> URL? { - switch media.mediaType { + /// Returns a preferred thumbnail size (in pixels) optimized for the device. + /// + /// - important: It makes sure the app uses the same thumbnails across + /// different screens and presentation modes to avoid fetching and caching + /// more than one version of the same image. + private static func getPreferredThumbnailSize(for thumbnail: ThumbnailSize) -> CGSize { + switch thumbnail { + case .small: + /// The size is calculated to fill a collection view cell, assuming the app + /// displays a 4 or 5 cells in one row. The cell size can vary depending + /// on whether the device is in landscape or portrait mode, but the thumbnail size is + /// guaranteed to always be the same across app launches and optimized for + /// a portraint (dominant) mode. + let screenSide = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) + let itemPerRow = UIDevice.current.userInterfaceIdiom == .pad ? 5 : 4 + let availableWidth = screenSide - MediaViewController.spacing * CGFloat(itemPerRow - 1) + let targetSide = (availableWidth / CGFloat(itemPerRow)).rounded(.down) + let targetSize = CGSize(width: targetSide, height: targetSide) + return targetSize.scaled(by: UIScreen.main.scale) + } + } + + /// Image CDN (Photon) and `MediaImageExporter` both don't support "aspect-fill" + /// resizing mode, so the service performs the necessary calculations by itself. + /// + /// Example: if media size is 2000x3000 px and targetSize is 200x200 px, the + /// returned value will be 200x300 px. For more examples, see `MediaImageServiceTests`. + static func targetSize(forMediaSize mediaSize: CGSize, targetSize originalTargetSize: CGSize) -> CGSize { + guard mediaSize.width > 0 && mediaSize.height > 0 else { + return originalTargetSize + } + // Scale image to fill the target size but avoid upscaling + let scale = min(1, max( + originalTargetSize.width / mediaSize.width, + originalTargetSize.height / mediaSize.height + )) + let targetSize = mediaSize.scaled(by: scale).rounded() + + // Sanitize the size to make sure ultra-wide panoramas are still resized + // to fit the target size, but increase it a bit for an acceptable size. + let threshold: CGFloat = 4 + if targetSize.width > originalTargetSize.width * threshold || targetSize.height > originalTargetSize.height * threshold { + return CGSize( + width: min(targetSize.width, originalTargetSize.width * threshold), + height: min(targetSize.height, originalTargetSize.height * threshold) + ) + } + return targetSize + } +} + +// MARK: - Helpers (RemoteURL) + +private extension Media { + /// Returns the thumbnail remote URL with a given target size. It uses + /// Image CDN (formerly Photon) if available. + /// + /// - parameter targetSize: Target size in pixels. + func getRemoteThumbnailURL(targetSize: CGSize) -> URL? { + switch mediaType { case .image: - guard let remoteURL = media.remoteURL.flatMap(URL.init) else { + guard let remoteURL = remoteURL.flatMap(URL.init) else { return nil } - if media.blog.isPrivateAtWPCom() || (!media.blog.isHostedAtWPcom && media.blog.isBasicAuthCredentialStored()) { + // Download a non-retina version for GIFs: makes a massive difference + // in terms of size. Example: 2.4 MB -> 350 KB. + let targetSize = remoteURL.isGif ? targetSize.scaled(by: 1.0 / UIScreen.main.scale) : targetSize + if !isEligibleForPhoton { return WPImageURLHelper.imageURLWithSize(targetSize, forImageURL: remoteURL) } else { - let scale = 1.0 / UIScreen.main.scale - let targetSize = targetSize.applying(CGAffineTransform(scaleX: scale, y: scale)) + let targetSize = targetSize.scaled(by: 1.0 / UIScreen.main.scale) return PhotonImageURLHelper.photonURL(with: targetSize, forImageURL: remoteURL) } default: - return media.remoteThumbnailURL.flatMap(URL.init) + return remoteThumbnailURL.flatMap(URL.init) } } - // MARK: - Stubs - - @MainActor - private func fetchStubMedia(for media: Media) async throws -> Media { - guard let mediaID = media.mediaID else { - throw MediaThumbnailExporter.ThumbnailExportError.failedToGenerateThumbnailFileURL - } - let mediaRepository = MediaRepository(coreDataStack: coreDataStack) - let objectID = try await mediaRepository.getMedia(withID: mediaID, in: .init(media.blog)) - return try coreDataStack.mainContext.existingObject(with: objectID) + var isEligibleForPhoton: Bool { + !(blog.isPrivateAtWPCom() || (!blog.isHostedAtWPcom && blog.isBasicAuthCredentialStored())) } } -// MARK: - Decompression +// MARK: - Helpers (Decompression) // Forces decompression (or bitmapping) to happen in the background. // It's very expensive for some image formats, such as JPEG. -private func decompressedImage(forFileURL fileURL: URL) async throws -> UIImage { - assert(fileURL.isFileURL, "Unsupported URL: \(fileURL)") - return try await Task.detached(priority: .userInitiated) { - let data = try Data(contentsOf: fileURL) - return try decompressedImage(from: data, fileExtension: fileURL.pathExtension) - }.value -} - -private func decompressedImage(from data: Data, fileExtension: String) throws -> UIImage { +private func decompressedImage(from data: Data) throws -> UIImage { guard let image = UIImage(data: data) else { throw URLError(.cannotDecodeContentData) } - guard isDecompressionNeeded(for: fileExtension) else { + guard isDecompressionNeeded(for: data) else { return image } return image.preparingForDisplay() ?? image } -private func isDecompressionNeeded(for fileExtension: String) -> Bool { +private func isDecompressionNeeded(for data: Data) -> Bool { // This check is required to avoid the following error messages when // using `preparingForDisplay`: // // [Decompressor] Error -17102 decompressing image -- possibly corrupt // // More info: https://github.com/SDWebImage/SDWebImage/issues/3365 - return fileExtension == "jpeg" || fileExtension == "jpg" + data.isMatchingMagicNumbers(Data.jpegMagicNumbers) +} + +private extension Data { + // JPEG magic numbers https://en.wikipedia.org/wiki/JPEG + static let jpegMagicNumbers: [UInt8] = [0xFF, 0xD8, 0xFF] + + func isMatchingMagicNumbers(_ numbers: [UInt8?]) -> Bool { + guard self.count >= numbers.count else { + return false + } + return zip(numbers.indices, numbers).allSatisfy { index, number in + guard let number = number else { return true } + return self[index] == number + } + } } diff --git a/WordPress/Classes/Services/MediaImportService.swift b/WordPress/Classes/Services/MediaImportService.swift index a414b60cb81d..c1d95fa76420 100644 --- a/WordPress/Classes/Services/MediaImportService.swift +++ b/WordPress/Classes/Services/MediaImportService.swift @@ -318,8 +318,7 @@ class MediaImportService: NSObject { /// using `absoluteThumbnailLocalURL`. func exportPlaceholderThumbnail(for media: Media, completion: ((URL?) -> Void)?) { let thumbnailService = MediaThumbnailService(coreDataStack: coreDataStack) - let targetSize: CGSize = FeatureFlag.mediaModernization.enabled ? MediaImageService.preferredThumbnailPointSize : .zero - thumbnailService.thumbnailURL(forMedia: media, preferredSize: targetSize) { url in + thumbnailService.thumbnailURL(forMedia: media, preferredSize: .zero) { url in self.coreDataStack.performAndSave({ context in let mediaInContext = try context.existingObject(with: media.objectID) as! Media // Set the absoluteThumbnailLocalURL with the generated thumbnail's URL. diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 42d9868890cc..988cb72dee17 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -92,6 +92,7 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { window = UIWindow(frame: UIScreen.main.bounds) AppAppearance.overrideAppearance() MemoryCache.shared.register() + MediaImageService.migrateCacheIfNeeded() // Start CrashLogging as soon as possible (in case a crash happens during startup) try? loggingStack.start() diff --git a/WordPress/Classes/Utility/Media/MediaThumbnailExporter.swift b/WordPress/Classes/Utility/Media/MediaThumbnailExporter.swift index 94b428c833ea..54843b33fb96 100644 --- a/WordPress/Classes/Utility/Media/MediaThumbnailExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaThumbnailExporter.swift @@ -22,7 +22,7 @@ class MediaThumbnailExporter: MediaExporter { /// of the image within a layout's dimensions. If nil, the image will not be resized. /// /// - Note: The final size may or may not match the preferred dimensions, depending - /// on the original image. + /// on the original image /// var preferredSize: CGSize? @@ -36,7 +36,7 @@ class MediaThumbnailExporter: MediaExporter { /// The compression quality of the thumbnail, if the image type supports compression. /// - var compressionQuality = 0.90 + var compressionQuality = 0.8 /// The target image type of the exported thumbnail images. /// @@ -59,7 +59,7 @@ class MediaThumbnailExporter: MediaExporter { guard let size = preferredSize else { return nil } - return max(size.width, size.height) * scale + return max(size.width, size.height) * min(2, scale) } lazy var identifier: String = { diff --git a/WordPress/Classes/ViewRelated/Media/Library/MediaCollectionCellViewModel.swift b/WordPress/Classes/ViewRelated/Media/Library/MediaCollectionCellViewModel.swift index f06052043b63..91d8efaa0aa6 100644 --- a/WordPress/Classes/ViewRelated/Media/Library/MediaCollectionCellViewModel.swift +++ b/WordPress/Classes/ViewRelated/Media/Library/MediaCollectionCellViewModel.swift @@ -36,7 +36,7 @@ final class MediaCollectionCellViewModel { // No sure why but `.initial` didn't work. self.updateOverlayState() - thumbnailObservation = media.observe(\.localThumbnailURL, options: [.new]) { [weak self] media, _ in + thumbnailObservation = media.observe(\.localURL, options: [.new]) { [weak self] media, _ in self?.didUpdateLocalThumbnail() } } @@ -118,7 +118,7 @@ final class MediaCollectionCellViewModel { // Monitors thumbnails generated by `MediaImportService`. private func didUpdateLocalThumbnail() { - guard media.remoteStatus != .sync, media.localThumbnailURL != nil else { return } + guard media.remoteStatus != .sync, media.localURL != nil else { return } fetchThumbnailIfNeeded() } diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index c4eb4c531f84..6e1ea5e59428 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -458,6 +458,7 @@ 0CDEC40D2A2FAF0500BB3A91 /* DashboardBlazeCampaignsCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CDEC40B2A2FAF0500BB3A91 /* DashboardBlazeCampaignsCardView.swift */; }; 0CED95602A460F4B0020F420 /* DebugFeatureFlagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CED955F2A460F4B0020F420 /* DebugFeatureFlagsView.swift */; }; 0CED95612A460F4B0020F420 /* DebugFeatureFlagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CED955F2A460F4B0020F420 /* DebugFeatureFlagsView.swift */; }; + 0CF7D6C32ABB753A006D1E89 /* MediaImageServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF7D6C22ABB753A006D1E89 /* MediaImageServiceTests.swift */; }; 1702BBDC1CEDEA6B00766A33 /* BadgeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1702BBDB1CEDEA6B00766A33 /* BadgeLabel.swift */; }; 1702BBE01CF3034E00766A33 /* DomainsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1702BBDF1CF3034E00766A33 /* DomainsService.swift */; }; 17039225282E6D2800F602E9 /* ViewsVisitorsLineChartCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC772B0728201F5300664C02 /* ViewsVisitorsLineChartCell.swift */; }; @@ -6098,6 +6099,7 @@ 0CD382852A4B6FCE00612173 /* DashboardBlazeCardCellViewModelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardBlazeCardCellViewModelTest.swift; sourceTree = ""; }; 0CDEC40B2A2FAF0500BB3A91 /* DashboardBlazeCampaignsCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardBlazeCampaignsCardView.swift; sourceTree = ""; }; 0CED955F2A460F4B0020F420 /* DebugFeatureFlagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugFeatureFlagsView.swift; sourceTree = ""; }; + 0CF7D6C22ABB753A006D1E89 /* MediaImageServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaImageServiceTests.swift; sourceTree = ""; }; 0CFD6C792A73E703003DD0A0 /* WordPress 152.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 152.xcdatamodel"; sourceTree = ""; }; 131D0EE49695795ECEDAA446 /* Pods-WordPressTest.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressTest.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressTest/Pods-WordPressTest.release-alpha.xcconfig"; sourceTree = ""; }; 150B6590614A28DF9AD25491 /* Pods-Apps-Jetpack.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Apps-Jetpack.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-Apps-Jetpack/Pods-Apps-Jetpack.release-alpha.xcconfig"; sourceTree = ""; }; @@ -12301,6 +12303,7 @@ 24A2948225D602710000A51E /* BlogTimeZoneTests.m */, D848CC1620FF38EA00A9038F /* FormattableCommentRangeTests.swift */, 0879FC151E9301DD00E1EFC8 /* MediaTests.swift */, + 0CF7D6C22ABB753A006D1E89 /* MediaImageServiceTests.swift */, C38C5D8027F61D2C002F517E /* MenuItemTests.swift */, D848CC1420FF33FC00A9038F /* NotificationContentRangeTests.swift */, D826D67E211D21C700A5D8FE /* NullMockUserDefaults.swift */, @@ -23371,6 +23374,7 @@ DC13DB7E293FD09F00E33561 /* StatsInsightsStoreTests.swift in Sources */, ACACE3AE28D729FA000992F9 /* NoResultsViewControllerTests.swift in Sources */, 4A2C73E42A943DEA00ACE79E /* TaggedManagedObjectIDTests.swift in Sources */, + 0CF7D6C32ABB753A006D1E89 /* MediaImageServiceTests.swift in Sources */, 8BFE36FF230F1C850061EBA8 /* AbstractPost+fixLocalMediaURLsTests.swift in Sources */, C995C22629D30AB000ACEF43 /* WidgetUrlSourceTests.swift in Sources */, 08A2AD791CCED2A800E84454 /* PostTagServiceTests.m in Sources */, diff --git a/WordPress/WordPressTest/MediaImageServiceTests.swift b/WordPress/WordPressTest/MediaImageServiceTests.swift new file mode 100644 index 000000000000..990dcdebdbe3 --- /dev/null +++ b/WordPress/WordPressTest/MediaImageServiceTests.swift @@ -0,0 +1,161 @@ +import XCTest +import OHHTTPStubs +@testable import WordPress + +class MediaImageServiceTests: CoreDataTestCase { + var mediaFileManager: MediaFileManager! + var sut: MediaImageService! + + override func setUp() { + super.setUp() + + mediaFileManager = MediaFileManager(directory: .temporary(id: UUID())) + sut = MediaImageService( + coreDataStack: contextManager, + mediaFileManager: mediaFileManager + ) + } + + override func tearDown() { + super.tearDown() + + HTTPStubs.removeAllStubs() + + if let directoryURL = try? mediaFileManager.directoryURL() { + try? FileManager.default.removeItem(at: directoryURL) + } + } + + // MARK: - Local Resources + + func testSmallThumbnailForLocalImage() async throws { + // GIVEN + let media = Media(context: mainContext) + media.mediaType = .image + media.width = 1024 + media.height = 680 + let localURL = try makeLocalURL(forResource: "test-image", fileExtension: "jpg") + media.absoluteLocalURL = localURL + try mainContext.obtainPermanentIDs(for: [media]) + + // WHEN + let thumbnail = try await sut.thumbnail(for: media) + + // THEN a small thumbnail is created + XCTAssertEqual(thumbnail.size, MediaImageService.getThumbnailSize(for: media, size: .small)) + + // GIVEN local asset is deleted + try FileManager.default.removeItem(at: localURL) + sut.flush() + + // WHEN + let cachedThumbnail = try await sut.thumbnail(for: media) + + // THEN cached thumbnail is still available + XCTAssertEqual(cachedThumbnail.size, MediaImageService.getThumbnailSize(for: media, size: .small)) + } + + // MARK: - Remote Resources + + func testSmallThumbnailForRemoteImage() async throws { + // GIVEN + let media = Media(context: mainContext) + media.mediaType = .image + media.width = 1024 + media.height = 680 + let remoteURL = try XCTUnwrap(URL(string: "https://example.files.wordpress.com/2023/09/image.jpg")) + media.remoteURL = remoteURL.absoluteString + try mainContext.obtainPermanentIDs(for: [media]) + + // GIVEN remote image is mocked and is resized based on the parameters + try mockRemoteImage(withResource: "test-image", fileExtension: "jpg") + + // WHEN + let thumbnail = try await sut.thumbnail(for: media) + + // THEN a small thumbnail is created + XCTAssertEqual(thumbnail.size, MediaImageService.getThumbnailSize(for: media, size: .small)) + + // GIVEN local asset is deleted + sut.flush() + + // WHEN + let cachedThumbnail = try await sut.thumbnail(for: media) + + // THEN cached thumbnail is still available + XCTAssertEqual(cachedThumbnail.size, MediaImageService.getThumbnailSize(for: media, size: .small)) + } + + // MARK: - Target Size + + func testThatLandscapeImageIsResizedToFillTargetSize() { + XCTAssertEqual( + MediaImageService.targetSize( + forMediaSize: CGSize(width: 3000, height: 2000), + targetSize: CGSize(width: 200, height: 200) + ), + CGSize(width: 300, height: 200) + ) + } + + func testThatPortraitImageIsResizedToFillTargetSize() { + XCTAssertEqual( + MediaImageService.targetSize( + forMediaSize: CGSize(width: 2000, height: 3000), + targetSize: CGSize(width: 200, height: 200) + ), + CGSize(width: 200, height: 300) + ) + } + + func testThatPanoramaIsResizedToSaneSize() { + XCTAssertEqual( + MediaImageService.targetSize( + forMediaSize: CGSize(width: 4000, height: 400), + targetSize: CGSize(width: 200, height: 200) + ), + CGSize(width: 800, height: 200) + ) + } + + func testThatImagesAreNotUpscaled() { + XCTAssertEqual( + MediaImageService.targetSize( + forMediaSize: CGSize(width: 30, height: 20), + targetSize: CGSize(width: 200, height: 200) + ), + CGSize(width: 30, height: 20) + ) + } + + // MARK: - Helpers + + /// `Media` is hardcoded to work with a specific direcoty URL managed by `MediaFileManager` + func makeLocalURL(forResource name: String, fileExtension: String) throws -> URL { + let sourceURL = try XCTUnwrap(Bundle.test.url(forResource: name, withExtension: fileExtension)) + let mediaURL = try MediaFileManager.default.makeLocalMediaURL(withFilename: name, fileExtension: fileExtension) + try FileManager.default.copyItem(at: sourceURL, to: mediaURL) + return mediaURL + } + + func mockRemoteImage(withResource name: String, fileExtension: String) throws { + let sourceURL = try XCTUnwrap(Bundle.test.url(forResource: name, withExtension: fileExtension)) + let image = try XCTUnwrap(UIImage(data: try Data(contentsOf: sourceURL))) + + stub(condition: { _ in + return true + }, response: { request in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems, + let resize = queryItems.first(where: { $0.name == "resize" }), + let values = resize.value?.components(separatedBy: ","), values.count == 2, + let width = Int(values[0]), let height = Int(values[1]) else { + return HTTPStubsResponse(error: URLError(.unknown)) + } + let resizedImage = image.resizedImage(CGSize(width: width, height: height), interpolationQuality: .default) + let responseData = resizedImage?.jpegData(compressionQuality: 0.8) ?? Data() + return HTTPStubsResponse(data: responseData, statusCode: 200, headers: nil) + }) + } +}