mv-experiment #1

Merged
fmodf merged 88 commits from mv-experiment into develop 2024-09-03 15:13:59 +00:00
217 changed files with 5 additions and 15120 deletions
Showing only changes of commit 6e8af91439 - Show all commits

View file

@ -48,6 +48,11 @@ extension Message {
} }
} }
// skip for non-visible messages
if martinMessage.body == nil, martinMessage.oob == nil, martinMessage.type == .chat {
return nil
}
// From/To // From/To
let from = martinMessage.from?.bareJid.stringValue ?? "" let from = martinMessage.from?.bareJid.stringValue ?? ""
let to = martinMessage.to?.bareJid.stringValue let to = martinMessage.to?.bareJid.stringValue

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,72 +0,0 @@
import Foundation
import GRDB
import Martin
extension Database: Martin.ChatManager {
func chats(for context: Martin.Context) -> [any Martin.ChatProtocol] {
do {
let chats: [Chat] = try _db.read { db in
try Chat.filter(Column("account") == context.userBareJid.stringValue).fetchAll(db)
}
return chats.map { chat in
Martin.ChatBase(context: context, jid: BareJID(chat.participant))
}
} catch {
logIt(.error, "Error fetching chats: \(error.localizedDescription)")
return []
}
}
func chat(for context: Martin.Context, with: Martin.BareJID) -> (any Martin.ChatProtocol)? {
do {
let chat: Chat? = try _db.read { db in
try Chat
.filter(Column("account") == context.userBareJid.stringValue)
.filter(Column("participant") == with.stringValue)
.fetchOne(db)
}
if chat != nil {
return Martin.ChatBase(context: context, jid: with)
} else {
return nil
}
} catch {
logIt(.error, "Error fetching chat: \(error.localizedDescription)")
return nil
}
}
func createChat(for context: Martin.Context, with: Martin.BareJID) -> (any Martin.ChatProtocol)? {
do {
let chat: Chat? = try _db.read { db in
try Chat
.filter(Column("account") == context.userBareJid.stringValue)
.filter(Column("participant") == with.stringValue)
.fetchOne(db)
}
if chat != nil {
return Martin.ChatBase(context: context, jid: with)
} else {
let chat = Chat(
id: UUID().uuidString,
account: context.userBareJid.stringValue,
participant: with.stringValue,
type: .chat
)
try _db.write { db in
try chat.save(db)
}
return Martin.ChatBase(context: context, jid: with)
}
} catch {
logIt(.error, "Error fetching chat: \(error.localizedDescription)")
return nil
}
}
func close(chat: any Martin.ChatProtocol) -> Bool {
// not used in Martin library for now
print("Closing chat: \(chat)")
return 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,153 +0,0 @@
import Foundation
import GRDB
import Martin
extension Database: Martin.RosterManager {
func clear(for context: Martin.Context) {
print("Clearing roster for context: \(context)")
do {
try _db.write { db in
try Roster
.filter(Column("bareJid") == context.userBareJid.stringValue)
.deleteAll(db)
try RosterVersion
.filter(Column("bareJid") == context.userBareJid.stringValue)
.deleteAll(db)
}
} catch {
logIt(.error, "Error clearing roster: \(error.localizedDescription)")
}
}
func items(for context: Martin.Context) -> [any Martin.RosterItemProtocol] {
do {
let rosters: [Roster] = try _db.read { db in
try Roster.filter(Column("bareJid") == context.userBareJid.stringValue).fetchAll(db)
}
return rosters.map { roster in
RosterItemBase(
jid: JID(roster.bareJid),
name: roster.name,
subscription: RosterItemSubscription(rawValue: roster.subscription) ?? .none,
groups: roster.data.groups,
ask: roster.ask,
annotations: roster.data.annotations
)
}
} catch {
logIt(.error, "Error fetching roster items: \(error.localizedDescription)")
return []
}
}
func item(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? {
do {
let roster: Roster? = try _db.read { db in
try Roster
.filter(Column("bareJid") == context.userBareJid.stringValue)
.filter(Column("contactBareJid") == jid.stringValue)
.fetchOne(db)
}
if let roster {
return RosterItemBase(
jid: JID(roster.bareJid),
name: roster.name,
subscription: RosterItemSubscription(rawValue: roster.subscription) ?? .none,
groups: roster.data.groups,
ask: roster.ask,
annotations: roster.data.annotations
)
} else {
return nil
}
} catch {
logIt(.error, "Error fetching roster item: \(error.localizedDescription)")
return nil
}
}
func updateItem(for context: Martin.Context, jid: Martin.JID, name: String?, subscription: Martin.RosterItemSubscription, groups: [String], ask: Bool, annotations: [Martin.RosterItemAnnotation]) -> (any Martin.RosterItemProtocol)? {
do {
let roster = Roster(
bareJid: context.userBareJid.stringValue,
contactBareJid: jid.stringValue,
name: name,
subscription: subscription.rawValue,
ask: ask,
data: DBRosterData(
groups: groups,
annotations: annotations
)
)
try _db.write { db in
try roster.save(db)
}
return RosterItemBase(jid: jid, name: name, subscription: subscription, groups: groups, ask: ask, annotations: annotations)
} catch {
logIt(.error, "Error updating roster item: \(error.localizedDescription)")
return nil
}
}
func deleteItem(for context: Martin.Context, jid: Martin.JID) -> (any Martin.RosterItemProtocol)? {
do {
let roster: Roster? = try _db.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 roster.delete(db)
}
return RosterItemBase(
jid: JID(roster.bareJid),
name: roster.name,
subscription: RosterItemSubscription(rawValue: roster.subscription) ?? .none,
groups: roster.data.groups,
ask: roster.ask,
annotations: roster.data.annotations
)
} else {
return nil
}
} catch {
logIt(.error, "Error fetching roster version: \(error.localizedDescription)")
return nil
}
}
func version(for context: Martin.Context) -> String? {
do {
let version: RosterVersion? = try _db.read { db in
try RosterVersion
.filter(Column("bareJid") == context.userBareJid.stringValue)
.fetchOne(db)
}
return version?.version
} catch {
logIt(.error, "Error fetching roster version: \(error.localizedDescription)")
return nil
}
}
func set(version: String?, for context: Martin.Context) {
guard let version else { return }
do {
try _db.write { db in
let rosterVersion = RosterVersion(
bareJid: context.userBareJid.stringValue,
version: version
)
try rosterVersion.save(db)
}
} catch {
logIt(.error, "Error setting roster version: \(error.localizedDescription)")
}
}
func initialize(context _: Martin.Context) {}
func deinitialize(context _: Martin.Context) {}
}

View file

@ -1,90 +0,0 @@
import Foundation
import GRDB
extension Database {
static var migrator: DatabaseMigrator = {
var migrator = DatabaseMigrator()
// flush db on schema change (only in DEV mode)
#if DEBUG
migrator.eraseDatabaseOnSchemaChange = true
#endif
// 1st migration - basic tables
migrator.registerMigration("Add basic tables") { db in
// accounts
try db.create(table: "accounts", 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
try db.create(table: "rosterVersions", options: [.ifNotExists]) { table in
table.column("bareJid", .text).notNull().primaryKey().unique(onConflict: .replace)
table.column("version", .text).notNull()
}
try db.create(table: "rosters", options: [.ifNotExists]) { table in
table.column("bareJid", .text).notNull()
table.column("contactBareJid", .text).notNull()
table.column("name", .text)
table.column("subscription", .text).notNull()
table.column("ask", .boolean).notNull().defaults(to: false)
table.column("data", .text).notNull()
table.primaryKey(["bareJid", "contactBareJid"], onConflict: .replace)
table.column("locallyDeleted", .boolean).notNull().defaults(to: false)
}
// chats
try db.create(table: "chats", options: [.ifNotExists]) { table in
table.column("id", .text).notNull().primaryKey().unique(onConflict: .replace)
table.column("account", .text).notNull()
table.column("participant", .text).notNull()
table.column("type", .integer).notNull()
}
// messages
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("contentType", .text).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)
table.column("account", .text).notNull()
table.column("nickname", .text).notNull()
table.column("password", .text)
}
// 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()
// }
}
// return migrator
return migrator
}()
}

