diff --git a/ConversationsClassic/AppData/Client/Client.swift-E b/ConversationsClassic/AppData/Client/Client.swift-E deleted file mode 100644 index 96ddf65..0000000 --- a/ConversationsClassic/AppData/Client/Client.swift-E +++ /dev/null @@ -1,202 +0,0 @@ -import Combine -import Foundation -import GRDB -import Martin - -enum ClientState: Equatable { - enum ClientConnectionState { - case connected - case disconnected - } - - case disabled - case enabled(ClientConnectionState) -} - -final class Client: ObservableObject { - @Published private(set) var state: ClientState = .enabled(.disconnected) - @Published private(set) var credentials: Credentials - @Published private(set) var rosters: [Roster] = [] - - private var connection: XMPPClient - private var connectionCancellable: AnyCancellable? - private var rostersCancellable: AnyCancellable? - - private var rosterManager = ClientMartinRosterManager() - private var chatsManager = ClientMartinChatsManager() - private var messageManager: ClientMartinMessagesManager - private var discoManager: ClientMartinDiscoManager - - init(credentials: Credentials) { - self.credentials = credentials - state = credentials.isActive ? .enabled(.disconnected) : .disabled - connection = Self.prepareConnection(credentials, rosterManager, chatsManager) - messageManager = ClientMartinMessagesManager(connection) - discoManager = ClientMartinDiscoManager(connection) - connectionCancellable = connection.$state - .sink { [weak self] state in - guard let self = self else { return } - guard self.credentials.isActive else { - self.state = .disabled - return - } - rostersCancellable = ValueObservation - .tracking { db in - try Roster - .filter(Column("bareJid") == self.credentials.bareJid) - .filter(Column("locallyDeleted") == false) - .fetchAll(db) - } - .publisher(in: Database.shared.dbQueue) - .catch { _ in Just([]) } - .sink { rosters in - self.rosters = rosters - } - switch state { - case .connected: - self.state = .enabled(.connected) - - default: - self.state = .enabled(.disconnected) - } - } - } -} - -extension Client { - func addRoster(_ jid: String, name: String?, groups: [String]) async throws { - _ = try await connection.module(.roster).addItem( - jid: JID(jid), - name: name, - groups: groups - ) - } - - func deleteRoster(_ roster: Roster) async throws { - _ = try await connection.module(.roster).removeItem(jid: JID(roster.contactBareJid)) - } - - func connect() async { - guard credentials.isActive, state == .enabled(.disconnected) else { - return - } - try? await connection.loginAndWait() - } - - func disconnect() { - _ = connection.disconnect() - } -} - -extension Client { - func sendMessage(_ message: Message) async throws { - guard let to = message.to else { - return - } - guard let chat = connection.module(MessageModule.self).chatManager.createChat(for: connection.context, with: BareJID(to)) else { - return - } - - let msg = chat.createMessage(text: message.body ?? "??", id: message.id) - msg.oob = message.oobUrl - try await chat.send(message: msg) - } - - func uploadFile(_ localURL: URL) async throws -> String { - guard let data = try? Data(contentsOf: localURL) else { - throw AppError.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: localURL.lastPathComponent, - size: data.count, - contentType: localURL.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(localURL.mimeType, forHTTPHeaderField: "Content-Type") - let (_, response) = try await URLSession.shared.data(for: request) - switch response { - case let httpResponse as HTTPURLResponse where httpResponse.statusCode == 201: - return slot.getUri.absoluteString - - default: - throw URLError(.badServerResponse) - } - } - - func requestArchivedMessages(for roster: Roster) async { - print(roster) - - // if !discoManager.features.map({ $0.xep }).contains("XEP-0313") { - // return - // } - // let module = connection.module(MessageArchiveManagementModule.self) - // let endDate = Date() - // let startDate = Calendar.current.date(byAdding: .day, value: -Const.mamRequestDaysLength, to: endDate) ?? Date() - // let response = try? await module.queryItems(componentJid: JID(credentials.bareJid), with: JID(roster.bareJid), start: startDate, end: endDate, queryId: UUID().uuidString) - } -} - -extension Client { - static func tryLogin(with credentials: Credentials) async throws -> Client { - let client = Client(credentials: credentials) - try await client.connection.loginAndWait() - return client - } -} - -private extension Client { - static func prepareConnection(_ credentials: Credentials, _ roster: RosterManager, _ chat: ChatManager) -> XMPPClient { - let client = XMPPClient() - - // register modules - // core modules RFC 6120 - client.modulesManager.register(StreamFeaturesModule()) - client.modulesManager.register(SaslModule()) - client.modulesManager.register(AuthModule()) - client.modulesManager.register(SessionEstablishmentModule()) - client.modulesManager.register(ResourceBinderModule()) - client.modulesManager.register(DiscoveryModule(identity: .init(category: "client", type: "iOS", name: Const.appName))) - - // messaging modules RFC 6121 - client.modulesManager.register(RosterModule(rosterManager: roster)) - client.modulesManager.register(PresenceModule()) - - 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()) - - // extensions - client.modulesManager.register(SoftwareVersionModule()) - client.modulesManager.register(PingModule()) - client.connectionConfiguration.userJid = .init(credentials.bareJid) - client.connectionConfiguration.credentials = .password(password: credentials.pass) - - // group chats - // client.modulesManager.register(MucModule(roomManager: manager)) - - // channels - // client.modulesManager.register(MixModule(channelManager: manager)) - - // add client to clients - return client - } -} diff --git a/ConversationsClassic/AppData/Store/AttachmentsStore.swift-E b/ConversationsClassic/AppData/Store/AttachmentsStore.swift-E deleted file mode 100644 index b12272a..0000000 --- a/ConversationsClassic/AppData/Store/AttachmentsStore.swift-E +++ /dev/null @@ -1,352 +0,0 @@ -import Combine -import Foundation -import GRDB -import Photos -import SwiftUI - -@MainActor -final class AttachmentsStore: ObservableObject { - @Published private(set) var cameraAccessGranted = false - @Published private(set) var galleryAccessGranted = false - @Published private(set) var galleryItems: [GalleryItem] = [] - - private let client: Client - private let roster: Roster - - private var messagesCancellable: AnyCancellable? - private var processing: Set = [] - - init(roster: Roster, client: Client) { - self.client = client - self.roster = roster - subscribe() - } -} - -// MARK: - Camera and Gallery access -extension AttachmentsStore { - func checkCameraAuthorization() async { - let status = AVCaptureDevice.authorizationStatus(for: .video) - var isAuthorized = status == .authorized - if status == .notDetermined { - isAuthorized = await AVCaptureDevice.requestAccess(for: .video) - } - cameraAccessGranted = isAuthorized - } - - func checkGalleryAuthorization() async { - let status = PHPhotoLibrary.authorizationStatus() - var isAuthorized = status == .authorized - if status == .notDetermined { - let req = await PHPhotoLibrary.requestAuthorization(for: .readWrite) - isAuthorized = (req == .authorized) || (req == .limited) - } - galleryAccessGranted = isAuthorized - if isAuthorized { - await fetchGalleryItems() - } - } - - private func fetchGalleryItems() async { - guard galleryAccessGranted else { return } - galleryItems = await GalleryItem.fetchAll() - } -} - -// MARK: - Save outgoing attachments for future uploadings -extension AttachmentsStore { - func sendMedia(_ items: [GalleryItem]) { - Task { - for item in items { - Task { - var message = Message.blank - message.from = roster.bareJid - message.to = roster.contactBareJid - - switch item.type { - case .photo: - guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [item.id], options: nil).firstObject else { return } - guard let photo = try? await PHImageManager.default().getPhoto(for: asset) else { return } - guard let data = photo.jpegData(compressionQuality: 1.0) else { return } - let localName = "\(message.id)_\(UUID().uuidString).jpg" - let localUrl = Const.fileFolder.appendingPathComponent(localName) - try? data.write(to: localUrl) - message.contentType = .attachment( - Attachment( - type: .image, - localName: localName, - thumbnailName: nil, - remotePath: nil - ) - ) - try? await message.save() - - case .video: - guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [item.id], options: nil).firstObject else { return } - guard let video = try? await PHImageManager.default().getVideo(for: asset) else { return } - // swiftlint:disable:next force_cast - let assetURL = video as! AVURLAsset - let url = assetURL.url - let localName = "\(message.id)_\(UUID().uuidString).mov" - let localUrl = Const.fileFolder.appendingPathComponent(localName) - try? FileManager.default.copyItem(at: url, to: localUrl) - message.contentType = .attachment( - Attachment( - type: .video, - localName: localName, - thumbnailName: nil, - remotePath: nil - ) - ) - try? await message.save() - } - } - } - } - } - - func sendCaptured(_ data: Data, _ type: GalleryMediaType) { - Task { - var message = Message.blank - message.from = roster.bareJid - message.to = roster.contactBareJid - - let localName: String - let msgType: AttachmentType - do { - (localName, msgType) = try await Task { - // local name - let fileId = UUID().uuidString - let localName: String - let msgType: AttachmentType - switch type { - case .photo: - localName = "\(message.id)_\(fileId).jpg" - msgType = .image - - case .video: - localName = "\(message.id)_\(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 - } - - // save message - message.contentType = .attachment( - Attachment( - type: msgType, - localName: localName, - thumbnailName: nil, - remotePath: nil - ) - ) - do { - try await message.save() - } catch { - logIt(.error, "Can't save message: \(error)") - return - } - } - } - - func sendDocuments(_ data: [Data], _ extensions: [String]) { - Task { - for (index, data) in data.enumerated() { - Task { - let newMessageId = UUID().uuidString - let fileId = UUID().uuidString - let localName = "\(newMessageId)_\(fileId).\(extensions[index])" - let localUrl = Const.fileFolder.appendingPathComponent(localName) - do { - try data.write(to: localUrl) - } catch { - print("FileProcessing: Error writing document: \(error)") - return - } - - var message = Message.blank - message.from = roster.bareJid - message.to = roster.contactBareJid - message.contentType = .attachment( - Attachment( - type: localName.attachmentType, - localName: localName, - thumbnailName: nil, - remotePath: nil - ) - ) - do { - try await message.save() - } catch { - print("FileProcessing: Error saving document: \(error)") - } - } - } - } - } -} - -// MARK: - Processing attachments -private extension AttachmentsStore { - func subscribe() { - messagesCancellable = ValueObservation.tracking(Message - .filter( - (Column("to") == roster.bareJid && Column("from") == roster.contactBareJid) || - (Column("from") == roster.bareJid && Column("to") == roster.contactBareJid) - ) - .order(Column("date").desc) - .fetchAll - ) - .publisher(in: Database.shared.dbQueue, scheduling: .immediate) - .receive(on: DispatchQueue.main) - .sink { _ in - } receiveValue: { [weak self] messages in - let forProcessing = messages - // .filter { $0.status == .pending } - .filter { self?.processing.contains($0.id) == false } - .filter { $0.contentType.isAttachment } - for message in forProcessing { - if case .attachment(let attachment) = message.contentType { - if attachment.localPath != nil, attachment.remotePath == nil { - // Uploading - self?.processing.insert(message.id) - Task(priority: .background) { - await self?.uploadAttachment(message) - } - } else if attachment.localPath == nil, attachment.remotePath != nil { - // Downloading - self?.processing.insert(message.id) - Task(priority: .background) { - await self?.downloadAttachment(message) - } - } else if attachment.localPath != nil, attachment.remotePath != nil, attachment.thumbnailName == nil, attachment.type == .image { - // Generate thumbnail - self?.processing.insert(message.id) - Task(priority: .background) { - await self?.generateThumbnail(message) - } - } - } - } - } - } -} - -// MARK: - Uploadings/Downloadings -extension AttachmentsStore { - private func uploadAttachment(_ message: Message) async { - do { - try await message.setStatus(.pending) - var message = message - guard case .attachment(let attachment) = message.contentType else { - throw AppError.invalidContentType - } - guard let localName = attachment.localPath else { - throw ClientStoreError.invalidLocalName - } - let remotePath = try await client.uploadFile(localName) - message.contentType = .attachment( - Attachment( - type: attachment.type, - localName: attachment.localName, - thumbnailName: nil, - remotePath: remotePath - ) - ) - message.body = remotePath - message.oobUrl = remotePath - try await message.save() - try await client.sendMessage(message) - processing.remove(message.id) - try await message.setStatus(.sent) - } catch { - processing.remove(message.id) - try? await message.setStatus(.error) - } - } - - private func downloadAttachment(_ message: Message) async { - guard case .attachment(let attachment) = message.contentType else { - return - } - guard let remotePath = attachment.remotePath, let remoteUrl = URL(string: remotePath) else { - return - } - do { - let localName = "\(message.id)_\(UUID().uuidString).\(remoteUrl.lastPathComponent)" - let localUrl = Const.fileFolder.appendingPathComponent(localName) - - // Download the file - let (tempUrl, _) = try await URLSession.shared.download(from: remoteUrl) - try FileManager.default.moveItem(at: tempUrl, to: localUrl) - - var message = message - message.contentType = .attachment( - Attachment( - type: attachment.type, - localName: localName, - thumbnailName: attachment.thumbnailName, - remotePath: remotePath - ) - ) - processing.remove(message.id) - try await message.save() - } catch { - logIt(.error, "Can't download attachment: \(error)") - } - } - - private func generateThumbnail(_ message: Message) async { - guard case .attachment(let attachment) = message.contentType else { - return - } - guard attachment.type == .image else { - return - } - guard let localName = attachment.localName, let localPath = attachment.localPath else { - return - } - let thumbnailFileName = "thumb_\(localName)" - let thumbnailUrl = Const.fileFolder.appendingPathComponent(thumbnailFileName) - - // - if !FileManager.default.fileExists(atPath: thumbnailUrl.path) { - guard let image = UIImage(contentsOfFile: localPath.path) else { - return - } - let targetSize = CGSize(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) - guard let thumbnail = try? await image.scaleAndCropImage(targetSize) else { - return - } - guard let data = thumbnail.jpegData(compressionQuality: 0.5) else { - return - } - do { - try data.write(to: thumbnailUrl) - } catch { - return - } - } - - // - var message = message - message.contentType = .attachment( - Attachment( - type: attachment.type, - localName: attachment.localName, - thumbnailName: thumbnailFileName, - remotePath: attachment.remotePath - ) - ) - processing.remove(message.id) - try? await message.save() - } -} diff --git a/ConversationsClassic/AppData/Store/ClientsStore.swift-E b/ConversationsClassic/AppData/Store/ClientsStore.swift-E deleted file mode 100644 index 98fd9ee..0000000 --- a/ConversationsClassic/AppData/Store/ClientsStore.swift-E +++ /dev/null @@ -1,172 +0,0 @@ -import Combine -import Foundation -import GRDB - -@MainActor -final class ClientsStore: ObservableObject { - static let shared = ClientsStore() - - @Published private(set) var ready = false - @Published private(set) var clients: [Client] = [] - @Published private(set) var actualRosters: [Roster] = [] - @Published private(set) var actualChats: [Chat] = [] - - private var credentialsCancellable: AnyCancellable? - private var rostersCancellable: AnyCancellable? - private var chatsCancellable: AnyCancellable? - - init() { - credentialsCancellable = ValueObservation - .tracking { db in - try Credentials.fetchAll(db) - } - .publisher(in: Database.shared.dbQueue) - .catch { _ in Just([]) } - .sink { [weak self] creds in - self?.processCredentials(creds) - } - } - - private func processCredentials(_ credentials: [Credentials]) { - let existsJids = Set(clients.map { $0.credentials.bareJid }) - let credentialsJids = Set(credentials.map { $0.bareJid }) - - let forAdd = credentials.filter { !existsJids.contains($0.bareJid) } - let newClients = forAdd.map { Client(credentials: $0) } - - let forRemove = clients.filter { !credentialsJids.contains($0.credentials.bareJid) } - forRemove.forEach { $0.disconnect() } - - var updatedClients = clients.filter { credentialsJids.contains($0.credentials.bareJid) } - updatedClients.append(contentsOf: newClients) - clients = updatedClients - - if !ready { - ready = true - } - - resubscribeRosters() - resubscribeChats() - reconnectAll() - } - - private func client(for credentials: Credentials) -> Client? { - clients.first { $0.credentials == credentials } - } -} - -extension ClientsStore { - func tryLogin(_ jidStr: String, _ pass: String) async throws { - // login with fake timeout - async let sleep: Void? = try? await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC) - async let request = try await Client.tryLogin(with: .init(bareJid: jidStr, pass: pass, isActive: true)) - let client = try await(request, sleep).0 - - clients.append(client) - try? await client.credentials.save() - } - - private func reconnectAll() { - Task { - await withTaskGroup(of: Void.self) { taskGroup in - for client in clients { - taskGroup.addTask { - await client.connect() - } - } - } - } - } -} - -extension ClientsStore { - private func resubscribeRosters() { - let clientsJids = clients - .filter { $0.state != .disabled } - .map { $0.credentials.bareJid } - - rostersCancellable = ValueObservation.tracking { db in - try Roster - .filter(clientsJids.contains(Column("bareJid"))) - .filter(Column("locallyDeleted") == false) - .fetchAll(db) - } - .publisher(in: Database.shared.dbQueue) - .catch { _ in Just([]) } - .sink { [weak self] rosters in - self?.actualRosters = rosters - } - } - - func addRoster(_ credentials: Credentials, contactJID: String, name: String?, groups: [String]) async throws { - // check that roster exist in db as locally deleted and undelete it - let deletedLocally = try await Roster.fetchDeletedLocally() - if var roster = deletedLocally.first(where: { $0.contactBareJid == contactJID }) { - try await roster.setLocallyDeleted(false) - return - } - - // add new roster - guard let client = client(for: credentials) else { - throw AppError.clientNotFound - } - try await client.addRoster(contactJID, name: name, groups: groups) - } - - func deleteRoster(_ roster: Roster) async throws { - guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else { - throw AppError.clientNotFound - } - try await client.deleteRoster(roster) - } -} - -extension ClientsStore { - private func resubscribeChats() { - let clientsJids = clients - .filter { $0.state != .disabled } - .map { $0.credentials.bareJid } - - chatsCancellable = ValueObservation.tracking { db in - try Chat - .filter(clientsJids.contains(Column("account"))) - .fetchAll(db) - } - .publisher(in: Database.shared.dbQueue) - .catch { _ in Just([]) } - .sink { [weak self] chats in - self?.actualChats = chats - } - } -} - -extension ClientsStore { - func conversationStores(for roster: Roster) async throws -> (ConversationStore, AttachmentsStore) { - while !ready { - await Task.yield() - } - - guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else { - throw AppError.clientNotFound - } - - let conversationStore = ConversationStore(roster: roster, client: client) - let attachmentsStore = AttachmentsStore(roster: roster, client: client) - return (conversationStore, attachmentsStore) - } - - func conversationStores(for chat: Chat) async throws -> (ConversationStore, AttachmentsStore) { - while !ready { - await Task.yield() - } - - guard let client = clients.first(where: { $0.credentials.bareJid == chat.account }) else { - throw ClientStoreError.clientNotFound - } - - let roster = try await chat.fetchRoster() - let conversationStore = ConversationStore(roster: roster, client: client) - let attachmentsStore = AttachmentsStore(roster: roster, client: client) - return (conversationStore, attachmentsStore) - } -}