diff --git a/Mail/Views/Thread/Message/InlineAttachmentWorker.swift b/Mail/Views/Thread/Message/InlineAttachmentWorker.swift new file mode 100644 index 000000000..3820b2818 --- /dev/null +++ b/Mail/Views/Thread/Message/InlineAttachmentWorker.swift @@ -0,0 +1,299 @@ +/* + Infomaniak Mail - iOS App + Copyright (C) 2022 Infomaniak Network SA + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + */ + +import Algorithms +import CocoaLumberjackSwift +import Foundation +import InfomaniakConcurrency +import InfomaniakCore +import MailCore +import SwiftUI + +/// Something to process the Attachments outside of the mainActor +/// +/// Call `start()` to begin processing, call `stop` to make sure internal Task is cancelled. +final class InlineAttachmentWorker: ObservableObject { + private let bodyImageProcessor = BodyImageProcessor() + + /// The presentableBody with the current pre-processing (partial or done) + @Published var presentableBody: PresentableBody + + /// Set to true when done processing + @Published var isMessagePreprocessed: Bool + + var mailboxManager: MailboxManager? + + private let messageUid: String + + /// Tracking the preprocessing Task tree + private var processing: Task? + + public init(messageUid: String) { + self.messageUid = messageUid + isMessagePreprocessed = false + presentableBody = PresentableBody() + } + + deinit { + stop() + } + + func stop() { + processing?.cancel() + processing = nil + } + + func start(mailboxManager: MailboxManager) { + // Content was processed or is processing + guard !isMessagePreprocessed else { + return + } + + self.mailboxManager = mailboxManager + processing = Task { [weak self] in + guard let message = mailboxManager.transactionExecutor.fetchObject(ofType: Message.self, forPrimaryKey: messageUid)? + .freeze() else { + return + } + + await self?.prepareBody(frozenMessage: message) + + guard !Task.isCancelled else { + return + } + + await self?.insertInlineAttachments(frozenMessage: message) + + guard !Task.isCancelled else { + return + } + + await self?.processingCompleted() + } + } + + private func prepareBody(frozenMessage: Message) async { + guard !Task.isCancelled else { + return + } + guard let updatedPresentableBody = await MessageBodyUtils.prepareWithPrintOption(message: frozenMessage) else { return } + + // Mutate DOM if task is active + guard !Task.isCancelled else { + return + } + await setPresentableBody(updatedPresentableBody) + } + + private func insertInlineAttachments(frozenMessage: Message) async { + guard !Task.isCancelled else { + return + } + + // Since mutation of the DOM is costly, I batch the processing of images, then mutate the DOM. + let attachmentsArray = frozenMessage.attachments.filter { $0.contentId != nil }.toArray() + + guard !attachmentsArray.isEmpty else { + return + } + + // Chunking, and processing each chunk. Opportunity to yield between each batch. + let chunks = attachmentsArray.chunks(ofCount: Constants.inlineAttachmentBatchSize) + for attachments in chunks { + guard !Task.isCancelled else { + return + } + + // Run each batch in a `Task` to get an `autoreleasepool` behaviour + let batchTask = Task { + await processInlineAttachments(attachments) + } + await batchTask.finish() + await Task.yield() + } + } + + private func processInlineAttachments(_ attachments: ArraySlice) async { + guard !Task.isCancelled else { + return + } + + guard let mailboxManager else { + DDLogError("processInlineAttachments will fail without a mailboxManager") + return + } + + let base64Images = await bodyImageProcessor.fetchBase64Images(attachments, mailboxManager: mailboxManager) + + guard !Task.isCancelled else { + return + } + + // Read the DOM once + let bodyParameters = await readPresentableBody() + let detachedBody = bodyParameters.detachedBody + + // process compact and base body in parallel + async let mailBody = bodyImageProcessor.injectImagesInBody(body: bodyParameters.bodyString, + attachments: attachments, + base64Images: base64Images) + + async let compactBody = bodyImageProcessor.injectImagesInBody(body: bodyParameters.compactBody, + attachments: attachments, + base64Images: base64Images) + + let bodyValue = await mailBody + let compactBodyCopy = await compactBody + detachedBody?.value = bodyValue + + let updatedPresentableBody = PresentableBody( + body: detachedBody, + compactBody: compactBodyCopy, + quotes: presentableBody.quotes + ) + + // Mutate DOM if task is still active + guard !Task.isCancelled else { + return + } + + await setPresentableBody(updatedPresentableBody) + } + + @MainActor private func setPresentableBody(_ body: PresentableBody) { + presentableBody = body + } + + @MainActor func processingCompleted() { + isMessagePreprocessed = true + } + + typealias BodyParts = (bodyString: String?, compactBody: String?, detachedBody: Body?) + @MainActor private func readPresentableBody() -> BodyParts { + let mailBody = presentableBody.body?.value + let compactBody = presentableBody.compactBody + let detachedBody = presentableBody.body?.detached() + + return (mailBody, compactBody, detachedBody) + } +} + +/// Something to package a base64 encoded image and its mime type +typealias ImageBase64AndMime = (imageEncoded: String, mimeType: String) + +/// Download compress and format images into a mail body +struct BodyImageProcessor { + private let bodyImageMutator = BodyImageMutator() + + /// Download and encode all images for the current chunk in parallel. + public func fetchBase64Images(_ attachments: ArraySlice, + mailboxManager: MailboxManager) async -> [ImageBase64AndMime?] { + // Force a fixed max concurrency to be a nice citizen to the network. + let base64Images: [ImageBase64AndMime?] = await attachments + .concurrentMap(customConcurrency: Constants.concurrentNetworkCalls) { attachment in + do { + let attachmentData = try await mailboxManager.attachmentData(attachment) + + // Skip compression on non static images types or already heic sources + guard attachment.mimeType.contains("jpg") + || attachment.mimeType.contains("jpeg") + || attachment.mimeType.contains("png") else { + let base64String = attachmentData.base64EncodedString() + return ImageBase64AndMime(base64String, attachment.mimeType) + } + + let compressedImage = self.compressedBase64ImageAndMime( + attachmentData: attachmentData, + attachmentMime: attachment.mimeType + ) + return compressedImage + + } catch { + DDLogError("Error \(error) : Failed to fetch data for attachment: \(attachment)") + return nil + } + } + + assert(base64Images.count == attachments.count, "Arrays count should match") + return base64Images + } + + /// Try to compress the attachment with the best matched algorithm. Trade CPU cycles to reduce render time and memory usage. + private func compressedBase64ImageAndMime(attachmentData: Data, attachmentMime: String) -> ImageBase64AndMime { + guard #available(iOS 17.0, *) else { + let base64String = attachmentData.base64EncodedString() + return ImageBase64AndMime(base64String, attachmentMime) + } + + // On iOS17 Safari _and_ iOS has support for heic. Quality is unchanged. Size is halved. + let image = UIImage(data: attachmentData) + guard let imageCompressed = image?.heicData(), + imageCompressed.count < attachmentData.count else { + let base64String = attachmentData.base64EncodedString() + return ImageBase64AndMime(base64String, attachmentMime) + } + + let base64String = imageCompressed.base64EncodedString() + return ImageBase64AndMime(base64String, "image/heic") + } + + /// Inject base64 images in a body + public func injectImagesInBody(body: String?, + attachments: ArraySlice, + base64Images: [ImageBase64AndMime?]) async -> String? { + guard let body = body, + !body.isEmpty else { + return nil + } + + var workingBody = body + for (index, attachment) in attachments.enumerated() { + guard !Task.isCancelled else { + break + } + + guard let contentId = attachment.contentId, + let base64Image = base64Images[safe: index] as? ImageBase64AndMime else { + continue + } + + bodyImageMutator.replaceContentIdForBase64Image( + in: &workingBody, + contentId: contentId, + mimeType: base64Image.mimeType, + contentBase64Encoded: base64Image.imageEncoded + ) + } + return workingBody + } +} + +/// Something to insert base64 image into a mail body. Easily testable. +struct BodyImageMutator { + func replaceContentIdForBase64Image( + in body: inout String, + contentId: String, + mimeType: String, + contentBase64Encoded: String + ) { + body = body.replacingOccurrences( + of: "cid:\(contentId)", + with: "data:\(mimeType);base64,\(contentBase64Encoded)" + ) + } +} diff --git a/Mail/Views/Thread/Message/MessageBodyView.swift b/Mail/Views/Thread/Message/MessageBodyView.swift index 90ab4cd35..0eb659b51 100644 --- a/Mail/Views/Thread/Message/MessageBodyView.swift +++ b/Mail/Views/Thread/Message/MessageBodyView.swift @@ -31,7 +31,8 @@ struct MessageBodyView: View { @StateObject private var model = WebViewModel() - @Binding var presentableBody: PresentableBody + let presentableBody: PresentableBody + let isMessagePreprocessed: Bool var blockRemoteContent: Bool @Binding var displayContentBlockedActionView: Bool @@ -53,6 +54,9 @@ struct MessageBodyView: View { .onChange(of: presentableBody) { _ in loadBody(blockRemoteContent: blockRemoteContent) } + .onChange(of: isMessagePreprocessed) { _ in + loadBody(blockRemoteContent: blockRemoteContent) + } .onChange(of: model.showBlockQuote) { _ in loadBody(blockRemoteContent: blockRemoteContent) } @@ -119,7 +123,8 @@ struct MessageBodyView: View { #Preview { MessageBodyView( - presentableBody: .constant(PreviewHelper.samplePresentableBody), + presentableBody: PreviewHelper.samplePresentableBody, + isMessagePreprocessed: true, blockRemoteContent: false, displayContentBlockedActionView: .constant(false), messageUid: "message_uid" diff --git a/Mail/Views/Thread/Message/MessageView+Preprocessing.swift b/Mail/Views/Thread/Message/MessageView+Preprocessing.swift deleted file mode 100644 index 80f7e42bd..000000000 --- a/Mail/Views/Thread/Message/MessageView+Preprocessing.swift +++ /dev/null @@ -1,262 +0,0 @@ -/* - Infomaniak Mail - iOS App - Copyright (C) 2022 Infomaniak Network SA - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - */ - -import Algorithms -import CocoaLumberjackSwift -import Foundation -import InfomaniakConcurrency -import InfomaniakCore -import MailCore -import SwiftUI - -/// MessageView code related to pre-processing -extension MessageView { - /// Cooldown before processing each batch of inline images - /// - /// 4 seconds feels fine - static let batchCooldown: UInt64 = 4_000_000_000 - - // MARK: - public interface - - func prepareBodyIfNeeded() { - // Message should be downloaded and expanded - guard message.fullyDownloaded, isMessageExpanded else { - return - } - - // Content was processed or is processing - guard !isMessagePreprocessed, inlineAttachmentWorker == nil else { - return - } - - let worker = InlineAttachmentWorker( - messageUid: message.uid, - presentableBody: $presentableBody, - isMessagePreprocessed: $isMessagePreprocessed, - mailboxManager: mailboxManager - ) - inlineAttachmentWorker = worker - worker.start() - } -} - -/// Something to process the Attachments outside of the mainActor -/// -/// Call `start()` to begin processing, call `stop` to make sure internal Task is cancelled. -final class InlineAttachmentWorker { - /// Something to base64 encode images - private let base64Encoder = Base64Encoder() - - /// The UID of the `Message` displayed - let messageUid: String - - /// Private accessor on the message - private var frozenMessage: Message? { - mailboxManager.fetchObject(ofType: Message.self, forPrimaryKey: messageUid)?.freezeIfNeeded() - } - - /// A binding on the `PresentableBody` from `MessageView` - @Binding var presentableBody: PresentableBody - - /// A binding on the `isMessagePreprocessed` from `MessageView` - @Binding var isMessagePreprocessed: Bool - - let mailboxManager: MailboxManager - - /// Tracking the preprocessing Task tree - private var processing: Task? - - public init(messageUid: String, - presentableBody: Binding, - isMessagePreprocessed: Binding, - mailboxManager: MailboxManager) { - self.messageUid = messageUid - _presentableBody = presentableBody - _isMessagePreprocessed = isMessagePreprocessed - self.mailboxManager = mailboxManager - } - - deinit { - self.stop() - } - - func stop() { - processing?.cancel() - processing = nil - } - - func start() { - processing = Task { [weak self] in - await self?.prepareBody() - - guard !Task.isCancelled else { - return - } - - await self?.insertInlineAttachments() - - guard !Task.isCancelled else { - return - } - - await self?.processingCompleted() - } - } - - func prepareBody() async { - guard !Task.isCancelled else { - return - } - guard let frozenMessage, - let updatedPresentableBody = await MessageBodyUtils.prepareWithPrintOption(message: frozenMessage) else { return } - - // Mutate DOM if task is active - guard !Task.isCancelled else { - return - } - await setPresentableBody(updatedPresentableBody) - } - - func insertInlineAttachments() async { - guard !Task.isCancelled else { - return - } - - guard let message = frozenMessage else { - return - } - - // Since mutation of the DOM is costly, I batch the processing of images, then mutate the DOM. - let attachmentsArray = message.attachments.filter { $0.contentId != nil }.toArray() - - // Early exit, nothing to process - guard !attachmentsArray.isEmpty else { - return - } - - // Chunking, and processing each chunk - let chunks = attachmentsArray.chunks(ofCount: Constants.inlineAttachmentBatchSize) - for attachments in chunks { - guard !Task.isCancelled else { - return - } - await processInlineAttachments(attachments) - } - } - - func processInlineAttachments(_ attachments: ArraySlice) async { - guard !Task.isCancelled else { - return - } - - // Download all images for the current chunk in parallel - let dataArray: [Data?] = await attachments.concurrentMap(customConcurrency: 4) { attachment in - do { - return try await self.mailboxManager.attachmentData(attachment) - } catch { - DDLogError("Error \(error) : Failed to fetch data for attachment: \(attachment)") - return nil - } - } - - // Safety check - assert(dataArray.count == attachments.count, "Arrays count should match") - - guard !Task.isCancelled else { - return - } - - // Read the DOM once - let bodyParameters = await readPresentableBody() - var mailBody = bodyParameters.bodyString - var compactBody = bodyParameters.compactBody - let detachedBody = bodyParameters.detachedBody - - // Prepare the new DOM with the loaded images - for (index, attachment) in attachments.enumerated() { - guard !Task.isCancelled else { - break - } - - guard let contentId = attachment.contentId, - let data = dataArray[safe: index] as? Data else { - continue - } - - base64Encoder.replaceContentIdForBase64Image( - in: &mailBody, - contentId: contentId, - mimeType: attachment.mimeType, - contentData: data - ) - - base64Encoder.replaceContentIdForBase64Image( - in: &compactBody, - contentId: contentId, - mimeType: attachment.mimeType, - contentData: data - ) - } - - let bodyValue = mailBody - let compactBodyCopy = compactBody - detachedBody?.value = bodyValue - - let updatedPresentableBody = PresentableBody( - body: detachedBody, - compactBody: compactBodyCopy, - quotes: presentableBody.quotes - ) - - // Mutate DOM if task is active - guard !Task.isCancelled else { - return - } - await setPresentableBody(updatedPresentableBody) - - // Delay between each chunk processing, just enough, so the user feels the UI is responsive. - // This goes beyond a simple Task.yield() - try? await Task.sleep(nanoseconds: MessageView.batchCooldown) - } - - @MainActor private func setPresentableBody(_ body: PresentableBody) { - presentableBody = body - } - - @MainActor func processingCompleted() { - isMessagePreprocessed = true - } - - typealias BodyParts = (bodyString: String?, compactBody: String?, detachedBody: Body?) - @MainActor private func readPresentableBody() -> BodyParts { - let mailBody = presentableBody.body?.value - let compactBody = presentableBody.compactBody - let detachedBody = presentableBody.body?.detached() - - return (mailBody, compactBody, detachedBody) - } -} - -struct Base64Encoder { - func replaceContentIdForBase64Image(in body: inout String?, contentId: String, mimeType: String, contentData: Data) { - body = body?.replacingOccurrences( - of: "cid:\(contentId)", - with: "data:\(mimeType);base64,\(contentData.base64EncodedString())" - ) - } -} diff --git a/Mail/Views/Thread/Message/MessageView.swift b/Mail/Views/Thread/Message/MessageView.swift index 4d32f6ef2..ba537b54a 100644 --- a/Mail/Views/Thread/Message/MessageView.swift +++ b/Mail/Views/Thread/Message/MessageView.swift @@ -39,39 +39,43 @@ struct MessageView: View { @EnvironmentObject var mailboxManager: MailboxManager - @State var presentableBody: PresentableBody @State var isHeaderExpanded = false - @State var isMessageExpanded: Bool @Binding private var threadForcedExpansion: [String: MessageExpansionType] - /// True once we finished preprocessing the content - @State var isMessagePreprocessed = false - @State private var isShowingErrorLoading = false - /// Something to preprocess inline attachments - @State var inlineAttachmentWorker: InlineAttachmentWorker? - @State var displayContentBlockedActionView = false @ObservedRealmObject var message: Message + /// Something to preprocess inline attachments + @StateObject var inlineAttachmentWorker: InlineAttachmentWorker + private var isRemoteContentBlocked: Bool { return (UserDefaults.shared.displayExternalContent == .askMe || message.folder?.role == .spam) && !message.localSafeDisplay } - init(message: Message, isMessageExpanded: Bool = false, threadForcedExpansion: Binding<[String: MessageExpansionType]>) { + private var isMessageExpanded: Bool { + threadForcedExpansion[message.uid] == .expanded + } + + init(message: Message, threadForcedExpansion: Binding<[String: MessageExpansionType]>) { self.message = message - presentableBody = PresentableBody(message: message) - self.isMessageExpanded = isMessageExpanded _threadForcedExpansion = threadForcedExpansion + _inlineAttachmentWorker = StateObject(wrappedValue: InlineAttachmentWorker(messageUid: message.uid)) } var body: some View { ZStack { VStack(spacing: 0) { - MessageHeaderView(message: message, isHeaderExpanded: $isHeaderExpanded, isMessageExpanded: $isMessageExpanded) + MessageHeaderView(message: message, + isHeaderExpanded: $isHeaderExpanded, + isMessageExpanded: Binding(get: { + isMessageExpanded + }, set: { newValue in + threadForcedExpansion[message.uid] = newValue ? .expanded : .collapsed + })) if isMessageExpanded { VStack(spacing: UIPadding.regular) { @@ -89,7 +93,8 @@ struct MessageView: View { .frame(maxWidth: .infinity, alignment: .leading) } else { MessageBodyView( - presentableBody: $presentableBody, + presentableBody: inlineAttachmentWorker.presentableBody, + isMessagePreprocessed: inlineAttachmentWorker.isMessagePreprocessed, blockRemoteContent: isRemoteContentBlocked, displayContentBlockedActionView: $displayContentBlockedActionView, messageUid: message.uid @@ -108,26 +113,19 @@ struct MessageView: View { await fetchMessageAndEventCalendar() } .onDisappear { - inlineAttachmentWorker?.stop() - inlineAttachmentWorker = nil + inlineAttachmentWorker.stop() } .onChange(of: message.fullyDownloaded) { _ in prepareBodyIfNeeded() } - .onChange(of: isMessageExpanded) { _ in + .onChange(of: isMessageExpanded) { newValue in + guard isMessageExpanded != newValue else { return } prepareBodyIfNeeded() } - .onChange(of: threadForcedExpansion[message.uid]) { newValue in - if newValue == .expanded { - withAnimation { - isMessageExpanded = true - } - } - } .accessibilityAction(named: MailResourcesStrings.Localizable.expandMessage) { guard isMessageInteractive else { return } withAnimation { - isMessageExpanded.toggle() + threadForcedExpansion[message.uid] = isMessageExpanded ? .collapsed : .expanded } } } @@ -166,6 +164,17 @@ struct MessageView: View { } } +/// MessageView code related to pre-processing +extension MessageView { + func prepareBodyIfNeeded() { + guard message.fullyDownloaded, isMessageExpanded else { + return + } + + inlineAttachmentWorker.start(mailboxManager: mailboxManager) + } +} + #Preview("Message collapsed") { MessageView( message: PreviewHelper.sampleMessage, @@ -178,7 +187,6 @@ struct MessageView: View { #Preview("Message expanded") { MessageView( message: PreviewHelper.sampleMessage, - isMessageExpanded: true, threadForcedExpansion: .constant([PreviewHelper.sampleMessage.uid: .expanded]) ) .environmentObject(PreviewHelper.sampleMailboxManager) diff --git a/Mail/Views/Thread/MessageListView.swift b/Mail/Views/Thread/MessageListView.swift index 8c7ce2cba..5a24d382b 100644 --- a/Mail/Views/Thread/MessageListView.swift +++ b/Mail/Views/Thread/MessageListView.swift @@ -45,14 +45,12 @@ struct MessageListView: View { VStack(spacing: 0) { MessageView( message: message, - isMessageExpanded: isExpanded(message: message, from: messages), threadForcedExpansion: $messageExpansion ) if divider(for: message) { IKDivider(type: .full) } } - .id(message.uid) } } } diff --git a/MailCore/Models/Body.swift b/MailCore/Models/Body.swift index cb68b9600..5fdeb44cc 100644 --- a/MailCore/Models/Body.swift +++ b/MailCore/Models/Body.swift @@ -84,13 +84,15 @@ final class ProxyBody: Codable { } } -public struct PresentableBody: Equatable { - public var body: Body? - public var compactBody: String? - public var quotes = [String]() +@frozen public struct PresentableBody: Equatable { + public let body: Body? + public let compactBody: String? + public let quotes: [String] public init(message: Message) { body = message.body + compactBody = nil + quotes = [] } public init(body: Body?, compactBody: String?, quotes: [String]) { @@ -99,6 +101,12 @@ public struct PresentableBody: Equatable { self.quotes = quotes } + public init() { + body = nil + compactBody = nil + quotes = [] + } + public init(presentableBody: PresentableBody) { self.init(body: presentableBody.body, compactBody: presentableBody.compactBody, quotes: presentableBody.quotes) } diff --git a/MailCore/Utils/Constants.swift b/MailCore/Utils/Constants.swift index d1ca34bb3..1ace946b4 100644 --- a/MailCore/Utils/Constants.swift +++ b/MailCore/Utils/Constants.swift @@ -207,6 +207,9 @@ public enum Constants { /// Batch size of inline attachments during processing. public static let inlineAttachmentBatchSize = 10 + /// Max parallelism that works well with network requests. + public static let concurrentNetworkCalls = 4 + public static let appGroupIdentifier = "group.com.infomaniak" /// Decodes the date according to the string format, yyyy-MM-dd or ISO 8601 diff --git a/MailNotificationContentExtension/NotificationViewController.swift b/MailNotificationContentExtension/NotificationViewController.swift index ed2df00e6..ffb8fb966 100644 --- a/MailNotificationContentExtension/NotificationViewController.swift +++ b/MailNotificationContentExtension/NotificationViewController.swift @@ -91,8 +91,7 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi let messageView = ScrollView { MessageView( message: message, - isMessageExpanded: true, - threadForcedExpansion: .constant([:]) + threadForcedExpansion: .constant([messageUid: .expanded]) ) .environment(\.isMessageInteractive, false) .environmentObject(mailboxManager) diff --git a/MailTests/MailBase64Encoder.swift b/MailTests/MailBodyImageMutator.swift similarity index 86% rename from MailTests/MailBase64Encoder.swift rename to MailTests/MailBodyImageMutator.swift index 5d707a0b2..c3c7b3ecf 100644 --- a/MailTests/MailBase64Encoder.swift +++ b/MailTests/MailBodyImageMutator.swift @@ -21,7 +21,7 @@ import MailResources @testable import SwiftSoup import XCTest -final class MailBase64Encoder: XCTestCase { +final class MailBodyImageMutator: XCTestCase { // MARK: - Test simple image func testEncodeSomeImage() { @@ -38,8 +38,8 @@ final class MailBase64Encoder: XCTestCase { """ - var processedBody: String? = htmlBody - let base64Encoder = Base64Encoder() + var processedBody = htmlBody + let bodyImageMutator = BodyImageMutator() guard let imageData = MailResourcesAsset.allFolders.image.pngData() else { XCTFail("Unexpected") return @@ -47,19 +47,14 @@ final class MailBase64Encoder: XCTestCase { let imageBase64 = imageData.base64EncodedString() // WHEN - base64Encoder.replaceContentIdForBase64Image( + bodyImageMutator.replaceContentIdForBase64Image( in: &processedBody, contentId: contentId, mimeType: mimeType, - contentData: imageData + contentBase64Encoded: imageBase64 ) // THEN - guard let processedBody else { - XCTFail("Unexpected") - return - } - XCTAssertNotEqual(htmlBody, processedBody) XCTAssertGreaterThan(processedBody.count, htmlBody.count, "processed body should be longer with the image") @@ -100,25 +95,21 @@ final class MailBase64Encoder: XCTestCase { """ - var processedBody: String? = htmlBody - let base64Encoder = Base64Encoder() + var processedBody = htmlBody + let bodyImageMutator = BodyImageMutator() let imageData = Data() let expectedResult = "data:image/png;base64," // WHEN - base64Encoder.replaceContentIdForBase64Image( + let imageBase64 = imageData.base64EncodedString() + bodyImageMutator.replaceContentIdForBase64Image( in: &processedBody, contentId: contentId, mimeType: mimeType, - contentData: imageData + contentBase64Encoded: imageBase64 ) // THEN - guard let processedBody else { - XCTFail("Unexpected") - return - } - XCTAssertNotEqual(htmlBody, processedBody) XCTAssertGreaterThan(processedBody.count, htmlBody.count, "processed body should be longer with the image")