This commit is contained in:
fmodf 2024-07-13 03:29:46 +02:00
parent 50fba234b0
commit e21610d425
9 changed files with 196 additions and 77 deletions

View file

@ -7,4 +7,6 @@ enum DatabaseAction: Codable {
case storedChatsLoaded(chats: [Chat])
case storeMessageFailed(reason: String)
case updateAttachmentFailed(id: String, reason: String)
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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<URL>()
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()
}
}

View file

@ -1,3 +0,0 @@
import Foundation
final class FileManager {}

View file

@ -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
}
}

View file

@ -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<AppAction, Never> { 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<AppAction, Never> { 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<AppAction, Never> { 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)
// }

View file

@ -4,68 +4,51 @@ import UIKit
final class FileMiddleware {
static let shared = FileMiddleware()
private var downloader = DownloadManager()
func middleware(state _: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
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)
}
}

View file

@ -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<Message> {
@ -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
}
}
}