mv-experiment #1
|
@ -1,4 +1,4 @@
|
||||||
enum ClientStoreError: Error {
|
enum AppError: Error {
|
||||||
case clientNotFound
|
case clientNotFound
|
||||||
case rosterNotFound
|
case rosterNotFound
|
||||||
case imageNotFound
|
case imageNotFound
|
|
@ -104,13 +104,13 @@ extension Client {
|
||||||
|
|
||||||
func uploadFile(_ localURL: URL) async throws -> String {
|
func uploadFile(_ localURL: URL) async throws -> String {
|
||||||
guard let data = try? Data(contentsOf: localURL) else {
|
guard let data = try? Data(contentsOf: localURL) else {
|
||||||
throw ClientStoreError.noData
|
throw AppError.noData
|
||||||
}
|
}
|
||||||
let httpModule = connection.module(HttpFileUploadModule.self)
|
let httpModule = connection.module(HttpFileUploadModule.self)
|
||||||
|
|
||||||
let components = try await httpModule.findHttpUploadComponents()
|
let components = try await httpModule.findHttpUploadComponents()
|
||||||
guard let component = components.first(where: { $0.maxSize > data.count }) else {
|
guard let component = components.first(where: { $0.maxSize > data.count }) else {
|
||||||
throw ClientStoreError.fileTooBig
|
throw AppError.fileTooBig
|
||||||
}
|
}
|
||||||
|
|
||||||
let slot = try await httpModule.requestUploadSlot(
|
let slot = try await httpModule.requestUploadSlot(
|
||||||
|
|
202
ConversationsClassic/AppData/Client/Client.swift-E
Normal file
202
ConversationsClassic/AppData/Client/Client.swift-E
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,7 +26,7 @@ extension Chat {
|
||||||
.filter(Column("bareJid") == account && Column("contactBareJid") == participant)
|
.filter(Column("bareJid") == account && Column("contactBareJid") == participant)
|
||||||
.fetchOne(db)
|
.fetchOne(db)
|
||||||
else {
|
else {
|
||||||
throw ClientStoreError.rosterNotFound
|
throw AppError.rosterNotFound
|
||||||
}
|
}
|
||||||
return roster
|
return roster
|
||||||
}
|
}
|
||||||
|
|
|
@ -247,10 +247,10 @@ extension AttachmentsStore {
|
||||||
try await message.setStatus(.pending)
|
try await message.setStatus(.pending)
|
||||||
var message = message
|
var message = message
|
||||||
guard case .attachment(let attachment) = message.contentType else {
|
guard case .attachment(let attachment) = message.contentType else {
|
||||||
throw ClientStoreError.invalidContentType
|
throw AppError.invalidContentType
|
||||||
}
|
}
|
||||||
guard let localName = attachment.localPath else {
|
guard let localName = attachment.localPath else {
|
||||||
throw ClientStoreError.invalidLocalName
|
throw AppError.invalidLocalName
|
||||||
}
|
}
|
||||||
let remotePath = try await client.uploadFile(localName)
|
let remotePath = try await client.uploadFile(localName)
|
||||||
message.contentType = .attachment(
|
message.contentType = .attachment(
|
||||||
|
|
352
ConversationsClassic/AppData/Store/AttachmentsStore.swift-E
Normal file
352
ConversationsClassic/AppData/Store/AttachmentsStore.swift-E
Normal file
|
@ -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<String> = []
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -108,14 +108,14 @@ extension ClientsStore {
|
||||||
|
|
||||||
// add new roster
|
// add new roster
|
||||||
guard let client = client(for: credentials) else {
|
guard let client = client(for: credentials) else {
|
||||||
throw ClientStoreError.clientNotFound
|
throw AppError.clientNotFound
|
||||||
}
|
}
|
||||||
try await client.addRoster(contactJID, name: name, groups: groups)
|
try await client.addRoster(contactJID, name: name, groups: groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteRoster(_ roster: Roster) async throws {
|
func deleteRoster(_ roster: Roster) async throws {
|
||||||
guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else {
|
guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else {
|
||||||
throw ClientStoreError.clientNotFound
|
throw AppError.clientNotFound
|
||||||
}
|
}
|
||||||
try await client.deleteRoster(roster)
|
try await client.deleteRoster(roster)
|
||||||
}
|
}
|
||||||
|
@ -147,7 +147,7 @@ extension ClientsStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else {
|
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)
|
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 {
|
guard let client = clients.first(where: { $0.credentials.bareJid == chat.account }) else {
|
||||||
throw ClientStoreError.clientNotFound
|
throw AppError.clientNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
let roster = try await chat.fetchRoster()
|
let roster = try await chat.fetchRoster()
|
||||||
|
|
172
ConversationsClassic/AppData/Store/ClientsStore.swift-E
Normal file
172
ConversationsClassic/AppData/Store/ClientsStore.swift-E
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ extension PHImageManager {
|
||||||
if let image {
|
if let image {
|
||||||
continuation.resume(returning: image)
|
continuation.resume(returning: image)
|
||||||
} else {
|
} else {
|
||||||
continuation.resume(throwing: ClientStoreError.imageNotFound)
|
continuation.resume(throwing: AppError.imageNotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,7 @@ extension PHImageManager {
|
||||||
if let avAsset {
|
if let avAsset {
|
||||||
continuation.resume(returning: avAsset)
|
continuation.resume(returning: avAsset)
|
||||||
} else {
|
} else {
|
||||||
continuation.resume(throwing: ClientStoreError.videoNotFound)
|
continuation.resume(throwing: AppError.videoNotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue