Skip to content
Snippets Groups Projects
Session.swift 17.9 KiB
Newer Older
Bruno Muniz's avatar
Bruno Muniz committed
import Retry
Bruno Muniz's avatar
Bruno Muniz committed
import os.log
Bruno Muniz's avatar
Bruno Muniz committed
import Models
import Shared
import Combine
import Defaults
Bruno Muniz's avatar
Bruno Muniz committed
import XXModels
Bruno Muniz's avatar
Bruno Muniz committed
import Foundation
Bruno Muniz's avatar
Bruno Muniz committed
import ToastFeature
import BackupFeature
Bruno Muniz's avatar
Bruno Muniz committed
import NetworkMonitor
import DependencyInjection
Bruno Muniz's avatar
Bruno Muniz committed
import XXLegacyDatabaseMigrator
Ahmed Shehata's avatar
Ahmed Shehata committed

let logHandler = OSLog(subsystem: "xx.network", category: "Performance debugging")

struct BackupParameters: Codable {
    var email: String?
    var phone: String?
    var username: String
}

struct BackupReport: Codable {
    var contactIds: [String]
    var parameters: String

    private enum CodingKeys: String, CodingKey {
        case parameters = "Params"
        case contactIds = "RestoredContacts"
    }
}

Bruno Muniz's avatar
Bruno Muniz committed
public final class Session: SessionType {
    @KeyObject(.theme, defaultValue: nil) var theme: String?
    @KeyObject(.email, defaultValue: nil) var email: String?
    @KeyObject(.phone, defaultValue: nil) var phone: String?
    @KeyObject(.avatar, defaultValue: nil) var avatar: Data?
    @KeyObject(.username, defaultValue: nil) var username: String?
    @KeyObject(.biometrics, defaultValue: false) var biometrics: Bool
    @KeyObject(.hideAppList, defaultValue: false) var hideAppList: Bool
    @KeyObject(.requestCounter, defaultValue: 0) var requestCounter: Int
    @KeyObject(.sharingEmail, defaultValue: false) var isSharingEmail: Bool
    @KeyObject(.sharingPhone, defaultValue: false) var isSharingPhone: Bool
    @KeyObject(.recordingLogs, defaultValue: true) var recordingLogs: Bool
    @KeyObject(.crashReporting, defaultValue: true) var crashReporting: Bool
    @KeyObject(.icognitoKeyboard, defaultValue: false) var icognitoKeyboard: Bool
    @KeyObject(.pushNotifications, defaultValue: false) var pushNotifications: Bool
    @KeyObject(.inappnotifications, defaultValue: true) var inappnotifications: Bool

    @Dependency var backupService: BackupService
    @Dependency var toastController: ToastController
Bruno Muniz's avatar
Bruno Muniz committed
    @Dependency var networkMonitor: NetworkMonitoring

    public let client: Client
Bruno Muniz's avatar
Bruno Muniz committed
    public let dbManager: Database
Bruno Muniz's avatar
Bruno Muniz committed
    private var cancellables = Set<AnyCancellable>()

    public var myId: Data { client.bindings.myId }
    public var version: String { type(of: client.bindings).version }

    public var myQR: Data {
        client
            .bindings
            .meMarshalled(
                username!,
                email: isSharingEmail ? email : nil,
                phone: isSharingPhone ? phone : nil
            )
    }

    public var hasRunningTasks: Bool {
        client.bindings.hasRunningTasks
    }

