From e21610d42500a84a8b1505fc9940dad2911796af Mon Sep 17 00:00:00 2001 From: fmodf Date: Sat, 13 Jul 2024 03:29:46 +0200 Subject: [PATCH] wip --- .../AppCore/Actions/DatabaseActions.swift | 2 + .../AppCore/Actions/FileActions.swift | 4 + .../Database/Database+Migrations.swift | 1 + .../AppCore/Files/DownloadManager.swift | 41 +++++++--- .../AppCore/Files/FileManager.swift | 3 - .../AppCore/Files/FileProcessing.swift | 72 +++++++++++++++++ .../Middlewares/DatabaseMiddleware.swift | 66 ++++++++++++---- .../AppCore/Middlewares/FileMiddleware.swift | 77 ++++++++----------- .../AppCore/Models/Attachment.swift | 7 +- 9 files changed, 196 insertions(+), 77 deletions(-) delete mode 100644 ConversationsClassic/AppCore/Files/FileManager.swift create mode 100644 ConversationsClassic/AppCore/Files/FileProcessing.swift diff --git a/ConversationsClassic/AppCore/Actions/DatabaseActions.swift b/ConversationsClassic/AppCore/Actions/DatabaseActions.swift index 1b2793c..b474cae 100644 --- a/ConversationsClassic/AppCore/Actions/DatabaseActions.swift +++ b/ConversationsClassic/AppCore/Actions/DatabaseActions.swift @@ -7,4 +7,6 @@ enum DatabaseAction: Codable { case storedChatsLoaded(chats: [Chat]) case storeMessageFailed(reason: String) + + case updateAttachmentFailed(id: String, reason: String) } diff --git a/ConversationsClassic/AppCore/Actions/FileActions.swift b/ConversationsClassic/AppCore/Actions/FileActions.swift index 4acada7..865dade 100644 --- a/ConversationsClassic/AppCore/Actions/FileActions.swift +++ b/ConversationsClassic/AppCore/Actions/FileActions.swift @@ -1,6 +1,10 @@ import Foundation enum FileAction: Stateable { + case downloadAttachmentFile(id: String, remotePath: URL) case attachmentFileDownloaded(id: String, localUrl: URL) + case downloadingAttachmentFileFailed(id: String, reason: String) + + case createAttachmentThumbnail(id: String, localUrl: URL) case attachmentThumbnailCreated(id: String, thumbnailUrl: URL) } diff --git a/ConversationsClassic/AppCore/Database/Database+Migrations.swift b/ConversationsClassic/AppCore/Database/Database+Migrations.swift index 2534351..eba10cf 100644 --- a/ConversationsClassic/AppCore/Database/Database+Migrations.swift +++ b/ConversationsClassic/AppCore/Database/Database+Migrations.swift @@ -67,6 +67,7 @@ extension Database { table.column("localPath", .text) table.column("remotePath", .text) table.column("localThumbnailPath", .text) + table.column("downloadFailed", .boolean).notNull().defaults(to: false) } } diff --git a/ConversationsClassic/AppCore/Files/DownloadManager.swift b/ConversationsClassic/AppCore/Files/DownloadManager.swift index f548244..83e5d39 100644 --- a/ConversationsClassic/AppCore/Files/DownloadManager.swift +++ b/ConversationsClassic/AppCore/Files/DownloadManager.swift @@ -1,26 +1,43 @@ import Foundation final class DownloadManager { + static let shared = DownloadManager() + private let urlSession: URLSession + private let downloadQueue = DispatchQueue(label: "com.example.downloadQueue") + private var activeDownloads = Set() init() { let configuration = URLSessionConfiguration.default urlSession = URLSession(configuration: configuration) } - func download(from url: URL, to localUrl: URL, completion: @escaping (Error?) -> Void) { - let task = urlSession.downloadTask(with: url) { tempLocalUrl, _, error in - if let tempLocalUrl = tempLocalUrl, error == nil { - do { - try FileManager.default.copyItem(at: tempLocalUrl, to: localUrl) - completion(nil) - } catch let writeError { - completion(writeError) - } - } else { - completion(error) + func enqueueDownload(from url: URL, to localUrl: URL, completion: @escaping (Error?) -> Void) { + downloadQueue.async { + if self.activeDownloads.contains(url) { + print("Download for this file is already in queue.") + return } + + self.activeDownloads.insert(url) + + let task = self.urlSession.downloadTask(with: url) { tempLocalUrl, _, error in + self.downloadQueue.async { + self.activeDownloads.remove(url) + + if let tempLocalUrl = tempLocalUrl, error == nil { + do { + try FileManager.default.copyItem(at: tempLocalUrl, to: localUrl) + completion(nil) + } catch let writeError { + completion(writeError) + } + } else { + completion(error) + } + } + } + task.resume() } - task.resume() } } diff --git a/ConversationsClassic/AppCore/Files/FileManager.swift b/ConversationsClassic/AppCore/Files/FileManager.swift deleted file mode 100644 index 8ee7823..0000000 --- a/ConversationsClassic/AppCore/Files/FileManager.swift +++ /dev/null @@ -1,3 +0,0 @@ -import Foundation - -final class FileManager {} diff --git a/ConversationsClassic/AppCore/Files/FileProcessing.swift b/ConversationsClassic/AppCore/Files/FileProcessing.swift new file mode 100644 index 0000000..9e2de3e --- /dev/null +++ b/ConversationsClassic/AppCore/Files/FileProcessing.swift @@ -0,0 +1,72 @@ +import Foundation +import UIKit + +final class FileProcessing { + static let shared = FileProcessing() + + static var fileFolder: URL { + // swiftlint:disable:next force_unwrapping + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + return documentsURL.appendingPathComponent(Const.fileFolder) + } + + func createThumbnail(id: String, localUrl: URL) -> URL? { + // make path for thumbnail + let thumbnailUrl = FileProcessing.fileFolder.appendingPathComponent(id).appendingPathExtension("png") + + // check if thumbnail already exists + if FileManager.default.fileExists(atPath: thumbnailUrl.path) { + return thumbnailUrl + } + + // create thumbnail if not exists + switch localUrl.lastPathComponent.attachmentType { + case .image: + guard let image = UIImage(contentsOfFile: localUrl.path) else { return nil } + let targetSize = CGSize(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) + guard let thumbnail = scaleAndCropImage(image, targetSize) else { return nil } + guard let data = thumbnail.pngData() else { return nil } + do { + try data.write(to: thumbnailUrl) + return thumbnailUrl + } catch { + return nil + } + + default: + return nil + } + } +} + +private extension FileProcessing { + func scaleAndCropImage(_ img: UIImage, _ size: CGSize) -> UIImage? { + guard let cgImage = img.cgImage else { + return nil + } + let contextImage: UIImage = .init(cgImage: cgImage) + var contextSize: CGSize = contextImage.size + + var posX: CGFloat = 0.0 + var posY: CGFloat = 0.0 + let cgwidth: CGFloat = size.width + let cgheight: CGFloat = size.height + + // Check and handle if the image is wider than the requested size + if contextSize.width > contextSize.height { + posX = ((contextSize.width - contextSize.height) / 2) + contextSize.width = contextSize.height + } else if contextSize.width < contextSize.height { + // Check and handle if the image is taller than the requested size + posY = ((contextSize.height - contextSize.width) / 2) + contextSize.height = contextSize.width + } + + let rect: CGRect = .init(x: posX, y: posY, width: cgwidth, height: cgheight) + guard let contextCg = contextImage.cgImage, let imgRef = contextCg.cropping(to: rect) else { + return nil + } + let image: UIImage = .init(cgImage: imgRef, scale: img.scale, orientation: img.imageOrientation) + return image + } +} diff --git a/ConversationsClassic/AppCore/Middlewares/DatabaseMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/DatabaseMiddleware.swift index 62b1d2c..a157758 100644 --- a/ConversationsClassic/AppCore/Middlewares/DatabaseMiddleware.swift +++ b/ConversationsClassic/AppCore/Middlewares/DatabaseMiddleware.swift @@ -2,6 +2,7 @@ import Combine import Foundation import GRDB +// swiftlint:disable:next type_body_length final class DatabaseMiddleware { static let shared = DatabaseMiddleware() private let database = Database.shared @@ -277,11 +278,36 @@ final class DatabaseMiddleware { } .eraseToAnyPublisher() + // MARK: Attachments + case .fileAction(.downloadingAttachmentFileFailed(let id, _)): + return Future { promise in + Task(priority: .background) { [weak self] in + guard let database = self?.database else { + promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError))) + ) + return + } + do { + _ = try database._db.write { db in + try Attachment + .filter(Column("id") == id) + .updateAll(db, Column("downloadFailed").set(to: true)) + } + promise(.success(.empty)) + } catch { + promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription))) + ) + } + } + } + .eraseToAnyPublisher() + case .fileAction(.attachmentFileDownloaded(let id, let localUrl)): return Future { promise in Task(priority: .background) { [weak self] in guard let database = self?.database else { - promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))) + promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError))) + ) return } do { @@ -292,7 +318,31 @@ final class DatabaseMiddleware { } promise(.success(.empty)) } catch { - promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))) + promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription))) + ) + } + } + } + .eraseToAnyPublisher() + + case .fileAction(.attachmentThumbnailCreated(let id, let thumbnailUrl)): + return Future { promise in + Task(priority: .background) { [weak self] in + guard let database = self?.database else { + promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError))) + ) + return + } + do { + _ = try database._db.write { db in + try Attachment + .filter(Column("id") == id) + .updateAll(db, Column("localThumbnailPath").set(to: thumbnailUrl)) + } + promise(.success(.empty)) + } catch { + promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription))) + ) } } } @@ -346,15 +396,3 @@ private extension DatabaseMiddleware { .store(in: &conversationCancellables) } } - -// try db.write { db in -// // Update the attachment -// var attachment = try Attachment.fetchOne(db, key: attachmentId)! -// attachment.someField = newValue -// try attachment.update(db) -// -// // Update the message -// var message = try Message.fetchOne(db, key: messageId)! -// message.someField = newValue -// try message.update(db) -// } diff --git a/ConversationsClassic/AppCore/Middlewares/FileMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/FileMiddleware.swift index ac69cc9..a796bb8 100644 --- a/ConversationsClassic/AppCore/Middlewares/FileMiddleware.swift +++ b/ConversationsClassic/AppCore/Middlewares/FileMiddleware.swift @@ -4,68 +4,51 @@ import UIKit final class FileMiddleware { static let shared = FileMiddleware() - private var downloader = DownloadManager() func middleware(state _: AppState, action: AppAction) -> AnyPublisher { switch action { case .conversationAction(.attachmentsUpdated(let attachments)): - DispatchQueue.global(qos: .background).async { - for attachment in attachments where attachment.localPath == nil { - if let remotePath = attachment.remotePath { - let localUrl = self.fileFolder.appendingPathComponent(attachment.id) - self.downloader.download(from: remotePath, to: localUrl) { error in - if error == nil { - DispatchQueue.main.async { - store.dispatch(.fileAction(.attachmentFileDownloaded(id: attachment.id, localUrl: localUrl))) - } - } + return Future { promise in + for attachment in attachments where attachment.localPath == nil && attachment.remotePath != nil { + DispatchQueue.main.async { + // swiftlint:disable:next force_unwrapping + store.dispatch(.fileAction(.downloadAttachmentFile(id: attachment.id, remotePath: attachment.remotePath!))) + } + } + promise(.success(.empty)) + }.eraseToAnyPublisher() + + case .fileAction(.downloadAttachmentFile(let id, let remotePath)): + return Future { promise in + let localUrl = FileProcessing.fileFolder.appendingPathComponent(id).appendingPathExtension(remotePath.pathExtension) + DownloadManager.shared.enqueueDownload(from: remotePath, to: localUrl) { error in + DispatchQueue.main.async { + if let error { + store.dispatch(.fileAction(.downloadingAttachmentFileFailed(id: id, reason: error.localizedDescription))) + } else { + store.dispatch(.fileAction(.attachmentFileDownloaded(id: id, localUrl: localUrl))) } } } - } - return Empty().eraseToAnyPublisher() + promise(.success(.empty)) + }.eraseToAnyPublisher() case .fileAction(.attachmentFileDownloaded(let id, let localUrl)): - DispatchQueue.global(qos: .background).async { - guard let attachment = store.state.conversationsState.currentAttachments.first(where: { $0.id == id }) else { return } - switch attachment.type { - case .image: - if let data = try? Data(contentsOf: localUrl), let image = UIImage(data: data) { - image.scaleAndCropImage(toExampleSize: CGSizeMake(Const.attachmentPreviewSize, Const.attachmentPreviewSize)) { img in - if let img = img, let data = img.jpegData(compressionQuality: 1.0) { - let thumbnailUrl = self.fileFolder.appendingPathComponent("\(id)_thumbnail.jpg") - do { - try data.write(to: thumbnailUrl) - DispatchQueue.main.async { - store.dispatch(.fileAction(.attachmentThumbnailCreated(id: id, thumbnailUrl: thumbnailUrl))) - } - } catch { - print("Error writing thumbnail: \(error)") - } - } - } - } + return Just(.fileAction(.createAttachmentThumbnail(id: id, localUrl: localUrl))) + .eraseToAnyPublisher() - case .movie: - // self.downloadAndMakeThumbnail(for: attachment) - break - - default: - break + case .fileAction(.createAttachmentThumbnail(let id, let localUrl)): + return Future { promise in + if let thumbnailUrl = FileProcessing.shared.createThumbnail(id: id, localUrl: localUrl) { + promise(.success(.fileAction(.attachmentThumbnailCreated(id: id, thumbnailUrl: thumbnailUrl)))) + } else { + promise(.success(.empty)) } } - return Empty().eraseToAnyPublisher() + .eraseToAnyPublisher() default: return Empty().eraseToAnyPublisher() } } } - -private extension FileMiddleware { - var fileFolder: URL { - // swiftlint:disable:next force_unwrapping - let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - return documentsURL.appendingPathComponent(Const.fileFolder) - } -} diff --git a/ConversationsClassic/AppCore/Models/Attachment.swift b/ConversationsClassic/AppCore/Models/Attachment.swift index 648a496..857b13d 100644 --- a/ConversationsClassic/AppCore/Models/Attachment.swift +++ b/ConversationsClassic/AppCore/Models/Attachment.swift @@ -19,6 +19,7 @@ struct Attachment: DBStorable { let remotePath: URL? let localThumbnailPath: URL? let messageId: String + var downloadFailed: Bool = false static let message = belongsTo(Message.self) var message: QueryInterfaceRequest { @@ -35,14 +36,18 @@ extension String { switch ext { case "mov", "mp4", "avi": return .movie + case "jpg", "png", "gif": return .image + case "mp3", "wav", "m4a": return .audio + case "txt", "doc", "pdf": return .file + default: - return .file // Default to .file if the extension is not recognized + return .file } } }