View file

@ -1,55 +0,0 @@
import Combine
import Foundation
import GRDB
import SwiftUI
// MARK: - Models protocol
typealias DBStorable = Codable & FetchableRecord & Identifiable & PersistableRecord & TableRecord
// MARK: - Database init
final class Database {
static let shared = Database()
let _db: DatabaseQueue
private init() {
do {
// Create db folder if not exists
let fileManager = FileManager.default
let appSupportURL = try fileManager.url(
for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true
)
let directoryURL = appSupportURL.appendingPathComponent("ConversationsClassic", isDirectory: true)
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
// Open or create the database
let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
_db = try DatabaseQueue(path: databaseURL.path, configuration: Database.config)
// Some debug info
#if DEBUG
print("Database path: \(databaseURL.path)")
#endif
// Apply migrations
try Database.migrator.migrate(_db)
} catch {
fatalError("Database initialization failed: \(error)")
}
}
}
// MARK: - Config
private extension Database {
static let config: Configuration = {
var config = Configuration()
#if DEBUG
// verbose and debugging in DEBUG builds only.
config.publicStatementArguments = true
config.prepareDatabase { db in
db.trace { print("SQL> \($0)\n") }
}
#endif
return config
}()
}

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,302 +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,69 +0,0 @@
import Combine
import Foundation
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}"
case error = "\u{EA76}"
}
// For database errors logging
func logIt(_ level: LogLevels, _ message: String) {
#if DEBUG
let timeStr = dateFormatter.string(from: Date())
let str = "\(timeStr) \(level.rawValue) \(message)"
print(str)
if isConsoleLoggingEnabled {
NSLog(str)
}
#endif
}
private var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.locale = NSLocale(localeIdentifier: "en_US_POSIX") as Locale
formatter.dateFormat = "MM-dd HH:mm:ss.SSS"
return formatter
}
// For thread debugging
func ptInfo(_ message: String) {
#if DEBUG
let timeStr = dateFormatter.string(from: Date())
let str = "\(timeStr) \(message) -> \(Thread.current), \(String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "no queue label")"
print(str)
if isConsoleLoggingEnabled {
NSLog(str)
}
#endif
}

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,62 +0,0 @@
import Foundation
import GRDB
import Martin
struct RosterVersion: DBStorable {
static let databaseTableName = "rosterVersions"
var bareJid: String
var version: String
var id: String { bareJid }
}
struct Roster: DBStorable {
static let databaseTableName = "rosters"
var bareJid: String = ""
var contactBareJid: String
var name: String?
var subscription: String
var ask: Bool
var data: DBRosterData
var locallyDeleted: Bool = false
var id: String { "\(bareJid)-\(contactBareJid)" }
}
struct DBRosterData: Codable, DatabaseValueConvertible {
let groups: [String]
let annotations: [RosterItemAnnotation]
public var databaseValue: DatabaseValue {
let encoder = JSONEncoder()
// swiftlint:disable:next force_try
let data = try! encoder.encode(self)
return data.databaseValue
}
public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Self? {
guard let data = Data.fromDatabaseValue(dbValue) else {
return nil
}
let decoder = JSONDecoder()
// swiftlint:disable:next force_try
return try! decoder.decode(Self.self, from: data)
}
static func == (lhs: DBRosterData, rhs: DBRosterData) -> Bool {
lhs.groups == rhs.groups && lhs.annotations == rhs.annotations
}
}
extension RosterItemAnnotation: Equatable {
public static func == (lhs: RosterItemAnnotation, rhs: RosterItemAnnotation) -> Bool {
lhs.type == rhs.type && lhs.values == rhs.values
}
}
extension Roster: Equatable {
static func == (lhs: Roster, rhs: Roster) -> Bool {
lhs.bareJid == rhs.bareJid && lhs.contactBareJid == rhs.contactBareJid
}
}

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

