mv-experiment (#1)
Reviewed-on: narayana/conversations-classic-ios#1 Co-authored-by: fmodf <fmodf.ios@gmail.com> Co-committed-by: fmodf <fmodf.ios@gmail.com>
This commit is contained in:
parent
44ef6c25ba
commit
b3b3b3aef7
|
@ -54,8 +54,12 @@ trailing_semicolon:
|
||||||
severity: error
|
severity: error
|
||||||
|
|
||||||
type_name:
|
type_name:
|
||||||
min_length: 3
|
min_length:
|
||||||
severity: warning
|
warninig: 3
|
||||||
|
error: 0
|
||||||
|
max_length:
|
||||||
|
warninig: 40
|
||||||
|
error: 80
|
||||||
|
|
||||||
identifier_name:
|
identifier_name:
|
||||||
min_length: 3
|
min_length: 3
|
||||||
|
@ -73,6 +77,7 @@ identifier_name:
|
||||||
- tz
|
- tz
|
||||||
- to
|
- to
|
||||||
- db
|
- db
|
||||||
|
- _db
|
||||||
|
|
||||||
# Disable rules from the default enabled set.
|
# Disable rules from the default enabled set.
|
||||||
disabled_rules:
|
disabled_rules:
|
||||||
|
@ -112,4 +117,5 @@ unused_declaration:
|
||||||
|
|
||||||
# paths to ignore during linting. Takes precedence over `included`.
|
# paths to ignore during linting. Takes precedence over `included`.
|
||||||
excluded:
|
excluded:
|
||||||
- SomePathHere
|
- .swiftgen
|
||||||
|
- "**/Generated"
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
enum AccountsAction: Codable {
|
|
||||||
case accountsListUpdated(accounts: [Account])
|
|
||||||
|
|
||||||
case goTo(AccountNavigationState)
|
|
||||||
|
|
||||||
case tryAddAccountWithCredentials(login: String, password: String)
|
|
||||||
case addAccountError(jid: String, reason: String?)
|
|
||||||
|
|
||||||
case makeAccountPermanent(account: Account)
|
|
||||||
|
|
||||||
case clientServerFeaturesUpdated(jid: String, features: [ServerFeature])
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
enum AppAction: Codable {
|
|
||||||
case info(String)
|
|
||||||
case flushState
|
|
||||||
case changeFlow(_ flow: AppFlow)
|
|
||||||
|
|
||||||
case startAction(_ action: StartAction)
|
|
||||||
case databaseAction(_ action: DatabaseAction)
|
|
||||||
case accountsAction(_ action: AccountsAction)
|
|
||||||
case xmppAction(_ action: XMPPAction)
|
|
||||||
case rostersAction(_ action: RostersAction)
|
|
||||||
case chatsAction(_ action: ChatsAction)
|
|
||||||
case conversationAction(_ action: ConversationAction)
|
|
||||||
case sharingAction(_ action: SharingAction)
|
|
||||||
case fileAction(_ action: FileAction)
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
enum ChatsAction: Codable {
|
|
||||||
case chatsListUpdated(chats: [Chat])
|
|
||||||
|
|
||||||
case startChat(accountJid: String, participantJid: String)
|
|
||||||
case chatStarted(chat: Chat)
|
|
||||||
|
|
||||||
case createNewChat(accountJid: String, participantJid: String)
|
|
||||||
case chatCreated(chat: Chat)
|
|
||||||
case chatCreationFailed(reason: String)
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
enum ConversationAction: Codable {
|
|
||||||
case makeConversationActive(chat: Chat, roster: Roster?)
|
|
||||||
|
|
||||||
case messagesUpdated(messages: [Message])
|
|
||||||
|
|
||||||
case sendMessage(from: String, to: String, body: String)
|
|
||||||
case setReplyText(String)
|
|
||||||
|
|
||||||
case sendMediaMessages(from: String, to: String, messagesIds: [String], localFilesNames: [String])
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
enum DatabaseAction: Codable {
|
|
||||||
case storedAccountsLoaded(accounts: [Account])
|
|
||||||
case loadingStoredAccountsFailed
|
|
||||||
case updateAccountFailed
|
|
||||||
|
|
||||||
case storedRostersLoaded(rosters: [Roster])
|
|
||||||
case storedChatsLoaded(chats: [Chat])
|
|
||||||
|
|
||||||
case storeMessageFailed(reason: String)
|
|
||||||
|
|
||||||
case updateAttachmentFailed(id: String, reason: String)
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum FileAction: Stateable {
|
|
||||||
case downloadAttachmentFile(messageId: String, attachmentRemotePath: URL)
|
|
||||||
case attachmentFileDownloaded(messageId: String, localName: String)
|
|
||||||
case downloadingAttachmentFileFailed(messageId: String, reason: String)
|
|
||||||
|
|
||||||
case createAttachmentThumbnail(messageId: String, localName: String)
|
|
||||||
case attachmentThumbnailCreated(messageId: String, thumbnailName: String)
|
|
||||||
|
|
||||||
case fetchItemsFromGallery
|
|
||||||
case itemsFromGalleryFetched(items: [SharingGalleryItem])
|
|
||||||
|
|
||||||
case copyGalleryItemsForUploading(items: [SharingGalleryItem])
|
|
||||||
case copyCameraCapturedForUploading(media: Data, type: SharingCameraMediaType)
|
|
||||||
case itemsCopiedForUploading(newMessageIds: [String], localNames: [String])
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
enum RostersAction: Codable {
|
|
||||||
case addRoster(ownerJID: String, contactJID: String, name: String?, groups: [String])
|
|
||||||
case addRosterDone(jid: String)
|
|
||||||
case addRosterError(reason: String)
|
|
||||||
|
|
||||||
case rostersListUpdated([Roster])
|
|
||||||
|
|
||||||
case markRosterAsLocallyDeleted(ownerJID: String, contactJID: String)
|
|
||||||
case unmarkRosterAsLocallyDeleted(ownerJID: String, contactJID: String)
|
|
||||||
case deleteRoster(ownerJID: String, contactJID: String)
|
|
||||||
case rosterDeletingFailed(reason: String)
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum SharingAction: Stateable {
|
|
||||||
case showSharing(Bool)
|
|
||||||
|
|
||||||
case shareLocation(lat: Double, lon: Double)
|
|
||||||
case shareContact(jid: String)
|
|
||||||
case shareDocuments([Data], [String])
|
|
||||||
case shareMedia(ids: [String])
|
|
||||||
|
|
||||||
case checkCameraAccess
|
|
||||||
case setCameraAccess(Bool)
|
|
||||||
|
|
||||||
case checkGalleryAccess
|
|
||||||
case setGalleryAccess(Bool)
|
|
||||||
case galleryItemsUpdated(items: [SharingGalleryItem])
|
|
||||||
|
|
||||||
case cameraCaptured(media: Data, type: SharingCameraMediaType)
|
|
||||||
|
|
||||||
case retrySharing(messageId: String)
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
enum StartAction: Codable {
|
|
||||||
case loadStoredAccounts
|
|
||||||
|
|
||||||
case goTo(StartNavigationState)
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum XMPPAction: Codable {
|
|
||||||
case clientConnectionChanged(jid: String, state: ConnectionStatus)
|
|
||||||
case xmppMessageReceived(Message)
|
|
||||||
|
|
||||||
case xmppMessageSent(Message)
|
|
||||||
case xmppMessageSendFailed(msgId: String)
|
|
||||||
case xmppMessageSendSuccess(msgId: String)
|
|
||||||
|
|
||||||
case xmppSharingTryUpload(Message)
|
|
||||||
case xmppSharingUploadFailed(msgId: String, reason: String)
|
|
||||||
case xmppSharingUploadSuccess(msgId: String, attachmentRemotePath: String)
|
|
||||||
case serverFeaturesLoaded(jid: String, features: [String])
|
|
||||||
|
|
||||||
case xmppLoadArchivedMessages(jid: String, to: String?, fromDate: Date)
|
|
||||||
}
|
|
|
@ -1,102 +0,0 @@
|
||||||
// This file declare global state object for whole app
|
|
||||||
// and reducers/actions/middleware types. Core of app.
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
typealias Stateable = Codable & Equatable
|
|
||||||
typealias AppStore = Store<AppState, AppAction>
|
|
||||||
typealias Reducer<State: Stateable, Action: Codable> = (inout State, Action) -> Void
|
|
||||||
typealias Middleware<State: Stateable, Action: Codable> = (State, Action) -> AnyPublisher<Action, Never>?
|
|
||||||
|
|
||||||
final class Store<State: Stateable, Action: Codable>: ObservableObject {
|
|
||||||
@Published private(set) var state: State
|
|
||||||
|
|
||||||
// Serial queue for performing any actions sequentially
|
|
||||||
private let serialQueue = DispatchQueue(label: "im.narayana.conversations.classic.serial.queue", qos: .userInteractive)
|
|
||||||
private let middlewareQueue = DispatchQueue(label: "im.narayana.conversations.classic.middleware.queue", qos: .default, attributes: .concurrent)
|
|
||||||
|
|
||||||
private let reducer: Reducer<State, Action>
|
|
||||||
private let middlewares: [Middleware<State, Action>]
|
|
||||||
private var middlewareCancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
// Init
|
|
||||||
init(
|
|
||||||
initialState: State,
|
|
||||||
reducer: @escaping Reducer<State, Action>,
|
|
||||||
middlewares: [Middleware<State, Action>] = []
|
|
||||||
) {
|
|
||||||
state = initialState
|
|
||||||
self.reducer = reducer
|
|
||||||
self.middlewares = middlewares
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run reducers/middlewares
|
|
||||||
func dispatch(_ action: Action) {
|
|
||||||
if !Thread.isMainThread {
|
|
||||||
print("❌WARNING!: AppStore.dispatch should be called from the main thread")
|
|
||||||
}
|
|
||||||
serialQueue.sync { [weak self] in
|
|
||||||
guard let wSelf = self else { return }
|
|
||||||
let newState = wSelf.dispatch(wSelf.state, action)
|
|
||||||
wSelf.state = newState
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dispatch(_ currentState: State, _ action: Action) -> State {
|
|
||||||
// Do reducing
|
|
||||||
var startTime = CFAbsoluteTimeGetCurrent()
|
|
||||||
var newState = currentState
|
|
||||||
reducer(&newState, action)
|
|
||||||
var timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
|
|
||||||
if timeElapsed > 0.05 {
|
|
||||||
#if DEBUG
|
|
||||||
print(
|
|
||||||
"""
|
|
||||||
--
|
|
||||||
(Ignore this warning ONLY in case, when execution is paused by your breakpoint)
|
|
||||||
🕐Execution time: \(timeElapsed)
|
|
||||||
❌WARNING! Some reducers work too long! It will lead to issues in production build!
|
|
||||||
Because of execution each action is synchronous the any stuck will reduce performance dramatically.
|
|
||||||
Probably you need check which part of reducer/middleware should be async (wrapped with Futures, as example)
|
|
||||||
--
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
#else
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch all middleware functions
|
|
||||||
for middleware in middlewares {
|
|
||||||
guard let middleware = middleware(newState, action) else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
startTime = CFAbsoluteTimeGetCurrent()
|
|
||||||
middleware
|
|
||||||
.subscribe(on: middlewareQueue)
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink(receiveValue: { [weak self] action in
|
|
||||||
self?.dispatch(action)
|
|
||||||
})
|
|
||||||
.store(in: &middlewareCancellables)
|
|
||||||
timeElapsed = CFAbsoluteTimeGetCurrent() - startTime
|
|
||||||
if timeElapsed > 0.05 {
|
|
||||||
#if DEBUG
|
|
||||||
print(
|
|
||||||
"""
|
|
||||||
--
|
|
||||||
(Ignore this warning ONLY in case, when execution is paused by your breakpoint)
|
|
||||||
🕐Execution time: \(timeElapsed)
|
|
||||||
❌WARNING! Middleware work too long! It will lead to issues in production build!
|
|
||||||
Because of execution each action is synchronous the any stuck will reduce performance dramatically.
|
|
||||||
Probably you need check which part of reducer/middleware should be async (wrapped with Futures, as example)
|
|
||||||
--
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
#else
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newState
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
extension Database: MartinsManager {}
|
|
||||||
|
|
||||||
// Check specific implementation in Database+Martin* files
|
|
|
@ -1,21 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
extension Database: Martin.ChannelManager {
|
|
||||||
func channels(for _: Martin.Context) -> [any Martin.ChannelProtocol] {
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
|
|
||||||
func createChannel(for _: Martin.Context, with _: Martin.BareJID, participantId _: String, nick _: String?, state _: Martin.ChannelState) -> Martin.ConversationCreateResult<any Martin.ChannelProtocol> {
|
|
||||||
.none
|
|
||||||
}
|
|
||||||
|
|
||||||
func channel(for _: Martin.Context, with _: Martin.BareJID) -> (any Martin.ChannelProtocol)? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func close(channel _: any Martin.ChannelProtocol) -> Bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,105 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
extension Database: Martin.RoomManager {
|
|
||||||
func rooms(for context: Martin.Context) -> [any Martin.RoomProtocol] {
|
|
||||||
do {
|
|
||||||
let rooms: [Room] = try _db.read { db in
|
|
||||||
try Room.filter(Column("account") == context.userBareJid.stringValue).fetchAll(db)
|
|
||||||
}
|
|
||||||
return rooms.map { room in
|
|
||||||
Martin.RoomBase(
|
|
||||||
context: context,
|
|
||||||
jid: context.userBareJid,
|
|
||||||
nickname: room.nickname,
|
|
||||||
password: room.password,
|
|
||||||
dispatcher: QueueDispatcher(label: "room-\(room.id)")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching channels: \(error.localizedDescription)")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func room(for context: Martin.Context, with roomJid: Martin.BareJID) -> (any Martin.RoomProtocol)? {
|
|
||||||
do {
|
|
||||||
let room: Room? = try _db.read { db in
|
|
||||||
try Room
|
|
||||||
.filter(Column("account") == context.userBareJid.stringValue)
|
|
||||||
.filter(Column("id") == roomJid.stringValue)
|
|
||||||
.fetchOne(db)
|
|
||||||
}
|
|
||||||
if let room {
|
|
||||||
return Martin.RoomBase(
|
|
||||||
context: context,
|
|
||||||
jid: context.userBareJid,
|
|
||||||
nickname: room.nickname,
|
|
||||||
password: room.password,
|
|
||||||
dispatcher: QueueDispatcher(label: "room-\(room.id)")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching room: \(error.localizedDescription)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createRoom(for context: Martin.Context, with roomJid: Martin.BareJID, nickname: String, password: String?) -> (any Martin.RoomProtocol)? {
|
|
||||||
do {
|
|
||||||
let room: Room? = try _db.read { db in
|
|
||||||
try Room
|
|
||||||
.filter(Column("account") == context.userBareJid.stringValue)
|
|
||||||
.filter(Column("id") == roomJid.stringValue)
|
|
||||||
.fetchOne(db)
|
|
||||||
}
|
|
||||||
if let room {
|
|
||||||
return Martin.RoomBase(
|
|
||||||
context: context,
|
|
||||||
jid: context.userBareJid,
|
|
||||||
nickname: room.nickname,
|
|
||||||
password: room.password,
|
|
||||||
dispatcher: QueueDispatcher(label: "room-\(room.id)")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
let room = Room(
|
|
||||||
id: roomJid.stringValue,
|
|
||||||
account: context.userBareJid.stringValue,
|
|
||||||
nickname: nickname,
|
|
||||||
password: password
|
|
||||||
)
|
|
||||||
try _db.write { db in
|
|
||||||
try room.save(db)
|
|
||||||
}
|
|
||||||
return Martin.RoomBase(
|
|
||||||
context: context,
|
|
||||||
jid: context.userBareJid,
|
|
||||||
nickname: nickname,
|
|
||||||
password: password,
|
|
||||||
dispatcher: QueueDispatcher(label: "room-\(room.id)")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error fetching room: \(error.localizedDescription)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func close(room: any Martin.RoomProtocol) -> Bool {
|
|
||||||
do {
|
|
||||||
try _db.write { db in
|
|
||||||
try Room
|
|
||||||
.filter(Column("account") == room.context?.userBareJid.stringValue ?? "")
|
|
||||||
.filter(Column("id") == room.jid.stringValue)
|
|
||||||
.deleteAll(db)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
logIt(.error, "Error closing room: \(error.localizedDescription)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
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<URL>()
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,301 +0,0 @@
|
||||||
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<T>(_ 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<T>(_ 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<T>(_ 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,69 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
final class AccountsMiddleware {
|
|
||||||
static let shared = AccountsMiddleware()
|
|
||||||
|
|
||||||
private lazy var allFeatures: [ServerFeature] = {
|
|
||||||
guard
|
|
||||||
let url = Bundle.main.url(forResource: "server_features", withExtension: "plist"),
|
|
||||||
let data = try? Data(contentsOf: url),
|
|
||||||
let loaded = try? PropertyListDecoder().decode([ServerFeature].self, from: data)
|
|
||||||
else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return loaded
|
|
||||||
}()
|
|
||||||
|
|
||||||
func middleware(state: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
case .databaseAction(.storedAccountsLoaded(let accounts)):
|
|
||||||
return Just(.accountsAction(.accountsListUpdated(accounts: accounts)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.clientConnectionChanged(let jid, let connectionStatus)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
guard let account = state.accountsState.accounts.first(where: { $0.bareJid == jid }) else {
|
|
||||||
promise(.success(.info("AccountsMiddleware: account not found for jid \(jid)")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if account.isTemp {
|
|
||||||
switch connectionStatus {
|
|
||||||
case .connected:
|
|
||||||
promise(.success(.accountsAction(.makeAccountPermanent(account: account))))
|
|
||||||
|
|
||||||
case .disconnected(let reason):
|
|
||||||
if reason != "No error!" {
|
|
||||||
promise(.success(.accountsAction(.addAccountError(jid: jid, reason: reason))))
|
|
||||||
} else {
|
|
||||||
promise(.success(.info("AccountsMiddleware: account \(jid) disconnected with no error")))
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
promise(.success(.info("AccountsMiddleware: account \(jid) connection status changed to \(connectionStatus)")))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
promise(.success(.info("AccountsMiddleware: account \(jid) is not temporary, ignoring")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.serverFeaturesLoaded(let jid, let features)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
let serverFeatures = features
|
|
||||||
.compactMap { featureId in
|
|
||||||
self?.allFeatures.first(where: { $0.xmppId == featureId })
|
|
||||||
}
|
|
||||||
promise(.success(.accountsAction(.clientServerFeaturesUpdated(jid: jid, features: serverFeatures))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
final class ArchivedMessagesMiddleware {
|
|
||||||
static let shared = ArchivedMessagesMiddleware()
|
|
||||||
|
|
||||||
func middleware(state _: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
// case .conversationAction(.messagesUpdated(let messages)):
|
|
||||||
// if state.conversationsState.archivedMessagesRequested {
|
|
||||||
// return Empty().eraseToAnyPublisher()
|
|
||||||
// } else {
|
|
||||||
// guard let chat = state.conversationsState.currentChat else {
|
|
||||||
// return Empty().eraseToAnyPublisher()
|
|
||||||
// }
|
|
||||||
// return Deferred {
|
|
||||||
// Future<AppAction, Never> { promise in
|
|
||||||
// if let currentClient = state.accountsState.accounts.first(where: { $0.bareJid == chat.account }) {
|
|
||||||
// let features = state.accountsState.discoFeatures[currentClient.bareJid] ?? []
|
|
||||||
// if features.map({ $0.xep }).contains("XEP-0313") {
|
|
||||||
// let roster = state.conversationsState.currentRoster
|
|
||||||
// let date = self.archivesRequestDate(from: messages)
|
|
||||||
// promise(.success(.xmppAction(.xmppLoadArchivedMessages(jid: currentClient.bareJid, to: roster?.bareJid, fromDate: date))))
|
|
||||||
// } else {
|
|
||||||
// promise(.success(.info("MessageMiddleware: XEP-0313 not supported for client \(currentClient.bareJid)")))
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// promise(.success(.info("MessageMiddleware: No client found for account \(chat.account), probably some error here")))
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// .eraseToAnyPublisher()
|
|
||||||
// }
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension ArchivedMessagesMiddleware {
|
|
||||||
func archivesRequestDate(from messages: [Message]) -> Date {
|
|
||||||
if let lastDate = messages.first?.date {
|
|
||||||
return lastDate
|
|
||||||
} else {
|
|
||||||
return Calendar.current.date(byAdding: .day, value: -Const.mamRequestDaysLength, to: Date()) ?? Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,34 +0,0 @@
|
||||||
import Combine
|
|
||||||
|
|
||||||
final class ChatsMiddleware {
|
|
||||||
static let shared = ChatsMiddleware()
|
|
||||||
|
|
||||||
func middleware(state: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
case .databaseAction(.storedChatsLoaded(let chats)):
|
|
||||||
return Just(.chatsAction(.chatsListUpdated(chats: chats)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .chatsAction(.startChat(accountJid: let accountJid, participantJid: let participantJid)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
if let exist = state.chatsState.chats.first(where: { $0.account == accountJid && $0.participant == participantJid }) {
|
|
||||||
// open existing chat
|
|
||||||
promise(.success(.chatsAction(.chatStarted(chat: exist))))
|
|
||||||
} else {
|
|
||||||
// create new chat
|
|
||||||
promise(.success(.chatsAction(.createNewChat(accountJid: accountJid, participantJid: participantJid))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .chatsAction(.chatCreated(let chat)):
|
|
||||||
return Just(.chatsAction(.chatStarted(chat: chat)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
import Combine
|
|
||||||
|
|
||||||
final class ConversationMiddleware {
|
|
||||||
static let shared = ConversationMiddleware()
|
|
||||||
|
|
||||||
func middleware(state: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
case .chatsAction(.chatStarted(let chat)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
let roster = state.rostersState.rosters
|
|
||||||
.first { $0.bareJid == chat.account && $0.contactBareJid == chat.participant }
|
|
||||||
promise(.success(.conversationAction(.makeConversationActive(chat: chat, roster: roster))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .conversationAction(.makeConversationActive):
|
|
||||||
return Just(AppAction.changeFlow(.conversation)).eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,530 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
|
|
||||||
// swiftlint:disable:next type_body_length
|
|
||||||
final class DatabaseMiddleware {
|
|
||||||
static let shared = DatabaseMiddleware()
|
|
||||||
private let database = Database.shared
|
|
||||||
private var cancellables: Set<AnyCancellable> = []
|
|
||||||
private var conversationCancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
// Database changes
|
|
||||||
ValueObservation
|
|
||||||
.tracking(Roster.fetchAll)
|
|
||||||
.publisher(in: database._db, scheduling: .immediate)
|
|
||||||
.sink { _ in
|
|
||||||
// Handle completion
|
|
||||||
} receiveValue: { rosters in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(.databaseAction(.storedRostersLoaded(rosters: rosters)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
ValueObservation
|
|
||||||
.tracking(Chat.fetchAll)
|
|
||||||
.publisher(in: database._db, scheduling: .immediate)
|
|
||||||
.sink { _ in
|
|
||||||
// Handle completion
|
|
||||||
} receiveValue: { chats in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(.databaseAction(.storedChatsLoaded(chats: chats)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
// swiftlint:disable:next function_body_length cyclomatic_complexity
|
|
||||||
func middleware(state _: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
// MARK: Accounts
|
|
||||||
case .startAction(.loadStoredAccounts):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.loadingStoredAccountsFailed)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try database._db.read { db in
|
|
||||||
let accounts = try Account.fetchAll(db)
|
|
||||||
promise(.success(.databaseAction(.storedAccountsLoaded(accounts: accounts))))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.loadingStoredAccountsFailed)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .accountsAction(.makeAccountPermanent(let account)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAccountFailed)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try database._db.write { db in
|
|
||||||
// make permanent and store to database
|
|
||||||
var acc = account
|
|
||||||
acc.isTemp = false
|
|
||||||
try acc.insert(db)
|
|
||||||
|
|
||||||
// Re-Fetch all accounts
|
|
||||||
let accounts = try Account.fetchAll(db)
|
|
||||||
|
|
||||||
// Use the accounts
|
|
||||||
promise(.success(.databaseAction(.storedAccountsLoaded(accounts: accounts))))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAccountFailed)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: Rosters
|
|
||||||
case .rostersAction(.markRosterAsLocallyDeleted(let ownerJID, let contactJID)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Roster
|
|
||||||
.filter(Column("bareJid") == ownerJID)
|
|
||||||
.filter(Column("contactBareJid") == contactJID)
|
|
||||||
.updateAll(db, Column("locallyDeleted").set(to: true))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: roster \(contactJID) for account \(ownerJID) marked as locally deleted")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .rostersAction(.unmarkRosterAsLocallyDeleted(let ownerJID, let contactJID)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Roster
|
|
||||||
.filter(Column("bareJid") == ownerJID)
|
|
||||||
.filter(Column("contactBareJid") == contactJID)
|
|
||||||
.updateAll(db, Column("locallyDeleted").set(to: false))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: roster \(contactJID) for account \(ownerJID) unmarked as locally deleted")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.rostersAction(.rosterDeletingFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: Chats
|
|
||||||
case .chatsAction(.createNewChat(let accountJid, let participantJid)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.chatsAction(.chatCreationFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try database._db.write { db in
|
|
||||||
let chat = Chat(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
account: accountJid,
|
|
||||||
participant: participantJid,
|
|
||||||
type: .chat
|
|
||||||
)
|
|
||||||
try chat.insert(db)
|
|
||||||
promise(.success(.chatsAction(.chatCreated(chat: chat))))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
promise(.success(.chatsAction(.chatCreationFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: Conversation and messages
|
|
||||||
case .conversationAction(.makeConversationActive(let chat, _)):
|
|
||||||
subscribeToMessages(chat: chat)
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppMessageReceived(let message)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard message.contentType != .typing, message.body != nil else {
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(message.id) received as 'typing...' or message body is nil")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try database._db.write { db in
|
|
||||||
try message.insert(db)
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(message.id) stored in db")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .conversationAction(.sendMessage(let from, let to, let body)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let message = Message(
|
|
||||||
id: UUID().uuidString,
|
|
||||||
type: .chat,
|
|
||||||
contentType: .text,
|
|
||||||
from: from,
|
|
||||||
to: to,
|
|
||||||
body: body,
|
|
||||||
subject: nil,
|
|
||||||
thread: nil,
|
|
||||||
oobUrl: nil,
|
|
||||||
date: Date(),
|
|
||||||
pending: true,
|
|
||||||
sentError: false
|
|
||||||
)
|
|
||||||
try database._db.write { db in
|
|
||||||
try message.insert(db)
|
|
||||||
}
|
|
||||||
promise(.success(.xmppAction(.xmppMessageSent(message))))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppMessageSendSuccess(let msgId)):
|
|
||||||
// mark message as pending false and sentError false
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == msgId)
|
|
||||||
.updateAll(db, Column("pending").set(to: false), Column("sentError").set(to: false))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(msgId) marked in db as sent")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppMessageSendFailed(let msgId)):
|
|
||||||
// mark message as pending false and sentError true
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError))))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == msgId)
|
|
||||||
.updateAll(db, Column("pending").set(to: false), Column("sentError").set(to: true))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(msgId) marked in db as failed to send")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: Attachments
|
|
||||||
case .fileAction(.downloadAttachmentFile(let id, _)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == id)
|
|
||||||
.updateAll(db, Column("attachmentDownloadFailed").set(to: false))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(id) marked in db as starting downloading attachment")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.downloadingAttachmentFileFailed(let id, _)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == id)
|
|
||||||
.updateAll(db, Column("attachmentDownloadFailed").set(to: true))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(id) marked in db as failed to download attachment")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.attachmentFileDownloaded(let id, let localName)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == id)
|
|
||||||
.updateAll(db, Column("attachmentLocalName").set(to: localName), Column("attachmentDownloadFailed").set(to: false))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(id) marked in db as downloaded attachment")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.attachmentThumbnailCreated(let id, let thumbnailName)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == id)
|
|
||||||
.updateAll(db, Column("attachmentThumbnailName").set(to: thumbnailName))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(id) marked in db as thumbnail created")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: id, reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: Sharing
|
|
||||||
case .conversationAction(.sendMediaMessages(let from, let to, let messageIds, let localFilesNames)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
for (index, id) in messageIds.enumerated() {
|
|
||||||
let message = Message(
|
|
||||||
id: id,
|
|
||||||
type: .chat,
|
|
||||||
contentType: .attachment,
|
|
||||||
from: from,
|
|
||||||
to: to,
|
|
||||||
body: nil,
|
|
||||||
subject: nil,
|
|
||||||
thread: nil,
|
|
||||||
oobUrl: nil,
|
|
||||||
date: Date(),
|
|
||||||
pending: true,
|
|
||||||
sentError: false,
|
|
||||||
attachmentType: localFilesNames[index].attachmentType,
|
|
||||||
attachmentLocalName: localFilesNames[index]
|
|
||||||
)
|
|
||||||
try database._db.write { db in
|
|
||||||
try message.insert(db)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: messages with sharings stored in db")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .sharingAction(.retrySharing(let id)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == id)
|
|
||||||
.updateAll(db, Column("pending").set(to: true), Column("sentError").set(to: false))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: message \(id) with shares marked in db as pending to send")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppSharingUploadSuccess(let messageId, let remotePath)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: messageId, reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == messageId)
|
|
||||||
.updateAll(db, Column("attachmentRemotePath").set(to: remotePath), Column("pending").set(to: false), Column("sentError").set(to: false))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: shared file uploaded and message \(messageId) marked in db as sent")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: messageId, reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppSharingUploadFailed(let messageId, _)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
Task(priority: .background) { [weak self] in
|
|
||||||
guard let database = self?.database else {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: messageId, reason: L10n.Global.Error.genericDbError)))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
_ = try database._db.write { db in
|
|
||||||
try Message
|
|
||||||
.filter(Column("id") == messageId)
|
|
||||||
.updateAll(db, Column("pending").set(to: false), Column("sentError").set(to: true))
|
|
||||||
}
|
|
||||||
promise(.success(.info("DatabaseMiddleware: shared file upload failed and message \(messageId) marked in db as failed to send")))
|
|
||||||
} catch {
|
|
||||||
promise(.success(.databaseAction(.updateAttachmentFailed(id: messageId, reason: error.localizedDescription)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension DatabaseMiddleware {
|
|
||||||
func subscribeToMessages(chat: Chat) {
|
|
||||||
conversationCancellables = []
|
|
||||||
ValueObservation
|
|
||||||
.tracking(
|
|
||||||
Message
|
|
||||||
.filter(
|
|
||||||
(Column("to") == chat.account && Column("from") == chat.participant) ||
|
|
||||||
(Column("from") == chat.account && Column("to") == chat.participant)
|
|
||||||
)
|
|
||||||
.order(Column("date").desc)
|
|
||||||
.fetchAll
|
|
||||||
)
|
|
||||||
.publisher(in: database._db, scheduling: .immediate)
|
|
||||||
.sink { _ in
|
|
||||||
} receiveValue: { messages in
|
|
||||||
// messages
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(.conversationAction(.messagesUpdated(messages: messages)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &conversationCancellables)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,141 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
final class FileMiddleware {
|
|
||||||
static let shared = FileMiddleware()
|
|
||||||
private var downloadingMessageIDs = ThreadSafeSet<String>()
|
|
||||||
|
|
||||||
func middleware(state _: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
// MARK: - For incomig attachments
|
|
||||||
case .conversationAction(.messagesUpdated(let messages)):
|
|
||||||
return Deferred {
|
|
||||||
Future { [weak self] promise in
|
|
||||||
guard let wSelf = self else {
|
|
||||||
promise(.success(.info("FileMiddleware: on checking attachments/shares messages, middleware self is nil")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// for incoming messages with attachments
|
|
||||||
for message in messages where message.attachmentRemotePath != nil && message.attachmentLocalPath == nil {
|
|
||||||
if wSelf.downloadingMessageIDs.contains(message.id) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wSelf.downloadingMessageIDs.insert(message.id)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
// swiftlint:disable:next force_unwrapping
|
|
||||||
store.dispatch(.fileAction(.downloadAttachmentFile(messageId: message.id, attachmentRemotePath: message.attachmentRemotePath!)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// for outgoing messages with shared attachments
|
|
||||||
for message in messages where message.attachmentLocalPath != nil && message.attachmentRemotePath == nil && message.pending {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(.xmppAction(.xmppSharingTryUpload(message)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// for outgoing messages with shared attachments which are already uploaded
|
|
||||||
// but have no thumbnail (only for images)
|
|
||||||
for message in messages where !message.pending && !message.sentError && message.attachmentType == .image {
|
|
||||||
if message.attachmentLocalName != nil && message.attachmentRemotePath != nil && message.attachmentThumbnailName == nil {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
// swiftlint:disable:next force_unwrapping
|
|
||||||
store.dispatch(.fileAction(.createAttachmentThumbnail(messageId: message.id, localName: message.attachmentLocalName!)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
promise(.success(.info("FileMiddleware: attachments/shares messages processed")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.downloadAttachmentFile(let id, let attachmentRemotePath)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
let localName = "\(id)_\(UUID().uuidString)\(attachmentRemotePath.lastPathComponent)"
|
|
||||||
let localUrl = FileProcessing.fileFolder.appendingPathComponent(localName)
|
|
||||||
DownloadManager.shared.enqueueDownload(from: attachmentRemotePath, to: localUrl) { error in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let error {
|
|
||||||
store.dispatch(.fileAction(.downloadingAttachmentFileFailed(messageId: id, reason: error.localizedDescription)))
|
|
||||||
} else {
|
|
||||||
store.dispatch(.fileAction(.attachmentFileDownloaded(messageId: id, localName: localName)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
promise(.success(.info("FileMiddleware: started downloading attachment for message \(id)")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.attachmentFileDownloaded(let id, let localName)):
|
|
||||||
return Deferred {
|
|
||||||
Future { [weak self] promise in
|
|
||||||
self?.downloadingMessageIDs.remove(id)
|
|
||||||
promise(.success(.fileAction(.createAttachmentThumbnail(messageId: id, localName: localName))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.createAttachmentThumbnail(let id, let localName)):
|
|
||||||
return Deferred {
|
|
||||||
Future { [weak self] promise in
|
|
||||||
if let thumbnailName = FileProcessing.shared.createThumbnail(localName: localName) {
|
|
||||||
self?.downloadingMessageIDs.remove(id)
|
|
||||||
promise(.success(.fileAction(.attachmentThumbnailCreated(messageId: id, thumbnailName: thumbnailName))))
|
|
||||||
} else {
|
|
||||||
self?.downloadingMessageIDs.remove(id)
|
|
||||||
promise(.success(.info("FileMiddleware: failed to create thumbnail from \(localName) for message \(id)")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: - For outgoing sharing
|
|
||||||
case .fileAction(.fetchItemsFromGallery):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
let items = FileProcessing.shared.fetchGallery()
|
|
||||||
promise(.success(.fileAction(.itemsFromGalleryFetched(items: items))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.itemsFromGalleryFetched(let items)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
let newItems = FileProcessing.shared.fillGalleryItemsThumbnails(items: items)
|
|
||||||
promise(.success(.sharingAction(.galleryItemsUpdated(items: newItems))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.copyGalleryItemsForUploading(let items)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
let ids = FileProcessing.shared.copyGalleryItemsForUploading(items: items)
|
|
||||||
promise(.success(.fileAction(.itemsCopiedForUploading(newMessageIds: ids.map { $0.0 }, localNames: ids.map { $0.1 }))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.copyCameraCapturedForUploading(let media, let type)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
if let (id, localName) = FileProcessing.shared.copyCameraCapturedForUploading(media: media, type: type) {
|
|
||||||
promise(.success(.fileAction(.itemsCopiedForUploading(newMessageIds: [id], localNames: [localName]))))
|
|
||||||
} else {
|
|
||||||
promise(.success(.info("FileMiddleware: failed to copy camera captured media for uploading")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
import Combine
|
|
||||||
|
|
||||||
final class RostersMiddleware {
|
|
||||||
static let shared = RostersMiddleware()
|
|
||||||
|
|
||||||
func middleware(state _: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
case .databaseAction(.storedRostersLoaded(let rosters)):
|
|
||||||
return Just(.rostersAction(.rostersListUpdated(rosters)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,131 +0,0 @@
|
||||||
import AVFoundation
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import Photos
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
final class SharingMiddleware {
|
|
||||||
static let shared = SharingMiddleware()
|
|
||||||
|
|
||||||
func middleware(state: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
// MARK: - Camera and Gallery Access
|
|
||||||
case .sharingAction(.checkCameraAccess):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
|
||||||
switch status {
|
|
||||||
case .authorized:
|
|
||||||
promise(.success(.sharingAction(.setCameraAccess(true))))
|
|
||||||
|
|
||||||
case .notDetermined:
|
|
||||||
AVCaptureDevice.requestAccess(for: .video) { granted in
|
|
||||||
promise(.success(.sharingAction(.setCameraAccess(granted))))
|
|
||||||
}
|
|
||||||
|
|
||||||
case .denied, .restricted:
|
|
||||||
promise(.success(.sharingAction(.setCameraAccess(false))))
|
|
||||||
|
|
||||||
@unknown default:
|
|
||||||
promise(.success(.sharingAction(.setCameraAccess(false))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .sharingAction(.checkGalleryAccess):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { promise in
|
|
||||||
let status = PHPhotoLibrary.authorizationStatus()
|
|
||||||
switch status {
|
|
||||||
case .authorized, .limited:
|
|
||||||
promise(.success(.sharingAction(.setGalleryAccess(true))))
|
|
||||||
|
|
||||||
case .notDetermined:
|
|
||||||
PHPhotoLibrary.requestAuthorization { status in
|
|
||||||
promise(.success(.sharingAction(.setGalleryAccess(status == .authorized))))
|
|
||||||
}
|
|
||||||
|
|
||||||
case .denied, .restricted:
|
|
||||||
promise(.success(.sharingAction(.setGalleryAccess(false))))
|
|
||||||
|
|
||||||
@unknown default:
|
|
||||||
promise(.success(.sharingAction(.setGalleryAccess(false))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.itemsFromGalleryFetched(let items)):
|
|
||||||
return Just(.sharingAction(.galleryItemsUpdated(items: items)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
// MARK: - Sharing
|
|
||||||
case .sharingAction(.shareMedia(let ids)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
let items = state.sharingState.galleryItems.filter { ids.contains($0.id) }
|
|
||||||
promise(.success(.fileAction(.copyGalleryItemsForUploading(items: items))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .fileAction(.itemsCopiedForUploading(let newMessageIds, let localNames)):
|
|
||||||
if let chat = state.conversationsState.currentChat {
|
|
||||||
return Just(.conversationAction(.sendMediaMessages(
|
|
||||||
from: chat.account,
|
|
||||||
to: chat.participant,
|
|
||||||
messagesIds: newMessageIds,
|
|
||||||
localFilesNames: localNames
|
|
||||||
)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
} else {
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
case .sharingAction(.cameraCaptured(let media, let type)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
if let (id, localName) = FileProcessing.shared.copyCameraCapturedForUploading(media: media, type: type) {
|
|
||||||
promise(.success(.fileAction(.itemsCopiedForUploading(newMessageIds: [id], localNames: [localName])))
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
promise(.success(.info("SharingMiddleware: camera's captured file didn't copied")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .sharingAction(.shareLocation(let lat, let lon)):
|
|
||||||
if let chat = state.conversationsState.currentChat {
|
|
||||||
let msg = "geo:\(lat),\(lon)"
|
|
||||||
return Just(.conversationAction(.sendMessage(from: chat.account, to: chat.participant, body: msg)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
} else {
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
case .sharingAction(.shareDocuments(let data, let extensions)):
|
|
||||||
return Deferred {
|
|
||||||
Future { promise in
|
|
||||||
let ids = FileProcessing.shared.copyDocumentsForUploading(data: data, extensions: extensions)
|
|
||||||
promise(.success(.fileAction(.itemsCopiedForUploading(newMessageIds: ids.map { $0.0 }, localNames: ids.map { $0.1 })))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .sharingAction(.shareContact(let jid)):
|
|
||||||
if let chat = state.conversationsState.currentChat {
|
|
||||||
let msg = "contact:\(jid)"
|
|
||||||
return Just(.conversationAction(.sendMessage(from: chat.account, to: chat.participant, body: msg)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
} else {
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
import Combine
|
|
||||||
|
|
||||||
final class StartMiddleware {
|
|
||||||
static let shared = StartMiddleware()
|
|
||||||
|
|
||||||
func middleware(state: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
case .accountsAction(.accountsListUpdated(let accounts)):
|
|
||||||
if accounts.isEmpty {
|
|
||||||
if state.currentFlow == .start {
|
|
||||||
return Just(.startAction(.goTo(.welcomeScreen)))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
} else {
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if state.currentFlow == .accounts, state.accountsState.navigation == .addAccount {
|
|
||||||
return Just(.changeFlow(.chats))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
} else if state.currentFlow == .start {
|
|
||||||
return Just(.changeFlow(.chats))
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
} else {
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,160 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
final class XMPPMiddleware {
|
|
||||||
static let shared = XMPPMiddleware()
|
|
||||||
private let service = XMPPService(manager: Database.shared)
|
|
||||||
private var cancellables: Set<AnyCancellable> = []
|
|
||||||
private var uploadingMessageIDs = ThreadSafeSet<String>()
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
service.clientState.sink { client, state in
|
|
||||||
let jid = client.userBareJid.stringValue
|
|
||||||
let status = ConnectionStatus.from(state)
|
|
||||||
let action = AppAction.xmppAction(.clientConnectionChanged(jid: jid, state: status))
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
service.clientMessages.sink { _, martinMessage in
|
|
||||||
guard let message = Message.map(martinMessage) else { return }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(.xmppAction(.xmppMessageReceived(message)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
service.clientFeatures.sink { client, features in
|
|
||||||
let jid = client.userBareJid.stringValue
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
store.dispatch(.xmppAction(.serverFeaturesLoaded(jid: jid, features: features)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
func middleware(state: AppState, action: AppAction) -> AnyPublisher<AppAction, Never> {
|
|
||||||
switch action {
|
|
||||||
case .accountsAction(.tryAddAccountWithCredentials):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
self?.service.updateClients(for: state.accountsState.accounts)
|
|
||||||
promise(.success(.info("XMPPMiddleware: clients updated in XMPP service")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .accountsAction(.addAccountError):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
self?.service.updateClients(for: state.accountsState.accounts)
|
|
||||||
promise(.success(.info("XMPPMiddleware: clients updated in XMPP service")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .databaseAction(.storedAccountsLoaded(let accounts)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
self?.service.updateClients(for: accounts.filter { $0.isActive && !$0.isTemp })
|
|
||||||
promise(.success(.info("XMPPMiddleware: clients updated in XMPP service")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .rostersAction(.addRoster(let ownerJID, let contactJID, let name, let groups)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
guard let service = self?.service, let client = service.clients.first(where: { $0.connectionConfiguration.userJid.stringValue == ownerJID }) else {
|
|
||||||
return promise(.success(.rostersAction(.addRosterError(reason: XMPPError.item_not_found.localizedDescription))))
|
|
||||||
}
|
|
||||||
let module = client.modulesManager.module(RosterModule.self)
|
|
||||||
module.addItem(jid: JID(contactJID), name: name, groups: groups, completionHandler: { result in
|
|
||||||
switch result {
|
|
||||||
case .success:
|
|
||||||
promise(.success(.rostersAction(.addRosterDone(jid: contactJID))))
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
promise(.success(.rostersAction(.addRosterError(reason: error.localizedDescription))))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .rostersAction(.deleteRoster(let ownerJID, let contactJID)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
guard let service = self?.service, let client = service.clients.first(where: { $0.connectionConfiguration.userJid.stringValue == ownerJID }) else {
|
|
||||||
return promise(.success(.rostersAction(.rosterDeletingFailed(reason: XMPPError.item_not_found.localizedDescription))))
|
|
||||||
}
|
|
||||||
let module = client.modulesManager.module(RosterModule.self)
|
|
||||||
module.removeItem(jid: JID(contactJID), completionHandler: { result in
|
|
||||||
switch result {
|
|
||||||
case .success:
|
|
||||||
promise(.success(.info("XMPPMiddleware: roster \(contactJID) deleted from \(ownerJID)")))
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
promise(.success(.rostersAction(.rosterDeletingFailed(reason: error.localizedDescription))))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppMessageSent(let message)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
self?.service.sendMessage(message: message) { done in
|
|
||||||
if done {
|
|
||||||
promise(.success(.xmppAction(.xmppMessageSendSuccess(msgId: message.id))))
|
|
||||||
} else {
|
|
||||||
promise(.success(.xmppAction(.xmppMessageSendFailed(msgId: message.id))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppSharingTryUpload(let message)):
|
|
||||||
return Deferred {
|
|
||||||
Future<AppAction, Never> { [weak self] promise in
|
|
||||||
if self?.uploadingMessageIDs.contains(message.id) ?? false {
|
|
||||||
return promise(.success(.info("XMPPMiddleware: attachment in message \(message.id) is already in uploading process")))
|
|
||||||
} else {
|
|
||||||
self?.uploadingMessageIDs.insert(message.id)
|
|
||||||
DispatchQueue.global().async {
|
|
||||||
self?.service.uploadAttachment(message: message) { error, remotePath in
|
|
||||||
self?.uploadingMessageIDs.remove(message.id)
|
|
||||||
if let error {
|
|
||||||
promise(.success(.xmppAction(.xmppSharingUploadFailed(msgId: message.id, reason: error.localizedDescription))))
|
|
||||||
} else {
|
|
||||||
promise(.success(.xmppAction(.xmppSharingUploadSuccess(msgId: message.id, attachmentRemotePath: remotePath))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
|
|
||||||
case .xmppAction(.xmppLoadArchivedMessages(let jid, let to, let fromDate)):
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
// return Deferred {
|
|
||||||
// Future<AppAction, Never> { [weak self] promise in
|
|
||||||
// self?.service.requestArchivedMessages(jid: jid, to: to, fromDate: fromDate)
|
|
||||||
// promise(.success(.conversationAction(.setArchivedMessagesRequested)))
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// .eraseToAnyPublisher()
|
|
||||||
|
|
||||||
default:
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Account
|
|
||||||
struct Account: DBStorable {
|
|
||||||
var bareJid: String
|
|
||||||
var pass: String
|
|
||||||
var isActive: Bool
|
|
||||||
var isTemp: Bool // account which is added by user, but not yet logged in
|
|
||||||
var id: String { bareJid }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Account: UniversalInputSelectionElement {
|
|
||||||
var text: String? { bareJid }
|
|
||||||
var icon: Image? { nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Account {
|
|
||||||
static let databaseTableName = "accounts"
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Account
|
|
||||||
struct Channel: DBStorable {
|
|
||||||
var id: String
|
|
||||||
var account: String
|
|
||||||
var channel: String
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Channel {
|
|
||||||
static let channelTableName = "channels"
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
|
|
||||||
enum ConversationType: Int, Codable, DatabaseValueConvertible {
|
|
||||||
case chat = 0
|
|
||||||
case room = 1
|
|
||||||
case channel = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Chat: DBStorable {
|
|
||||||
static let databaseTableName = "chats"
|
|
||||||
|
|
||||||
var id: String
|
|
||||||
var account: String
|
|
||||||
var participant: String
|
|
||||||
var type: ConversationType
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Chat: Equatable {}
|
|
|
@ -1,27 +0,0 @@
|
||||||
// This struct is simpliest variant of Martin's Client State.
|
|
||||||
// Just for more comfortable using in App State
|
|
||||||
import Foundation
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
enum ConnectionStatus: Stateable {
|
|
||||||
case connecting
|
|
||||||
case connected(resumed: Bool = false)
|
|
||||||
case disconnecting
|
|
||||||
case disconnected(reason: String)
|
|
||||||
|
|
||||||
static func from(_ state: XMPPClient.State) -> ConnectionStatus {
|
|
||||||
switch state {
|
|
||||||
case .connecting:
|
|
||||||
return .connecting
|
|
||||||
|
|
||||||
case .connected(let resumed):
|
|
||||||
return .connected(resumed: resumed)
|
|
||||||
|
|
||||||
case .disconnecting:
|
|
||||||
return .disconnecting
|
|
||||||
|
|
||||||
case .disconnected(let reason):
|
|
||||||
return .disconnected(reason: reason.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,128 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
enum MessageType: String, Codable, DatabaseValueConvertible {
|
|
||||||
case chat
|
|
||||||
case groupchat
|
|
||||||
case error
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MessageAttachmentType: Int, Stateable, DatabaseValueConvertible {
|
|
||||||
case movie = 0
|
|
||||||
case image = 1
|
|
||||||
case audio = 2
|
|
||||||
case file = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MessageContentType: String, Codable, DatabaseValueConvertible {
|
|
||||||
case text
|
|
||||||
case typing
|
|
||||||
case invite
|
|
||||||
case attachment
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Message: DBStorable, Equatable {
|
|
||||||
static let databaseTableName = "messages"
|
|
||||||
|
|
||||||
let id: String
|
|
||||||
let type: MessageType
|
|
||||||
let contentType: MessageContentType
|
|
||||||
|
|
||||||
let from: String
|
|
||||||
let to: String?
|
|
||||||
|
|
||||||
let body: String?
|
|
||||||
let subject: String?
|
|
||||||
let thread: String?
|
|
||||||
let oobUrl: String?
|
|
||||||
|
|
||||||
let date: Date
|
|
||||||
let pending: Bool
|
|
||||||
let sentError: Bool
|
|
||||||
|
|
||||||
var attachmentType: MessageAttachmentType?
|
|
||||||
var attachmentLocalName: String?
|
|
||||||
var attachmentRemotePath: URL?
|
|
||||||
var attachmentThumbnailName: String?
|
|
||||||
var attachmentDownloadFailed: Bool = false
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Message {
|
|
||||||
// Universal mapping from Martin's Message to App Message
|
|
||||||
static func map(_ martinMessage: Martin.Message) -> Message? {
|
|
||||||
#if DEBUG
|
|
||||||
print("---")
|
|
||||||
print("Message received: \(martinMessage)")
|
|
||||||
print("---")
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Check that the message type is supported
|
|
||||||
let chatTypes: [StanzaType] = [.chat, .groupchat]
|
|
||||||
guard let mType = martinMessage.type, chatTypes.contains(mType) else {
|
|
||||||
#if DEBUG
|
|
||||||
print("Unsupported message type: \(martinMessage.type?.rawValue ?? "nil")")
|
|
||||||
#endif
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type
|
|
||||||
let type = MessageType(rawValue: martinMessage.type?.rawValue ?? "") ?? .chat
|
|
||||||
|
|
||||||
// Content type
|
|
||||||
var contentType: MessageContentType = .text
|
|
||||||
if martinMessage.oob != nil {
|
|
||||||
contentType = .attachment
|
|
||||||
} else if martinMessage.hints.contains(.noStore) {
|
|
||||||
contentType = .typing
|
|
||||||
}
|
|
||||||
|
|
||||||
// From/To
|
|
||||||
let from = martinMessage.from?.bareJid.stringValue ?? ""
|
|
||||||
let to = martinMessage.to?.bareJid.stringValue
|
|
||||||
|
|
||||||
// Extract date or set current
|
|
||||||
var date = Date()
|
|
||||||
if let timestampStr = martinMessage.attribute("archived_date"), let timeInterval = TimeInterval(timestampStr) {
|
|
||||||
date = Date(timeIntervalSince1970: timeInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Msg
|
|
||||||
var msg = Message(
|
|
||||||
id: martinMessage.id ?? UUID().uuidString,
|
|
||||||
type: type,
|
|
||||||
contentType: contentType,
|
|
||||||
from: from,
|
|
||||||
to: to,
|
|
||||||
body: martinMessage.body,
|
|
||||||
subject: martinMessage.subject,
|
|
||||||
thread: martinMessage.thread,
|
|
||||||
oobUrl: martinMessage.oob,
|
|
||||||
date: date,
|
|
||||||
pending: false,
|
|
||||||
sentError: false,
|
|
||||||
attachmentType: nil,
|
|
||||||
attachmentLocalName: nil,
|
|
||||||
attachmentRemotePath: nil,
|
|
||||||
attachmentThumbnailName: nil,
|
|
||||||
attachmentDownloadFailed: false
|
|
||||||
)
|
|
||||||
if let oob = martinMessage.oob {
|
|
||||||
msg.attachmentType = oob.attachmentType
|
|
||||||
msg.attachmentRemotePath = URL(string: oob)
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Message {
|
|
||||||
var attachmentLocalPath: URL? {
|
|
||||||
guard let attachmentLocalName = attachmentLocalName else { return nil }
|
|
||||||
return FileProcessing.fileFolder.appendingPathComponent(attachmentLocalName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var attachmentThumbnailPath: URL? {
|
|
||||||
guard let attachmentThumbnailName = attachmentThumbnailName else { return nil }
|
|
||||||
return FileProcessing.fileFolder.appendingPathComponent(attachmentThumbnailName)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - Account
|
|
||||||
struct Room: DBStorable {
|
|
||||||
var id: String
|
|
||||||
var account: String
|
|
||||||
var nickname: String
|
|
||||||
var password: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Room {
|
|
||||||
static let roomTableName = "rooms"
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct ServerFeature: Stateable, Identifiable {
|
|
||||||
let xep: String
|
|
||||||
let name: String
|
|
||||||
let xmppId: String?
|
|
||||||
let description: String?
|
|
||||||
|
|
||||||
var id: String { xep }
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
extension AccountsState {
|
|
||||||
static func reducer(state: inout AccountsState, action: AccountsAction) {
|
|
||||||
switch action {
|
|
||||||
case .accountsListUpdated(let accounts):
|
|
||||||
state.accounts = accounts
|
|
||||||
|
|
||||||
case .goTo(let navigation):
|
|
||||||
state.navigation = navigation
|
|
||||||
|
|
||||||
case .tryAddAccountWithCredentials(let login, let password):
|
|
||||||
let account = Account(bareJid: login, pass: password, isActive: true, isTemp: true)
|
|
||||||
state.accounts.append(account)
|
|
||||||
|
|
||||||
case .addAccountError(let jid, let reason):
|
|
||||||
state.accounts = state.accounts.filter { $0.bareJid != jid }
|
|
||||||
state.addAccountError = reason
|
|
||||||
|
|
||||||
case .clientServerFeaturesUpdated(let jid, let features):
|
|
||||||
state.discoFeatures[jid] = features
|
|
||||||
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension AppState {
|
|
||||||
static func reducer(state: inout AppState, action: AppAction) {
|
|
||||||
switch action {
|
|
||||||
case .flushState:
|
|
||||||
state = AppState()
|
|
||||||
|
|
||||||
case .changeFlow(let flow):
|
|
||||||
state.previousFlow = state.currentFlow
|
|
||||||
state.currentFlow = flow
|
|
||||||
|
|
||||||
case .startAction(let action):
|
|
||||||
StartState.reducer(state: &state.startState, action: action)
|
|
||||||
|
|
||||||
case .databaseAction, .xmppAction, .fileAction, .info:
|
|
||||||
break // this actions are processed by other middlewares
|
|
||||||
|
|
||||||
case .accountsAction(let action):
|
|
||||||
AccountsState.reducer(state: &state.accountsState, action: action)
|
|
||||||
|
|
||||||
case .rostersAction(let action):
|
|
||||||
RostersState.reducer(state: &state.rostersState, action: action)
|
|
||||||
|
|
||||||
case .chatsAction(let action):
|
|
||||||
ChatsState.reducer(state: &state.chatsState, action: action)
|
|
||||||
|
|
||||||
case .conversationAction(let action):
|
|
||||||
ConversationState.reducer(state: &state.conversationsState, action: action)
|
|
||||||
|
|
||||||
case .sharingAction(let action):
|
|
||||||
SharingState.reducer(state: &state.sharingState, action: action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
extension ChatsState {
|
|
||||||
static func reducer(state: inout ChatsState, action: ChatsAction) {
|
|
||||||
switch action {
|
|
||||||
case .chatsListUpdated(let chats):
|
|
||||||
state.chats = chats
|
|
||||||
|
|
||||||
case .chatStarted(let chat):
|
|
||||||
state.currentChat = chat
|
|
||||||
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
extension ConversationState {
|
|
||||||
static func reducer(state: inout ConversationState, action: ConversationAction) {
|
|
||||||
switch action {
|
|
||||||
case .makeConversationActive(let chat, let roster):
|
|
||||||
state.currentChat = chat
|
|
||||||
state.currentRoster = roster
|
|
||||||
|
|
||||||
case .messagesUpdated(let messages):
|
|
||||||
state.currentMessages = messages
|
|
||||||
|
|
||||||
case .setReplyText(let text):
|
|
||||||
if text.isEmpty {
|
|
||||||
state.replyText = ""
|
|
||||||
} else {
|
|
||||||
state.replyText = text.makeReply
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
extension RostersState {
|
|
||||||
static func reducer(state: inout RostersState, action: RostersAction) {
|
|
||||||
switch action {
|
|
||||||
case .addRosterDone(let jid):
|
|
||||||
state.newAddedRosterJid = jid
|
|
||||||
state.newAddedRosterError = nil
|
|
||||||
|
|
||||||
case .addRosterError(let reason):
|
|
||||||
state.newAddedRosterJid = nil
|
|
||||||
state.newAddedRosterError = reason
|
|
||||||
|
|
||||||
case .rostersListUpdated(let rosters):
|
|
||||||
state.rosters = rosters
|
|
||||||
|
|
||||||
case .markRosterAsLocallyDeleted, .deleteRoster:
|
|
||||||
state.deleteRosterError = nil
|
|
||||||
|
|
||||||
case .rosterDeletingFailed(let reson):
|
|
||||||
state.deleteRosterError = reson
|
|
||||||
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension SharingState {
|
|
||||||
static func reducer(state: inout SharingState, action: SharingAction) {
|
|
||||||
switch action {
|
|
||||||
case .showSharing(let shown):
|
|
||||||
state.sharingShown = shown
|
|
||||||
|
|
||||||
case .setCameraAccess(let granted):
|
|
||||||
state.isCameraAccessGranted = granted
|
|
||||||
|
|
||||||
case .setGalleryAccess(let granted):
|
|
||||||
state.isGalleryAccessGranted = granted
|
|
||||||
|
|
||||||
case .galleryItemsUpdated(let items):
|
|
||||||
state.galleryItems = items
|
|
||||||
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
extension StartState {
|
|
||||||
static func reducer(state: inout StartState, action: StartAction) {
|
|
||||||
switch action {
|
|
||||||
case .loadStoredAccounts:
|
|
||||||
break
|
|
||||||
|
|
||||||
case .goTo(let navigation):
|
|
||||||
state.navigation = navigation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
enum AccountNavigationState: Stateable {
|
|
||||||
case addAccount
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AccountsState: Stateable {
|
|
||||||
var navigation: AccountNavigationState
|
|
||||||
var accounts: [Account]
|
|
||||||
var discoFeatures: [String: [ServerFeature]]
|
|
||||||
|
|
||||||
var addAccountError: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension AccountsState {
|
|
||||||
init() {
|
|
||||||
navigation = .addAccount
|
|
||||||
accounts = []
|
|
||||||
discoFeatures = [:]
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum AppFlow: Codable {
|
|
||||||
case start
|
|
||||||
case accounts
|
|
||||||
case chats
|
|
||||||
case contacts
|
|
||||||
case settings
|
|
||||||
case conversation
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AppState: Stateable {
|
|
||||||
var appVersion: String
|
|
||||||
var previousFlow: AppFlow
|
|
||||||
var currentFlow: AppFlow
|
|
||||||
|
|
||||||
var startState: StartState
|
|
||||||
var accountsState: AccountsState
|
|
||||||
var rostersState: RostersState
|
|
||||||
var chatsState: ChatsState
|
|
||||||
var conversationsState: ConversationState
|
|
||||||
var sharingState: SharingState
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension AppState {
|
|
||||||
init() {
|
|
||||||
appVersion = Const.appVersion
|
|
||||||
previousFlow = .start
|
|
||||||
currentFlow = .start
|
|
||||||
|
|
||||||
startState = StartState()
|
|
||||||
accountsState = AccountsState()
|
|
||||||
rostersState = RostersState()
|
|
||||||
chatsState = ChatsState()
|
|
||||||
conversationsState = ConversationState()
|
|
||||||
sharingState = SharingState()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
struct ChatsState: Stateable {
|
|
||||||
var chats: [Chat]
|
|
||||||
var currentChat: Chat?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension ChatsState {
|
|
||||||
init() {
|
|
||||||
chats = []
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
struct ConversationState: Stateable {
|
|
||||||
var currentChat: Chat?
|
|
||||||
var currentRoster: Roster?
|
|
||||||
var currentMessages: [Message]
|
|
||||||
|
|
||||||
var replyText: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension ConversationState {
|
|
||||||
init() {
|
|
||||||
currentMessages = []
|
|
||||||
replyText = ""
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
struct RostersState: Stateable {
|
|
||||||
var rosters: [Roster]
|
|
||||||
|
|
||||||
var newAddedRosterJid: String?
|
|
||||||
var newAddedRosterError: String?
|
|
||||||
|
|
||||||
var deleteRosterError: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension RostersState {
|
|
||||||
init() {
|
|
||||||
rosters = []
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum SharingCameraMediaType: Stateable {
|
|
||||||
case video
|
|
||||||
case photo
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SharingGalleryItem: Stateable, Identifiable {
|
|
||||||
var id: String
|
|
||||||
var type: SharingCameraMediaType
|
|
||||||
var thumbnail: Data?
|
|
||||||
var duration: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SharingState: Stateable {
|
|
||||||
var sharingShown: Bool
|
|
||||||
var isCameraAccessGranted: Bool
|
|
||||||
var isGalleryAccessGranted: Bool
|
|
||||||
|
|
||||||
var galleryItems: [SharingGalleryItem]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension SharingState {
|
|
||||||
init() {
|
|
||||||
sharingShown = false
|
|
||||||
isCameraAccessGranted = false
|
|
||||||
isGalleryAccessGranted = false
|
|
||||||
|
|
||||||
galleryItems = []
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
enum StartNavigationState: Stateable {
|
|
||||||
case startScreen
|
|
||||||
case welcomeScreen
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StartState: Stateable {
|
|
||||||
var navigation: StartNavigationState
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Init
|
|
||||||
extension StartState {
|
|
||||||
init() {
|
|
||||||
navigation = .startScreen
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,266 +0,0 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import Martin
|
|
||||||
|
|
||||||
protocol MartinsManager: Martin.RosterManager & Martin.ChatManager & Martin.ChannelManager & Martin.RoomManager {}
|
|
||||||
|
|
||||||
final class XMPPService: ObservableObject {
|
|
||||||
private let manager: MartinsManager
|
|
||||||
|
|
||||||
private let clientStatePublisher = PassthroughSubject<(XMPPClient, XMPPClient.State), Never>()
|
|
||||||
private var clientStateCancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
private let clientMessagesPublisher = PassthroughSubject<(XMPPClient, Martin.Message), Never>()
|
|
||||||
private var clientMessagesCancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
private let clientFeaturesPublisher = PassthroughSubject<(XMPPClient, [String]), Never>()
|
|
||||||
private var clientFeaturesCancellables: Set<AnyCancellable> = []
|
|
||||||
|
|
||||||
@Published private(set) var clients: [XMPPClient] = []
|
|
||||||
var clientState: AnyPublisher<(XMPPClient, XMPPClient.State), Never> {
|
|
||||||
clientStatePublisher.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
var clientMessages: AnyPublisher<(XMPPClient, Martin.Message), Never> {
|
|
||||||
clientMessagesPublisher.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
var clientFeatures: AnyPublisher<(XMPPClient, [String]), Never> {
|
|
||||||
clientFeaturesPublisher.eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
|
|
||||||
init(manager: MartinsManager) {
|
|
||||||
self.manager = manager
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateClients(for accounts: [Account]) {
|
|
||||||
// get simple diff
|
|
||||||
let forAdd = accounts
|
|
||||||
.filter { !self.clients.map { $0.connectionConfiguration.userJid.stringValue }.contains($0.bareJid) }
|
|
||||||
let forRemove = clients
|
|
||||||
.map { $0.connectionConfiguration.userJid.stringValue }
|
|
||||||
.filter { !accounts.map { $0.bareJid }.contains($0) }
|
|
||||||
|
|
||||||
// init and add clients
|
|
||||||
for account in forAdd {
|
|
||||||
// add client
|
|
||||||
let client = makeClient(for: account, with: manager)
|
|
||||||
clients.append(client)
|
|
||||||
|
|
||||||
// subscribe to client state
|
|
||||||
client.$state
|
|
||||||
.sink { [weak self] state in
|
|
||||||
self?.clientStatePublisher.send((client, state))
|
|
||||||
}
|
|
||||||
.store(in: &clientStateCancellables)
|
|
||||||
|
|
||||||
// subscribe to client server features
|
|
||||||
client.module(DiscoveryModule.self).$serverDiscoResult
|
|
||||||
.sink { [weak self] disco in
|
|
||||||
self?.clientFeaturesPublisher.send((client, disco.features))
|
|
||||||
}
|
|
||||||
.store(in: &clientFeaturesCancellables)
|
|
||||||
|
|
||||||
// subscribe to client messages
|
|
||||||
client.module(MessageModule.self).messagesPublisher
|
|
||||||
.sink { [weak self] message in
|
|
||||||
self?.clientMessagesPublisher.send((client, message.message))
|
|
||||||
}
|
|
||||||
.store(in: &clientMessagesCancellables)
|
|
||||||
|
|
||||||
// subscribe to carbons
|
|
||||||
client.module(MessageCarbonsModule.self).carbonsPublisher
|
|
||||||
.sink { [weak self] carbon in
|
|
||||||
self?.clientMessagesPublisher.send((client, carbon.message))
|
|
||||||
}
|
|
||||||
.store(in: &clientMessagesCancellables)
|
|
||||||
|
|
||||||
// subscribe to archived messages
|
|
||||||
// client.module(.mam).archivedMessagesPublisher
|
|
||||||
// .sink(receiveValue: { [weak self] archived in
|
|
||||||
// let message = archived.message
|
|
||||||
// message.attribute("archived_date", newValue: "\(archived.timestamp.timeIntervalSince1970)")
|
|
||||||
// self?.clientMessagesPublisher.send((client, message))
|
|
||||||
// })
|
|
||||||
// .store(in: &clientMessagesCancellables)
|
|
||||||
|
|
||||||
// enable carbons if available
|
|
||||||
client.module(.messageCarbons).$isAvailable.filter { $0 }
|
|
||||||
.sink(receiveValue: { [weak client] _ in
|
|
||||||
client?.module(.messageCarbons).enable()
|
|
||||||
})
|
|
||||||
.store(in: &clientMessagesCancellables)
|
|
||||||
|
|
||||||
// finally, do login
|
|
||||||
client.login()
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove clients
|
|
||||||
for jid in forRemove {
|
|
||||||
deinitClient(jid: jid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeClient(for account: Account, with manager: MartinsManager) -> 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: manager))
|
|
||||||
client.modulesManager.register(PresenceModule())
|
|
||||||
|
|
||||||
client.modulesManager.register(PubSubModule())
|
|
||||||
client.modulesManager.register(MessageModule(chatManager: manager))
|
|
||||||
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(account.bareJid)
|
|
||||||
client.connectionConfiguration.credentials = .password(password: account.pass)
|
|
||||||
|
|
||||||
// group chats
|
|
||||||
client.modulesManager.register(MucModule(roomManager: manager))
|
|
||||||
|
|
||||||
// channels
|
|
||||||
// client.modulesManager.register(MixModule(channelManager: manager))
|
|
||||||
|
|
||||||
// add client to clients
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
func deinitClient(jid: String) {
|
|
||||||
if let index = clients.firstIndex(where: { $0.connectionConfiguration.userJid.stringValue == jid }) {
|
|
||||||
let client = clients.remove(at: index)
|
|
||||||
_ = client.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getClient(for jid: String) -> XMPPClient? {
|
|
||||||
clients.first { $0.connectionConfiguration.userJid.stringValue == jid }
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendMessage(message: Message, completion: @escaping (Bool) -> Void) {
|
|
||||||
guard let client = getClient(for: message.from), let to = message.to else {
|
|
||||||
completion(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let chat = client.module(MessageModule.self).chatManager.chat(for: client.context, with: BareJID(to)) else {
|
|
||||||
completion(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg = chat.createMessage(text: message.body ?? "??", id: message.id)
|
|
||||||
chat.send(message: msg) { res in
|
|
||||||
switch res {
|
|
||||||
case .success:
|
|
||||||
completion(true)
|
|
||||||
|
|
||||||
case .failure:
|
|
||||||
completion(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func uploadAttachment(message: Message, completion: @escaping (Error?, String) -> Void) {
|
|
||||||
guard let client = getClient(for: message.from), let to = message.to else {
|
|
||||||
completion(XMPPError.bad_request("No such client"), "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let fileName = message.attachmentLocalName else {
|
|
||||||
completion(XMPPError.bad_request("No such file"), "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let url = FileProcessing.fileFolder.appendingPathComponent(fileName)
|
|
||||||
guard let data = try? Data(contentsOf: url) else {
|
|
||||||
completion(XMPPError.bad_request("No such file"), "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let chat = client.module(MessageModule.self).chatManager.chat(for: client.context, with: BareJID(to)) else {
|
|
||||||
completion(XMPPError.bad_request("No such chat"), "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let httpModule = client.module(HttpFileUploadModule.self)
|
|
||||||
httpModule.findHttpUploadComponent { res in
|
|
||||||
switch res {
|
|
||||||
case .success(let components):
|
|
||||||
guard let component = components.first(where: { $0.maxSize > data.count }) else {
|
|
||||||
completion(XMPPError.bad_request("File too big"), "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
httpModule.requestUploadSlot(componentJid: component.jid, filename: fileName, size: data.count, contentType: url.mimeType) { res in
|
|
||||||
switch res {
|
|
||||||
case .success(let slot):
|
|
||||||
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(url.mimeType, forHTTPHeaderField: "Content-Type")
|
|
||||||
let session = URLSession(configuration: URLSessionConfiguration.default)
|
|
||||||
session.dataTask(with: request) { _, response, error in
|
|
||||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 500
|
|
||||||
guard error == nil, code == 200 || code == 201 else {
|
|
||||||
completion(XMPPError.bad_request("Upload failed"), "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if code == 200 {
|
|
||||||
completion(XMPPError.bad_request("Invalid response code"), "")
|
|
||||||
} else {
|
|
||||||
let mesg = chat.createMessage(text: slot.getUri.absoluteString, id: message.id)
|
|
||||||
mesg.oob = slot.getUri.absoluteString
|
|
||||||
chat.send(message: mesg) { res in
|
|
||||||
switch res {
|
|
||||||
case .success:
|
|
||||||
completion(nil, slot.getUri.absoluteString)
|
|
||||||
|
|
||||||
case .failure:
|
|
||||||
completion(XMPPError.bad_request("File uploaded, but message sent failed"), slot.getUri.absoluteString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.resume()
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
completion(error, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
completion(error, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestArchivedMessages(jid: String, to: String?, fromDate: Date) {
|
|
||||||
guard let client = getClient(for: jid) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
client.module(.mam).queryItems(componentJid: JID(jid), with: JID(to), start: fromDate, end: Date(), queryId: UUID().uuidString) { result in
|
|
||||||
switch result {
|
|
||||||
case .success(let response):
|
|
||||||
print("MAM response: \(response)")
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
print("MAM error: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
11
ConversationsClassic/AppData/AppError.swift
Normal file
11
ConversationsClassic/AppData/AppError.swift
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
enum AppError: Error {
|
||||||
|
case clientNotFound
|
||||||
|
case rosterNotFound
|
||||||
|
case imageNotFound
|
||||||
|
case videoNotFound
|
||||||
|
case noData
|
||||||
|
case fileTooBig
|
||||||
|
case invalidContentType
|
||||||
|
case invalidLocalName
|
||||||
|
case featureNotSupported
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import Martin
|
||||||
|
|
||||||
|
final class ClientMartinCarbonsManager {
|
||||||
|
private var cancellables: Set<AnyCancellable> = []
|
||||||
|
|
||||||
|
init(_ xmppConnection: XMPPClient) {
|
||||||
|
// subscribe to carbons
|
||||||
|
xmppConnection.module(MessageCarbonsModule.self).carbonsPublisher
|
||||||
|
.sink { [weak self] carbon in
|
||||||
|
self?.handleMessage(carbon)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// enable carbons if available
|
||||||
|
xmppConnection.module(.messageCarbons).$isAvailable.filter { $0 }
|
||||||
|
.sink(receiveValue: { [weak xmppConnection] _ in
|
||||||
|
xmppConnection?.module(.messageCarbons).enable()
|
||||||
|
})
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleMessage(_ received: Martin.MessageCarbonsModule.CarbonReceived) {
|
||||||
|
let message = received.message
|
||||||
|
let action = received.action
|
||||||
|
let onJid = received.jid
|
||||||
|
#if DEBUG
|
||||||
|
print("---")
|
||||||
|
print("Carbons message received: \(message)")
|
||||||
|
print("Action: \(action)")
|
||||||
|
print("On JID: \(onJid)")
|
||||||
|
print("---")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if let msg = Message.map(message, context: nil) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await msg.save()
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error saving message: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,10 +2,10 @@ import Foundation
|
||||||
import GRDB
|
import GRDB
|
||||||
import Martin
|
import Martin
|
||||||
|
|
||||||
extension Database: Martin.ChatManager {
|
final class ClientMartinChatsManager: Martin.ChatManager {
|
||||||
func chats(for context: Martin.Context) -> [any Martin.ChatProtocol] {
|
func chats(for context: Martin.Context) -> [any Martin.ChatProtocol] {
|
||||||
do {
|
do {
|
||||||
let chats: [Chat] = try _db.read { db in
|
let chats: [Chat] = try Database.shared.dbQueue.read { db in
|
||||||
try Chat.filter(Column("account") == context.userBareJid.stringValue).fetchAll(db)
|
try Chat.filter(Column("account") == context.userBareJid.stringValue).fetchAll(db)
|
||||||
}
|
}
|
||||||
return chats.map { chat in
|
return chats.map { chat in
|
||||||
|
@ -19,7 +19,7 @@ extension Database: Martin.ChatManager {
|
||||||
|
|
||||||
func chat(for context: Martin.Context, with: Martin.BareJID) -> (any Martin.ChatProtocol)? {
|
func chat(for context: Martin.Context, with: Martin.BareJID) -> (any Martin.ChatProtocol)? {
|
||||||
do {
|
do {
|
||||||
let chat: Chat? = try _db.read { db in
|
let chat: Chat? = try Database.shared.dbQueue.read { db in
|
||||||
try Chat
|
try Chat
|
||||||
.filter(Column("account") == context.userBareJid.stringValue)
|
.filter(Column("account") == context.userBareJid.stringValue)
|
||||||
.filter(Column("participant") == with.stringValue)
|
.filter(Column("participant") == with.stringValue)
|
||||||
|
@ -38,7 +38,7 @@ extension Database: Martin.ChatManager {
|
||||||
|
|
||||||
func createChat(for context: Martin.Context, with: Martin.BareJID) -> (any Martin.ChatProtocol)? {
|
func createChat(for context: Martin.Context, with: Martin.BareJID) -> (any Martin.ChatProtocol)? {
|
||||||
do {
|
do {
|
||||||
let chat: Chat? = try _db.read { db in
|
let chat: Chat? = try Database.shared.dbQueue.read { db in
|
||||||
try Chat
|
try Chat
|
||||||
.filter(Column("account") == context.userBareJid.stringValue)
|
.filter(Column("account") == context.userBareJid.stringValue)
|
||||||
.filter(Column("participant") == with.stringValue)
|
.filter(Column("participant") == with.stringValue)
|
||||||
|
@ -53,7 +53,7 @@ extension Database: Martin.ChatManager {
|
||||||
participant: with.stringValue,
|
participant: with.stringValue,
|
||||||
type: .chat
|
type: .chat
|
||||||
)
|
)
|
||||||
try _db.write { db in
|
try Database.shared.dbQueue.write { db in
|
||||||
try chat.save(db)
|
try chat.save(db)
|
||||||
}
|
}
|
||||||
return Martin.ChatBase(context: context, jid: with)
|
return Martin.ChatBase(context: context, jid: with)
|
||||||
|
@ -69,4 +69,7 @@ extension Database: Martin.ChatManager {
|
||||||
print("Closing chat: \(chat)")
|
print("Closing chat: \(chat)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initialize(context _: Martin.Context) {}
|
||||||
|
func deinitialize(context _: Martin.Context) {}
|
||||||
}
|
}
|
23
ConversationsClassic/AppData/Client/Client+MartinDisco.swift
Normal file
23
ConversationsClassic/AppData/Client/Client+MartinDisco.swift
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import Martin
|
||||||
|
|
||||||
|
final class ClientMartinDiscoManager {
|
||||||
|
private(set) var features: [ServerFeature] = []
|
||||||
|
private var cancellables: Set<AnyCancellable> = []
|
||||||
|
|
||||||
|
init(_ xmppConnection: XMPPClient) {
|
||||||
|
// subscribe to client server features
|
||||||
|
xmppConnection.module(DiscoveryModule.self).$serverDiscoResult
|
||||||
|
.sink { [weak self] disco in
|
||||||
|
let allFeatures = ServerFeature.allFeatures
|
||||||
|
let features = disco.features
|
||||||
|
.compactMap { featureId in
|
||||||
|
allFeatures.first(where: { $0.xmppId == featureId })
|
||||||
|
}
|
||||||
|
self?.features = features
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
}
|
79
ConversationsClassic/AppData/Client/Client+MartinMAM.swift
Normal file
79
ConversationsClassic/AppData/Client/Client+MartinMAM.swift
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import Martin
|
||||||
|
|
||||||
|
private typealias ArchMsg = Martin.MessageArchiveManagementModule.ArchivedMessageReceived
|
||||||
|
|
||||||
|
final class ClientMartinMAM {
|
||||||
|
private var cancellables: Set<AnyCancellable> = []
|
||||||
|
private var processor = ArchiveMessageProcessor()
|
||||||
|
|
||||||
|
init(_ xmppConnection: XMPPClient) {
|
||||||
|
// subscribe to archived messages
|
||||||
|
xmppConnection.module(.mam).archivedMessagesPublisher
|
||||||
|
.sink(receiveValue: { [weak self] archived in
|
||||||
|
guard let self = self else { return }
|
||||||
|
Task {
|
||||||
|
await self.processor.append(archived)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor ArchiveMessageProcessor {
|
||||||
|
private var accumulator: [ArchMsg] = []
|
||||||
|
|
||||||
|
init() {
|
||||||
|
Task {
|
||||||
|
while true {
|
||||||
|
try? await Task.sleep(nanoseconds: 700 * NSEC_PER_MSEC)
|
||||||
|
await process()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func append(_ msg: ArchMsg) async {
|
||||||
|
accumulator.append(msg)
|
||||||
|
if accumulator.count >= Const.mamRequestPageSize {
|
||||||
|
await process()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func process() async {
|
||||||
|
if accumulator.isEmpty { return }
|
||||||
|
await handleMessages(accumulator)
|
||||||
|
accumulator.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleMessages(_ received: [ArchMsg]) async {
|
||||||
|
if received.isEmpty { return }
|
||||||
|
try? await Database.shared.dbQueue.write { db in
|
||||||
|
for recv in received {
|
||||||
|
let message = recv.message
|
||||||
|
let date = recv.timestamp
|
||||||
|
if let msgId = message.id {
|
||||||
|
if try Message.fetchOne(db, key: msgId) != nil {
|
||||||
|
#if DEBUG
|
||||||
|
print("---")
|
||||||
|
print("Skipping archived message with id \(msgId) (message exists)")
|
||||||
|
print("---")
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
#if DEBUG
|
||||||
|
print("---")
|
||||||
|
print("Archive message received: \(message)")
|
||||||
|
print("Date: \(date)")
|
||||||
|
print("---")
|
||||||
|
#endif
|
||||||
|
if var msg = Message.map(message, context: nil) {
|
||||||
|
msg.date = date
|
||||||
|
try msg.insert(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import Martin
|
||||||
|
|
||||||
|
final class ClientMartinMessagesManager {
|
||||||
|
private var cancellables: Set<AnyCancellable> = []
|
||||||
|
|
||||||
|
init(_ xmppConnection: XMPPClient) {
|
||||||
|
xmppConnection.module(MessageModule.self).messagesPublisher
|
||||||
|
.sink { [weak self] message in
|
||||||
|
self?.handleMessage(message)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleMessage(_ received: Martin.MessageModule.MessageReceived) {
|
||||||
|
let message = received.message
|
||||||
|
let chat = received.chat
|
||||||
|
#if DEBUG
|
||||||
|
print("---")
|
||||||
|
print("Message received: \(received)")
|
||||||
|
print("Chat: \(chat)")
|
||||||
|
print("---")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Process image
|
||||||
|
if let msg = Message.map(message, context: chat.context) {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await msg.save()
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error saving message: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
375
ConversationsClassic/AppData/Client/Client+MartinOMEMO.swift
Normal file
375
ConversationsClassic/AppData/Client/Client+MartinOMEMO.swift
Normal file
|
@ -0,0 +1,375 @@
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import Martin
|
||||||
|
import MartinOMEMO
|
||||||
|
|
||||||
|
final class ClientMartinOMEMO {
|
||||||
|
let credentials: Credentials
|
||||||
|
|
||||||
|
init(_ credentials: Credentials) {
|
||||||
|
self.credentials = credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
var signal: (SignalStorage, SignalContext) {
|
||||||
|
let signalStorage = SignalStorage(sessionStore: self, preKeyStore: self, signedPreKeyStore: self, identityKeyStore: self, senderKeyStore: self)
|
||||||
|
// swiftlint:disable:next force_unwrapping
|
||||||
|
let signalContext = SignalContext(withStorage: signalStorage)!
|
||||||
|
signalStorage.setup(withContext: signalContext)
|
||||||
|
|
||||||
|
_ = regenerateKeys(wipe: false, context: signalContext)
|
||||||
|
|
||||||
|
return (signalStorage, signalContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func regenerateKeys(wipe: Bool = false, context: SignalContext) -> Bool {
|
||||||
|
if wipe {
|
||||||
|
OMEMOSession.wipe(account: credentials.bareJid)
|
||||||
|
OMEMOPreKey.wipe(account: credentials.bareJid)
|
||||||
|
OMEMOSignedPreKey.wipe(account: credentials.bareJid)
|
||||||
|
OMEMOIdentity.wipe(account: credentials.bareJid)
|
||||||
|
Settings.getFor(credentials.bareJid)?.wipeOmemoRegId()
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasKeyPair = keyPair() != nil
|
||||||
|
if wipe || localRegistrationId() == 0 || !hasKeyPair {
|
||||||
|
let regId = context.generateRegistrationId()
|
||||||
|
let address = SignalAddress(name: credentials.bareJid, deviceId: Int32(regId))
|
||||||
|
|
||||||
|
if var settings = Settings.getFor(credentials.bareJid) {
|
||||||
|
settings.omemoRegId = Int(regId)
|
||||||
|
settings.save()
|
||||||
|
} else {
|
||||||
|
Settings(bareJid: credentials.bareJid, omemoRegId: Int(regId)).save()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let keyPair = SignalIdentityKeyPair.generateKeyPair(context: context), let publicKey = keyPair.publicKey else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let fingerprint = publicKey.map { byte -> String in
|
||||||
|
String(format: "%02x", byte)
|
||||||
|
}.joined()
|
||||||
|
|
||||||
|
return save(address: address, fingerprint: fingerprint, own: true, data: keyPair.serialized())
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save(address: SignalAddress, fingerprint: String, own: Bool, data: Data) -> Bool {
|
||||||
|
guard !OMEMOIdentity.existsFor(account: credentials.bareJid, name: address.name, fingerprint: fingerprint) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOIdentity(
|
||||||
|
account: credentials.bareJid,
|
||||||
|
name: address.name,
|
||||||
|
deviceId: Int(address.deviceId),
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
key: data,
|
||||||
|
own: own,
|
||||||
|
status: MartinOMEMO.IdentityStatus.trustedActive.rawValue
|
||||||
|
)
|
||||||
|
.insert(db)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error storing identity key: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session
|
||||||
|
extension ClientMartinOMEMO: SignalSessionStoreProtocol {
|
||||||
|
func sessionRecord(forAddress address: MartinOMEMO.SignalAddress) -> Data? {
|
||||||
|
if let key = OMEMOSession.keyFor(account: credentials.bareJid, name: address.name, deviceId: address.deviceId) {
|
||||||
|
return Data(base64Encoded: key)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allDevices(for name: String, activeAndTrusted: Bool) -> [Int32] {
|
||||||
|
activeAndTrusted ?
|
||||||
|
OMEMOSession.trustedDevicesIdsFor(account: credentials.bareJid, name: name) :
|
||||||
|
OMEMOSession.devicesIdsFor(account: credentials.bareJid, name: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeSessionRecord(_ data: Data, forAddress: MartinOMEMO.SignalAddress) -> Bool {
|
||||||
|
do {
|
||||||
|
try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOSession(
|
||||||
|
account: credentials.bareJid,
|
||||||
|
name: forAddress.name,
|
||||||
|
deviceId: Int(forAddress.deviceId),
|
||||||
|
key: data.base64EncodedString()
|
||||||
|
)
|
||||||
|
.insert(db)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error storing session info: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSessionRecord(forAddress: MartinOMEMO.SignalAddress) -> Bool {
|
||||||
|
OMEMOSession.keyFor(account: credentials.bareJid, name: forAddress.name, deviceId: forAddress.deviceId) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSessionRecord(forAddress: MartinOMEMO.SignalAddress) -> Bool {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOSession
|
||||||
|
.filter(Column("account") == credentials.bareJid)
|
||||||
|
.filter(Column("name") == forAddress.name)
|
||||||
|
.filter(Column("deviceId") == forAddress.deviceId)
|
||||||
|
.deleteAll(db)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error deleting session: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAllSessions(for name: String) -> Bool {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOSession
|
||||||
|
.filter(Column("account") == credentials.bareJid)
|
||||||
|
.filter(Column("name") == name)
|
||||||
|
.deleteAll(db)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error deleting all sessions: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionsWipe() {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOSession
|
||||||
|
.filter(Column("account") == credentials.bareJid)
|
||||||
|
.deleteAll(db)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error wiping sessions: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Identity
|
||||||
|
extension ClientMartinOMEMO: SignalIdentityKeyStoreProtocol {
|
||||||
|
func keyPair() -> (any MartinOMEMO.SignalIdentityKeyPairProtocol)? {
|
||||||
|
let deviceId = localRegistrationId()
|
||||||
|
guard deviceId != 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let record = try Database.shared.dbQueue.read { db in
|
||||||
|
try OMEMOIdentity
|
||||||
|
.filter(Column("account") == credentials.bareJid)
|
||||||
|
.filter(Column("name") == credentials.bareJid)
|
||||||
|
.filter(Column("deviceId") == deviceId)
|
||||||
|
.fetchOne(db)
|
||||||
|
}
|
||||||
|
guard let key = record?.key else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return SignalIdentityKeyPair(fromKeyPairData: key)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func localRegistrationId() -> UInt32 {
|
||||||
|
if let settings = Settings.getFor(credentials.bareJid) {
|
||||||
|
return UInt32(settings.omemoRegId)
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(identity: MartinOMEMO.SignalAddress, key: (any MartinOMEMO.SignalIdentityKeyProtocol)?) -> Bool {
|
||||||
|
guard let key = key as SignalIdentityKeyProtocol?, let publicKey = key.publicKey else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let fingerprint = publicKey.map { byte -> String in
|
||||||
|
String(format: "%02x", byte)
|
||||||
|
}.joined()
|
||||||
|
|
||||||
|
defer {
|
||||||
|
_ = self.setStatus(.verifiedActive, forIdentity: identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
return save(address: identity, fingerprint: fingerprint, own: true, data: key.serialized())
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(identity: MartinOMEMO.SignalAddress, publicKeyData: Data?) -> Bool {
|
||||||
|
guard let publicKeyData = publicKeyData else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let fingerprint = publicKeyData.map { byte -> String in
|
||||||
|
String(format: "%02x", byte)
|
||||||
|
}.joined()
|
||||||
|
|
||||||
|
return save(address: identity, fingerprint: fingerprint, own: false, data: publicKeyData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTrusted(identity _: MartinOMEMO.SignalAddress, key _: (any MartinOMEMO.SignalIdentityKeyProtocol)?) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTrusted(identity _: MartinOMEMO.SignalAddress, publicKeyData _: Data?) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStatus(_ status: MartinOMEMO.IdentityStatus, forIdentity: MartinOMEMO.SignalAddress) -> Bool {
|
||||||
|
if let identity = OMEMOIdentity.getFor(account: credentials.bareJid, name: forIdentity.name, deviceId: forIdentity.deviceId) {
|
||||||
|
return identity.updateStatus(status.rawValue)
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStatus(active: Bool, forIdentity: MartinOMEMO.SignalAddress) -> Bool {
|
||||||
|
if let identity = OMEMOIdentity.getFor(account: credentials.bareJid, name: forIdentity.name, deviceId: forIdentity.deviceId) {
|
||||||
|
let status = IdentityStatus(rawValue: identity.status) ?? .undecidedActive
|
||||||
|
return identity.updateStatus(active ? status.toActive().rawValue : status.toInactive().rawValue)
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func identities(forName name: String) -> [MartinOMEMO.Identity] {
|
||||||
|
OMEMOIdentity.getAllFor(account: credentials.bareJid, name: name)
|
||||||
|
.compactMap { identity in
|
||||||
|
guard let status = IdentityStatus(rawValue: identity.status) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return MartinOMEMO.Identity(
|
||||||
|
address: MartinOMEMO.SignalAddress(name: identity.name, deviceId: Int32(identity.deviceId)),
|
||||||
|
status: status,
|
||||||
|
fingerprint: identity.fingerprint,
|
||||||
|
key: identity.key,
|
||||||
|
own: identity.own
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func identityFingerprint(forAddress address: MartinOMEMO.SignalAddress) -> String? {
|
||||||
|
OMEMOIdentity.getFor(account: credentials.bareJid, name: address.name, deviceId: address.deviceId)?.fingerprint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PreKey
|
||||||
|
extension ClientMartinOMEMO: SignalPreKeyStoreProtocol {
|
||||||
|
func currentPreKeyId() -> UInt32 {
|
||||||
|
let id = OMEMOPreKey.currentIdFor(account: credentials.bareJid)
|
||||||
|
return UInt32(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPreKey(withId: UInt32) -> Data? {
|
||||||
|
OMEMOPreKey.keyFor(account: credentials.bareJid, id: withId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func storePreKey(_ data: Data, withId: UInt32) -> Bool {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOPreKey(
|
||||||
|
account: credentials.bareJid,
|
||||||
|
id: Int(withId),
|
||||||
|
key: data,
|
||||||
|
markForDeletion: false
|
||||||
|
)
|
||||||
|
.insert(db)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error pre key store: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsPreKey(withId: UInt32) -> Bool {
|
||||||
|
OMEMOPreKey.contains(account: credentials.bareJid, id: withId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletePreKey(withId: UInt32) -> Bool {
|
||||||
|
OMEMOPreKey.markForDeletion(account: credentials.bareJid, id: withId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flushDeletedPreKeys() -> Bool {
|
||||||
|
OMEMOPreKey.deleteMarked(account: credentials.bareJid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func preKeysWipe() {
|
||||||
|
OMEMOPreKey.wipe(account: credentials.bareJid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SignedPreKey
|
||||||
|
extension ClientMartinOMEMO: SignalSignedPreKeyStoreProtocol {
|
||||||
|
func countSignedPreKeys() -> Int {
|
||||||
|
OMEMOSignedPreKey.countsFor(account: credentials.bareJid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSignedPreKey(withId: UInt32) -> Data? {
|
||||||
|
OMEMOSignedPreKey.keyFor(account: credentials.bareJid, id: withId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeSignedPreKey(_ data: Data, withId: UInt32) -> Bool {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOSignedPreKey(
|
||||||
|
account: credentials.bareJid,
|
||||||
|
id: Int(withId),
|
||||||
|
key: data
|
||||||
|
).insert(db)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error storing signed pre key: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSignedPreKey(withId: UInt32) -> Bool {
|
||||||
|
OMEMOSignedPreKey.keyFor(account: credentials.bareJid, id: withId) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSignedPreKey(withId: UInt32) -> Bool {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOSignedPreKey
|
||||||
|
.filter(Column("account") == credentials.bareJid)
|
||||||
|
.filter(Column("id") == withId)
|
||||||
|
.deleteAll(db)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error deleting signed pre key: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wipeSignedPreKeys() {
|
||||||
|
OMEMOSignedPreKey.wipe(account: credentials.bareJid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SenderKey
|
||||||
|
extension ClientMartinOMEMO: SignalSenderKeyStoreProtocol {
|
||||||
|
func storeSenderKey(_: Data, address _: MartinOMEMO.SignalAddress?, groupId _: String?) -> Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSenderKey(forAddress _: MartinOMEMO.SignalAddress?, groupId _: String?) -> Data? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,11 +2,10 @@ import Foundation
|
||||||
import GRDB
|
import GRDB
|
||||||
import Martin
|
import Martin
|
||||||
|
|
||||||
extension Database: Martin.RosterManager {
|
final class ClientMartinRosterManager: Martin.RosterManager {
|
||||||
func clear(for context: Martin.Context) {
|
func clear(for context: Martin.Context) {
|
||||||
print("Clearing roster for context: \(context)")
|
|
||||||
do {
|
do {
|
||||||
try _db.write { db in
|
try Database.shared.dbQueue.write { db in
|
||||||
try Roster
|
try Roster
|
||||||
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
||||||
.deleteAll(db)
|
.deleteAll(db)
|
||||||
|
@ -22,7 +21,7 @@ extension Database: Martin.RosterManager {
|
||||||
|
|
||||||
func items(for context: Martin.Context) -> [any Martin.RosterItemProtocol] {
|
func items(for context: Martin.Context) -> [any Martin.RosterItemProtocol] {
|
||||||
do {
|
do {
|
||||||
let rosters: [Roster] = try _db.read { db in
|
let rosters: [Roster] = try Database.shared.dbQueue.read { db in
|
||||||
try Roster.filter(Column("bareJid") == context.userBareJid.stringValue).fetchAll(db)
|
try Roster.filter(Column("bareJid") == context.userBareJid.stringValue).fetchAll(db)
|
||||||
}
|
}
|
||||||
return rosters.map { roster in
|
return rosters.map { roster in
|
||||||
|
@ -43,7 +42,7 @@ extension Database: Martin.RosterManager {
|
||||||
|
|
||||||
func item(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? {
|
func item(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? {
|
||||||
do {
|
do {
|
||||||
let roster: Roster? = try _db.read { db in
|
let roster: Roster? = try Database.shared.dbQueue.read { db in
|
||||||
try Roster
|
try Roster
|
||||||
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
||||||
.filter(Column("contactBareJid") == jid.stringValue)
|
.filter(Column("contactBareJid") == jid.stringValue)
|
||||||
|
@ -80,7 +79,7 @@ extension Database: Martin.RosterManager {
|
||||||
annotations: annotations
|
annotations: annotations
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
try _db.write { db in
|
try Database.shared.dbQueue.write { db in
|
||||||
try roster.save(db)
|
try roster.save(db)
|
||||||
}
|
}
|
||||||
return RosterItemBase(jid: jid, name: name, subscription: subscription, groups: groups, ask: ask, annotations: annotations)
|
return RosterItemBase(jid: jid, name: name, subscription: subscription, groups: groups, ask: ask, annotations: annotations)
|
||||||
|
@ -92,14 +91,14 @@ extension Database: Martin.RosterManager {
|
||||||
|
|
||||||
func deleteItem(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? {
|
func deleteItem(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? {
|
||||||
do {
|
do {
|
||||||
let roster: Roster? = try _db.read { db in
|
let roster: Roster? = try Database.shared.dbQueue.read { db in
|
||||||
try Roster
|
try Roster
|
||||||
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
||||||
.filter(Column("contactBareJid") == jid.stringValue)
|
.filter(Column("contactBareJid") == jid.stringValue)
|
||||||
.fetchOne(db)
|
.fetchOne(db)
|
||||||
}
|
}
|
||||||
if let roster {
|
if let roster {
|
||||||
_ = try _db.write { db in
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
try roster.delete(db)
|
try roster.delete(db)
|
||||||
}
|
}
|
||||||
return RosterItemBase(
|
return RosterItemBase(
|
||||||
|
@ -121,7 +120,7 @@ extension Database: Martin.RosterManager {
|
||||||
|
|
||||||
func version(for context: Martin.Context) -> String? {
|
func version(for context: Martin.Context) -> String? {
|
||||||
do {
|
do {
|
||||||
let version: RosterVersion? = try _db.read { db in
|
let version: RosterVersion? = try Database.shared.dbQueue.read { db in
|
||||||
try RosterVersion
|
try RosterVersion
|
||||||
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
.filter(Column("bareJid") == context.userBareJid.stringValue)
|
||||||
.fetchOne(db)
|
.fetchOne(db)
|
||||||
|
@ -136,7 +135,7 @@ extension Database: Martin.RosterManager {
|
||||||
func set(version: String?, for context: Martin.Context) {
|
func set(version: String?, for context: Martin.Context) {
|
||||||
guard let version else { return }
|
guard let version else { return }
|
||||||
do {
|
do {
|
||||||
try _db.write { db in
|
try Database.shared.dbQueue.write { db in
|
||||||
let rosterVersion = RosterVersion(
|
let rosterVersion = RosterVersion(
|
||||||
bareJid: context.userBareJid.stringValue,
|
bareJid: context.userBareJid.stringValue,
|
||||||
version: version
|
version: version
|
235
ConversationsClassic/AppData/Client/Client.swift
Normal file
235
ConversationsClassic/AppData/Client/Client.swift
Normal file
|
@ -0,0 +1,235 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import Martin
|
||||||
|
import MartinOMEMO
|
||||||
|
|
||||||
|
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 discoManager: ClientMartinDiscoManager
|
||||||
|
private var messageManager: ClientMartinMessagesManager
|
||||||
|
private var carbonsManager: ClientMartinCarbonsManager
|
||||||
|
private var mamManager: ClientMartinMAM
|
||||||
|
|
||||||
|
init(credentials: Credentials) {
|
||||||
|
self.credentials = credentials
|
||||||
|
state = credentials.isActive ? .enabled(.disconnected) : .disabled
|
||||||
|
connection = Self.prepareConnection(credentials, rosterManager, chatsManager)
|
||||||
|
discoManager = ClientMartinDiscoManager(connection)
|
||||||
|
messageManager = ClientMartinMessagesManager(connection)
|
||||||
|
carbonsManager = ClientMartinCarbonsManager(connection)
|
||||||
|
mamManager = ClientMartinMAM(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
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg = chat.createMessage(text: message.body ?? "??", id: message.id)
|
||||||
|
msg.oob = message.oobUrl
|
||||||
|
msg = try await encryptMessage(msg)
|
||||||
|
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 AppError.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 fetchArchiveMessages(for roster: Roster, query: RSM.Query) async throws -> Martin.MessageArchiveManagementModule.QueryResult {
|
||||||
|
if !discoManager.features.map({ $0.xep }).contains("XEP-0313") {
|
||||||
|
throw AppError.featureNotSupported
|
||||||
|
}
|
||||||
|
let module = connection.module(MessageArchiveManagementModule.self)
|
||||||
|
return try await module.queryItems(componentJid: JID(roster.bareJid), with: JID(roster.contactBareJid), queryId: UUID().uuidString, rsm: query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Client {
|
||||||
|
func encryptMessage(_ message: Martin.Message) async throws -> Martin.Message {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
connection.module(.omemo).encode(message: message, completionHandler: { result in
|
||||||
|
switch result {
|
||||||
|
case .successMessage(let encodedMessage, _):
|
||||||
|
// guard connection.isConnected else {
|
||||||
|
// continuation.resume(returning: message)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
continuation.resume(returning: encodedMessage)
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
var errorMessage = NSLocalizedString("It was not possible to send encrypted message due to encryption error", comment: "message encryption failure")
|
||||||
|
switch error {
|
||||||
|
case .noSession:
|
||||||
|
errorMessage = NSLocalizedString("There is no trusted device to send message to", comment: "message encryption failure")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continuation.resume(throwing: XMPPError.unexpected_request(errorMessage))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// OMEMO
|
||||||
|
let omemoManager = ClientMartinOMEMO(credentials)
|
||||||
|
let (signalStorage, signalContext) = omemoManager.signal
|
||||||
|
client.modulesManager.register(OMEMOModule(aesGCMEngine: AESGSMEngine.shared, signalContext: signalContext, signalStorage: signalStorage))
|
||||||
|
|
||||||
|
// group chats
|
||||||
|
// client.modulesManager.register(MucModule(roomManager: manager))
|
||||||
|
|
||||||
|
// channels
|
||||||
|
// client.modulesManager.register(MixModule(channelManager: manager))
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
}
|
34
ConversationsClassic/AppData/Model/Chat.swift
Normal file
34
ConversationsClassic/AppData/Model/Chat.swift
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
|
||||||
|
enum ConversationType: Int, Codable, DatabaseValueConvertible {
|
||||||
|
case chat = 0
|
||||||
|
case room = 1
|
||||||
|
case channel = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Chat: DBStorable {
|
||||||
|
static let databaseTableName = "chats"
|
||||||
|
|
||||||
|
var id: String
|
||||||
|
var account: String
|
||||||
|
var participant: String
|
||||||
|
var type: ConversationType
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Chat: Equatable {}
|
||||||
|
|
||||||
|
extension Chat {
|
||||||
|
func fetchRoster() async throws -> Roster {
|
||||||
|
try await Database.shared.dbQueue.read { db in
|
||||||
|
guard
|
||||||
|
let roster = try Roster
|
||||||
|
.filter(Column("bareJid") == account && Column("contactBareJid") == participant)
|
||||||
|
.fetchOne(db)
|
||||||
|
else {
|
||||||
|
throw AppError.rosterNotFound
|
||||||
|
}
|
||||||
|
return roster
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
ConversationsClassic/AppData/Model/Credentials.swift
Normal file
32
ConversationsClassic/AppData/Model/Credentials.swift
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct Credentials: DBStorable, Hashable {
|
||||||
|
static let databaseTableName = "credentials"
|
||||||
|
|
||||||
|
var id: String { bareJid }
|
||||||
|
var bareJid: String
|
||||||
|
var pass: String
|
||||||
|
var isActive: Bool
|
||||||
|
|
||||||
|
func save() async throws {
|
||||||
|
let db = Database.shared.dbQueue
|
||||||
|
try await db.write { db in
|
||||||
|
try self.save(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete() async throws {
|
||||||
|
let db = Database.shared.dbQueue
|
||||||
|
_ = try await db.write { db in
|
||||||
|
try self.delete(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Credentials: UniversalInputSelectionElement {
|
||||||
|
var text: String? { bareJid }
|
||||||
|
var icon: Image? { nil }
|
||||||
|
}
|
53
ConversationsClassic/AppData/Model/GalleryItem.swift
Normal file
53
ConversationsClassic/AppData/Model/GalleryItem.swift
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import Photos
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum GalleryMediaType {
|
||||||
|
case video
|
||||||
|
case photo
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GalleryItem: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let type: GalleryMediaType
|
||||||
|
var thumbnail: Image?
|
||||||
|
var duration: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension GalleryItem {
|
||||||
|
static func fetchAll() async -> [GalleryItem] {
|
||||||
|
await Task {
|
||||||
|
let fetchOptions = PHFetchOptions()
|
||||||
|
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||||||
|
let assets = PHAsset.fetchAssets(with: fetchOptions)
|
||||||
|
var tmpGalleryItems: [GalleryItem] = []
|
||||||
|
assets.enumerateObjects { asset, _, _ in
|
||||||
|
if asset.mediaType == .image {
|
||||||
|
let item = GalleryItem(id: asset.localIdentifier, type: .photo, thumbnail: nil, duration: nil)
|
||||||
|
tmpGalleryItems.append(item)
|
||||||
|
}
|
||||||
|
if asset.mediaType == .video {
|
||||||
|
let item = GalleryItem(id: asset.localIdentifier, type: .video, thumbnail: nil, duration: asset.duration.minAndSec)
|
||||||
|
tmpGalleryItems.append(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tmpGalleryItems
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func fetchThumbnail() async throws {
|
||||||
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else { return }
|
||||||
|
let size = CGSize(width: Const.galleryGridSize, height: Const.galleryGridSize)
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .photo:
|
||||||
|
let originalImage = try await PHImageManager.default().getPhoto(for: asset)
|
||||||
|
let cropped = try await originalImage.scaleAndCropImage(size)
|
||||||
|
thumbnail = Image(uiImage: cropped)
|
||||||
|
|
||||||
|
case .video:
|
||||||
|
let avAsset = try await PHImageManager.default().getVideo(for: asset)
|
||||||
|
let cropped = try await avAsset.generateVideoThumbnail(size)
|
||||||
|
thumbnail = Image(uiImage: cropped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
76
ConversationsClassic/AppData/Model/Message+OMEMO.swift
Normal file
76
ConversationsClassic/AppData/Model/Message+OMEMO.swift
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import Martin
|
||||||
|
|
||||||
|
extension Message {
|
||||||
|
static func map(_ martinMessage: Martin.Message, context: Martin.Context?) -> Message? {
|
||||||
|
// Check that the message type is supported
|
||||||
|
var martinMessage = martinMessage
|
||||||
|
let chatTypes: [StanzaType] = [.chat, .groupchat]
|
||||||
|
guard let mType = martinMessage.type, chatTypes.contains(mType) else {
|
||||||
|
#if DEBUG
|
||||||
|
print("Unsupported martinMessage type: \(martinMessage.type?.rawValue ?? "nil")")
|
||||||
|
#endif
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type
|
||||||
|
let type = MessageType(rawValue: martinMessage.type?.rawValue ?? "") ?? .chat
|
||||||
|
|
||||||
|
// Content type
|
||||||
|
var contentType: MessageContentType = .text
|
||||||
|
if let oob = martinMessage.oob {
|
||||||
|
contentType = .attachment(.init(
|
||||||
|
type: oob.attachmentType,
|
||||||
|
localName: nil,
|
||||||
|
thumbnailName: nil,
|
||||||
|
remotePath: oob
|
||||||
|
))
|
||||||
|
} else if martinMessage.hints.contains(.noStore) {
|
||||||
|
contentType = .typing
|
||||||
|
// skip for now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to recognize if message is omemo-encoded and decode it
|
||||||
|
if let omemo = context?.module(.omemo) {
|
||||||
|
let decodingResult = omemo.decode(message: martinMessage)
|
||||||
|
switch decodingResult {
|
||||||
|
case .successMessage(let decodedMessage, _):
|
||||||
|
martinMessage = decodedMessage
|
||||||
|
// print(decodedMessage, fingerprint)
|
||||||
|
|
||||||
|
case .successTransportKey:
|
||||||
|
break
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
logIt(.error, "Error decoding omemo message: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip for non-visible messages
|
||||||
|
if martinMessage.body == nil, martinMessage.oob == nil, martinMessage.type == .chat {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// From/To
|
||||||
|
let from = martinMessage.from?.bareJid.stringValue ?? ""
|
||||||
|
let to = martinMessage.to?.bareJid.stringValue
|
||||||
|
|
||||||
|
// Msg
|
||||||
|
let msg = Message(
|
||||||
|
id: martinMessage.id ?? UUID().uuidString,
|
||||||
|
type: type,
|
||||||
|
date: Date(),
|
||||||
|
contentType: contentType,
|
||||||
|
status: .sent,
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
body: martinMessage.body,
|
||||||
|
subject: martinMessage.subject,
|
||||||
|
thread: martinMessage.thread,
|
||||||
|
oobUrl: martinMessage.oob
|
||||||
|
)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
103
ConversationsClassic/AppData/Model/Message.swift
Normal file
103
ConversationsClassic/AppData/Model/Message.swift
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import Martin
|
||||||
|
|
||||||
|
enum MessageType: String, Codable, DatabaseValueConvertible {
|
||||||
|
case chat
|
||||||
|
case groupchat
|
||||||
|
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?
|
||||||
|
|
||||||
|
var localPath: URL? {
|
||||||
|
guard let attachmentLocalName = localName else { return nil }
|
||||||
|
return FolderWrapper.shared.fileFolder.appendingPathComponent(attachmentLocalName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var thumbnailPath: URL? {
|
||||||
|
guard let attachmentThumbnailName = thumbnailName else { return nil }
|
||||||
|
return FolderWrapper.shared.fileFolder.appendingPathComponent(attachmentThumbnailName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MessageContentType: Codable & Equatable, DatabaseValueConvertible {
|
||||||
|
case text
|
||||||
|
case typing
|
||||||
|
case invite
|
||||||
|
case attachment(Attachment)
|
||||||
|
|
||||||
|
var isAttachment: Bool {
|
||||||
|
if case .attachment = self {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MessageStatus: Int, Codable, DatabaseValueConvertible {
|
||||||
|
case pending
|
||||||
|
case sent
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Message: DBStorable, Equatable {
|
||||||
|
static let databaseTableName = "messages"
|
||||||
|
|
||||||
|
let id: String
|
||||||
|
var type: MessageType
|
||||||
|
var date: Date
|
||||||
|
var contentType: MessageContentType
|
||||||
|
var status: MessageStatus
|
||||||
|
|
||||||
|
var from: String
|
||||||
|
var to: String?
|
||||||
|
|
||||||
|
var body: String?
|
||||||
|
var subject: String?
|
||||||
|
var thread: String?
|
||||||
|
var oobUrl: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Message {
|
||||||
|
func save() async throws {
|
||||||
|
try await Database.shared.dbQueue.write { db in
|
||||||
|
try self.insert(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStatus(_ status: MessageStatus) async throws {
|
||||||
|
try await Database.shared.dbQueue.write { db in
|
||||||
|
var updatedMessage = self
|
||||||
|
updatedMessage.status = status
|
||||||
|
try updatedMessage.update(db, columns: ["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var blank: Message {
|
||||||
|
Message(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
type: .chat,
|
||||||
|
date: Date(),
|
||||||
|
contentType: .text,
|
||||||
|
status: .pending,
|
||||||
|
from: "",
|
||||||
|
to: nil,
|
||||||
|
body: nil,
|
||||||
|
subject: nil,
|
||||||
|
thread: nil,
|
||||||
|
oobUrl: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
314
ConversationsClassic/AppData/Model/OMEMO.swift
Normal file
314
ConversationsClassic/AppData/Model/OMEMO.swift
Normal file
|
@ -0,0 +1,314 @@
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import Martin
|
||||||
|
|
||||||
|
// MARK: - Session
|
||||||
|
struct OMEMOSession: DBStorable {
|
||||||
|
static let databaseTableName = "omemo_sessions"
|
||||||
|
|
||||||
|
let account: String
|
||||||
|
let name: String
|
||||||
|
let deviceId: Int
|
||||||
|
let key: String
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
"\(account)_\(name)_\(deviceId)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OMEMOSession {
|
||||||
|
static func keyFor(account: String, name: String, deviceId: Int32) -> String? {
|
||||||
|
do {
|
||||||
|
return try Database.shared.dbQueue.read { db in
|
||||||
|
try OMEMOSession
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.filter(Column("name") == name)
|
||||||
|
.filter(Column("deviceId") == deviceId)
|
||||||
|
.fetchOne(db)
|
||||||
|
}?.key
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func devicesIdsFor(account: String, name: String) -> [Int32] {
|
||||||
|
do {
|
||||||
|
return try Database.shared.dbQueue.read { db in
|
||||||
|
try OMEMOSession
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.filter(Column("name") == name)
|
||||||
|
.fetchAll(db)
|
||||||
|
.map(\.deviceId)
|
||||||
|
}.map { Int32($0) }
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func trustedDevicesIdsFor(account: String, name: String) -> [Int32] {
|
||||||
|
do {
|
||||||
|
let sql =
|
||||||
|
"""
|
||||||
|
SELECT s.device_id
|
||||||
|
FROM omemo_sessions s
|
||||||
|
LEFT JOIN omemo_identities i
|
||||||
|
ON s.account = i.account
|
||||||
|
AND s.name = i.name
|
||||||
|
AND s.device_id = i.device_id
|
||||||
|
WHERE s.account = :account
|
||||||
|
AND s.name = :name
|
||||||
|
AND ((i.status >= 0 AND i.status % 2 = 0) OR i.status IS NULL)
|
||||||
|
"""
|
||||||
|
|
||||||
|
let arguments: StatementArguments = ["account": account, "name": name]
|
||||||
|
|
||||||
|
return try Database.shared.dbQueue.read { db in
|
||||||
|
try Int32.fetchAll(db, sql: sql, arguments: arguments)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func wipe(account: String) {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOSession
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.deleteAll(db)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Failed to wipe OMEMO session: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Identity
|
||||||
|
struct OMEMOIdentity: DBStorable {
|
||||||
|
static let databaseTableName = "omemo_identities"
|
||||||
|
|
||||||
|
let account: String
|
||||||
|
let name: String
|
||||||
|
let deviceId: Int
|
||||||
|
let fingerprint: String
|
||||||
|
let key: Data
|
||||||
|
let own: Bool
|
||||||
|
let status: Int
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
"\(account)_\(name)_\(deviceId)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OMEMOIdentity {
|
||||||
|
static func wipe(account: String) {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOIdentity
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.deleteAll(db)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Failed to wipe OMEMO identity: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getFor(account: String, name: String, deviceId: Int32) -> OMEMOIdentity? {
|
||||||
|
do {
|
||||||
|
return try Database.shared.dbQueue.read { db in
|
||||||
|
try OMEMOIdentity
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.filter(Column("name") == name)
|
||||||
|
.filter(Column("deviceId") == deviceId)
|
||||||
|
.fetchOne(db)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func existsFor(account: String, name: String, fingerprint: String) -> Bool {
|
||||||
|
do {
|
||||||
|
return try Database.shared.dbQueue.read { db in
|
||||||
|
try OMEMOIdentity
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.filter(Column("name") == name)
|
||||||
|
.filter(Column("fingerprint") == fingerprint)
|
||||||
|
.fetchOne(db) != nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateStatus(_ status: Int) -> Bool {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOIdentity
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.filter(Column("name") == name)
|
||||||
|
.filter(Column("deviceId") == deviceId)
|
||||||
|
.updateAll(db, Column("status").set(to: status))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Failed to update OMEMO identity status: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getAllFor(account: String, name: String) -> [OMEMOIdentity] {
|
||||||
|
do {
|
||||||
|
return try Database.shared.dbQueue.read { db in
|
||||||
|
try OMEMOIdentity
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.filter(Column("name") == name)
|
||||||
|
.fetchAll(db)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PreKey
|
||||||
|
struct OMEMOPreKey: DBStorable {
|
||||||
|
static let databaseTableName = "omemo_pre_keys"
|
||||||
|
|
||||||
|
let account: String
|
||||||
|
let id: Int
|
||||||
|
let key: Data
|
||||||
|
let markForDeletion: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OMEMOPreKey {
|
||||||
|
static func wipe(account: String) {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOPreKey
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.deleteAll(db)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Failed to wipe OMEMO pre key: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func currentIdFor(account: String) -> Int {
|
||||||
|
do {
|
||||||
|
return try Database.shared.dbQueue.read { db in
|
||||||
|
try OMEMOPreKey
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.order(Column("id").desc)
|
||||||
|
.fetchOne(db)
|
||||||
|
.map(\.id)
|
||||||
|
} ?? 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func keyFor(account: String, id: UInt32) -> Data? {
|
||||||
|
do {
|
||||||
|
return try Database.shared.dbQueue.read { db in
|
||||||
|
try OMEMOPreKey
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.filter(Column("id") == id)
|
||||||
|
.fetchOne(db)
|
||||||
|
}?.key
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func contains(account: String, id: UInt32) -> Bool {
|
||||||
|
do {
|
||||||
|
return try Database.shared.dbQueue.read { db in
|
||||||
|
try OMEMOPreKey
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.filter(Column("id") == id)
|
||||||
|
.fetchOne(db) != nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func markForDeletion(account: String, id: UInt32) -> Bool {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOPreKey
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.filter(Column("id") == id)
|
||||||
|
.updateAll(db, Column("markForDeletion").set(to: true))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Failed to mark OMEMO pre key for deletion: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deleteMarked(account: String) -> Bool {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOPreKey
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.filter(Column("markForDeletion") == true)
|
||||||
|
.deleteAll(db)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Failed to delete marked OMEMO pre keys: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SignedPreKey
|
||||||
|
struct OMEMOSignedPreKey: DBStorable {
|
||||||
|
static let databaseTableName = "omemo_signed_pre_keys"
|
||||||
|
|
||||||
|
let account: String
|
||||||
|
let id: Int
|
||||||
|
let key: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OMEMOSignedPreKey {
|
||||||
|
static func wipe(account: String) {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try OMEMOSignedPreKey
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.deleteAll(db)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Failed to wipe OMEMO signed pre key: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func countsFor(account: String) -> Int {
|
||||||
|
do {
|
||||||
|
return try Database.shared.dbQueue.read { db in
|
||||||
|
try OMEMOSignedPreKey
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.fetchCount(db)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func keyFor(account: String, id: UInt32) -> Data? {
|
||||||
|
do {
|
||||||
|
return try Database.shared.dbQueue.read { db in
|
||||||
|
try OMEMOSignedPreKey
|
||||||
|
.filter(Column("account") == account)
|
||||||
|
.filter(Column("id") == id)
|
||||||
|
.fetchOne(db)
|
||||||
|
}?.key
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -60,3 +60,45 @@ extension Roster: Equatable {
|
||||||
lhs.bareJid == rhs.bareJid && lhs.contactBareJid == rhs.contactBareJid
|
lhs.bareJid == rhs.bareJid && lhs.contactBareJid == rhs.contactBareJid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Roster {
|
||||||
|
mutating func setLocallyDeleted(_ value: Bool) async throws {
|
||||||
|
locallyDeleted = value
|
||||||
|
let copy = self
|
||||||
|
try? await Database.shared.dbQueue.write { db in
|
||||||
|
try copy.save(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Roster {
|
||||||
|
static var allDeletedLocally: [Roster] {
|
||||||
|
get async {
|
||||||
|
do {
|
||||||
|
let rosters = try await Database.shared.dbQueue.read { db in
|
||||||
|
try Roster
|
||||||
|
.filter(Column("locallyDeleted") == true)
|
||||||
|
.fetchAll(db)
|
||||||
|
}
|
||||||
|
return rosters
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var allActive: [Roster] {
|
||||||
|
get async {
|
||||||
|
do {
|
||||||
|
let rosters = try await Database.shared.dbQueue.read { db in
|
||||||
|
try Roster
|
||||||
|
.filter(Column("locallyDeleted") == false)
|
||||||
|
.fetchAll(db)
|
||||||
|
}
|
||||||
|
return rosters
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
ConversationsClassic/AppData/Model/ServerFeature.swift
Normal file
21
ConversationsClassic/AppData/Model/ServerFeature.swift
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ServerFeature: Identifiable & Codable {
|
||||||
|
let xep: String
|
||||||
|
let name: String
|
||||||
|
let xmppId: String?
|
||||||
|
let description: String?
|
||||||
|
|
||||||
|
var id: String { xep }
|
||||||
|
|
||||||
|
static var allFeatures: [ServerFeature] {
|
||||||
|
guard
|
||||||
|
let url = Bundle.main.url(forResource: "server_features", withExtension: "plist"),
|
||||||
|
let data = try? Data(contentsOf: url),
|
||||||
|
let loaded = try? PropertyListDecoder().decode([ServerFeature].self, from: data)
|
||||||
|
else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
}
|
49
ConversationsClassic/AppData/Model/Settings.swift
Normal file
49
ConversationsClassic/AppData/Model/Settings.swift
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
|
||||||
|
struct Settings: DBStorable {
|
||||||
|
static let databaseTableName = "settings"
|
||||||
|
|
||||||
|
let bareJid: String
|
||||||
|
var omemoRegId: Int
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
bareJid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Settings {
|
||||||
|
static func getFor(_ bareJid: String) -> Settings? {
|
||||||
|
do {
|
||||||
|
return try Database.shared.dbQueue.read { db in
|
||||||
|
let settings = try Settings.filter(Column("bareJid") == bareJid).fetchOne(db)
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Settings not exists for \(bareJid)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wipeOmemoRegId() {
|
||||||
|
do {
|
||||||
|
_ = try Database.shared.dbQueue.write { db in
|
||||||
|
try Settings
|
||||||
|
.filter(Column("bareJid") == bareJid)
|
||||||
|
.updateAll(db, Column("omemoRegId").set(to: 0))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Failed to wipe omemoRegId for \(bareJid)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
do {
|
||||||
|
try Database.shared.dbQueue.write { db in
|
||||||
|
try self.insert(db)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Failed to save settings for \(bareJid)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
47
ConversationsClassic/AppData/Services/AESGSMEngine.swift
Normal file
47
ConversationsClassic/AppData/Services/AESGSMEngine.swift
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import MartinOMEMO
|
||||||
|
|
||||||
|
final class AESGSMEngine: AES_GCM_Engine {
|
||||||
|
static let shared = AESGSMEngine()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func encrypt(iv: Data, key: Data, message: Data, output: UnsafeMutablePointer<Data>?, tag: UnsafeMutablePointer<Data>?) -> Bool {
|
||||||
|
do {
|
||||||
|
let symmetricKey = SymmetricKey(data: key)
|
||||||
|
let sealedBox = try AES.GCM.seal(message, using: symmetricKey, nonce: AES.GCM.Nonce(data: iv))
|
||||||
|
|
||||||
|
if let output = output {
|
||||||
|
output.pointee = sealedBox.ciphertext
|
||||||
|
}
|
||||||
|
if let tag = tag {
|
||||||
|
tag.pointee = sealedBox.tag
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
print("Encryption error: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrypt(iv: Data, key: Data, encoded: Data, auth tag: Data?, output: UnsafeMutablePointer<Data>?) -> Bool {
|
||||||
|
do {
|
||||||
|
let symmetricKey = SymmetricKey(data: key)
|
||||||
|
guard let tag = tag else {
|
||||||
|
print("Tag is missing")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let sealedBox = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: iv), ciphertext: encoded, tag: tag)
|
||||||
|
let decryptedData = try AES.GCM.open(sealedBox, using: symmetricKey)
|
||||||
|
|
||||||
|
if let output = output {
|
||||||
|
output.pointee = decryptedData
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
print("Decryption error: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,12 +12,11 @@ extension Database {
|
||||||
|
|
||||||
// 1st migration - basic tables
|
// 1st migration - basic tables
|
||||||
migrator.registerMigration("Add basic tables") { db in
|
migrator.registerMigration("Add basic tables") { db in
|
||||||
// accounts
|
// credentials
|
||||||
try db.create(table: "accounts", options: [.ifNotExists]) { table in
|
try db.create(table: "credentials", options: [.ifNotExists]) { table in
|
||||||
table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace)
|
table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace)
|
||||||
table.column("pass", .text).notNull()
|
table.column("pass", .text).notNull()
|
||||||
table.column("isActive", .boolean).notNull().defaults(to: true)
|
table.column("isActive", .boolean).notNull().defaults(to: true)
|
||||||
table.column("isTemp", .boolean).notNull().defaults(to: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// rosters
|
// rosters
|
||||||
|
@ -48,40 +47,68 @@ extension Database {
|
||||||
try db.create(table: "messages", options: [.ifNotExists]) { table in
|
try db.create(table: "messages", options: [.ifNotExists]) { table in
|
||||||
table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace)
|
table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace)
|
||||||
table.column("type", .text).notNull()
|
table.column("type", .text).notNull()
|
||||||
|
table.column("date", .datetime).notNull()
|
||||||
table.column("contentType", .text).notNull()
|
table.column("contentType", .text).notNull()
|
||||||
|
table.column("status", .integer).notNull()
|
||||||
table.column("from", .text).notNull()
|
table.column("from", .text).notNull()
|
||||||
table.column("to", .text)
|
table.column("to", .text)
|
||||||
table.column("body", .text)
|
table.column("body", .text)
|
||||||
table.column("subject", .text)
|
table.column("subject", .text)
|
||||||
table.column("thread", .text)
|
table.column("thread", .text)
|
||||||
table.column("oobUrl", .text)
|
table.column("oobUrl", .text)
|
||||||
table.column("date", .datetime).notNull()
|
|
||||||
table.column("pending", .boolean).notNull()
|
|
||||||
table.column("sentError", .boolean).notNull()
|
|
||||||
table.column("attachmentType", .integer)
|
|
||||||
table.column("attachmentLocalName", .text)
|
|
||||||
table.column("attachmentRemotePath", .text)
|
|
||||||
table.column("attachmentThumbnailName", .text)
|
|
||||||
table.column("attachmentDownloadFailed", .boolean).notNull().defaults(to: false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2nd migration - channels/rooms
|
migrator.registerMigration("Add OMEMO tables") { db in
|
||||||
migrator.registerMigration("Add channels/rooms") { db in
|
try db.create(table: "omemo_sessions", options: [.ifNotExists]) { table in
|
||||||
// rooms
|
|
||||||
try db.create(table: "rooms", options: [.ifNotExists]) { table in
|
|
||||||
table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace)
|
|
||||||
table.column("account", .text).notNull()
|
table.column("account", .text).notNull()
|
||||||
table.column("nickname", .text).notNull()
|
table.column("name", .text).notNull()
|
||||||
table.column("password", .text)
|
table.column("deviceId", .integer).notNull()
|
||||||
|
table.column("key", .text).notNull()
|
||||||
|
table.primaryKey(["account", "name", "deviceId"], onConflict: .replace)
|
||||||
}
|
}
|
||||||
|
|
||||||
// channels
|
try db.create(table: "omemo_identities", options: [.ifNotExists]) { table in
|
||||||
// try db.create(table: "channels", options: [.ifNotExists]) { table in
|
table.column("account", .text).notNull()
|
||||||
// table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace)
|
table.column("name", .text).notNull()
|
||||||
// table.column("account", .text).notNull()
|
table.column("deviceId", .integer).notNull()
|
||||||
// table.column("channel", .text).notNull()
|
table.column("fingerprint", .text).notNull()
|
||||||
|
table.column("key", .blob).notNull()
|
||||||
|
table.column("own", .integer).notNull()
|
||||||
|
table.column("status", .integer).notNull()
|
||||||
|
table.primaryKey(["account", "name", "fingerprint"], onConflict: .ignore)
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: "omemo_pre_keys", options: [.ifNotExists]) { table in
|
||||||
|
table.column("account", .text).notNull()
|
||||||
|
table.column("id", .integer).notNull()
|
||||||
|
table.column("key", .blob).notNull()
|
||||||
|
table.column("markForDeletion", .boolean).notNull().defaults(to: false)
|
||||||
|
table.primaryKey(["account", "id"], onConflict: .replace)
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: "omemo_signed_pre_keys", options: [.ifNotExists]) { table in
|
||||||
|
table.column("account", .text).notNull()
|
||||||
|
table.column("id", .integer).notNull()
|
||||||
|
table.column("key", .blob).notNull()
|
||||||
|
table.primaryKey(["account", "id"], onConflict: .replace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// try db.alter(table: "chats") { table in
|
||||||
|
// table.add(column: "encryption", .text)
|
||||||
// }
|
// }
|
||||||
|
//
|
||||||
|
// try db.alter(table: "messages") { table in
|
||||||
|
// table.add(column: "encryption", .integer)
|
||||||
|
// table.add(column: "fingerprint", .text)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
migrator.registerMigration("Add settings table") { db in
|
||||||
|
try db.create(table: "settings", options: [.ifNotExists]) { table in
|
||||||
|
table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace)
|
||||||
|
table.column("omemoRegId", .integer).notNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// return migrator
|
// return migrator
|
|
@ -9,7 +9,7 @@ typealias DBStorable = Codable & FetchableRecord & Identifiable & PersistableRec
|
||||||
// MARK: - Database init
|
// MARK: - Database init
|
||||||
final class Database {
|
final class Database {
|
||||||
static let shared = Database()
|
static let shared = Database()
|
||||||
let _db: DatabaseQueue
|
let dbQueue: DatabaseQueue
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
do {
|
do {
|
||||||
|
@ -24,7 +24,7 @@ final class Database {
|
||||||
|
|
||||||
// Open or create the database
|
// Open or create the database
|
||||||
let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
|
let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
|
||||||
_db = try DatabaseQueue(path: databaseURL.path, configuration: Database.config)
|
dbQueue = try DatabaseQueue(path: databaseURL.path, configuration: Database.config)
|
||||||
|
|
||||||
// Some debug info
|
// Some debug info
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
@ -32,7 +32,7 @@ final class Database {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Apply migrations
|
// Apply migrations
|
||||||
try Database.migrator.migrate(_db)
|
try Database.migrator.migrate(dbQueue)
|
||||||
} catch {
|
} catch {
|
||||||
fatalError("Database initialization failed: \(error)")
|
fatalError("Database initialization failed: \(error)")
|
||||||
}
|
}
|
|
@ -4,33 +4,6 @@ import SwiftUI
|
||||||
|
|
||||||
let isConsoleLoggingEnabled = false
|
let isConsoleLoggingEnabled = false
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
let prefixLength = 400
|
|
||||||
func loggerMiddleware() -> Middleware<AppState, AppAction> {
|
|
||||||
{ state, action in
|
|
||||||
let timeStr = dateFormatter.string(from: Date())
|
|
||||||
var actionStr = "\(action)"
|
|
||||||
actionStr = String(actionStr.prefix(prefixLength)) + " ..."
|
|
||||||
var stateStr = "\(state)"
|
|
||||||
stateStr = String(stateStr.prefix(prefixLength)) + " ..."
|
|
||||||
|
|
||||||
let str = "\(timeStr) \u{EA86} \(actionStr)\n\(timeStr) \u{F129} \(stateStr)\n"
|
|
||||||
|
|
||||||
print(str)
|
|
||||||
if isConsoleLoggingEnabled {
|
|
||||||
NSLog(str)
|
|
||||||
}
|
|
||||||
return Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
func loggerMiddleware() -> Middleware<AppState, AppAction> {
|
|
||||||
{ _, _ in
|
|
||||||
Empty().eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
enum LogLevels: String {
|
enum LogLevels: String {
|
||||||
case info = "\u{F449}"
|
case info = "\u{F449}"
|
||||||
case warning = "\u{F071}"
|
case warning = "\u{F071}"
|
37
ConversationsClassic/AppData/Services/NetworkMonitor.swift
Normal file
37
ConversationsClassic/AppData/Services/NetworkMonitor.swift
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import Combine
|
||||||
|
import Network
|
||||||
|
|
||||||
|
extension NWPathMonitor {
|
||||||
|
func paths() -> AsyncStream<NWPath> {
|
||||||
|
AsyncStream { continuation in
|
||||||
|
pathUpdateHandler = { path in
|
||||||
|
continuation.yield(path)
|
||||||
|
}
|
||||||
|
continuation.onTermination = { [weak self] _ in
|
||||||
|
self?.cancel()
|
||||||
|
}
|
||||||
|
start(queue: DispatchQueue(label: "NSPathMonitor.paths"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final actor NetworkMonitor: ObservableObject {
|
||||||
|
static let shared = NetworkMonitor()
|
||||||
|
|
||||||
|
@Published private(set) var isOnline: Bool = false
|
||||||
|
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
Task(priority: .background) {
|
||||||
|
await startMonitoring()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startMonitoring() async {
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
for await path in monitor.paths() {
|
||||||
|
isOnline = path.status == .satisfied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
353
ConversationsClassic/AppData/Store/AttachmentsStore.swift
Normal file
353
ConversationsClassic/AppData/Store/AttachmentsStore.swift
Normal file
|
@ -0,0 +1,353 @@
|
||||||
|
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 = FolderWrapper.shared.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 = FolderWrapper.shared.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 = FolderWrapper.shared.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 = FolderWrapper.shared.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 != .error }
|
||||||
|
.filter { self?.processing.contains($0.id) == false }
|
||||||
|
.filter { $0.contentType.isAttachment }
|
||||||
|
for message in forProcessing {
|
||||||
|
if case .attachment(let attachment) = message.contentType {
|
||||||
|
let localPath = attachment.localPath
|
||||||
|
if localPath != nil, attachment.remotePath == nil {
|
||||||
|
// Uploading
|
||||||
|
self?.processing.insert(message.id)
|
||||||
|
Task {
|
||||||
|
await self?.uploadAttachment(message)
|
||||||
|
}
|
||||||
|
} else if localPath == nil, attachment.remotePath != nil {
|
||||||
|
// Downloading
|
||||||
|
self?.processing.insert(message.id)
|
||||||
|
Task {
|
||||||
|
await self?.downloadAttachment(message)
|
||||||
|
}
|
||||||
|
} else if localPath != nil, attachment.remotePath != nil, attachment.thumbnailName == nil, attachment.type == .image {
|
||||||
|
// Generate thumbnail
|
||||||
|
self?.processing.insert(message.id)
|
||||||
|
Task {
|
||||||
|
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 AppError.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 = FolderWrapper.shared.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 = FolderWrapper.shared.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()
|
||||||
|
}
|
||||||
|
}
|
188
ConversationsClassic/AppData/Store/ClientsStore.swift
Normal file
188
ConversationsClassic/AppData/Store/ClientsStore.swift
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Login/Connections
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manage Rosters
|
||||||
|
extension ClientsStore {
|
||||||
|
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 = await Roster.allDeletedLocally
|
||||||
|
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 {
|
||||||
|
func addRosterForNewChatIfNeeded(_ chat: Chat) async throws {
|
||||||
|
let exists = try? await chat.fetchRoster()
|
||||||
|
if exists == nil {
|
||||||
|
guard let client = clients.first(where: { $0.credentials.bareJid == chat.account }) else {
|
||||||
|
throw AppError.clientNotFound
|
||||||
|
}
|
||||||
|
try await addRoster(client.credentials, contactJID: chat.participant, name: nil, groups: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Produce stores for conversation
|
||||||
|
extension ClientsStore {
|
||||||
|
func conversationStores(for roster: Roster) async throws -> (MessagesStore, AttachmentsStore) {
|
||||||
|
while !ready {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let client = clients.first(where: { $0.credentials.bareJid == roster.bareJid }) else {
|
||||||
|
throw AppError.clientNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
let conversationStore = MessagesStore(roster: roster, client: client)
|
||||||
|
let attachmentsStore = AttachmentsStore(roster: roster, client: client)
|
||||||
|
return (conversationStore, attachmentsStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func conversationStores(for chat: Chat) async throws -> (MessagesStore, AttachmentsStore) {
|
||||||
|
while !ready {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let client = clients.first(where: { $0.credentials.bareJid == chat.account }) else {
|
||||||
|
throw AppError.clientNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
let roster = try await chat.fetchRoster()
|
||||||
|
let conversationStore = MessagesStore(roster: roster, client: client)
|
||||||
|
let attachmentsStore = AttachmentsStore(roster: roster, client: client)
|
||||||
|
return (conversationStore, attachmentsStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subscriptions
|
||||||
|
private 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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
149
ConversationsClassic/AppData/Store/MessagesStore.swift
Normal file
149
ConversationsClassic/AppData/Store/MessagesStore.swift
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import Martin
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MessagesStore: ObservableObject {
|
||||||
|
@Published private(set) var messages: [Message] = []
|
||||||
|
@Published var replyText = ""
|
||||||
|
|
||||||
|
private(set) var roster: Roster
|
||||||
|
private let client: Client
|
||||||
|
|
||||||
|
private var messagesCancellable: AnyCancellable?
|
||||||
|
private let archiver = ArchiveMessageFetcher()
|
||||||
|
|
||||||
|
init(roster: Roster, client: Client) {
|
||||||
|
self.client = client
|
||||||
|
self.roster = roster
|
||||||
|
subscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Send message
|
||||||
|
extension MessagesStore {
|
||||||
|
func sendMessage(_ message: String) {
|
||||||
|
Task {
|
||||||
|
var msg = Message.blank
|
||||||
|
msg.from = roster.bareJid
|
||||||
|
msg.to = roster.contactBareJid
|
||||||
|
msg.body = message
|
||||||
|
|
||||||
|
// store as pending on db, and send
|
||||||
|
do {
|
||||||
|
try await msg.save()
|
||||||
|
try await client.sendMessage(msg)
|
||||||
|
try await msg.setStatus(.sent)
|
||||||
|
} catch {
|
||||||
|
try? await msg.setStatus(.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendContact(_ jidStr: String) {
|
||||||
|
sendMessage("contact:\(jidStr)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendLocation(_ lat: Double, _ lon: Double) {
|
||||||
|
sendMessage("geo:\(lat),\(lon)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subscriptions
|
||||||
|
private extension MessagesStore {
|
||||||
|
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
|
||||||
|
guard let self else { return }
|
||||||
|
self.messages = messages
|
||||||
|
Task {
|
||||||
|
await self.archiver.initialFetch(messages, self.roster, self.client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Archived messages
|
||||||
|
extension MessagesStore {
|
||||||
|
func scrolledMessage(_ messageId: String) {
|
||||||
|
if messageId == messages.last?.id {
|
||||||
|
Task {
|
||||||
|
await archiver.fetchBackward(roster, client)
|
||||||
|
}
|
||||||
|
} else if messageId == messages.first?.id {
|
||||||
|
Task {
|
||||||
|
await archiver.fetchForward(roster, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor ArchiveMessageFetcher {
|
||||||
|
private var initFetchStarted = false
|
||||||
|
private var forwardRsm: RSM.Query?
|
||||||
|
private var backwardRsm: RSM.Query?
|
||||||
|
private var fetchInProgress = false
|
||||||
|
|
||||||
|
func initialFetch(_ messages: [Message], _ roster: Roster, _ client: Client) async {
|
||||||
|
if initFetchStarted { return }
|
||||||
|
initFetchStarted = true
|
||||||
|
fetchInProgress = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
if let firstExistId = messages.first?.id {
|
||||||
|
let result = try await client.fetchArchiveMessages(for: roster, query: .init(before: firstExistId, max: Const.mamRequestPageSize))
|
||||||
|
result.complete ? forwardRsm = nil : (forwardRsm = .init(after: result.rsm?.last, max: Const.mamRequestPageSize))
|
||||||
|
result.complete ? backwardRsm = nil : (backwardRsm = .init(before: result.rsm?.first, max: Const.mamRequestPageSize))
|
||||||
|
} else {
|
||||||
|
let result = try await client.fetchArchiveMessages(for: roster, query: .init(lastItems: Const.mamRequestPageSize))
|
||||||
|
result.complete ? backwardRsm = nil : (backwardRsm = .init(before: result.rsm?.first, max: Const.mamRequestPageSize))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logIt(.error, "Error requesting archived messages: \(error)")
|
||||||
|
initFetchStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchInProgress = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchForward(_ roster: Roster, _ client: Client) async {
|
||||||
|
while !initFetchStarted {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
guard let rsm = forwardRsm else { return }
|
||||||
|
if fetchInProgress { return }
|
||||||
|
|
||||||
|
fetchInProgress = true
|
||||||
|
Task {
|
||||||
|
let result = try await client.fetchArchiveMessages(for: roster, query: rsm)
|
||||||
|
result.complete ? (forwardRsm = nil) : (forwardRsm = .init(after: result.rsm?.last, max: Const.mamRequestPageSize))
|
||||||
|
fetchInProgress = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchBackward(_ roster: Roster, _ client: Client) async {
|
||||||
|
while !initFetchStarted {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
guard let rsm = backwardRsm else { return }
|
||||||
|
if fetchInProgress { return }
|
||||||
|
|
||||||
|
fetchInProgress = true
|
||||||
|
Task {
|
||||||
|
let result = try await client.fetchArchiveMessages(for: roster, query: rsm)
|
||||||
|
result.complete ? (backwardRsm = nil) : (backwardRsm = .init(before: result.rsm?.first, max: Const.mamRequestPageSize))
|
||||||
|
fetchInProgress = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,31 +1,21 @@
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
let appState = AppState()
|
|
||||||
let store = AppStore(
|
|
||||||
initialState: appState,
|
|
||||||
reducer: AppState.reducer,
|
|
||||||
middlewares: [
|
|
||||||
loggerMiddleware(),
|
|
||||||
StartMiddleware.shared.middleware,
|
|
||||||
DatabaseMiddleware.shared.middleware,
|
|
||||||
AccountsMiddleware.shared.middleware,
|
|
||||||
XMPPMiddleware.shared.middleware,
|
|
||||||
RostersMiddleware.shared.middleware,
|
|
||||||
ChatsMiddleware.shared.middleware,
|
|
||||||
ArchivedMessagesMiddleware.shared.middleware,
|
|
||||||
ConversationMiddleware.shared.middleware,
|
|
||||||
SharingMiddleware.shared.middleware,
|
|
||||||
FileMiddleware.shared.middleware
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
|
@MainActor
|
||||||
struct ConversationsClassic: App {
|
struct ConversationsClassic: App {
|
||||||
|
private let clientsStore = ClientsStore.shared
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// There's a bug on iOS 17 where sheet may not load with large title, even if modifiers are set, which causes some tests to fail
|
||||||
|
// https://stackoverflow.com/questions/77253122/swiftui-navigationstack-title-loads-inline-instead-of-large-when-sheet-is-pres
|
||||||
|
UINavigationBar.appearance().prefersLargeTitles = true
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
BaseNavigationView()
|
RootView()
|
||||||
.environmentObject(store)
|
.environmentObject(clientsStore)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
16
ConversationsClassic/Helpers/AVAsset+Thumbnail.swift
Normal file
16
ConversationsClassic/Helpers/AVAsset+Thumbnail.swift
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import AVFoundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension AVAsset {
|
||||||
|
func generateVideoThumbnail(_ size: CGSize) async throws -> UIImage {
|
||||||
|
try await Task {
|
||||||
|
let assetImgGenerate = AVAssetImageGenerator(asset: self)
|
||||||
|
assetImgGenerate.appliesPreferredTrackTransform = true
|
||||||
|
let time = CMTimeMakeWithSeconds(Float64(1), preferredTimescale: 600)
|
||||||
|
let cgImage = try assetImgGenerate.copyCGImage(at: time, actualTime: nil)
|
||||||
|
let image = UIImage(cgImage: cgImage)
|
||||||
|
let result = try await image.scaleAndCropImage(size)
|
||||||
|
return result
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension Bool {
|
|
||||||
var intValue: Int {
|
|
||||||
self ? 1 : 0
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,15 +2,6 @@ import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
enum Const {
|
enum Const {
|
||||||
// // Network
|
|
||||||
// #if DEBUG
|
|
||||||
// static let baseUrl = "staging.some.com/api"
|
|
||||||
// #else
|
|
||||||
// static let baseUrl = "prod.some.com/api"
|
|
||||||
// #endif
|
|
||||||
// static let requestTimeout = 15.0
|
|
||||||
// static let networkLogging = true
|
|
||||||
|
|
||||||
// App
|
// App
|
||||||
static var appVersion: String {
|
static var appVersion: String {
|
||||||
let info = Bundle.main.infoDictionary
|
let info = Bundle.main.infoDictionary
|
||||||
|
@ -32,9 +23,6 @@ enum Const {
|
||||||
// Limit for video for sharing
|
// Limit for video for sharing
|
||||||
static let videoDurationLimit = 60.0
|
static let videoDurationLimit = 60.0
|
||||||
|
|
||||||
// Upload/download file folder
|
|
||||||
static let fileFolder = "Downloads"
|
|
||||||
|
|
||||||
// Grid size for gallery preview (3 in a row)
|
// Grid size for gallery preview (3 in a row)
|
||||||
static let galleryGridSize = UIScreen.main.bounds.width / 3
|
static let galleryGridSize = UIScreen.main.bounds.width / 3
|
||||||
|
|
||||||
|
@ -44,10 +32,20 @@ enum Const {
|
||||||
// Size for attachment preview
|
// Size for attachment preview
|
||||||
static let attachmentPreviewSize = UIScreen.main.bounds.width * 0.5
|
static let attachmentPreviewSize = UIScreen.main.bounds.width * 0.5
|
||||||
|
|
||||||
// Lenght in days for MAM request
|
// MAM request page size
|
||||||
static let mamRequestDaysLength = 30
|
static let mamRequestPageSize = 50
|
||||||
|
}
|
||||||
// Limits for messages pagination
|
|
||||||
static let messagesPageMin = 20
|
final class FolderWrapper {
|
||||||
static let messagesPageMax = 100
|
static let shared = FolderWrapper()
|
||||||
|
let fileFolder: URL
|
||||||
|
private init() {
|
||||||
|
// swiftlint:disable:next force_unwrapping
|
||||||
|
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||||
|
let subdirectoryURL = documentsURL.appendingPathComponent("Downloads")
|
||||||
|
if !FileManager.default.fileExists(atPath: subdirectoryURL.path) {
|
||||||
|
try? FileManager.default.createDirectory(at: subdirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
}
|
||||||
|
fileFolder = subdirectoryURL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
43
ConversationsClassic/Helpers/PHImageManager+Fetch.swift
Normal file
43
ConversationsClassic/Helpers/PHImageManager+Fetch.swift
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import Photos
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension PHImageManager {
|
||||||
|
func getPhoto(for asset: PHAsset) async throws -> UIImage {
|
||||||
|
let options = PHImageRequestOptions()
|
||||||
|
options.version = .original
|
||||||
|
options.isSynchronous = true
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
requestImage(
|
||||||
|
for: asset,
|
||||||
|
targetSize: PHImageManagerMaximumSize,
|
||||||
|
contentMode: .aspectFill,
|
||||||
|
options: options
|
||||||
|
) { image, _ in
|
||||||
|
if let image {
|
||||||
|
continuation.resume(returning: image)
|
||||||
|
} else {
|
||||||
|
continuation.resume(throwing: AppError.imageNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVideo(for asset: PHAsset) async throws -> AVAsset {
|
||||||
|
let options = PHVideoRequestOptions()
|
||||||
|
options.version = .original
|
||||||
|
options.deliveryMode = .highQualityFormat
|
||||||
|
options.isNetworkAccessAllowed = true
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
requestAVAsset(
|
||||||
|
forVideo: asset,
|
||||||
|
options: options
|
||||||
|
) { avAsset, _, _ in
|
||||||
|
if let avAsset {
|
||||||
|
continuation.resume(returning: avAsset)
|
||||||
|
} else {
|
||||||
|
continuation.resume(throwing: AppError.videoNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,26 +0,0 @@
|
||||||
import Foundation
|
|
||||||
|
|
||||||
class ThreadSafeSet<T: Hashable> {
|
|
||||||
private var set: Set<T> = []
|
|
||||||
private let accessQueue = DispatchQueue(label: "com.example.ThreadSafeSet")
|
|
||||||
|
|
||||||
func insert(_ newElement: T) {
|
|
||||||
_ = accessQueue.sync {
|
|
||||||
set.insert(newElement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func remove(_ element: T) {
|
|
||||||
_ = accessQueue.sync {
|
|
||||||
set.remove(element)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var elements: Set<T> {
|
|
||||||
accessQueue.sync { set }
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(_ element: T) -> Bool {
|
|
||||||
accessQueue.sync { set.contains(element) }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -37,12 +37,12 @@ extension String {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
var attachmentType: MessageAttachmentType {
|
var attachmentType: AttachmentType {
|
||||||
let ext = (self as NSString).pathExtension.lowercased()
|
let ext = (self as NSString).pathExtension.lowercased()
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case "mov", "mp4", "avi":
|
case "mov", "mp4", "avi":
|
||||||
return .movie
|
return .video
|
||||||
|
|
||||||
case "jpg", "png", "gif":
|
case "jpg", "png", "gif":
|
||||||
return .image
|
return .image
|
||||||
|
|
30
ConversationsClassic/Helpers/UIImage+Crop.swift
Normal file
30
ConversationsClassic/Helpers/UIImage+Crop.swift
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIImage {
|
||||||
|
func scaleAndCropImage(_ size: CGSize) async throws -> UIImage {
|
||||||
|
try await Task {
|
||||||
|
let aspect = self.size.width / self.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)
|
||||||
|
self.draw(in: CGRect(x: (size.width - newWidth) / 2, y: (size.height - newHeight) / 2, width: newWidth, height: newHeight))
|
||||||
|
let newImage = UIGraphicsGetImageFromCurrentImageContext()
|
||||||
|
UIGraphicsEndImageContext()
|
||||||
|
if let newImage = newImage {
|
||||||
|
return newImage
|
||||||
|
} else {
|
||||||
|
throw NSError(domain: "UIImage", code: -900, userInfo: nil)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
}
|
15
ConversationsClassic/Helpers/View+Flip.swift
Normal file
15
ConversationsClassic/Helpers/View+Flip.swift
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FlipView: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.rotationEffect(.radians(Double.pi))
|
||||||
|
.scaleEffect(x: -1, y: 1, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func flip() -> some View {
|
||||||
|
modifier(FlipView())
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,64 +5,50 @@
|
||||||
"Global.cancel" = "Cancel";
|
"Global.cancel" = "Cancel";
|
||||||
"Global.save" = "Save";
|
"Global.save" = "Save";
|
||||||
"Global.Error.title" = "Error";
|
"Global.Error.title" = "Error";
|
||||||
"Global.Error.genericText" = "Something went wrong";
|
|
||||||
"Global.Error.genericDbError" = "Database error";
|
|
||||||
|
|
||||||
// MARK: Onboar screen
|
// MARK: Welcome screen
|
||||||
"Start.subtitle" = "Free and secure messaging and calls between any existed messengers";
|
"Start.subtitle" = "Free and secure messaging and calls between any existed messengers";
|
||||||
"Start.Btn.login" = "Enter with JID";
|
"Start.Btn.login" = "Enter with JID";
|
||||||
"Start.Btn.register" = "New Account";
|
"Start.Btn.register" = "New Account";
|
||||||
|
|
||||||
|
// MARK: Login
|
||||||
"Login.title" = "Let\'s go!";
|
"Login.title" = "Let\'s go!";
|
||||||
"Login.subtitle" = "Enter your JID, it should looks like email address";
|
"Login.subtitle" = "Enter your JID, it should looks like email address";
|
||||||
"Login.Hint.jid" = "user@domain.im";
|
"Login.Hint.jid" = "user@domain.im";
|
||||||
"Login.Hint.password" = "password";
|
"Login.Hint.password" = "password";
|
||||||
"Login.btn" = "Continue";
|
"Login.btn" = "Continue";
|
||||||
"Login.Error.wrongPassword" = "Wrong password or JID";
|
"Login.error" = "Check internet connection, and make sure that JID and password are correct";
|
||||||
"Login.Error.noServer" = "Server not exists";
|
|
||||||
"Login.Error.serverError" = "Server error. Check internet connection";
|
// MARK: Tabs
|
||||||
|
"Tabs.Name.contacts" = "Contacts";
|
||||||
|
"Tabs.Name.conversations" = "Chats";
|
||||||
|
"Tabs.Name.settings" = "Settings";
|
||||||
|
|
||||||
// MARK: Contacts screen
|
// MARK: Contacts screen
|
||||||
"Contacts.title" = "Contacts";
|
"Contacts.title" = "Contacts";
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: Add contact/channel screen
|
||||||
|
"Contacts.Add.title" = "Add Contact";
|
||||||
|
"Contacts.Add.explanation" = "Contact or group/channel name are usually JID in format name@domain.ltd (like email)";
|
||||||
|
"Contacts.Add.serverError" = "Contact adding dailed. Server returned error";
|
||||||
|
"Contacts.deleteContact" = "Delete contact";
|
||||||
|
"Contacts.Delete.deleteFromDevice" = "Delete from device";
|
||||||
|
"Contacts.Delete.deleteCompletely" = "Delete completely";
|
||||||
"Contacts.sendMessage" = "Send message";
|
"Contacts.sendMessage" = "Send message";
|
||||||
"Contacts.editContact" = "Edit contact";
|
"Contacts.editContact" = "Edit contact";
|
||||||
"Contacts.selectContact" = "Select contact";
|
"Contacts.selectContact" = "Select contact";
|
||||||
"Contacts.deleteContact" = "Delete contact";
|
|
||||||
"Contacts.Add.title" = "Add Contact";
|
|
||||||
"Contacts.Add.explanation" = "Contact or group/channel name are usually JID in format name@domain.ltd (like email)";
|
|
||||||
"Contacts.Add.error" = "Contact not added. Server returns error.";
|
|
||||||
"Contacts.Delete.title" = "Delete contact";
|
|
||||||
"Contacts.Delete.message" = "You can delete contact from this device (contact will be available on other devices), or delete it completely";
|
"Contacts.Delete.message" = "You can delete contact from this device (contact will be available on other devices), or delete it completely";
|
||||||
"Contacts.Delete.deleteFromDevice" = "Delete from device";
|
|
||||||
"Contacts.Delete.deleteCompletely" = "Delete completely";
|
|
||||||
"Contacts.Delete.error" = "Contact not deleted. Server returns error.";
|
"Contacts.Delete.error" = "Contact not deleted. Server returns error.";
|
||||||
|
|
||||||
|
// MARK: Chats list screen
|
||||||
// MARK: Chats screen
|
"ChatsList.title" = "Chats";
|
||||||
"Chats.title" = "Chats";
|
|
||||||
|
|
||||||
"Chat.title" = "Chat";
|
|
||||||
"Chat.textfieldPrompt" = "Type a message";
|
|
||||||
|
|
||||||
"Chats.Create.Main.title" = "Create";
|
"Chats.Create.Main.title" = "Create";
|
||||||
"Chats.Create.Main.createGroup" = "Create public group";
|
|
||||||
"Chats.Create.Main.createPrivateGroup" = "Create private group";
|
|
||||||
"Chats.Create.Main.findGroup" = "Find public group";
|
|
||||||
|
|
||||||
// MARK: Accounts add screen
|
// MARK: Conversation
|
||||||
"Accounts.Add.or" = "or";
|
"Conversation.title" = "Conversation";
|
||||||
"Accounts.Add.Exist.title" = "Add existing\naccount";
|
"Conversation.startError" = "Error occurs in conversation starting";
|
||||||
"Accounts.Add.Exist.Prompt.jid" = "Enter your XMPP ID";
|
"Chat.textfieldPrompt" = "Type a message";
|
||||||
"Accounts.Add.Exist.Prompt.password" = "Enter password";
|
|
||||||
"Accounts.Add.Exist.Hint.jid" = "user@domain.im";
|
|
||||||
"Accounts.Add.Exist.Hint.password" = "password";
|
|
||||||
"Accounts.Add.Exist.Btn.link" = "create a new one";
|
|
||||||
"Accounts.Add.Exist.Btn.main" = "Continue";
|
|
||||||
"Accounts.Add.Exist.loginError" = "Wrong login or password";
|
|
||||||
|
|
||||||
// MARK: Server connecting indicator
|
|
||||||
"ServerConnectingIndicator.State.connecting" = "Connecting to server";
|
|
||||||
"ServerConnectingIndicator.State.connected" = "Connected";
|
|
||||||
"ServerConnectingIndicator.State.error" = "Server unreachable. Check internet connection and server name";
|
|
||||||
|
|
||||||
// MARK: Attachments
|
// MARK: Attachments
|
||||||
"Attachment.Prompt.main" = "Select attachment";
|
"Attachment.Prompt.main" = "Select attachment";
|
||||||
|
@ -74,3 +60,16 @@
|
||||||
"Attachment.Send.location" = "Send location";
|
"Attachment.Send.location" = "Send location";
|
||||||
"Attachment.Send.contact" = "Send contact";
|
"Attachment.Send.contact" = "Send contact";
|
||||||
"Attachment.Downloading.retry" = "Retry";
|
"Attachment.Downloading.retry" = "Retry";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//"Chats.Create.Main.createGroup" = "Create public group";
|
||||||
|
//"Chats.Create.Main.createPrivateGroup" = "Create private group";
|
||||||
|
//"Chats.Create.Main.findGroup" = "Find public group";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
import Martin
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct BaseNavigationView: View {
|
|
||||||
@EnvironmentObject var store: AppStore
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
Group {
|
|
||||||
switch store.state.currentFlow {
|
|
||||||
case .start:
|
|
||||||
switch store.state.startState.navigation {
|
|
||||||
case .startScreen:
|
|
||||||
StartScreen()
|
|
||||||
|
|
||||||
case .welcomeScreen:
|
|
||||||
WelcomeScreen()
|
|
||||||
}
|
|
||||||
|
|
||||||
case .accounts:
|
|
||||||
switch store.state.accountsState.navigation {
|
|
||||||
case .addAccount:
|
|
||||||
AddAccountScreen()
|
|
||||||
}
|
|
||||||
|
|
||||||
case .chats:
|
|
||||||
ChatsListScreen()
|
|
||||||
|
|
||||||
case .contacts:
|
|
||||||
ContactsScreen()
|
|
||||||
|
|
||||||
case .settings:
|
|
||||||
SettingsScreen()
|
|
||||||
|
|
||||||
case .conversation:
|
|
||||||
ConversationScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,8 +2,9 @@ import Combine
|
||||||
import Martin
|
import Martin
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct AddAccountScreen: View {
|
struct LoginScreen: View {
|
||||||
@EnvironmentObject var store: AppStore
|
@Environment(\.router) var router
|
||||||
|
@EnvironmentObject var clientsStore: ClientsStore
|
||||||
|
|
||||||
enum Field {
|
enum Field {
|
||||||
case userJid
|
case userJid
|
||||||
|
@ -11,9 +12,6 @@ struct AddAccountScreen: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@FocusState private var focus: Field?
|
@FocusState private var focus: Field?
|
||||||
@State private var errorMsg: String = ""
|
|
||||||
@State private var isShowingAlert = false
|
|
||||||
@State private var isShowingLoader = false
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@State private var jidStr: String = "nartest1@conversations.im"
|
@State private var jidStr: String = "nartest1@conversations.im"
|
||||||
|
@ -77,8 +75,12 @@ struct AddAccountScreen: View {
|
||||||
)
|
)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
isShowingLoader = true
|
router.showModal {
|
||||||
store.dispatch(.accountsAction(.tryAddAccountWithCredentials(login: jidStr, password: pass)))
|
LoadingScreen()
|
||||||
|
}
|
||||||
|
Task(priority: .background) {
|
||||||
|
await tryLogin()
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(L10n.Login.btn)
|
Text(L10n.Login.btn)
|
||||||
}
|
}
|
||||||
|
@ -86,8 +88,7 @@ struct AddAccountScreen: View {
|
||||||
.disabled(!loginInputValid)
|
.disabled(!loginInputValid)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
store.dispatch(.startAction(.goTo(.welcomeScreen)))
|
router.dismissScreen()
|
||||||
store.dispatch(.changeFlow(.start))
|
|
||||||
} label: {
|
} label: {
|
||||||
Text("\(Image(systemName: "chevron.left")) \(L10n.Global.back)")
|
Text("\(Image(systemName: "chevron.left")) \(L10n.Global.back)")
|
||||||
.foregroundColor(.Material.Elements.active)
|
.foregroundColor(.Material.Elements.active)
|
||||||
|
@ -97,26 +98,27 @@ struct AddAccountScreen: View {
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
}
|
}
|
||||||
.loadingIndicator(isShowingLoader)
|
|
||||||
.alert(isPresented: $isShowingAlert) {
|
|
||||||
Alert(
|
|
||||||
title: Text(L10n.Global.Error.title),
|
|
||||||
message: Text(errorMsg),
|
|
||||||
dismissButton: .default(Text(L10n.Global.ok)) {
|
|
||||||
store.dispatch(.accountsAction(.addAccountError(jid: jidStr, reason: nil)))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onChange(of: store.state.accountsState.addAccountError) { err in
|
|
||||||
if let err {
|
|
||||||
isShowingLoader = false
|
|
||||||
isShowingAlert = true
|
|
||||||
errorMsg = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var loginInputValid: Bool {
|
private var loginInputValid: Bool {
|
||||||
!jidStr.isEmpty && !pass.isEmpty && UniversalInputCollection.Validators.isEmail(jidStr)
|
!jidStr.isEmpty && !pass.isEmpty && UniversalInputCollection.Validators.isEmail(jidStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func tryLogin() async {
|
||||||
|
defer {
|
||||||
|
router.dismissModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await clientsStore.tryLogin(jidStr, pass)
|
||||||
|
} catch {
|
||||||
|
router.showAlert(
|
||||||
|
.alert,
|
||||||
|
title: L10n.Global.Error.title,
|
||||||
|
subtitle: L10n.Login.error
|
||||||
|
) {
|
||||||
|
Button(L10n.Global.ok, role: .cancel) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,13 +1,13 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RegistrationScreen: View {
|
struct RegistrationScreen: View {
|
||||||
// @EnvironmentObject var state: AppState
|
@Environment(\.router) var router
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.Material.Background.light
|
Color.Material.Background.light
|
||||||
Button {
|
Button {
|
||||||
// state.flow = .welcome
|
router.dismissScreen()
|
||||||
} label: {
|
} label: {
|
||||||
VStack {
|
VStack {
|
||||||
Text("Not yet implemented")
|
Text("Not yet implemented")
|
|
@ -1,9 +1,9 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct WelcomeScreen: View {
|
struct WelcomeScreen: View {
|
||||||
@EnvironmentObject var store: AppStore
|
@Environment(\.router) var router
|
||||||
|
|
||||||
public var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// background
|
// background
|
||||||
Color.Material.Background.light
|
Color.Material.Background.light
|
||||||
|
@ -33,14 +33,19 @@ struct WelcomeScreen: View {
|
||||||
// buttons
|
// buttons
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Button {
|
Button {
|
||||||
store.dispatch(.accountsAction(.goTo(.addAccount)))
|
router.showScreen(.push) { _ in
|
||||||
store.dispatch(.changeFlow(.accounts))
|
LoginScreen()
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(L10n.Start.Btn.login)
|
Text(L10n.Start.Btn.login)
|
||||||
}
|
}
|
||||||
.buttonStyle(SecondaryButtonStyle())
|
.buttonStyle(SecondaryButtonStyle())
|
||||||
Button {
|
Button {
|
||||||
// state.flow = .registration
|
router.showScreen(.push) { _ in
|
||||||
|
RegistrationScreen()
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(L10n.Start.Btn.register)
|
Text(L10n.Start.Btn.register)
|
||||||
}
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ChatsCreateMainScreen: View {
|
struct ChatsCreateScreenMain: View {
|
||||||
@Binding var isPresented: Bool
|
@Environment(\.router) var router
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
@ -16,7 +16,7 @@ struct ChatsCreateMainScreen: View {
|
||||||
leftButton: .init(
|
leftButton: .init(
|
||||||
image: Image(systemName: "xmark"),
|
image: Image(systemName: "xmark"),
|
||||||
action: {
|
action: {
|
||||||
isPresented = false
|
router.dismissScreen()
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
centerText: .init(text: L10n.Chats.Create.Main.title)
|
centerText: .init(text: L10n.Chats.Create.Main.title)
|
||||||
|
@ -24,6 +24,7 @@ struct ChatsCreateMainScreen: View {
|
||||||
|
|
||||||
// List
|
// List
|
||||||
List {
|
List {
|
||||||
|
Text("test")
|
||||||
// ChatsCreateRowButton(
|
// ChatsCreateRowButton(
|
||||||
// title: L10n.Chats.Create.Main.createGroup,
|
// title: L10n.Chats.Create.Main.createGroup,
|
||||||
// image: "person.2.fill",
|
// image: "person.2.fill",
|
||||||
|
@ -41,10 +42,10 @@ struct ChatsCreateMainScreen: View {
|
||||||
// )
|
// )
|
||||||
|
|
||||||
// for contacts list
|
// for contacts list
|
||||||
let rosters = store.state.rostersState.rosters.filter { !$0.locallyDeleted }
|
// let rosters = store.state.rostersState.rosters.filter { !$0.locallyDeleted }
|
||||||
if rosters.isEmpty {
|
// if rosters.isEmpty {
|
||||||
ChatsCreateRowSeparator()
|
// ChatsCreateRowSeparator()
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
|
|
||||||
|
@ -157,26 +158,26 @@ private struct ChatsCreateRowSeparator: View {
|
||||||
//
|
//
|
||||||
|
|
||||||
// Preview
|
// Preview
|
||||||
#if DEBUG
|
// #if DEBUG
|
||||||
struct ChatsCreateMainScreen_Previews: PreviewProvider {
|
// struct ChatsCreateMainScreen_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
// static var previews: some View {
|
||||||
ChatsCreateMainScreen(isPresented: .constant(true))
|
// ChatsCreateMainScreen(isPresented: .constant(true))
|
||||||
.environmentObject(pStore)
|
// .environmentObject(pStore)
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
static var pStore: AppStore {
|
// static var pStore: AppStore {
|
||||||
let state = pState
|
// let state = pState
|
||||||
return AppStore(initialState: state, reducer: AppState.reducer, middlewares: [])
|
// return AppStore(initialState: state, reducer: AppState.reducer, middlewares: [])
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
static var pState: AppState {
|
// static var pState: AppState {
|
||||||
var state = AppState()
|
// var state = AppState()
|
||||||
|
//
|
||||||
state.rostersState.rosters = [
|
// state.rostersState.rosters = [
|
||||||
.init(contactBareJid: "test@me.com", subscription: "both", ask: true, data: .init(groups: [], annotations: []))
|
// .init(contactBareJid: "test@me.com", subscription: "both", ask: true, data: .init(groups: [], annotations: []))
|
||||||
]
|
// ]
|
||||||
|
//
|
||||||
return state
|
// return state
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
#endif
|
// #endif
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue