diff --git a/ConversationsClassic/AppData/Store/ClientStoreError.swift b/ConversationsClassic/AppData/AppError.swift similarity index 88% rename from ConversationsClassic/AppData/Store/ClientStoreError.swift rename to ConversationsClassic/AppData/AppError.swift index 92d4aff..98b97f5 100644 --- a/ConversationsClassic/AppData/Store/ClientStoreError.swift +++ b/ConversationsClassic/AppData/AppError.swift @@ -1,4 +1,4 @@ -enum ClientStoreError: Error { +enum AppError: Error { case clientNotFound case rosterNotFound case imageNotFound diff --git a/ConversationsClassic/AppData/Client/Client.swift b/ConversationsClassic/AppData/Client/Client.swift index 6e16238..67816e4 100644 --- a/ConversationsClassic/AppData/Client/Client.swift +++ b/ConversationsClassic/AppData/Client/Client.swift @@ -104,13 +104,13 @@ extension Client { func uploadFile(_ localURL: URL) async throws -> String { guard let data = try? Data(contentsOf: localURL) else { - throw ClientStoreError.noData + 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 + throw AppError.fileTooBig } let slot = try await httpModule.requestUploadSlot( diff --git a/ConversationsClassic/AppData/Client/Client.swift-E b/ConversationsClassic/AppData/Client/Client.swift-E new file mode 100644 index 0000000..96ddf65 --- /dev/null +++ b/ConversationsClassic/AppData/Client/Client.swift-E @@ -0,0 +1,202 @@ +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/Model/Chat.swift b/ConversationsClassic/AppData/Model/Chat.swift index 3b87934..0f49c7b 100644 --- a/ConversationsClassic/AppData/Model/Chat.swift +++ b/ConversationsClassic/AppData/Model/Chat.swift @@ -26,7 +26,7 @@ extension Chat { .filter(Column("bareJid") == account && Column("contactBareJid") == participant) .fetchOne(db) else { - throw ClientStoreError.rosterNotFound + throw AppError.rosterNotFound } return roster } diff --git a/ConversationsClassic/AppData/Store/AttachmentsStore.swift b/ConversationsClassic/AppData/Store/AttachmentsStore.swift index 1553385..165bd8d 100644 --- a/ConversationsClassic/AppData/Store/AttachmentsStore.swift +++ b/ConversationsClassic/AppData/Store/AttachmentsStore.swift @@ -247,10 +247,10 @@ extension AttachmentsStore { try await message.setStatus(.pending) var message = message guard case .attachment(let attachment) = message.contentType else { - throw ClientStoreError.invalidContentType + throw AppError.invalidContentType } guard let localName = attachment.localPath else { - throw ClientStoreError.invalidLocalName + throw AppError.invalidLocalName } let remotePath = try await client.uploadFile(localName) message.contentType = .attachment( diff --git a/ConversationsClassic/AppData/Store/AttachmentsStore.swift-E b/ConversationsClassic/AppData/Store/AttachmentsStore.swift-E new file mode 100644 index 0000000..b12272a --- /dev/null +++ b/ConversationsClassic/AppData/Store/AttachmentsStore.swift-E @@ -0,0 +1,352 @@ +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 b/ConversationsClassic/AppData/Store/ClientsStore.swift index ce0bb67..5dead90 100644 --- a/ConversationsClassic/AppData/Store/ClientsStore.swift +++ b/ConversationsClassic/AppData/Store/ClientsStore.swift @@ -108,14 +108,14 @@ extension ClientsStore { // add new roster guard let client = client(for: credentials) else { - throw ClientStoreError.clientNotFound + 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 ClientStoreError.clientNotFound + throw AppError.clientNotFound } try await client.deleteRoster(roster) } @@ -147,7 +147,7 @@ extension ClientsStore { } guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else { - throw ClientStoreError.clientNotFound + throw AppError.clientNotFound } let conversationStore = ConversationStore(roster: roster, client: client) @@ -161,7 +161,7 @@ extension ClientsStore { } guard let client = clients.first(where: { $0.credentials.bareJid == chat.account }) else { - throw ClientStoreError.clientNotFound + throw AppError.clientNotFound } let roster = try await chat.fetchRoster() diff --git a/ConversationsClassic/AppData/Store/ClientsStore.swift-E b/ConversationsClassic/AppData/Store/ClientsStore.swift-E new file mode 100644 index 0000000..98fd9ee --- /dev/null +++ b/ConversationsClassic/AppData/Store/ClientsStore.swift-E @@ -0,0 +1,172 @@ +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) + } +} diff --git a/ConversationsClassic/Helpers/PHImageManager+Fetch.swift b/ConversationsClassic/Helpers/PHImageManager+Fetch.swift index b97fe1f..5bec4a8 100644 --- a/ConversationsClassic/Helpers/PHImageManager+Fetch.swift +++ b/ConversationsClassic/Helpers/PHImageManager+Fetch.swift @@ -16,7 +16,7 @@ extension PHImageManager { if let image { continuation.resume(returning: image) } else { - continuation.resume(throwing: ClientStoreError.imageNotFound) + continuation.resume(throwing: AppError.imageNotFound) } } } @@ -35,7 +35,7 @@ extension PHImageManager { if let avAsset { continuation.resume(returning: avAsset) } else { - continuation.resume(throwing: ClientStoreError.videoNotFound) + continuation.resume(throwing: AppError.videoNotFound) } } }