@ -1,2 +0,0 @@
*
!.gitignore

View file

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

View file

@ -1,53 +0,0 @@
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
let appVersion = info?["CFBundleShortVersionString"] as? String ?? "Unknown"
let appBuild = info?[kCFBundleVersionKey as String] as? String ?? "Unknown"
return "v \(appVersion)(\(appBuild))"
}
static var appName: String {
Bundle.main.bundleIdentifier ?? "Conversations Classic iOS"
}
// Trusted servers
enum TrustedServers: String {
case narayana = "narayana.im"
case conversations = "conversations.im"
}
// 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
// Size for map preview for location messages
static let mapPreviewSize = UIScreen.main.bounds.width * 0.5
// 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
}

View file

@ -1,16 +0,0 @@
import MapKit
extension MKCoordinateRegion: Equatable {
public static func == (lhs: MKCoordinateRegion, rhs: MKCoordinateRegion) -> Bool {
lhs.center.latitude == rhs.center.latitude &&
lhs.center.longitude == rhs.center.longitude &&
lhs.span.latitudeDelta == rhs.span.latitudeDelta &&
lhs.span.longitudeDelta == rhs.span.longitudeDelta
}
}
extension CLLocationCoordinate2D: Identifiable {
public var id: String {
"\(latitude)-\(longitude)"
}
}

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