    public var isOnline: AnyPublisher<Bool, Never> {
        networkMonitor.statusPublisher.map { $0 == .available }.eraseToAnyPublisher()
    }

Ahmed Shehata's avatar
Ahmed Shehata committed
    public init(
        passphrase: String,
        backupFile: Data,
        ndf: String
    ) throws {
        let network = try! DependencyInjection.Container.shared.resolve() as XXNetworking
Ahmed Shehata's avatar
Ahmed Shehata committed

        os_signpost(.begin, log: logHandler, name: "Decrypting", "Calling newClientFromBackup")
        let (client, backupData) = try network.newClientFromBackup(passphrase: passphrase, data: backupFile, ndf: ndf)
        os_signpost(.end, log: logHandler, name: "Decrypting", "Finished newClientFromBackup")

        self.client = client
Bruno Muniz's avatar
Bruno Muniz committed

Bruno Muniz's avatar
Bruno Muniz committed
        let legacyOldPath = NSSearchPathForDirectoriesInDomains(
Bruno Muniz's avatar
Bruno Muniz committed
            .documentDirectory, .userDomainMask, true
        )[0].appending("/xxmessenger.sqlite")

Bruno Muniz's avatar
Bruno Muniz committed
        let legacyPath = FileManager.default
Bruno Muniz's avatar
Bruno Muniz committed
            .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")!
            .appendingPathComponent("database")
            .appendingPathExtension("sqlite").path

Bruno Muniz's avatar
Bruno Muniz committed
        let dbExistsInLegacyOldPath = FileManager.default.fileExists(atPath: legacyOldPath)
        let dbExistsInLegacyPath = FileManager.default.fileExists(atPath: legacyPath)
Bruno Muniz's avatar
Bruno Muniz committed

Bruno Muniz's avatar
Bruno Muniz committed
        if dbExistsInLegacyOldPath && !dbExistsInLegacyPath {
            try? FileManager.default.moveItem(atPath: legacyOldPath, toPath: legacyPath)
        }

        let dbPath = FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")!
            .appendingPathComponent("xxm_database")
            .appendingPathExtension("sqlite").path

        dbManager = try Database.onDisk(path: dbPath)

        if dbExistsInLegacyPath {
            try Migrator.live()(
                try .init(path: legacyPath),
                to: dbManager,
                myContactId: client.bindings.myId,
                meMarshaled: client.bindings.meMarshalled
            )

            try FileManager.default.moveItem(atPath: legacyPath, toPath: legacyPath.appending("-backup"))
        }

        let report = try! JSONDecoder().decode(BackupReport.self, from: backupData!)

        if !report.parameters.isEmpty {
            let params = try! JSONDecoder().decode(BackupParameters.self, from: Data(report.parameters.utf8))

            username = params.username

            if let paramsPhone = params.phone, !paramsPhone.isEmpty {
                phone = paramsPhone
            }

            if let paramsEmail = params.email, !paramsEmail.isEmpty {
                email = paramsEmail
            }
        guard username!.isEmpty == false else {
            fatalError("Trying to restore an account that has no username")
        }

        try continueInitialization()

        if !report.contactIds.isEmpty {
            client.restoreContacts(fromBackup: try! JSONSerialization.data(withJSONObject: report.contactIds))
        }
    }

Bruno Muniz's avatar
Bruno Muniz committed
    public init(ndf: String) throws {
        let network = try! DependencyInjection.Container.shared.resolve() as XXNetworking
        self.client = try network.newClient(ndf: ndf)
Bruno Muniz's avatar
Bruno Muniz committed

Bruno Muniz's avatar
Bruno Muniz committed
        let legacyOldPath = NSSearchPathForDirectoriesInDomains(
Bruno Muniz's avatar
Bruno Muniz committed
            .documentDirectory, .userDomainMask, true
        )[0].appending("/xxmessenger.sqlite")

Bruno Muniz's avatar
Bruno Muniz committed
        let legacyPath = FileManager.default
Bruno Muniz's avatar
Bruno Muniz committed
            .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")!
            .appendingPathComponent("database")
            .appendingPathExtension("sqlite").path

Bruno Muniz's avatar
Bruno Muniz committed
        let dbExistsInLegacyOldPath = FileManager.default.fileExists(atPath: legacyOldPath)
        let dbExistsInLegacyPath = FileManager.default.fileExists(atPath: legacyPath)
Bruno Muniz's avatar
Bruno Muniz committed

Bruno Muniz's avatar
Bruno Muniz committed
        if dbExistsInLegacyOldPath && !dbExistsInLegacyPath {
            try? FileManager.default.moveItem(atPath: legacyOldPath, toPath: legacyPath)
        }

        let dbPath = FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier: "group.elixxir.messenger")!
            .appendingPathComponent("xxm_database")
            .appendingPathExtension("sqlite").path

        dbManager = try Database.onDisk(path: dbPath)

        if dbExistsInLegacyPath {
            try Migrator.live()(
                try .init(path: legacyPath),
                to: dbManager,
                myContactId: client.bindings.myId,
                meMarshaled: client.bindings.meMarshalled
            )

            try FileManager.default.moveItem(atPath: legacyPath, toPath: legacyPath.appending("-backup"))
        }
Bruno Muniz's avatar
Bruno Muniz committed

        try continueInitialization()
    }

