mv-experiment (#1)

Reviewed-on: #1
Co-authored-by: fmodf <fmodf.ios@gmail.com>
Co-committed-by: fmodf <fmodf.ios@gmail.com>
This commit is contained in:
fmodf 2024-09-03 15:13:58 +00:00 committed by fmodf
parent 44ef6c25ba
commit b3b3b3aef7
131 changed files with 3640 additions and 4071 deletions

View file

@ -54,8 +54,12 @@ trailing_semicolon:
severity: error
type_name:
min_length: 3
severity: warning
min_length:
warninig: 3
error: 0
max_length:
warninig: 40
error: 80
identifier_name:
min_length: 3
@ -73,6 +77,7 @@ identifier_name:
- tz
- to
- db
- _db
# Disable rules from the default enabled set.
disabled_rules:
@ -112,4 +117,5 @@ unused_declaration:
# paths to ignore during linting. Takes precedence over `included`.
excluded:
- SomePathHere
- .swiftgen
- "**/Generated"

View file

@ -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])
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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])
}

View file

@ -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)
}

View file

@ -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])
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -1,5 +0,0 @@
enum StartAction: Codable {
case loadStoredAccounts
case goTo(StartNavigationState)
}

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -1,7 +0,0 @@
import Foundation
import GRDB
import Martin
extension Database: MartinsManager {}
// Check specific implementation in Database+Martin* files

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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()
}
}
}

View file

@ -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
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}

View file

@ -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)
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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 {}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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"
}

View file

@ -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 }
}

View file

@ -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
}
}
}

View file

@ -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)
}
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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 = [:]
}
}

View file

@ -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()
}
}

View file

@ -1,11 +0,0 @@
struct ChatsState: Stateable {
var chats: [Chat]
var currentChat: Chat?
}
// MARK: Init
extension ChatsState {
init() {
chats = []
}
}

View file

@ -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 = ""
}
}

View file

@ -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 = []
}
}

View file

@ -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 = []
}
}

View file

@ -1,15 +0,0 @@
enum StartNavigationState: Stateable {
case startScreen
case welcomeScreen
}
struct StartState: Stateable {
var navigation: StartNavigationState
}
// MARK: Init
extension StartState {
init() {
navigation = .startScreen
}
}

View file

@ -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)")
}
}
}
}

View 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
}

View file

@ -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)")
}
}
}
}
}

View file

@ -2,10 +2,10 @@ import Foundation
import GRDB
import Martin
extension Database: Martin.ChatManager {
final class ClientMartinChatsManager: Martin.ChatManager {
func chats(for context: Martin.Context) -> [any Martin.ChatProtocol] {
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)
}
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)? {
do {
let chat: Chat? = try _db.read { db in
let chat: Chat? = try Database.shared.dbQueue.read { db in
try Chat
.filter(Column("account") == context.userBareJid.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)? {
do {
let chat: Chat? = try _db.read { db in
let chat: Chat? = try Database.shared.dbQueue.read { db in
try Chat
.filter(Column("account") == context.userBareJid.stringValue)
.filter(Column("participant") == with.stringValue)
@ -53,7 +53,7 @@ extension Database: Martin.ChatManager {
participant: with.stringValue,
type: .chat
)
try _db.write { db in
try Database.shared.dbQueue.write { db in
try chat.save(db)
}
return Martin.ChatBase(context: context, jid: with)
@ -69,4 +69,7 @@ extension Database: Martin.ChatManager {
print("Closing chat: \(chat)")
return false
}
func initialize(context _: Martin.Context) {}
func deinitialize(context _: Martin.Context) {}
}

View 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)
}
}

View 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)
}
}
}
}
}
}
}

View file

@ -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)")
}
}
}
}
}

View 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
}
}

View file