@ -1,106 +0,0 @@
import CoreLocation
import Foundation
import SwiftUI
extension String {
var firstLetter: String {
String(prefix(1)).uppercased()
}
var makeReply: String {
let allLines = components(separatedBy: .newlines)
let nonBlankLines = allLines.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
var result = nonBlankLines.joined(separator: "\n")
result = "> \(result)"
return result
}
var isLocation: Bool {
hasPrefix("geo:")
}
var getLatLon: CLLocationCoordinate2D {
let geo = components(separatedBy: ":")[1]
let parts = geo.components(separatedBy: ",")
let lat = Double(parts[0]) ?? 0.0
let lon = Double(parts[1]) ?? 0.0
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
}
var isContact: Bool {
hasPrefix("contact:")
}
var getContactJid: String {
components(separatedBy: ":")[1]
}
}
extension String {
var attachmentType: MessageAttachmentType {
let ext = (self as NSString).pathExtension.lowercased()
switch ext {
case "mov", "mp4", "avi":
return .movie
case "jpg", "png", "gif":
return .image
case "mp3", "wav", "m4a":
return .audio
case "txt", "doc", "pdf":
return .file
default:
return .file
}
}
}
extension String {
var firstLetterColor: Color {
let firstLetter = self.firstLetter
switch firstLetter {
case "A", "M", "Y":
return Color.Rainbow.tortoiseLight500
case "B", "N", "Z":
return Color.Rainbow.orangeLight500
case "C", "O":
return Color.Rainbow.yellowLight500
case "D", "P":
return Color.Rainbow.greenLight500
case "E", "Q":
return Color.Rainbow.blueLight500
case "F", "R":
return Color.Rainbow.magentaLight500
case "G", "S":
return Color.Rainbow.tortoiseDark500
case "H", "T":
return Color.Rainbow.orangeDark500
case "I", "U":
return Color.Rainbow.yellowDark500
case "J", "V":
return Color.Rainbow.greenDark500
case "K", "W":
return Color.Rainbow.blueDark500
case "L", "X":
return Color.Rainbow.magentaDark500
default:
return Color.Rainbow.tortoiseLight500
}
}
}

View file

@ -1,9 +0,0 @@
import Foundation
extension TimeInterval {
var minAndSec: String {
let minutes = Int(self) / 60
let seconds = Int(self) % 60
return String(format: "%02d:%02d", minutes, seconds)
}
}

View file

@ -1,10 +0,0 @@
import UIKit
func openAppSettings() {
if
let appSettingsUrl = URL(string: UIApplication.openSettingsURLString),
UIApplication.shared.canOpenURL(appSettingsUrl)
{
UIApplication.shared.open(appSettingsUrl, completionHandler: nil)
}
}

View file

@ -1,13 +0,0 @@
import UniformTypeIdentifiers
extension URL {
var mimeType: String {
let pathExtension = self.pathExtension
if let uti = UTType(filenameExtension: pathExtension) {
return uti.preferredMIMEType ?? "application/octet-stream"
} else {
return "application/octet-stream"
}
}
}

View file

@ -1,32 +0,0 @@
import Foundation
// Wrapper
@propertyWrapper
struct Storage<T> {
private let key: String
private let defaultValue: T
init(key: String, defaultValue: T) {
self.key = key
self.defaultValue = defaultValue
}
var wrappedValue: T {
get {
// Read value from UserDefaults
UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
// Set value to UserDefaults
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
// Storage
private let keyLocalizationSelected = "conversations.classic.user.defaults.localizationSelected"
enum UserSettings {
@Storage(key: keyLocalizationSelected, defaultValue: false)
static var localizationSelectedByUser: Bool
}

View file

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,9 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View file

@ -1,9 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xE4",
"green" : "0xE4",
"red" : "0xE4"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "235",
"green" : "235",
"red" : "235"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,9 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x4D",
"green" : "0x46",
"red" : "0x3C"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xAC",
"green" : "0xA3",
"red" : "0x95"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,9 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "201",
"green" : "227",
"red" : "199"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x62",
"green" : "0x59",
"red" : "0x4A"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "189",
"green" : "189",
"red" : "189"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,9 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x36",
"green" : "0x31",
"red" : "0x2A"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x78",
"green" : "0x6D",
"red" : "0x5A"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xEF",
"green" : "0xEF",
"red" : "0xEF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,9 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View file

@ -1,9 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View file

@ -1,9 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xD9",
"green" : "0xD7",
"red" : "0xD3"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xC2",
"green" : "0xBD",
"red" : "0xB5"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x9A",
"green" : "0x8F",
"red" : "0x7D"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFE"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x89",
"green" : "0x7C",
"red" : "0x66"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,9 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xB4",
"green" : "0xEC",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x83",
"green" : "0xF0",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x52",
"green" : "0xD5",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x2D",
"green" : "0xCA",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xE1",
"green" : "0xF8",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x14",
"green" : "0xC1",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x0F",
"green" : "0xB3",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x0D",
"green" : "0xA0",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x0C",
"green" : "0x8F",
"red" : "0xFE"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

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