    private func continueInitialization() throws {
Bruno Muniz's avatar
Bruno Muniz committed
        var myContact = try self.myContact()
        myContact.marshaled = client.bindings.meMarshalled
        myContact.username = username
        myContact.email = email
        myContact.phone = phone
        myContact.authStatus = .friend
        myContact.isRecent = false
        _ = try dbManager.saveContact(myContact)

Bruno Muniz's avatar
Bruno Muniz committed
        setupBindings()
        networkMonitor.start()

        networkMonitor.statusPublisher
            .filter { $0 == .available }.first()
            .sink { [unowned self] _ in
                client.bindings.replayRequests()
                scanStrangers {}
            }
Bruno Muniz's avatar
Bruno Muniz committed
            .store(in: &cancellables)

        registerUnfinishedUploadTransfers()
        registerUnfinishedDownloadTransfers()
Bruno Muniz's avatar
Bruno Muniz committed

        let query = Contact.Query(authStatus: [.verificationInProgress])
        _ = try? dbManager.bulkUpdateContacts(query, .init(authStatus: .verificationFailed))
Bruno Muniz's avatar
Bruno Muniz committed
    }

    public func setDummyTraffic(status: Bool) {
        client.dummyManager?.setStatus(status: status)
    }

    public func deleteMyself() throws {
        guard let username = username, let ud = client.userDiscovery else { return }
Bruno Muniz's avatar
Bruno Muniz committed

        try? unregisterNotifications()
Bruno Muniz's avatar
Bruno Muniz committed
        try ud.deleteMyself(username)

        stop()
        cleanUp()
    }

    private func cleanUp() {
        retry(max: 10, retryStrategy: .delay(seconds: 1)) { [unowned self] in
            guard self.hasRunningTasks == false else { throw NSError.create("") }
Bruno Muniz's avatar
Bruno Muniz committed
        }.finalCatch { _ in fatalError("Couldn't delete account because network is not stopping") }

Bruno Muniz's avatar
Bruno Muniz committed
        try! dbManager.drop()
Bruno Muniz's avatar
Bruno Muniz committed
        FileManager.xxCleanup()

        email = nil
        phone = nil
        theme = nil
        avatar = nil
        self.username = nil
        isSharingEmail = false
        isSharingPhone = false
        requestCounter = 0
        biometrics = false
        hideAppList = false
        recordingLogs = true
        crashReporting = true
        icognitoKeyboard = false
        pushNotifications = false
        inappnotifications = true
    }