@ -2,11 +2,10 @@ import Foundation
import GRDB
import Martin
extension Database: Martin.RosterManager {
final class ClientMartinRosterManager: Martin.RosterManager {
func clear(for context: Martin.Context) {
print("Clearing roster for context: \(context)")
do {
try _db.write { db in
try Database.shared.dbQueue.write { db in
try Roster
.filter(Column("bareJid") == context.userBareJid.stringValue)
.deleteAll(db)
@ -22,7 +21,7 @@ extension Database: Martin.RosterManager {
func items(for context: Martin.Context) -> [any Martin.RosterItemProtocol] {
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)
}
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)? {
do {
let roster: Roster? = try _db.read { db in
let roster: Roster? = try Database.shared.dbQueue.read { db in
try Roster
.filter(Column("bareJid") == context.userBareJid.stringValue)
.filter(Column("contactBareJid") == jid.stringValue)
@ -80,7 +79,7 @@ extension Database: Martin.RosterManager {
annotations: annotations
)
)
try _db.write { db in
try Database.shared.dbQueue.write { db in
try roster.save(db)
}
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)? {
do {
let roster: Roster? = try _db.read { db in
let roster: Roster? = try Database.shared.dbQueue.read { db in
try Roster
.filter(Column("bareJid") == context.userBareJid.stringValue)
.filter(Column("contactBareJid") == jid.stringValue)
.fetchOne(db)
}
if let roster {
_ = try _db.write { db in
_ = try Database.shared.dbQueue.write { db in
try roster.delete(db)
}
return RosterItemBase(
@ -121,7 +120,7 @@ extension Database: Martin.RosterManager {
func version(for context: Martin.Context) -> String? {
do {
let version: RosterVersion? = try _db.read { db in
let version: RosterVersion? = try Database.shared.dbQueue.read { db in
try RosterVersion
.filter(Column("bareJid") == context.userBareJid.stringValue)
.fetchOne(db)
@ -136,7 +135,7 @@ extension Database: Martin.RosterManager {
func set(version: String?, for context: Martin.Context) {
guard let version else { return }
do {
try _db.write { db in
try Database.shared.dbQueue.write { db in
let rosterVersion = RosterVersion(
bareJid: context.userBareJid.stringValue,
version: version

View 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
}
}

View 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
}
}
}

View 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 }
}

View 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)
}
}
}

View 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
}
}

View 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
)
}
}

View 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
}
}
}

View file

@ -60,3 +60,45 @@ extension Roster: Equatable {
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 []
}
}
}
}

View 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
}
}

View 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)")
}
}
}

View 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
}
}
}

View file

@ -12,12 +12,11 @@ extension Database {
// 1st migration - basic tables
migrator.registerMigration("Add basic tables") { db in
// accounts
try db.create(table: "accounts", options: [.ifNotExists]) { table in
// credentials
try db.create(table: "credentials", options: [.ifNotExists]) { table in
table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace)
table.column("pass", .text).notNull()
table.column("isActive", .boolean).notNull().defaults(to: true)
table.column("isTemp", .boolean).notNull().defaults(to: false)
}
// rosters
@ -48,40 +47,68 @@ extension Database {
try db.create(table: "messages", options: [.ifNotExists]) { table in
table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace)
table.column("type", .text).notNull()
table.column("date", .datetime).notNull()
table.column("contentType", .text).notNull()
table.column("status", .integer).notNull()
table.column("from", .text).notNull()
table.column("to", .text)
table.column("body", .text)
table.column("subject", .text)
table.column("thread", .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 channels/rooms") { db in
// rooms
try db.create(table: "rooms", options: [.ifNotExists]) { table in
table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace)
migrator.registerMigration("Add OMEMO tables") { db in
try db.create(table: "omemo_sessions", options: [.ifNotExists]) { table in
table.column("account", .text).notNull()
table.column("nickname", .text).notNull()
table.column("password", .text)
table.column("name", .text).notNull()
table.column("deviceId", .integer).notNull()
table.column("key", .text).notNull()
table.primaryKey(["account", "name", "deviceId"], onConflict: .replace)
}
// channels
// try db.create(table: "channels", options: [.ifNotExists]) { table in
// table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace)
// table.column("account", .text).notNull()
// table.column("channel", .text).notNull()
try db.create(table: "omemo_identities", options: [.ifNotExists]) { table in
table.column("account", .text).notNull()
table.column("name", .text).notNull()
table.column("deviceId", .integer).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

View file

@ -9,7 +9,7 @@ typealias DBStorable = Codable & FetchableRecord & Identifiable & PersistableRec
// MARK: - Database init
final class Database {
static let shared = Database()
let _db: DatabaseQueue
let dbQueue: DatabaseQueue
private init() {
do {
@ -24,7 +24,7 @@ final class Database {
// Open or create the database
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
#if DEBUG
@ -32,7 +32,7 @@ final class Database {
#endif
// Apply migrations
try Database.migrator.migrate(_db)
try Database.migrator.migrate(dbQueue)
} catch {
fatalError("Database initialization failed: \(error)")
}

View file

@ -4,33 +4,6 @@ import SwiftUI
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 {
case info = "\u{F449}"
case warning = "\u{F071}"

View 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
}
}
}

View 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()
}
}

View 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
}
}
}

View 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
}
}
}

View file

