diff --git a/ConversationsClassic/AppData/Client/Client.swift b/ConversationsClassic/AppData/Client/Client.swift index db99443..08852a9 100644 --- a/ConversationsClassic/AppData/Client/Client.swift +++ b/ConversationsClassic/AppData/Client/Client.swift @@ -98,6 +98,40 @@ extension Client { let msg = chat.createMessage(text: message.body ?? "??", id: message.id) try await chat.send(message: msg) } + + func uploadFile(_ localPath: URL) async throws -> String { + guard let data = try? Data(contentsOf: localPath) else { + throw ClientStoreError.noData + } + let httpModule = connection.module(HttpFileUploadModule.self) + + let components = try await httpModule.findHttpUploadComponents() + guard let component = components.first(where: { $0.maxSize > data.count }) else { + throw ClientStoreError.fileTooBig + } + + let slot = try await httpModule.requestUploadSlot( + componentJid: component.jid, + filename: localPath.lastPathComponent, + size: data.count, + contentType: localPath.mimeType + ) + var request = URLRequest(url: slot.putUri) + for (key, value) in slot.putHeaders { + request.addValue(value, forHTTPHeaderField: key) + } + request.httpMethod = "PUT" + request.httpBody = data + request.addValue(String(data.count), forHTTPHeaderField: "Content-Length") + request.addValue(localPath.mimeType, forHTTPHeaderField: "Content-Type") + let (_, response) = try await URLSession.shared.data(for: request) + switch response { + case let httpResponse as HTTPURLResponse where httpResponse.statusCode == 200: + return slot.getUri.absoluteString + default: + throw URLError(.badServerResponse) + } + } } extension Client { @@ -125,14 +159,14 @@ private extension Client { client.modulesManager.register(RosterModule(rosterManager: roster)) client.modulesManager.register(PresenceModule()) - // client.modulesManager.register(PubSubModule()) + client.modulesManager.register(PubSubModule()) client.modulesManager.register(MessageModule(chatManager: chat)) // client.modulesManager.register(MessageArchiveManagementModule()) // client.modulesManager.register(MessageCarbonsModule()) // file transfer modules - // client.modulesManager.register(HttpFileUploadModule()) + client.modulesManager.register(HttpFileUploadModule()) // extensions client.modulesManager.register(SoftwareVersionModule()) diff --git a/ConversationsClassic/AppData/Model/Message.swift b/ConversationsClassic/AppData/Model/Message.swift index e327afb..240069f 100644 --- a/ConversationsClassic/AppData/Model/Message.swift +++ b/ConversationsClassic/AppData/Model/Message.swift @@ -8,9 +8,25 @@ enum MessageType: String, Codable, DatabaseValueConvertible { case error } +enum AttachmentType: Int, Codable, DatabaseValueConvertible { + case image + case video + case audio + case file +} + +struct Attachment: Codable & Equatable, DatabaseValueConvertible { + let type: AttachmentType + var localName: String? + var thumbnailName: String? + var remotePath: String? +} + enum MessageContentType: Codable & Equatable, DatabaseValueConvertible { case text case typing + case invite + case attachment(Attachment) } enum MessageStatus: Int, Codable, DatabaseValueConvertible { @@ -25,7 +41,7 @@ struct Message: DBStorable, Equatable { let id: String let type: MessageType let date: Date - let contentType: MessageContentType + var contentType: MessageContentType var status: MessageStatus let from: String diff --git a/ConversationsClassic/AppData/Services/FileStore.swift b/ConversationsClassic/AppData/Services/FileStore.swift new file mode 100644 index 0000000..b5f6af6 --- /dev/null +++ b/ConversationsClassic/AppData/Services/FileStore.swift @@ -0,0 +1,389 @@ +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/ClientStoreError.swift b/ConversationsClassic/AppData/Store/ClientStoreError.swift index 5f8f01c..feb0db8 100644 --- a/ConversationsClassic/AppData/Store/ClientStoreError.swift +++ b/ConversationsClassic/AppData/Store/ClientStoreError.swift @@ -3,4 +3,7 @@ enum ClientStoreError: Error { case rosterNotFound case imageNotFound case videoNotFound + case fileStoreError + case noData + case fileTooBig } diff --git a/ConversationsClassic/AppData/Store/ConversationStore.swift b/ConversationsClassic/AppData/Store/ConversationStore.swift index 7adf0d9..fc71be7 100644 --- a/ConversationsClassic/AppData/Store/ConversationStore.swift +++ b/ConversationsClassic/AppData/Store/ConversationStore.swift @@ -66,13 +66,49 @@ extension ConversationStore { } func sendCaptured(_ data: Data, _ type: GalleryMediaType) async { - print("captured!", data, type) - // - // - // + // save locally and make message + let messageId = UUID().uuidString + do { + let (localName, msgType) = try await FileStore.shared.storeCaptured(messageId: messageId, data, type) + let message = Message( + id: UUID().uuidString, + type: .chat, + date: Date(), + contentType: .attachment( + Attachment( + type: msgType, + localName: localName, + thumbnailName: nil, + remotePath: nil + ) + ), + status: .pending, + from: roster.bareJid, + to: roster.contactBareJid, + body: nil, + subject: nil, + thread: nil, + oobUrl: nil + ) + try await message.save() + } catch { + logIt(.error, "Can't save file for uploading: \(error)") + } + + // upload and save + upload(message: message) } func sendDocuments(_ data: [Data], _ extensions: [String]) async { + // do { + // let newMessageId = UUID().uuidString + // let fileId = UUID().uuidString + // let localName = "\(newMessageId)_\(fileId).\(ext)" + // try await FileStore.shared.storeForUploading(data, localName) + // + // } catch { + // print("error", error) + // } print("documents!", data, extensions) // // @@ -86,6 +122,25 @@ extension ConversationStore { func sendLocation(_ lat: Double, _ lon: Double) async { await sendMessage("geo:\(lat),\(lon)") } + + private func upload(message: Message) async { + let remotePath: String + do { + remotePath = try await client.uploadFile(localPath) + message.contentType = .attachment( + Attachment( + type: msgType, + localName: localName, + thumbnailName: nil, + remotePath: remotePath + ) + ) + try await message.save() + } catch { + message.status = .error + try? await message.save() + } + } } extension ConversationStore { diff --git a/ConversationsClassic/Helpers/String+Extensions.swift b/ConversationsClassic/Helpers/String+Extensions.swift index 4d0bad9..5701e61 100644 --- a/ConversationsClassic/Helpers/String+Extensions.swift +++ b/ConversationsClassic/Helpers/String+Extensions.swift @@ -36,28 +36,28 @@ extension String { } } -// extension String { -// var attachmentType: MessageAttachmentType { -// let ext = (self as NSString).pathExtension.lowercased() -// -// 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 -// } -// } -// } +extension String { + var attachmentType: AttachmentType { + let ext = (self as NSString).pathExtension.lowercased() + + switch ext { + case "mov", "mp4", "avi": + return .video + + case "jpg", "png", "gif": + return .image + + case "mp3", "wav", "m4a": + return .audio + + case "txt", "doc", "pdf": + return .file + + default: + return .file + } + } +} extension String { var firstLetterColor: Color {