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 storedChatsLoaded(chats: [Chat])
case storeMessageFailed(reason: String) case storeMessageFailed(reason: String)
case updateAttachmentFailed(id: String, reason: String)
} }

View file

@ -1,6 +1,10 @@
import Foundation import Foundation
enum FileAction: Stateable { enum FileAction: Stateable {
case downloadAttachmentFile(id: String, remotePath: URL)
case attachmentFileDownloaded(id: String, localUrl: 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) case attachmentThumbnailCreated(id: String, thumbnailUrl: URL)
} }

View file

@ -67,6 +67,7 @@ extension Database {
table.column("localPath", .text) table.column("localPath", .text)
table.column("remotePath", .text) table.column("remotePath", .text)
table.column("localThumbnailPath", .text) table.column("localThumbnailPath", .text)
table.column("downloadFailed", .boolean).notNull().defaults(to: false)
} }
} }

View file

@ -1,15 +1,30 @@
import Foundation import Foundation
final class DownloadManager { final class DownloadManager {
static let shared = DownloadManager()
private let urlSession: URLSession private let urlSession: URLSession
private let downloadQueue = DispatchQueue(label: "com.example.downloadQueue")
private var activeDownloads = Set<URL>()
init() { init() {
let configuration = URLSessionConfiguration.default let configuration = URLSessionConfiguration.default
urlSession = URLSession(configuration: configuration) urlSession = URLSession(configuration: configuration)
} }
func download(from url: URL, to localUrl: URL, completion: @escaping (Error?) -> Void) { func enqueueDownload(from url: URL, to localUrl: URL, completion: @escaping (Error?) -> Void) {
let task = urlSession.downloadTask(with: url) { tempLocalUrl, _, error in 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 { if let tempLocalUrl = tempLocalUrl, error == nil {
do { do {
try FileManager.default.copyItem(at: tempLocalUrl, to: localUrl) try FileManager.default.copyItem(at: tempLocalUrl, to: localUrl)
@ -21,6 +36,8 @@ final class DownloadManager {
completion(error) 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 Foundation
import GRDB import GRDB
// swiftlint:disable:next type_body_length
final class DatabaseMiddleware { final class DatabaseMiddleware {
static let shared = DatabaseMiddleware() static let shared = DatabaseMiddleware()
private let database = Database.shared private let database = Database.shared
@ -277,11 +278,36 @@ final class DatabaseMiddleware {
} }
.eraseToAnyPublisher() .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)): case .fileAction(.attachmentFileDownloaded(let id, let localUrl)):
return Future<AppAction, Never> { promise in return Future<AppAction, Never> { promise in
Task(priority: .background) { [weak self] in Task(priority: .background) { [weak self] in
guard let database = self?.database else { 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 return
} }
do { do {
@ -292,7 +318,31 @@ final class DatabaseMiddleware {
} }
promise(.success(.empty)) promise(.success(.empty))
} catch { } 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) .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 { final class FileMiddleware {
static let shared = FileMiddleware() static let shared = FileMiddleware()
private var downloader = DownloadManager()
func middleware(state _: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> { func middleware(state _: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
switch action { switch action {
case .conversationAction(.attachmentsUpdated(let attachments)): case .conversationAction(.attachmentsUpdated(let attachments)):
DispatchQueue.global(qos: .background).async { return Future { promise in
for attachment in attachments where attachment.localPath == nil { for attachment in attachments where attachment.localPath == nil && attachment.remotePath != 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 { DispatchQueue.main.async {
store.dispatch(.fileAction(.attachmentFileDownloaded(id: attachment.id, localUrl: localUrl))) // 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)))
} }
} }
} }
} promise(.success(.empty))
} }.eraseToAnyPublisher()
}
return Empty().eraseToAnyPublisher()
case .fileAction(.attachmentFileDownloaded(let id, let localUrl)): case .fileAction(.attachmentFileDownloaded(let id, let localUrl)):
DispatchQueue.global(qos: .background).async { return Just(.fileAction(.createAttachmentThumbnail(id: id, localUrl: localUrl)))
guard let attachment = store.state.conversationsState.currentAttachments.first(where: { $0.id == id }) else { return } .eraseToAnyPublisher()
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)")
}
}
}
}
case .movie: case .fileAction(.createAttachmentThumbnail(let id, let localUrl)):
// self.downloadAndMakeThumbnail(for: attachment) return Future { promise in
break if let thumbnailUrl = FileProcessing.shared.createThumbnail(id: id, localUrl: localUrl) {
promise(.success(.fileAction(.attachmentThumbnailCreated(id: id, thumbnailUrl: thumbnailUrl))))
default: } else {
break promise(.success(.empty))
} }
} }
return Empty().eraseToAnyPublisher() .eraseToAnyPublisher()
default: default:
return Empty().eraseToAnyPublisher() 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 remotePath: URL?
let localThumbnailPath: URL? let localThumbnailPath: URL?
let messageId: String let messageId: String
var downloadFailed: Bool = false
static let message = belongsTo(Message.self) static let message = belongsTo(Message.self)
var message: QueryInterfaceRequest<Message> { var message: QueryInterfaceRequest<Message> {
@ -35,14 +36,18 @@ extension String {
switch ext { switch ext {
case "mov", "mp4", "avi": case "mov", "mp4", "avi":
return .movie return .movie
case "jpg", "png", "gif": case "jpg", "png", "gif":
return .image return .image
case "mp3", "wav", "m4a": case "mp3", "wav", "m4a":
return .audio return .audio
case "txt", "doc", "pdf": case "txt", "doc", "pdf":
return .file return .file
default: default:
return .file // Default to .file if the extension is not recognized return .file
} }
} }
} }