diff --git a/ConversationsClassic/AppData/Client/Client.swift b/ConversationsClassic/AppData/Client/Client.swift index cda7f62..73de0b8 100644 --- a/ConversationsClassic/AppData/Client/Client.swift +++ b/ConversationsClassic/AppData/Client/Client.swift @@ -91,7 +91,7 @@ extension Client { guard let to = message.to else { return } - guard let chat = connection.module(MessageModule.self).chatManager.chat(for: connection.context, with: BareJID(to)) else { + guard let chat = connection.module(MessageModule.self).chatManager.createChat(for: connection.context, with: BareJID(to)) else { return } @@ -99,11 +99,8 @@ extension Client { try await chat.send(message: msg) } - func uploadFile(_ localPath: String) async throws -> String { - guard let localPath = URL(string: localPath) else { - throw ClientStoreError.invalidPath - } - guard let data = try? Data(contentsOf: localPath) else { + func uploadFile(_ localURL: URL) async throws -> String { + guard let data = try? Data(contentsOf: localURL) else { throw ClientStoreError.noData } let httpModule = connection.module(HttpFileUploadModule.self) @@ -115,9 +112,9 @@ extension Client { let slot = try await httpModule.requestUploadSlot( componentJid: component.jid, - filename: localPath.lastPathComponent, + filename: localURL.lastPathComponent, size: data.count, - contentType: localPath.mimeType + contentType: localURL.mimeType ) var request = URLRequest(url: slot.putUri) for (key, value) in slot.putHeaders { @@ -126,11 +123,12 @@ extension Client { request.httpMethod = "PUT" request.httpBody = data request.addValue(String(data.count), forHTTPHeaderField: "Content-Length") - request.addValue(localPath.mimeType, forHTTPHeaderField: "Content-Type") + request.addValue(localURL.mimeType, forHTTPHeaderField: "Content-Type") let (_, response) = try await URLSession.shared.data(for: request) switch response { - case let httpResponse as HTTPURLResponse where httpResponse.statusCode == 200: + case let httpResponse as HTTPURLResponse where httpResponse.statusCode == 201: return slot.getUri.absoluteString + default: throw URLError(.badServerResponse) } diff --git a/ConversationsClassic/AppData/Model/Message.swift b/ConversationsClassic/AppData/Model/Message.swift index 240069f..6254351 100644 --- a/ConversationsClassic/AppData/Model/Message.swift +++ b/ConversationsClassic/AppData/Model/Message.swift @@ -20,6 +20,16 @@ struct Attachment: Codable & Equatable, DatabaseValueConvertible { var localName: String? var thumbnailName: String? var remotePath: String? + + var localPath: URL? { + guard let attachmentLocalName = localName else { return nil } + return Const.fileFolder.appendingPathComponent(attachmentLocalName) + } + + var thumbnailPath: URL? { + guard let attachmentThumbnailName = thumbnailName else { return nil } + return Const.fileFolder.appendingPathComponent(attachmentThumbnailName) + } } enum MessageContentType: Codable & Equatable, DatabaseValueConvertible { @@ -47,10 +57,10 @@ struct Message: DBStorable, Equatable { let from: String let to: String? - let body: String? + var body: String? let subject: String? let thread: String? - let oobUrl: String? + var oobUrl: String? } extension Message { diff --git a/ConversationsClassic/AppData/Services/FileStore.swift b/ConversationsClassic/AppData/Services/FileStore.swift deleted file mode 100644 index b5f6af6..0000000 --- a/ConversationsClassic/AppData/Services/FileStore.swift +++ /dev/null @@ -1,389 +0,0 @@ -import Foundation - -final class FileStore { - static let shared = FileStore() - - static var fileFolder: URL { - // swiftlint:disable:next force_unwrapping - let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - let subdirectoryURL = documentsURL.appendingPathComponent(Const.fileFolder) - if !FileManager.default.fileExists(atPath: subdirectoryURL.path) { - try? FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil) - } - return subdirectoryURL - } - - func storeCaptured(messageId: String, _ data: Data, _ type: GalleryMediaType) async throws -> (String, AttachmentType) { - try await Task { - // local name - let fileId = UUID().uuidString - let localName: String - let msgType: AttachmentType - switch type { - case .photo: - localName = "\(messageId)_\(fileId).jpg" - msgType = .image - - case .video: - localName = "\(messageId)_\(fileId).mov" - msgType = .video - } - - // save - let localUrl = FileStore.fileFolder.appendingPathComponent(localName) - try data.write(to: localUrl) - return (localName, msgType) - }.value - } -} - -// import Foundation -// import Photos -// 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! -// let subdirectoryURL = documentsURL.appendingPathComponent(Const.fileFolder) -// if !FileManager.default.fileExists(atPath: subdirectoryURL.path) { -// try? FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil) -// } -// return subdirectoryURL -// } -// -// func createThumbnail(localName: String) -> String? { -// let thumbnailFileName = "thumb_\(localName)" -// let thumbnailUrl = FileProcessing.fileFolder.appendingPathComponent(thumbnailFileName) -// let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName) -// -// // check if thumbnail already exists -// if FileManager.default.fileExists(atPath: thumbnailUrl.path) { -// return thumbnailFileName -// } -// -// // create thumbnail if not exists -// switch localName.attachmentType { -// case .image: -// guard let image = UIImage(contentsOfFile: localUrl.path) else { -// print("FileProcessing: Error loading image: \(localUrl)") -// return nil -// } -// let targetSize = CGSize(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) -// guard let thumbnail = scaleAndCropImage(image, targetSize) else { -// print("FileProcessing: Error scaling image: \(localUrl)") -// return nil -// } -// guard let data = thumbnail.pngData() else { -// print("FileProcessing: Error converting thumbnail of \(localUrl) to data") -// return nil -// } -// do { -// try data.write(to: thumbnailUrl) -// return thumbnailFileName -// } catch { -// print("FileProcessing: Error writing thumbnail: \(error)") -// return nil -// } -// -// default: -// return nil -// } -// } -// -// func fetchGallery() -> [SharingGalleryItem] { -// let items = syncGalleryEnumerate() -// .map { -// SharingGalleryItem( -// id: $0.localIdentifier, -// type: $0.mediaType == .image ? .photo : .video, -// duration: $0.mediaType == .video ? $0.duration.minAndSec : nil -// ) -// } -// return items -// } -// -// func fillGalleryItemsThumbnails(items: [SharingGalleryItem]) -> [SharingGalleryItem] { -// let ids = items -// .filter { $0.thumbnail == nil } -// .map { $0.id } -// -// let assets = syncGalleryEnumerate(ids) -// return assets.compactMap { asset in -// if asset.mediaType == .image { -// return syncGalleryProcessImage(asset) { [weak self] image in -// if let thumbnail = self?.scaleAndCropImage(image, CGSize(width: Const.galleryGridSize, height: Const.galleryGridSize)) { -// let data = thumbnail.jpegData(compressionQuality: 1.0) ?? Data() -// return SharingGalleryItem(id: asset.localIdentifier, type: .photo, thumbnail: data) -// } else { -// return nil -// } -// } -// } else if asset.mediaType == .video { -// return syncGalleryProcessVideo(asset) { [weak self] avAsset in -// // swiftlint:disable:next force_cast -// let assetURL = avAsset as! AVURLAsset -// let url = assetURL.url -// if let thumbnail = self?.generateVideoThumbnail(url, CGSize(width: Const.galleryGridSize, height: Const.galleryGridSize)) { -// let data = thumbnail.jpegData(compressionQuality: 1.0) ?? Data() -// return SharingGalleryItem( -// id: asset.localIdentifier, -// type: .video, -// thumbnail: data, -// duration: asset.duration.minAndSec -// ) -// } else { -// return nil -// } -// } -// } else { -// return nil -// } -// } -// } -// -// // This function also creates new ids for messages for each new attachment -// func copyGalleryItemsForUploading(items: [SharingGalleryItem]) -> [(String, String)] { -// let assets = syncGalleryEnumerate(items.map { $0.id }) -// return assets -// .compactMap { asset in -// let newMessageId = UUID().uuidString -// let fileId = UUID().uuidString -// if asset.mediaType == .image { -// return syncGalleryProcessImage(asset) { image in -// let localName = "\(newMessageId)_\(fileId).jpg" -// let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName) -// if let data = image.jpegData(compressionQuality: 1.0) { -// do { -// try data.write(to: localUrl) -// return (newMessageId, localName) -// } catch { -// return nil -// } -// } else { -// return nil -// } -// } -// } else if asset.mediaType == .video { -// return syncGalleryProcessVideo(asset) { avAsset in -// // swiftlint:disable:next force_cast -// let assetURL = avAsset as! AVURLAsset -// let url = assetURL.url -// let localName = "\(newMessageId)_\(fileId).mov" -// let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName) -// do { -// try FileManager.default.copyItem(at: url, to: localUrl) -// return (newMessageId, localName) -// } catch { -// return nil -// } -// } -// } else { -// return nil -// } -// } -// } -// -// // This function also creates new id for file from camera capturing -// func copyCameraCapturedForUploading(media: Data, type: SharingCameraMediaType) -> (String, String)? { -// let newMessageId = UUID().uuidString -// let fileId = UUID().uuidString -// let localName: String -// switch type { -// case .photo: -// localName = "\(newMessageId)_\(fileId).jpg" -// case .video: -// localName = "\(newMessageId)_\(fileId).mov" -// } -// let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName) -// do { -// try media.write(to: localUrl) -// return (newMessageId, localName) -// } catch { -// return nil -// } -// } -// -// // This function also creates new id for file from document sharing -// func copyDocumentsForUploading(data: [Data], extensions: [String]) -> [(String, String)] { -// data.enumerated().compactMap { index, data in -// let newMessageId = UUID().uuidString -// let fileId = UUID().uuidString -// let localName = "\(newMessageId)_\(fileId).\(extensions[index])" -// let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName) -// do { -// try data.write(to: localUrl) -// return (newMessageId, localName) -// } catch { -// print("FileProcessing: Error writing document: \(error)") -// return nil -// } -// } -// } -// } -// -// private extension FileProcessing { -// func scaleAndCropImage(_ img: UIImage, _ size: CGSize) -> UIImage? { -// let aspect = img.size.width / img.size.height -// let targetAspect = size.width / size.height -// var newWidth: CGFloat -// var newHeight: CGFloat -// if aspect < targetAspect { -// newWidth = size.width -// newHeight = size.width / aspect -// } else { -// newHeight = size.height -// newWidth = size.height * aspect -// } -// -// UIGraphicsBeginImageContextWithOptions(size, false, 0.0) -// img.draw(in: CGRect(x: (size.width - newWidth) / 2, y: (size.height - newHeight) / 2, width: newWidth, height: newHeight)) -// let newImage = UIGraphicsGetImageFromCurrentImageContext() -// UIGraphicsEndImageContext() -// -// return newImage -// } -// -// func syncGalleryEnumerate(_ ids: [String]? = nil) -> [PHAsset] { -// var result: [PHAsset] = [] -// -// let group = DispatchGroup() -// DispatchQueue.global(qos: .userInitiated).sync { -// let fetchOptions = PHFetchOptions() -// fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] -// if let ids { -// fetchOptions.predicate = NSPredicate(format: "localIdentifier IN %@", ids) -// } -// let assets = PHAsset.fetchAssets(with: fetchOptions) -// assets.enumerateObjects { asset, _, _ in -// group.enter() -// result.append(asset) -// group.leave() -// } -// } -// group.wait() -// return result -// } -// -// func syncGalleryProcess(_ assets: [PHAsset], _ block: @escaping (PHAsset) -> T) -> [T] { -// var result: [T] = [] -// let group = DispatchGroup() -// DispatchQueue.global(qos: .userInitiated).sync { -// for asset in assets { -// group.enter() -// let res = block(asset) -// result.append(res) -// group.leave() -// } -// } -// group.wait() -// return result -// } -// -// func syncGalleryProcessImage(_ asset: PHAsset, _ block: @escaping (UIImage) -> T?) -> T? { -// var result: T? -// let semaphore = DispatchSemaphore(value: 0) -// DispatchQueue.global(qos: .userInitiated).sync { -// let options = PHImageRequestOptions() -// options.version = .original -// options.isSynchronous = true -// PHImageManager.default().requestImage( -// for: asset, -// targetSize: PHImageManagerMaximumSize, -// contentMode: .aspectFill, -// options: options -// ) { image, _ in -// if let image { -// result = block(image) -// } else { -// result = nil -// } -// semaphore.signal() -// } -// } -// semaphore.wait() -// return result -// } -// -// func syncGalleryProcessVideo(_ asset: PHAsset, _ block: @escaping (AVAsset) -> T?) -> T? { -// var result: T? -// let semaphore = DispatchSemaphore(value: 0) -// _ = DispatchQueue.global(qos: .userInitiated).sync { -// PHImageManager.default().requestAVAsset(forVideo: asset, options: nil) { avAsset, _, _ in -// if let avAsset { -// result = block(avAsset) -// } else { -// result = nil -// } -// semaphore.signal() -// } -// } -// semaphore.wait() -// return result -// } -// -// func generateVideoThumbnail(_ url: URL, _ size: CGSize) -> UIImage? { -// let asset = AVAsset(url: url) -// let assetImgGenerate = AVAssetImageGenerator(asset: asset) -// assetImgGenerate.appliesPreferredTrackTransform = true -// let time = CMTimeMakeWithSeconds(Float64(1), preferredTimescale: 600) -// do { -// let cgImage = try assetImgGenerate.copyCGImage(at: time, actualTime: nil) -// let image = UIImage(cgImage: cgImage) -// return scaleAndCropImage(image, size) -// } catch { -// return nil -// } -// } -// } - -// 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 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) -// -// guard let tempLocalUrl = tempLocalUrl, error == nil else { -// completion(error) -// return -// } -// -// do { -// if FileManager.default.fileExists(atPath: localUrl.path) { -// try FileManager.default.removeItem(at: localUrl) -// } -// let data = try Data(contentsOf: tempLocalUrl) -// try data.write(to: localUrl) -// completion(nil) -// } catch let writeError { -// completion(writeError) -// } -// } -// } -// task.resume() -// } -// } -// } diff --git a/ConversationsClassic/AppData/Store/ConversationStore.swift b/ConversationsClassic/AppData/Store/ConversationStore.swift index 58f2319..b48c421 100644 --- a/ConversationsClassic/AppData/Store/ConversationStore.swift +++ b/ConversationsClassic/AppData/Store/ConversationStore.swift @@ -71,7 +71,26 @@ extension ConversationStore { let localName: String let msgType: AttachmentType do { - (localName, msgType) = try await FileStore.shared.storeCaptured(messageId: messageId, data, type) + (localName, msgType) = try await Task { + // local name + let fileId = UUID().uuidString + let localName: String + let msgType: AttachmentType + switch type { + case .photo: + localName = "\(messageId)_\(fileId).jpg" + msgType = .image + + case .video: + localName = "\(messageId)_\(fileId).mov" + msgType = .video + } + + // save + let localUrl = Const.fileFolder.appendingPathComponent(localName) + try data.write(to: localUrl) + return (localName, msgType) + }.value } catch { logIt(.error, "Can't save file for uploading: \(error)") return @@ -140,7 +159,7 @@ extension ConversationStore { guard case .attachment(let attachment) = message.contentType else { throw ClientStoreError.invalidContentType } - guard let localName = attachment.localName else { + guard let localName = attachment.localPath else { throw ClientStoreError.invalidLocalName } let remotePath = try await client.uploadFile(localName) @@ -152,6 +171,8 @@ extension ConversationStore { remotePath: remotePath ) ) + message.body = remotePath + message.oobUrl = remotePath try await message.save() try await client.sendMessage(message) try await message.setStatus(.sent) diff --git a/ConversationsClassic/Helpers/Const.swift b/ConversationsClassic/Helpers/Const.swift index 8a9eb50..c627201 100644 --- a/ConversationsClassic/Helpers/Const.swift +++ b/ConversationsClassic/Helpers/Const.swift @@ -23,6 +23,17 @@ enum Const { Bundle.main.bundleIdentifier ?? "Conversations Classic iOS" } + // Folder for storing files + static var fileFolder: URL { + // swiftlint:disable:next force_unwrapping + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let subdirectoryURL = documentsURL.appendingPathComponent("Downloads") + if !FileManager.default.fileExists(atPath: subdirectoryURL.path) { + try? FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil) + } + return subdirectoryURL + } + // Trusted servers enum TrustedServers: String { case narayana = "narayana.im" @@ -32,9 +43,6 @@ enum Const { // Limit for video for sharing static let videoDurationLimit = 60.0 - // Upload/download file folder - static let fileFolder = "Downloads" - // Grid size for gallery preview (3 in a row) static let galleryGridSize = UIScreen.main.bounds.width / 3