@ -1,31 +1,21 @@
import Combine
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
@MainActor
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 {
WindowGroup {
BaseNavigationView()
.environmentObject(store)
RootView()
.environmentObject(clientsStore)
}
}
}

View 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
}
}

View file

@ -1,7 +0,0 @@
import Foundation
extension Bool {
var intValue: Int {
self ? 1 : 0
}
}

View file

@ -2,15 +2,6 @@ import Foundation
import UIKit
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
static var appVersion: String {
let info = Bundle.main.infoDictionary
@ -32,9 +23,6 @@ enum Const {
// Limit for video for sharing
static let videoDurationLimit = 60.0
// Upload/download file folder
static let fileFolder = "Downloads"
// Grid size for gallery preview (3 in a row)
static let galleryGridSize = UIScreen.main.bounds.width / 3
@ -44,10 +32,20 @@ enum Const {
// Size for attachment preview
static let attachmentPreviewSize = UIScreen.main.bounds.width * 0.5
// Lenght in days for MAM request
static let mamRequestDaysLength = 30
// Limits for messages pagination
static let messagesPageMin = 20
static let messagesPageMax = 100
// MAM request page size
static let mamRequestPageSize = 50
}
final class FolderWrapper {
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
}
}

View 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)
}
}
}
}
}

View file

@ -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) }
}
}

View file

@ -37,12 +37,12 @@ extension String {
}
extension String {
var attachmentType: MessageAttachmentType {
var attachmentType: AttachmentType {
let ext = (self as NSString).pathExtension.lowercased()
switch ext {
case "mov", "mp4", "avi":
return .movie
return .video
case "jpg", "png", "gif":
return .image

View 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
}
}

View 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())
}
}

View file

@ -5,64 +5,50 @@
"Global.cancel" = "Cancel";
"Global.save" = "Save";
"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.Btn.login" = "Enter with JID";
"Start.Btn.register" = "New Account";
// MARK: Login
"Login.title" = "Let\'s go!";
"Login.subtitle" = "Enter your JID, it should looks like email address";
"Login.Hint.jid" = "user@domain.im";
"Login.Hint.password" = "password";
"Login.btn" = "Continue";
"Login.Error.wrongPassword" = "Wrong password or JID";
"Login.Error.noServer" = "Server not exists";
"Login.Error.serverError" = "Server error. Check internet connection";
"Login.error" = "Check internet connection, and make sure that JID and password are correct";
// MARK: Tabs
"Tabs.Name.contacts" = "Contacts";
"Tabs.Name.conversations" = "Chats";
"Tabs.Name.settings" = "Settings";
// MARK: Contacts screen
"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.editContact" = "Edit 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.deleteFromDevice" = "Delete from device";
"Contacts.Delete.deleteCompletely" = "Delete completely";
"Contacts.Delete.error" = "Contact not deleted. Server returns error.";
// MARK: Chats screen
"Chats.title" = "Chats";
"Chat.title" = "Chat";
"Chat.textfieldPrompt" = "Type a message";
// MARK: Chats list screen
"ChatsList.title" = "Chats";
"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
"Accounts.Add.or" = "or";
"Accounts.Add.Exist.title" = "Add existing\naccount";
"Accounts.Add.Exist.Prompt.jid" = "Enter your XMPP ID";
"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: Conversation
"Conversation.title" = "Conversation";
"Conversation.startError" = "Error occurs in conversation starting";
"Chat.textfieldPrompt" = "Type a message";
// MARK: Attachments
"Attachment.Prompt.main" = "Select attachment";
@ -74,3 +60,16 @@
"Attachment.Send.location" = "Send location";
"Attachment.Send.contact" = "Send contact";
"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";

View file

@ -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()
}
}
}
}

View file