    private func registerUnfinishedDownloadTransfers() {
        guard let unfinishedReceivingMessages = try? dbManager.fetchMessages(.init(status: [.receiving])),
              let unfinishedReceivingTransfers = try? dbManager.fetchFileTransfers(.init(
                id: Set(unfinishedReceivingMessages
                    .filter { $0.fileTransferId != nil }
                    .compactMap(\.fileTransferId))))
        else { return }

        let pairs = unfinishedReceivingMessages.compactMap { message -> (Message, FileTransfer)? in
            guard let transfer = unfinishedReceivingTransfers.first(where: { ft in
                ft.id == message.fileTransferId
            }) else { return nil }

            return (message, transfer)
        }

        pairs.forEach { message, transfer in
            var message = message
            var transfer = transfer

            do {
                try client.transferManager?.listenDownloadFromTransfer(with: transfer.id) { [weak self] completed, received, total, error in
                    guard let self = self else { return }
                    if completed {
                        transfer.progress = 1.0
                        message.status = .received

                        if let data = try? self.client.transferManager?.downloadFileFromTransfer(with: transfer.id),
                           let _ = try? FileManager.store(data: data, name: transfer.name, type: transfer.type) {
                            transfer.data = data
                        }
                    } else {
                        if error != nil {
                            message.status = .receivingFailed
                        } else {
                            transfer.progress = Float(received)/Float(total)
                        }
                    }

                    _ = try? self.dbManager.saveFileTransfer(transfer)
                    _ = try? self.dbManager.saveMessage(message)
                }
            } catch {
                message.status = .receivingFailed
                _ = try? self.dbManager.saveMessage(message)
            }
        }
    }