@ -2,8 +2,9 @@ import Combine
import Martin
import SwiftUI
struct AddAccountScreen: View {
@EnvironmentObject var store: AppStore
struct LoginScreen: View {
@Environment(\.router) var router
@EnvironmentObject var clientsStore: ClientsStore
enum Field {
case userJid
@ -11,9 +12,6 @@ struct AddAccountScreen: View {
}
@FocusState private var focus: Field?
@State private var errorMsg: String = ""
@State private var isShowingAlert = false
@State private var isShowingLoader = false
#if DEBUG
@State private var jidStr: String = "nartest1@conversations.im"
@ -77,8 +75,12 @@ struct AddAccountScreen: View {
)
Button {
isShowingLoader = true
store.dispatch(.accountsAction(.tryAddAccountWithCredentials(login: jidStr, password: pass)))
router.showModal {
LoadingScreen()
}
Task(priority: .background) {
await tryLogin()
}
} label: {
Text(L10n.Login.btn)
}
@ -86,8 +88,7 @@ struct AddAccountScreen: View {
.disabled(!loginInputValid)
Button {
store.dispatch(.startAction(.goTo(.welcomeScreen)))
store.dispatch(.changeFlow(.start))
router.dismissScreen()
} label: {
Text("\(Image(systemName: "chevron.left")) \(L10n.Global.back)")
.foregroundColor(.Material.Elements.active)
@ -97,26 +98,27 @@ struct AddAccountScreen: View {
}
.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 {
!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) {}
}
}
}
}

View file

@ -1,13 +1,13 @@
import SwiftUI
struct RegistrationScreen: View {
// @EnvironmentObject var state: AppState
@Environment(\.router) var router
public var body: some View {
ZStack {
Color.Material.Background.light
Button {
// state.flow = .welcome
router.dismissScreen()
} label: {
VStack {
Text("Not yet implemented")

View file

@ -1,9 +1,9 @@
import SwiftUI
struct WelcomeScreen: View {
@EnvironmentObject var store: AppStore
@Environment(\.router) var router
public var body: some View {
var body: some View {
ZStack {
// background
Color.Material.Background.light
@ -33,14 +33,19 @@ struct WelcomeScreen: View {
// buttons
VStack(spacing: 16) {
Button {
store.dispatch(.accountsAction(.goTo(.addAccount)))
store.dispatch(.changeFlow(.accounts))
router.showScreen(.push) { _ in
LoginScreen()
.navigationBarBackButtonHidden(true)
}
} label: {
Text(L10n.Start.Btn.login)
}
.buttonStyle(SecondaryButtonStyle())
Button {
// state.flow = .registration
router.showScreen(.push) { _ in
RegistrationScreen()
.navigationBarBackButtonHidden(true)
}
} label: {
Text(L10n.Start.Btn.register)
}

View file

@ -1,7 +1,7 @@
import SwiftUI
struct ChatsCreateMainScreen: View {
@Binding var isPresented: Bool
struct ChatsCreateScreenMain: View {
@Environment(\.router) var router
var body: some View {
ZStack {
@ -16,7 +16,7 @@ struct ChatsCreateMainScreen: View {
leftButton: .init(
image: Image(systemName: "xmark"),
action: {
isPresented = false
router.dismissScreen()
}
),
centerText: .init(text: L10n.Chats.Create.Main.title)
@ -24,6 +24,7 @@ struct ChatsCreateMainScreen: View {
// List
List {
Text("test")
// ChatsCreateRowButton(
// title: L10n.Chats.Create.Main.createGroup,
// image: "person.2.fill",
@ -41,10 +42,10 @@ struct ChatsCreateMainScreen: View {
// )
// for contacts list
let rosters = store.state.rostersState.rosters.filter { !$0.locallyDeleted }
if rosters.isEmpty {
ChatsCreateRowSeparator()
}
// let rosters = store.state.rostersState.rosters.filter { !$0.locallyDeleted }
// if rosters.isEmpty {
// ChatsCreateRowSeparator()
// }
}
.listStyle(.plain)
@ -157,26 +158,26 @@ private struct ChatsCreateRowSeparator: View {
//
// Preview
#if DEBUG
struct ChatsCreateMainScreen_Previews: PreviewProvider {
static var previews: some View {
ChatsCreateMainScreen(isPresented: .constant(true))
.environmentObject(pStore)
}
static var pStore: AppStore {
let state = pState
return AppStore(initialState: state, reducer: AppState.reducer, middlewares: [])
}
static var pState: AppState {
var state = AppState()
state.rostersState.rosters = [
.init(contactBareJid: "test@me.com", subscription: "both", ask: true, data: .init(groups: [], annotations: []))
]
return state
}
}
#endif
// #if DEBUG
// struct ChatsCreateMainScreen_Previews: PreviewProvider {
// static var previews: some View {
// ChatsCreateMainScreen(isPresented: .constant(true))
// .environmentObject(pStore)
// }
//
// static var pStore: AppStore {
// let state = pState
// return AppStore(initialState: state, reducer: AppState.reducer, middlewares: [])
// }
//
// static var pState: AppState {
// var state = AppState()
//
// state.rostersState.rosters = [
// .init(contactBareJid: "test@me.com", subscription: "both", ask: true, data: .init(groups: [], annotations: []))
// ]
//
// return state
// }
// }
// #endif

Some files were not shown because too many files have changed in this diff Show more