    private func registerUnfinishedUploadTransfers() {
        guard let unfinishedSendingMessages = try? dbManager.fetchMessages(.init(status: [.sending])),
              let unfinishedSendingTransfers = try? dbManager.fetchFileTransfers(.init(
                id: Set(unfinishedSendingMessages
                    .filter { $0.fileTransferId != nil }
                    .compactMap(\.fileTransferId))))
        else { return }

        let pairs = unfinishedSendingMessages.compactMap { message -> (Message, FileTransfer)? in
            guard let transfer = unfinishedSendingTransfers.first(where: { ft in
                ft.id == message.fileTransferId
            }) else { return nil }
            return (message, transfer)
        }

        pairs.forEach { message, transfer in
            var message = message
            var transfer = transfer

            do {
                try client.transferManager?.listenUploadFromTransfer(with: transfer.id) { [weak self] completed, sent, arrived, total, error in
                    guard let self = self else { return }

                    if completed {
                        transfer.progress = 1.0
                        message.status = .sent

                        try? self.client.transferManager?.endTransferUpload(with: transfer.id)
                    } else {
                        if error != nil {
                            message.status = .sendingFailed
                        } else {
                            transfer.progress = Float(arrived)/Float(total)
                        }
                    }

                    _ = try? self.dbManager.saveFileTransfer(transfer)
                    _ = try? self.dbManager.saveMessage(message)
                }
            } catch {
                message.status = .sendingFailed
                _ = try? self.dbManager.saveMessage(message)
            }
        }
Bruno Muniz's avatar
Bruno Muniz committed

    func updateFactsOnBackup() {
        struct BackupParameters: Codable {
            var email: String?
            var phone: String?
            var username: String

            var jsonFormat: String {
                let data = try! JSONEncoder().encode(self)
                let json = String(data: data, encoding: .utf8)
                return json!
Bruno Muniz's avatar
Bruno Muniz committed
            }
        }

        let params = BackupParameters(
            email: email,
            phone: phone,
            username: username!
        ).jsonFormat

        client.addJson(params)

        guard username!.isEmpty == false else {
            fatalError("Tried to build a backup with my username but an empty string was set to it")
        }

        backupService.performBackupIfAutomaticIsEnabled()
Bruno Muniz's avatar
Bruno Muniz committed
    }

    private func setupBindings() {
        client.requests
Bruno Muniz's avatar
Bruno Muniz committed
            .sink { [unowned self] contact in
                let query = Contact.Query(id: [contact.id])

                if let prexistent = try? dbManager.fetchContacts(query).first {
                    guard prexistent.authStatus == .stranger else { return }
Bruno Muniz's avatar
Bruno Muniz committed

                if self.inappnotifications {
                    DeviceFeedback.sound(.contactAdded)
                    DeviceFeedback.shake(.notification)
                }

Bruno Muniz's avatar
Bruno Muniz committed
                verify(contact: contact)
            }.store(in: &cancellables)

        client.requestsSent
            .sink { [unowned self] in _ = try? dbManager.saveContact($0) }
Bruno Muniz's avatar
Bruno Muniz committed
            .store(in: &cancellables)

        client.backup
            .sink { [unowned self] in backupService.updateBackup(data: $0) }
            .store(in: &cancellables)

        client.resets
Bruno Muniz's avatar
Bruno Muniz committed
            .sink { [unowned self] in
                /// This will get called when my contact restore its contact.
                /// TODO: Hold a record on the chat that this contact restored.
                ///
                if var contact = try? dbManager.fetchContacts(.init(id: [$0.id])).first {
                    contact.authStatus = .friend
                    _ = try? dbManager.saveContact(contact)
                }
Bruno Muniz's avatar
Bruno Muniz committed
            }.store(in: &cancellables)

        backupService.settingsPublisher
            .map { $0.enabledService != nil }
            .removeDuplicates()
Bruno Muniz's avatar
Bruno Muniz committed
            .sink { [unowned self] in
                if $0 == true {
Ahmed Shehata's avatar
Ahmed Shehata committed
                    guard let passphrase = backupService.passphrase else {
                        client.resumeBackup()
                        return
                    }

                    client.initializeBackup(passphrase: passphrase)
                    backupService.passphrase = nil
                    updateFactsOnBackup()
                } else {
Ahmed Shehata's avatar
Ahmed Shehata committed
                    backupService.passphrase = nil
                    client.stopListeningBackup()
Bruno Muniz's avatar
Bruno Muniz committed
                }
            }
            .store(in: &cancellables)

        client.messages
Ahmed Shehata's avatar
Ahmed Shehata committed
            .sink { [unowned self] in
                if var contact = try? dbManager.fetchContacts(.init(id: [$0.senderId])).first {
Ahmed Shehata's avatar
Ahmed Shehata committed
                    contact.isRecent = false
                    _ = try? dbManager.saveContact(contact)
Ahmed Shehata's avatar
Ahmed Shehata committed
                }

                _ = try? dbManager.saveMessage($0)
Ahmed Shehata's avatar
Ahmed Shehata committed
            }.store(in: &cancellables)
Bruno Muniz's avatar
Bruno Muniz committed

        client.network
            .sink { [unowned self] in networkMonitor.update($0) }
            .store(in: &cancellables)

        client.groupRequests
            .sink { [unowned self] request in
                if let _ = try? dbManager.fetchGroups(.init(id: [request.0.id])).first {
                    return
                }
Bruno Muniz's avatar
Bruno Muniz committed

                DispatchQueue.global().async { [weak self] in
                    self?.processGroupCreation(request.0, memberIds: request.1, welcome: request.2)
                }
            }.store(in: &cancellables)

        client.confirmations
            .sink { [unowned self] in
                if var contact = try? dbManager.fetchContacts(.init(id: [$0.id])).first {
                    contact.authStatus = .friend
Ahmed Shehata's avatar
Ahmed Shehata committed
                    contact.isRecent = true
                    contact.createdAt = Date()
                    _ = try? dbManager.saveContact(contact)

                    toastController.enqueueToast(model: .init(
                        title: contact.nickname ?? contact.username!,
                        subtitle: Localized.Requests.Confirmations.toaster,
                        leftImage: Asset.sharedSuccess.image
                    ))
Bruno Muniz's avatar
Bruno Muniz committed
                }
            }.store(in: &cancellables)
Bruno Muniz's avatar
Bruno Muniz committed

        client.transfers
            .sink { [unowned self] in
Bruno Muniz's avatar
Bruno Muniz committed
                guard let transfer = try? dbManager.saveFileTransfer($0) else { return }
                handle(incomingTransfer: transfer)
Bruno Muniz's avatar
Bruno Muniz committed
            }
            .store(in: &cancellables)
Bruno Muniz's avatar
Bruno Muniz committed
    }
Bruno Muniz's avatar
Bruno Muniz committed

    func myContact() throws -> Contact {
        if let contact = try dbManager.fetchContacts(.init(id: [client.bindings.myId])).first {
            return contact
        } else {
            return try dbManager.saveContact(.init(id: client.bindings.myId))
        }
    }
Bruno Muniz's avatar
Bruno Muniz committed
}