diff --git a/App/client-ios.xcodeproj/project.pbxproj b/App/client-ios.xcodeproj/project.pbxproj index c5951402ab44c15cbccd0d4017c7b572c1365596..df25d8568ed39b8a2eac0b9e49346bf9065e5e6d 100644 --- a/App/client-ios.xcodeproj/project.pbxproj +++ b/App/client-ios.xcodeproj/project.pbxproj @@ -448,7 +448,7 @@ CODE_SIGN_ENTITLEMENTS = "client-ios/Resources/client-ios.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 283; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = S6JDM2WW29; ENABLE_BITCODE = NO; @@ -463,7 +463,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.7; + MARKETING_VERSION = 1.1.8; OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = xx.messenger.mock; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -487,7 +487,7 @@ CODE_SIGN_ENTITLEMENTS = "client-ios/Resources/client-ios.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 283; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = S6JDM2WW29; ENABLE_BITCODE = NO; @@ -503,7 +503,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.7; + MARKETING_VERSION = 1.1.8; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = xx.messenger; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -522,7 +522,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationExtension/NotificationExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 283; DEVELOPMENT_TEAM = S6JDM2WW29; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -536,7 +536,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1.7; + MARKETING_VERSION = 1.1.8; PRODUCT_BUNDLE_IDENTIFIER = xx.messenger.mock.notifications; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -553,7 +553,7 @@ CODE_SIGN_ENTITLEMENTS = NotificationExtension/NotificationExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 267; + CURRENT_PROJECT_VERSION = 283; DEVELOPMENT_TEAM = S6JDM2WW29; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -567,7 +567,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1.7; + MARKETING_VERSION = 1.1.8; PRODUCT_BUNDLE_IDENTIFIER = xx.messenger.notifications; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Package.swift b/Package.swift index b3f848aaf253b239def7a62d44a4703b3fea0bcf..14e5880804888513d778f51ee860c843bd3e3060 100644 --- a/Package.swift +++ b/Package.swift @@ -345,10 +345,13 @@ let package = Package( .target(name: "HUD"), .target(name: "Shared"), .target(name: "Presentation"), - .target(name: "BackupFeature"), .target(name: "DependencyInjection"), .product(name: "XXDatabase", package: "client-ios-db"), .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "CloudFilesDrive", package: "xxm-cloud-providers"), + .product(name: "CloudFilesDropbox", package: "xxm-cloud-providers"), + .product(name: "CloudFilesSFTP", package: "xxm-cloud-providers"), + .product(name: "CloudFilesICloud", package: "xxm-cloud-providers"), ] ), .target( @@ -441,9 +444,11 @@ let package = Package( .target(name: "ReportingFeature"), .target(name: "DependencyInjection"), .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), - .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "CloudFilesSFTP", package: "xxm-cloud-providers"), .product(name: "CombineSchedulers", package: "combine-schedulers"), + .product(name: "CloudFilesDropbox", package: "xxm-cloud-providers"), .product(name: "XXLegacyDatabaseMigrator", package: "client-ios-db"), + .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), ] ), .target( @@ -580,7 +585,14 @@ let package = Package( .target(name: "InputField"), .target(name: "Presentation"), .target(name: "DrawerFeature"), + .target(name: "NetworkMonitor"), .target(name: "DependencyInjection"), + .product(name: "XXClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "CloudFilesSFTP", package: "xxm-cloud-providers"), + .product(name: "CloudFilesDrive", package: "xxm-cloud-providers"), + .product(name: "CloudFilesICloud", package: "xxm-cloud-providers"), + .product(name: "CloudFilesDropbox", package: "xxm-cloud-providers"), + .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), ] ), .target( diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index b85cc758f100ce2c383b144772cd9f0c18921633..365a58740c054b5b86d90819a943fd1e9c6e67d0 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -14,11 +14,14 @@ import DependencyInjection import XXClient import XXMessengerClient +import CloudFiles +import CloudFilesDrive +import CloudFilesDropbox + public class AppDelegate: UIResponder, UIApplicationDelegate { @Dependency private var pushRouter: PushRouter @Dependency private var pushHandler: PushHandling @Dependency private var crashReporter: CrashReporter - @Dependency private var dropboxService: DropboxInterface @KeyObject(.hideAppList, defaultValue: false) var hideAppList: Bool @KeyObject(.recordingLogs, defaultValue: true) var recordingLogs: Bool @@ -61,6 +64,7 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { PushRouter.live(navigationController: navController) ) + restoreIfPossible() return true } @@ -148,7 +152,7 @@ public class AppDelegate: UIResponder, UIApplicationDelegate { open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:] ) -> Bool { - dropboxService.handleOpenUrl(url) + handleRedirectURL(url) } public func application( diff --git a/Sources/App/DependencyRegistrator.swift b/Sources/App/DependencyRegistrator.swift index bbc8ef75a756e0ce2c10c5a41d142fd8a5c60993..b15fe648a8c9d98c6faf73de46d1b5025d68cb0c 100644 --- a/Sources/App/DependencyRegistrator.swift +++ b/Sources/App/DependencyRegistrator.swift @@ -63,13 +63,6 @@ struct DependencyRegistrator { container.register(MockKeychainHandler() as KeychainHandling) container.register(MockPermissionHandler() as PermissionHandling) - /// Restore / Backup - - container.register(SFTPService.mock) - container.register(iCloudServiceMock() as iCloudInterface) - container.register(DropboxServiceMock() as DropboxInterface) - container.register(GoogleDriveServiceMock() as GoogleDriveInterface) - registerCommonDependencies() } @@ -94,13 +87,6 @@ struct DependencyRegistrator { container.register(KeychainHandler() as KeychainHandling) container.register(PermissionHandler() as PermissionHandling) - /// Restore / Backup - - container.register(SFTPService.live) - container.register(iCloudService() as iCloudInterface) - container.register(DropboxService() as DropboxInterface) - container.register(GoogleDriveService() as GoogleDriveInterface) - registerCommonDependencies() } @@ -143,6 +129,7 @@ struct DependencyRegistrator { container.register( BackupCoordinator( + sftpFactory: BackupSFTPController.init(_:), passphraseFactory: BackupPassphraseController.init(_:_:) ) as BackupCoordinating) @@ -188,6 +175,7 @@ struct DependencyRegistrator { successFactory: RestoreSuccessController.init, chatListFactory: ChatListController.init, restoreFactory: RestoreController.init(_:), + sftpFactory: RestoreSFTPController.init(_:), passphraseFactory: RestorePassphraseController.init(_:_:) ) as RestoreCoordinating) diff --git a/Sources/BackupFeature/Controllers/BackupConfigController.swift b/Sources/BackupFeature/Controllers/BackupConfigController.swift index a901e6b9668fc89df2cd1ae1184ca655fa8bdb1b..722e5530adcbe0bf9e4e8be9d0f9518f3893fd02 100644 --- a/Sources/BackupFeature/Controllers/BackupConfigController.swift +++ b/Sources/BackupFeature/Controllers/BackupConfigController.swift @@ -72,7 +72,7 @@ final class BackupConfigController: UIViewController { return } - screenView.latestBackupDetailView.subtitleLabel.text = backup.date.backupStyle() + screenView.latestBackupDetailView.subtitleLabel.text = backup.lastModified.backupStyle() }.store(in: &cancellables) screenView.actionView.backupNowButton @@ -131,7 +131,7 @@ final class BackupConfigController: UIViewController { .store(in: &cancellables) } - private func decorate(enabledService: CloudService?) { + private func decorate(enabledService: BackupProvider?) { var button: BackupSwitcherButton? switch enabledService { @@ -188,7 +188,7 @@ final class BackupConfigController: UIViewController { } } - private func decorate(connectedServices: Set<CloudService>) { + private func decorate(connectedServices: Set<BackupProvider>) { if connectedServices.contains(.icloud) { screenView.iCloudButton.showSwitcher(enabled: false) } else { diff --git a/Sources/BackupFeature/Coordinator/BackupCoordinator.swift b/Sources/BackupFeature/Coordinator/BackupCoordinator.swift index f16acd561f4ca41c9f2abe0db1101a58df4cfcad..9ef0fdfb6087c96941953ef25b5bafdf686b1a80 100644 --- a/Sources/BackupFeature/Coordinator/BackupCoordinator.swift +++ b/Sources/BackupFeature/Coordinator/BackupCoordinator.swift @@ -3,67 +3,89 @@ import Shared import Presentation import ScrollViewController +public typealias SFTPDetailsClosure = (String, String, String) -> Void + public protocol BackupCoordinating { - func toDrawer( - _: UIViewController, - from: UIViewController - ) + func toDrawer( + _: UIViewController, + from: UIViewController + ) + + func toSFTP( + from: UIViewController, + detailsClosure: @escaping SFTPDetailsClosure + ) - func toPassphrase( - from: UIViewController, - cancelClosure: @escaping EmptyClosure, - passphraseClosure: @escaping StringClosure - ) + func toPassphrase( + from: UIViewController, + cancelClosure: @escaping EmptyClosure, + passphraseClosure: @escaping StringClosure + ) } public struct BackupCoordinator: BackupCoordinating { - var fullscreenPresenter: Presenting = FullscreenPresenter() + var pushPresenter: Presenting = PushPresenter() + var fullscreenPresenter: Presenting = FullscreenPresenter() - var passphraseFactory: ( - @escaping EmptyClosure, - @escaping StringClosure - ) -> UIViewController + var sftpFactory: (@escaping SFTPDetailsClosure) -> UIViewController - public init( - passphraseFactory: @escaping ( - @escaping EmptyClosure, - @escaping StringClosure - ) -> UIViewController - ) { - self.passphraseFactory = passphraseFactory - } + var passphraseFactory: ( + @escaping EmptyClosure, + @escaping StringClosure + ) -> UIViewController + + public init( + sftpFactory: @escaping ( + @escaping SFTPDetailsClosure + ) -> UIViewController, + passphraseFactory: @escaping ( + @escaping EmptyClosure, + @escaping StringClosure + ) -> UIViewController + ) { + self.sftpFactory = sftpFactory + self.passphraseFactory = passphraseFactory + } } public extension BackupCoordinator { - func toDrawer( - _ screen: UIViewController, - from parent: UIViewController - ) { - let target = ScrollViewController.embedding(screen) - fullscreenPresenter.present(target, from: parent) - } + func toSFTP( + from parent: UIViewController, + detailsClosure: @escaping SFTPDetailsClosure + ) { + let screen = sftpFactory(detailsClosure) + pushPresenter.present(screen, from: parent) + } + + func toDrawer( + _ screen: UIViewController, + from parent: UIViewController + ) { + let target = ScrollViewController.embedding(screen) + fullscreenPresenter.present(target, from: parent) + } - func toPassphrase( - from parent: UIViewController, - cancelClosure: @escaping EmptyClosure, - passphraseClosure: @escaping StringClosure - ) { - let screen = passphraseFactory(cancelClosure, passphraseClosure) - let target = ScrollViewController.embedding(screen) - fullscreenPresenter.present(target, from: parent) - } + func toPassphrase( + from parent: UIViewController, + cancelClosure: @escaping EmptyClosure, + passphraseClosure: @escaping StringClosure + ) { + let screen = passphraseFactory(cancelClosure, passphraseClosure) + let target = ScrollViewController.embedding(screen) + fullscreenPresenter.present(target, from: parent) + } } extension ScrollViewController { - static func embedding(_ viewController: UIViewController) -> ScrollViewController { - let scrollViewController = ScrollViewController() - scrollViewController.addChild(viewController) - scrollViewController.contentView = viewController.view - scrollViewController.wrapperView.handlesTouchesOutsideContent = false - scrollViewController.wrapperView.alignContentToBottom = true - scrollViewController.scrollView.bounces = false + static func embedding(_ viewController: UIViewController) -> ScrollViewController { + let scrollViewController = ScrollViewController() + scrollViewController.addChild(viewController) + scrollViewController.contentView = viewController.view + scrollViewController.wrapperView.handlesTouchesOutsideContent = false + scrollViewController.wrapperView.alignContentToBottom = true + scrollViewController.scrollView.bounces = false - viewController.didMove(toParent: scrollViewController) - return scrollViewController - } + viewController.didMove(toParent: scrollViewController) + return scrollViewController + } } diff --git a/Sources/BackupFeature/Models/BackupSettings.swift b/Sources/BackupFeature/Models/BackupSettings.swift new file mode 100644 index 0000000000000000000000000000000000000000..5442f3015665a829f20287318a35ae47221a27f9 --- /dev/null +++ b/Sources/BackupFeature/Models/BackupSettings.swift @@ -0,0 +1,48 @@ +import Foundation +import CloudFiles + +public struct BackupSettings: Equatable, Codable { + public var wifiOnlyBackup: Bool + public var automaticBackups: Bool + public var enabledService: BackupProvider? + public var connectedServices: Set<BackupProvider> + public var backups: [BackupProvider: Fetch.Metadata] + + public init( + wifiOnlyBackup: Bool = false, + automaticBackups: Bool = false, + enabledService: BackupProvider? = nil, + connectedServices: Set<BackupProvider> = [], + backups: [BackupProvider: Fetch.Metadata] = [:] + ) { + self.wifiOnlyBackup = wifiOnlyBackup + self.automaticBackups = automaticBackups + self.enabledService = enabledService + self.connectedServices = connectedServices + self.backups = backups + } + + public func toData() -> Data { + (try? PropertyListEncoder().encode(self)) ?? Data() + } + + public init(fromData data: Data?) { + if let data = data, let settings = try? PropertyListDecoder().decode(BackupSettings.self, from: data) { + self.init( + wifiOnlyBackup: settings.wifiOnlyBackup, + automaticBackups: settings.automaticBackups, + enabledService: settings.enabledService, + connectedServices: settings.connectedServices, + backups: settings.backups + ) + } else { + self.init( + wifiOnlyBackup: false, + automaticBackups: true, + enabledService: nil, + connectedServices: [], + backups: [:] + ) + } + } +} diff --git a/Sources/BackupFeature/Service/BackupService.swift b/Sources/BackupFeature/Service/BackupService.swift index 1ffc64356fa3307d5312ea76c1294f3f7d18848f..72cbe7fee7b3fb7cc1c6381f62720e11c394770f 100644 --- a/Sources/BackupFeature/Service/BackupService.swift +++ b/Sources/BackupFeature/Service/BackupService.swift @@ -1,21 +1,30 @@ import UIKit import Models import Combine +import XXClient import Defaults -import Keychain import NetworkMonitor -import DependencyInjection -import XXClient import XXMessengerClient +import DependencyInjection + +import CloudFiles +import CloudFilesSFTP +import CloudFilesDrive +import CloudFilesICloud +import CloudFilesDropbox + +import KeychainAccess + +public enum BackupProvider: Equatable, Codable { + case sftp + case drive + case icloud + case dropbox +} public final class BackupService { @Dependency var messenger: Messenger - @Dependency var sftpService: SFTPService - @Dependency var icloudService: iCloudInterface - @Dependency var dropboxService: DropboxInterface @Dependency var networkManager: NetworkMonitoring - @Dependency var keychainHandler: KeychainHandling - @Dependency var driveService: GoogleDriveInterface @KeyObject(.username, defaultValue: nil) var username: String? @KeyObject(.backupSettings, defaultValue: nil) var storedSettings: Data? @@ -44,17 +53,23 @@ public final class BackupService { .sink { [unowned self] in connType = $0 } .store(in: &cancellables) } + + public func setupSFTP(host: String, username: String, password: String) { + managers[.sftp] = .sftp( + host: host, + username: username, + password: password, + fileName: "backup.xxm" + ) + refreshBackups() + refreshConnections() + } } extension BackupService { public func stopBackups() { - print(">>> [AccountBackup] Requested to stop backup mechanism") - if messenger.isBackupRunning() == true { - print(">>> [AccountBackup] messenger.isBackupRunning() == true") try! messenger.stopBackup() - - print(">>> [AccountBackup] Stopped backup mechanism") } } @@ -63,20 +78,14 @@ extension BackupService { password: passphrase, params: .init(username: username!) ) - - print(">>> [AccountBackup] Initialized backup mechanism") } public func performBackupIfAutomaticIsEnabled() { - print(">>> [AccountBackup] Requested backup if automatic is enabled") - guard settings.value.automaticBackups == true else { return } performBackup() } public func performBackup() { - print(">>> [AccountBackup] Requested backup without explicitly passing data") - guard let directoryUrl = try? FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, @@ -88,17 +97,11 @@ extension BackupService { .appendingPathComponent("backup") .appendingPathExtension("xxm") - guard let data = try? Data(contentsOf: fileUrl) else { - print(">>> [AccountBackup] Tried to backup arbitrarily but there was nothing to be backed up. Aborting...") - return - } - + guard let data = try? Data(contentsOf: fileUrl) else { return } performBackup(data: data) } public func updateBackup(data: Data) { - print(">>> [AccountBackup] Requested to update backup passing data") - guard let directoryUrl = try? FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, @@ -144,161 +147,64 @@ extension BackupService { performBackup() } - public func toggle(service: CloudService, enabling: Bool) { + public func toggle(service: BackupProvider, enabling: Bool) { settings.value.enabledService = enabling ? service : nil } - public func authorize(service: CloudService, presenting screen: UIViewController) { - switch service { - case .drive: - driveService.authorize(presenting: screen) { [weak self] _ in - guard let self = self else { return } - self.refreshConnections() - self.refreshBackups() - } - case .icloud: - if !icloudService.isAuthorized() { - icloudService.openSettings() - } else { - refreshConnections() - refreshBackups() - } - case .dropbox: - if !dropboxService.isAuthorized() { - dropboxService.authorize(presenting: screen) - .sink { [weak self] _ in - guard let self = self else { return } - self.refreshConnections() - self.refreshBackups() - }.store(in: &cancellables) - } - case .sftp: - if !sftpService.isAuthorized() { - sftpService.authorizeFlow((screen, { [weak self] in - guard let self = self else { return } - screen.navigationController?.popViewController(animated: true) + public func authorize( + service: BackupProvider, + presenting screen: UIViewController + ) { + do { + try managers[service]?.link(screen) { [weak self] in + guard let self else { return } + switch $0 { + case .success: self.refreshConnections() self.refreshBackups() - })) + case .failure(let error): + print(error.localizedDescription) + } } + } catch { + print(error.localizedDescription) } } } extension BackupService { private func refreshConnections() { - if icloudService.isAuthorized() && !settings.value.connectedServices.contains(.icloud) { - settings.value.connectedServices.insert(.icloud) - } else if !icloudService.isAuthorized() && settings.value.connectedServices.contains(.icloud) { - settings.value.connectedServices.remove(.icloud) - } - - if dropboxService.isAuthorized() && !settings.value.connectedServices.contains(.dropbox) { - settings.value.connectedServices.insert(.dropbox) - } else if !dropboxService.isAuthorized() && settings.value.connectedServices.contains(.dropbox) { - settings.value.connectedServices.remove(.dropbox) - } - - if sftpService.isAuthorized() && !settings.value.connectedServices.contains(.sftp) { - settings.value.connectedServices.insert(.sftp) - } else if !sftpService.isAuthorized() && settings.value.connectedServices.contains(.sftp) { - settings.value.connectedServices.remove(.sftp) - } - - driveService.isAuthorized { [weak settings] isAuthorized in - guard let settings = settings else { return } - - if isAuthorized && !settings.value.connectedServices.contains(.drive) { - settings.value.connectedServices.insert(.drive) - } else if !isAuthorized && settings.value.connectedServices.contains(.drive) { - settings.value.connectedServices.remove(.drive) + managers.forEach { provider, manager in + if manager.isLinked() && !settings.value.connectedServices.contains(provider) { + settings.value.connectedServices.insert(provider) + } else if !manager.isLinked() && settings.value.connectedServices.contains(provider) { + settings.value.connectedServices.remove(provider) } } } private func refreshBackups() { - print(">>> Refreshing backups...") - - if icloudService.isAuthorized() { - print(">>> Refreshing icloud backup...") - - icloudService.downloadMetadata { [weak settings] in - guard let settings = settings else { return } - - guard let metadata = try? $0.get() else { - settings.value.backups[.icloud] = nil - return - } - - settings.value.backups[.icloud] = BackupModel( - id: metadata.path, - date: metadata.modifiedDate, - size: metadata.size - ) - } - } - - if sftpService.isAuthorized() { - print(">>> Refreshing sftp backup...") - - sftpService.fetchMetadata { [weak settings] in - guard let settings = settings else { return } - - guard let metadata = try? $0.get()?.backup else { - settings.value.backups[.sftp] = nil - return - } - - settings.value.backups[.sftp] = BackupModel( - id: metadata.id, - date: metadata.date, - size: metadata.size - ) - } - } - - if dropboxService.isAuthorized() { - print(">>> Refreshing dropbox backup...") - - dropboxService.downloadMetadata { [weak settings] in - guard let settings = settings else { return } - - guard let metadata = try? $0.get() else { - settings.value.backups[.dropbox] = nil - return - } - - settings.value.backups[.dropbox] = BackupModel( - id: metadata.path, - date: metadata.modifiedDate, - size: metadata.size - ) - } - } - - driveService.isAuthorized { [weak settings] isAuthorized in - print(">>> Refreshing drive backup...") - guard let settings = settings else { return } - - if isAuthorized { - self.driveService.downloadMetadata { - guard let metadata = try? $0.get() else { return } - - settings.value.backups[.drive] = BackupModel( - id: metadata.identifier, - date: metadata.modifiedDate, - size: metadata.size - ) + managers.forEach { provider, manager in + if manager.isLinked() { + do { + try manager.fetch { [weak self] in + guard let self else { return } + + switch $0 { + case .success(let metadata): + self.settings.value.backups[provider] = metadata + case .failure(let error): + print(error.localizedDescription) + } + } + } catch { + print(error.localizedDescription) } - } else { - settings.value.backups[.drive] = nil } } } private func performBackup(data: Data) { - print(">>> Did call performBackup(data)") - guard let enabledService = settings.value.enabledService else { fatalError("Trying to backup but nothing is enabled") } @@ -313,62 +219,39 @@ extension BackupService { return } - switch enabledService { - case .drive: - print(">>> Performing upload on drive") - driveService.uploadBackup(url) { - switch $0 { - case .success(let metadata): - self.settings.value.backups[.drive] = .init( - id: metadata.identifier, - date: metadata.modifiedDate, - size: metadata.size - ) - case .failure(let error): - print(error.localizedDescription) - } - } - case .icloud: - print(">>> Performing upload on iCloud") - icloudService.uploadBackup(url) { - switch $0 { - case .success(let metadata): - self.settings.value.backups[.icloud] = .init( - id: metadata.path, - date: metadata.modifiedDate, - size: metadata.size - ) - case .failure(let error): - print(error.localizedDescription) - } + if enabledService == .sftp { + let keychain = Keychain(service: "SFTP-XXM") + guard let host = try? keychain.get("host"), + let password = try? keychain.get("pwd"), + let username = try? keychain.get("username") else { + fatalError("Tried to perform an sftp backup but its not configured") } - case .dropbox: - print(">>> Performing upload on dropbox") - dropboxService.uploadBackup(url) { - switch $0 { - case .success(let metadata): - print(">>> Performed upload on dropbox: \(metadata)") - self.settings.value.backups[.dropbox] = .init( - id: metadata.path, - date: metadata.modifiedDate, - size: metadata.size - ) + managers[.sftp] = .sftp( + host: host, + username: username, + password: password, + fileName: "backup.xxm" + ) + } - self.refreshBackups() - case .failure(let error): - print(error.localizedDescription) - } - } - case .sftp: - print(">>> Performing upload on sftp") - sftpService.uploadBackup(url: url) { - switch $0 { - case .success(let backup): - self.settings.value.backups[.sftp] = backup - case .failure(let error): - print(error.localizedDescription) + if let manager = managers[enabledService] { + do { + try manager.upload(data) { [weak self] in + guard let self else { return } + + switch $0 { + case .success(let metadata): + self.settings.value.backups[enabledService] = .init( + size: metadata.size, + lastModified: metadata.lastModified + ) + case .failure(let error): + print(error.localizedDescription) + } } + } catch { + print(error.localizedDescription) } } } diff --git a/Sources/BackupFeature/ViewModels/BackupConfigViewModel.swift b/Sources/BackupFeature/ViewModels/BackupConfigViewModel.swift index 0b205733e2f44e8554a8c3df9add0d8382d72af7..ba2ce35ef322919fa4584106eca3fdc848ff89e4 100644 --- a/Sources/BackupFeature/ViewModels/BackupConfigViewModel.swift +++ b/Sources/BackupFeature/ViewModels/BackupConfigViewModel.swift @@ -9,6 +9,8 @@ import Foundation import DependencyInjection +import CloudFiles + enum BackupActionState { case backupFinished case backupAllowed(Bool) @@ -19,15 +21,15 @@ struct BackupConfigViewModel { var didTapBackupNow: () -> Void var didChooseWifiOnly: (Bool) -> Void var didChooseAutomatic: (Bool) -> Void - var didToggleService: (UIViewController, CloudService, Bool) -> Void - var didTapService: (CloudService, UIViewController) -> Void + var didToggleService: (UIViewController, BackupProvider, Bool) -> Void + var didTapService: (BackupProvider, UIViewController) -> Void var wifiOnly: () -> AnyPublisher<Bool, Never> var automatic: () -> AnyPublisher<Bool, Never> - var lastBackup: () -> AnyPublisher<BackupModel?, Never> + var lastBackup: () -> AnyPublisher<Fetch.Metadata?, Never> var actionState: () -> AnyPublisher<BackupActionState, Never> - var enabledService: () -> AnyPublisher<CloudService?, Never> - var connectedServices: () -> AnyPublisher<Set<CloudService>, Never> + var enabledService: () -> AnyPublisher<BackupProvider?, Never> + var connectedServices: () -> AnyPublisher<Set<BackupProvider>, Never> } extension BackupConfigViewModel { @@ -56,7 +58,6 @@ extension BackupConfigViewModel { context.service.stopBackups() return } - context.coordinator.toPassphrase(from: controller, cancelClosure: { context.service.toggle(service: service, enabling: false) }, passphraseClosure: { passphrase in @@ -66,7 +67,16 @@ extension BackupConfigViewModel { context.hud.update(with: .none) }) }, - didTapService: context.service.authorize, + didTapService: { service, controller in + if service == .sftp { + context.coordinator.toSFTP(from: controller) { host, username, password in + context.service.setupSFTP(host: host, username: username, password: password) + } + return + } + + context.service.authorize(service: service, presenting: controller) + }, wifiOnly: { context.service.settingsPublisher .map(\.wifiOnlyBackup) @@ -79,7 +89,6 @@ extension BackupConfigViewModel { }, lastBackup: { context.service.settingsPublisher - .print(">>> lastBackup updated!") .map { guard let enabledService = $0.enabledService else { return nil } return $0.backups[enabledService] diff --git a/Sources/BackupFeature/ViewModels/BackupSetupViewModel.swift b/Sources/BackupFeature/ViewModels/BackupSetupViewModel.swift index fe94e5b00d0090c8bd39d443dd771ad3468fccfc..c504cd0dec0903c6b21add727adedddc83d6b16b 100644 --- a/Sources/BackupFeature/ViewModels/BackupSetupViewModel.swift +++ b/Sources/BackupFeature/ViewModels/BackupSetupViewModel.swift @@ -5,7 +5,7 @@ import Combine import DependencyInjection struct BackupSetupViewModel { - var didTapService: (CloudService, UIViewController) -> Void + var didTapService: (BackupProvider, UIViewController) -> Void } extension BackupSetupViewModel { diff --git a/Sources/LaunchFeature/LaunchViewModel.swift b/Sources/LaunchFeature/LaunchViewModel.swift index 4ac8d1bfa793b4b4fa9854de283f62dec0770ccc..a207de77bfd825167e5f71efeac4600512daa929 100644 --- a/Sources/LaunchFeature/LaunchViewModel.swift +++ b/Sources/LaunchFeature/LaunchViewModel.swift @@ -24,6 +24,10 @@ import XXLegacyDatabaseMigrator import XXMessengerClient import NetworkMonitor +import CloudFiles +import CloudFilesSFTP +import CloudFilesDropbox + struct Update { let content: String let urlString: String @@ -42,7 +46,6 @@ final class LaunchViewModel { @Dependency var database: Database @Dependency var backupService: BackupService @Dependency var versionChecker: VersionChecker - @Dependency var dropboxService: DropboxInterface @Dependency var fetchBannedList: FetchBannedList @Dependency var reportingStatus: ReportingStatus @Dependency var toastController: ToastController @@ -72,6 +75,18 @@ final class LaunchViewModel { DispatchQueue.global().eraseToAnyScheduler() }() + private let dropboxManager = CloudFilesManager.dropbox( + appKey: "ppx0de5f16p9aq2", + path: "/backup/backup.xxm" + ) + + private let sftpManager = CloudFilesManager.sftp( + host: "", + username: "", + password: "", + fileName: "" + ) + private var cancellables = Set<AnyCancellable>() private let routeSubject = PassthroughSubject<LaunchRoute, Never>() private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) @@ -139,7 +154,8 @@ final class LaunchViewModel { hudSubject.send(.none) routeSubject.send(.chats) } else { - dropboxService.unlink() + try? sftpManager.unlink() + try? dropboxManager.unlink() hudSubject.send(.none) routeSubject.send(.onboarding) } @@ -161,8 +177,8 @@ final class LaunchViewModel { } private func cleanUp() { - // try? cMixManager.remove() - // try? keychainHandler.clear() + // try? cMixManager.remove() + // try? keychainHandler.clear() } private func presentOnboardingFlow() { @@ -699,13 +715,13 @@ extension LaunchViewModel { }) ) } else { - // print(DependencyInjection.Container.shared.dependencies) + //print(DependencyInjection.Container.shared.dependencies) } } } private func setupLogWriter() { - _ = try! SetLogLevel.live(.debug) + _ = try! SetLogLevel.live(.fatal) RegisterLogWriter.live(.init(handle: { XXLogger.live().debug($0) })) } diff --git a/Sources/Models/Backup.swift b/Sources/Models/Backup.swift deleted file mode 100644 index 104462f05c616bc5556d12b1185237e416ea24ee..0000000000000000000000000000000000000000 --- a/Sources/Models/Backup.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -public struct BackupModel: Equatable, Codable { - public var id: String - public var date: Date - public var size: Float - - public init( - id: String, - date: Date, - size: Float - ) { - self.id = id - self.date = date - self.size = size - } -} diff --git a/Sources/Models/BackupSettings.swift b/Sources/Models/BackupSettings.swift deleted file mode 100644 index 41b5d13d19dccc4aa84fea4a980d6efe0ee26eb7..0000000000000000000000000000000000000000 --- a/Sources/Models/BackupSettings.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -public struct BackupSettings: Equatable, Codable { - public var wifiOnlyBackup: Bool - public var automaticBackups: Bool - public var enabledService: CloudService? - public var connectedServices: Set<CloudService> - public var backups: [CloudService: BackupModel] - - public init( - wifiOnlyBackup: Bool = false, - automaticBackups: Bool = false, - enabledService: CloudService? = nil, - connectedServices: Set<CloudService> = [], - backups: [CloudService: BackupModel] = [:] - ) { - self.wifiOnlyBackup = wifiOnlyBackup - self.automaticBackups = automaticBackups - self.enabledService = enabledService - self.connectedServices = connectedServices - self.backups = backups - } - - public func toData() -> Data { - (try? PropertyListEncoder().encode(self)) ?? Data() - } - - public init(fromData data: Data?) { - if let data = data, let settings = try? PropertyListDecoder().decode(BackupSettings.self, from: data) { - self.init( - wifiOnlyBackup: settings.wifiOnlyBackup, - automaticBackups: settings.automaticBackups, - enabledService: settings.enabledService, - connectedServices: settings.connectedServices, - backups: settings.backups - ) - } else { - self.init( - wifiOnlyBackup: false, - automaticBackups: true, - enabledService: nil, - connectedServices: [], - backups: [:] - ) - } - } -} - -public struct RestoreSettings { - public var backup: BackupModel? - public var cloudService: CloudService - - public init( - backup: BackupModel? = nil, - cloudService: CloudService - ) { - self.backup = backup - self.cloudService = cloudService - } -} diff --git a/Sources/Models/CloudService.swift b/Sources/Models/CloudService.swift deleted file mode 100644 index d217dca853e6ecf22f119520d9805567f7ead3a5..0000000000000000000000000000000000000000 --- a/Sources/Models/CloudService.swift +++ /dev/null @@ -1,6 +0,0 @@ -public enum CloudService: Equatable, Codable { - case drive - case icloud - case dropbox - case sftp -} diff --git a/Sources/RestoreFeature/Controllers/RestoreController.swift b/Sources/RestoreFeature/Controllers/RestoreController.swift index 83af547638a6b8ec1808ec234f76adb801fd44ba..27031b7556a1aa0c7550358bc704b0fa181e257c 100644 --- a/Sources/RestoreFeature/Controllers/RestoreController.swift +++ b/Sources/RestoreFeature/Controllers/RestoreController.swift @@ -1,5 +1,4 @@ import UIKit -import Models import Shared import Combine import DrawerFeature @@ -14,8 +13,8 @@ public final class RestoreController: UIViewController { private var cancellables = Set<AnyCancellable>() private var drawerCancellables = Set<AnyCancellable>() - public init(_ settings: RestoreSettings) { - viewModel = .init(settings: settings) + public init(_ details: RestorationDetails) { + viewModel = .init(details: details) super.init(nibName: nil, bundle: nil) } @@ -29,7 +28,7 @@ public final class RestoreController: UIViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationItem.backButtonTitle = "" - navigationController?.navigationBar.customize() + navigationController?.navigationBar.customize(translucent: true) } public override func viewDidLoad() { @@ -43,56 +42,55 @@ public final class RestoreController: UIViewController { title.text = Localized.AccountRestore.header title.textColor = Asset.neutralActive.color title.font = Fonts.Mulish.semiBold.font(size: 18.0) - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: title) navigationItem.leftItemsSupplementBackButton = true } private func setupBindings() { - viewModel.step - .receive(on: DispatchQueue.main) - .removeDuplicates() - .sink { [unowned self] in - screenView.updateFor(step: $0) - - if $0 == .wrongPass { - coordinator.toPassphrase( - from: self, - cancelClosure: { self.dismiss(animated: true) }, - passphraseClosure: { pwd in - self.viewModel.retryWith(passphrase: pwd) - } - ) - - return - } - - if $0 == .done { - coordinator.toSuccess(from: self) - } - }.store(in: &cancellables) - - screenView.backButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in didTapBack() } - .store(in: &cancellables) - - screenView.cancelButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in didTapBack() } - .store(in: &cancellables) - - screenView.restoreButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in - coordinator.toPassphrase( - from: self, - cancelClosure: { self.dismiss(animated: true) }, - passphraseClosure: { pwd in - self.viewModel.didTapRestore(passphrase: pwd) - } - ) - }.store(in: &cancellables) + viewModel.stepPublisher + .receive(on: DispatchQueue.main) + .removeDuplicates() + .sink { [unowned self] in + screenView.updateFor(step: $0) + + if $0 == .wrongPass { + coordinator.toPassphrase( + from: self, + cancelClosure: { self.dismiss(animated: true) }, + passphraseClosure: { pwd in + self.viewModel.retryWith(passphrase: pwd) + } + ) + + return + } + + if $0 == .done { + coordinator.toSuccess(from: self) + } + }.store(in: &cancellables) + + screenView.backButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in didTapBack() } + .store(in: &cancellables) + + screenView.cancelButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in didTapBack() } + .store(in: &cancellables) + + screenView.restoreButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in + coordinator.toPassphrase( + from: self, + cancelClosure: { self.dismiss(animated: true) }, + passphraseClosure: { pwd in + self.viewModel.didTapRestore(passphrase: pwd) + } + ) + }.store(in: &cancellables) } @objc private func didTapBack() { diff --git a/Sources/RestoreFeature/Controllers/RestoreListController.swift b/Sources/RestoreFeature/Controllers/RestoreListController.swift index a10fdfcf8101fc59cd63b8ff7a8807aa56aea77f..15acce20539862d69c36ab73b483a97fa356dc2f 100644 --- a/Sources/RestoreFeature/Controllers/RestoreListController.swift +++ b/Sources/RestoreFeature/Controllers/RestoreListController.swift @@ -6,104 +6,126 @@ import DrawerFeature import DependencyInjection public final class RestoreListController: UIViewController { - @Dependency private var hud: HUD - @Dependency private var coordinator: RestoreCoordinating - - lazy private var screenView = RestoreListView() - - private let viewModel = RestoreListViewModel() - private var cancellables = Set<AnyCancellable>() - private var drawerCancellables = Set<AnyCancellable>() - - public override func loadView() { - view = screenView - presentWarning() - } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationItem.backButtonTitle = "" - navigationController?.navigationBar.customize(translucent: true) - } - - public override func viewDidLoad() { - super.viewDidLoad() - - viewModel.hudPublisher - .receive(on: DispatchQueue.main) - .sink { [hud] in hud.update(with: $0) } - .store(in: &cancellables) - - viewModel.backupPublisher - .receive(on: DispatchQueue.main) - .sink { [unowned self] in - coordinator.toRestore(with: $0, from: self) - }.store(in: &cancellables) - - screenView.cancelButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in didTapBack() } - .store(in: &cancellables) - - screenView.driveButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in - viewModel.didTapCloud(.drive, from: self) - }.store(in: &cancellables) - - screenView.icloudButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in - viewModel.didTapCloud(.icloud, from: self) - }.store(in: &cancellables) - - screenView.dropboxButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in - viewModel.didTapCloud(.dropbox, from: self) - }.store(in: &cancellables) - - screenView.sftpButton - .publisher(for: .touchUpInside) - .sink { [unowned self] in - viewModel.didTapCloud(.sftp, from: self) - }.store(in: &cancellables) - } - - @objc private func didTapBack() { - navigationController?.popViewController(animated: true) - } + @Dependency var hud: HUD + @Dependency var coordinator: RestoreCoordinating + + lazy private var screenView = RestoreListView() + + private let viewModel = RestoreListViewModel() + private var cancellables = Set<AnyCancellable>() + private var drawerCancellables = Set<AnyCancellable>() + + public override func loadView() { + view = screenView + presentWarning() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + navigationController?.navigationBar.customize(translucent: true) + } + + public override func viewDidLoad() { + super.viewDidLoad() + + viewModel.sftpPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] _ in + coordinator.toSFTP(from: self) { [weak self] host, username, password in + guard let self else { return } + self.viewModel.setupSFTP( + host: host, + username: username, + password: password + ) + } + }.store(in: &cancellables) + + viewModel.hudPublisher + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + viewModel.detailsPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in + coordinator.toRestore(with: $0, from: self) + }.store(in: &cancellables) + + screenView.cancelButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in didTapBack() } + .store(in: &cancellables) + + screenView.driveButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in + viewModel.link(provider: .drive, from: self) { [weak self] in + guard let self else { return } + self.viewModel.fetch(provider: .drive) + } + }.store(in: &cancellables) + + screenView.icloudButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in + viewModel.link(provider: .icloud, from: self) { [weak self] in + guard let self else { return } + self.viewModel.fetch(provider: .icloud) + } + }.store(in: &cancellables) + + screenView.dropboxButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in + viewModel.link(provider: .dropbox, from: self) { [weak self] in + guard let self else { return } + self.viewModel.fetch(provider: .dropbox) + } + }.store(in: &cancellables) + + screenView.sftpButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in + viewModel.link(provider: .sftp, from: self) {} + }.store(in: &cancellables) + } + + @objc private func didTapBack() { + navigationController?.popViewController(animated: true) + } } extension RestoreListController { - private func presentWarning() { - let actionButton = DrawerCapsuleButton(model: .init( - title: Localized.AccountRestore.Warning.action, - style: .brandColored - )) - - let drawer = DrawerController(with: [ - DrawerText( - font: Fonts.Mulish.bold.font(size: 26.0), - text: Localized.AccountRestore.Warning.title, - spacingAfter: 19 - ), - DrawerText( - text: Localized.AccountRestore.Warning.subtitle, - spacingAfter: 37 - ), - actionButton - ]) - - actionButton.action - .receive(on: DispatchQueue.main) - .sink { - drawer.dismiss(animated: true) { [weak self] in - guard let self = self else { return } - self.drawerCancellables.removeAll() - } - }.store(in: &drawerCancellables) - - coordinator.toDrawer(drawer, from: self) - } + private func presentWarning() { + let actionButton = DrawerCapsuleButton(model: .init( + title: Localized.AccountRestore.Warning.action, + style: .brandColored + )) + + let drawer = DrawerController(with: [ + DrawerText( + font: Fonts.Mulish.bold.font(size: 26.0), + text: Localized.AccountRestore.Warning.title, + spacingAfter: 19 + ), + DrawerText( + text: Localized.AccountRestore.Warning.subtitle, + spacingAfter: 37 + ), + actionButton + ]) + + actionButton.action + .receive(on: DispatchQueue.main) + .sink { + drawer.dismiss(animated: true) { [weak self] in + guard let self = self else { return } + self.drawerCancellables.removeAll() + } + }.store(in: &drawerCancellables) + + coordinator.toDrawer(drawer, from: self) + } } diff --git a/Sources/RestoreFeature/Controllers/RestoreSFTPController.swift b/Sources/RestoreFeature/Controllers/RestoreSFTPController.swift new file mode 100644 index 0000000000000000000000000000000000000000..c60b41d9e19d3540dd3090abdd9ea550a21b3906 --- /dev/null +++ b/Sources/RestoreFeature/Controllers/RestoreSFTPController.swift @@ -0,0 +1,89 @@ +import HUD +import UIKit +import Combine +import DependencyInjection +import ScrollViewController + +public final class RestoreSFTPController: UIViewController { + @Dependency private var hud: HUD + + lazy private var screenView = RestoreSFTPView() + lazy private var scrollViewController = ScrollViewController() + + private let completion: (String, String, String) -> Void + private let viewModel = RestoreSFTPViewModel() + private var cancellables = Set<AnyCancellable>() + + public init(_ completion: @escaping (String, String, String) -> Void) { + self.completion = completion + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { nil } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationItem.backButtonTitle = "" + navigationController?.navigationBar.customize(translucent: true) + } + + public override func viewDidLoad() { + super.viewDidLoad() + setupScrollView() + setupBindings() + } + + private func setupScrollView() { + scrollViewController.scrollView.backgroundColor = .white + + addChild(scrollViewController) + view.addSubview(scrollViewController.view) + scrollViewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + scrollViewController.didMove(toParent: self) + scrollViewController.contentView = screenView + } + + private func setupBindings() { + viewModel.hudPublisher + .receive(on: DispatchQueue.main) + .sink { [hud] in hud.update(with: $0) } + .store(in: &cancellables) + + viewModel.authPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] params in + dismiss(animated: true) { + self.completion(params.0, params.1, params.2) + } + }.store(in: &cancellables) + + screenView.hostField + .textPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didEnterHost($0) } + .store(in: &cancellables) + + screenView.usernameField + .textPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didEnterUsername($0) } + .store(in: &cancellables) + + screenView.passwordField + .textPublisher + .receive(on: DispatchQueue.main) + .sink { [unowned self] in viewModel.didEnterPassword($0) } + .store(in: &cancellables) + + viewModel.statePublisher + .receive(on: DispatchQueue.main) + .map(\.isButtonEnabled) + .sink { [unowned self] in screenView.loginButton.isEnabled = $0 } + .store(in: &cancellables) + + screenView.loginButton + .publisher(for: .touchUpInside) + .sink { [unowned self] in viewModel.didTapLogin() } + .store(in: &cancellables) + } +} diff --git a/Sources/RestoreFeature/Coordinator/RestoreCoordinator.swift b/Sources/RestoreFeature/Coordinator/RestoreCoordinator.swift index 83c698fb30595098474fcf5e324f3723eee4cf59..ee3e429c60263a60bbd3df20fb7fbdc91c8e7f1c 100644 --- a/Sources/RestoreFeature/Coordinator/RestoreCoordinator.swift +++ b/Sources/RestoreFeature/Coordinator/RestoreCoordinator.swift @@ -1,14 +1,20 @@ import UIKit -import Models import Shared import Presentation import ScrollViewController +public typealias SFTPDetailsClosure = (String, String, String) -> Void + public protocol RestoreCoordinating { func toChats(from: UIViewController) func toSuccess(from: UIViewController) func toDrawer(_: UIViewController, from: UIViewController) - func toRestore(with: RestoreSettings, from: UIViewController) + func toRestore(with: RestorationDetails, from: UIViewController) + + func toSFTP( + from: UIViewController, + detailsClosure: @escaping SFTPDetailsClosure + ) func toPassphrase( from: UIViewController, @@ -25,7 +31,8 @@ public struct RestoreCoordinator: RestoreCoordinating { var successFactory: () -> UIViewController var chatListFactory: () -> UIViewController - var restoreFactory: (RestoreSettings) -> UIViewController + var restoreFactory: (RestorationDetails) -> UIViewController + var sftpFactory: (@escaping SFTPDetailsClosure) -> UIViewController var passphraseFactory: ( @escaping EmptyClosure, @@ -35,12 +42,16 @@ public struct RestoreCoordinator: RestoreCoordinating { public init( successFactory: @escaping () -> UIViewController, chatListFactory: @escaping () -> UIViewController, - restoreFactory: @escaping (RestoreSettings) -> UIViewController, + restoreFactory: @escaping (RestorationDetails) -> UIViewController, + sftpFactory: @escaping ( + @escaping SFTPDetailsClosure + ) -> UIViewController, passphraseFactory: @escaping ( @escaping EmptyClosure, @escaping StringClosure ) -> UIViewController ) { + self.sftpFactory = sftpFactory self.successFactory = successFactory self.restoreFactory = restoreFactory self.chatListFactory = chatListFactory @@ -49,11 +60,19 @@ public struct RestoreCoordinator: RestoreCoordinating { } public extension RestoreCoordinator { + func toSFTP( + from parent: UIViewController, + detailsClosure: @escaping SFTPDetailsClosure + ) { + let screen = sftpFactory(detailsClosure) + pushPresenter.present(screen, from: parent) + } + func toRestore( - with settings: RestoreSettings, + with details: RestorationDetails, from parent: UIViewController ) { - let screen = restoreFactory(settings) + let screen = restoreFactory(details) pushPresenter.present(screen, from: parent) } diff --git a/Sources/RestoreFeature/Utils/PlistSecrets.swift b/Sources/RestoreFeature/Utils/PlistSecrets.swift new file mode 100644 index 0000000000000000000000000000000000000000..a96d25f9e7a272043daa2ade32e9dea5626bfb08 --- /dev/null +++ b/Sources/RestoreFeature/Utils/PlistSecrets.swift @@ -0,0 +1,48 @@ +import Foundation + +struct PlistSecrets { + struct GooglePlist: Decodable { + let apiKey: String + let clientId: String + + enum CodingKeys: String, CodingKey { + case apiKey = "API_KEY" + case clientId = "CLIENT_ID" + } + } + + struct InfoPlist: Decodable { + let dropboxAppKey: String + + enum CodingKeys: String, CodingKey { + case dropboxAppKey = "DROPBOX_APP_KEY" + } + } + + static var googleAPIKey: String { + guard let url = Bundle.main.url(forResource: "GoogleService-Info", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListDecoder().decode(GooglePlist.self, from: data) else { + fatalError("Can't decode GoogleService-Info.plist") + } + return plist.apiKey + } + + static var googleClientId: String { + guard let url = Bundle.main.url(forResource: "GoogleService-Info", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListDecoder().decode(GooglePlist.self, from: data) else { + fatalError("Can't decode GoogleService-Info.plist") + } + return plist.clientId + } + + static var dropboxAppKey: String { + guard let url = Bundle.main.url(forResource: "Info", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListDecoder().decode(InfoPlist.self, from: data) else { + fatalError("Can't decode info.plist") + } + return plist.dropboxAppKey + } +} diff --git a/Sources/RestoreFeature/ViewModels/RestoreListViewModel.swift b/Sources/RestoreFeature/ViewModels/RestoreListViewModel.swift index a8bc39c3a3f3fa106d9500044b8091813e1baeff..f9b252fa459fb1f38ff8b7e5c3167588442f0dfe 100644 --- a/Sources/RestoreFeature/ViewModels/RestoreListViewModel.swift +++ b/Sources/RestoreFeature/ViewModels/RestoreListViewModel.swift @@ -1,149 +1,119 @@ import HUD import UIKit -import Models -import Shared import Combine -import BackupFeature -import DependencyInjection -final class RestoreListViewModel { - @Dependency private var sftpService: SFTPService - @Dependency private var icloudService: iCloudInterface - @Dependency private var dropboxService: DropboxInterface - @Dependency private var googleDriveService: GoogleDriveInterface +import CloudFiles +import CloudFilesSFTP +import CloudFilesDrive +import CloudFilesICloud +import CloudFilesDropbox - var hudPublisher: AnyPublisher<HUDStatus, Never> { - hudSubject.eraseToAnyPublisher() - } +import DependencyInjection - var backupPublisher: AnyPublisher<RestoreSettings, Never> { - backupSubject.eraseToAnyPublisher() - } +enum RestorationProvider: String, Equatable, Hashable { + case sftp + case drive + case icloud + case dropbox +} - private var dropboxAuthCancellable: AnyCancellable? - private let hudSubject = PassthroughSubject<HUDStatus, Never>() - private let backupSubject = PassthroughSubject<RestoreSettings, Never>() +public struct RestorationDetails { + var provider: RestorationProvider + var metadata: Fetch.Metadata? +} - func didTapCloud(_ cloudService: CloudService, from parent: UIViewController) { - switch cloudService { - case .drive: - didRequestDriveAuthorization(from: parent) - case .icloud: - didRequestICloudAuthorization() - case .dropbox: - didRequestDropboxAuthorization(from: parent) - case .sftp: - didRequestSFTPAuthorization(from: parent) - } +final class RestoreListViewModel { + var sftpPublisher: AnyPublisher<Void, Never> { + sftpSubject.eraseToAnyPublisher() + } + + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() + } + + var detailsPublisher: AnyPublisher<RestorationDetails, Never> { + detailsSubject.eraseToAnyPublisher() + } + + private var managers: [RestorationProvider: CloudFilesManager] = [ + .icloud: .iCloud( + fileName: "backup.xxm" + ), + .dropbox: .dropbox( + appKey: PlistSecrets.dropboxAppKey, + path: "/backup/backup.xxm" + ), + .drive: .drive( + apiKey: PlistSecrets.googleAPIKey, + clientId: PlistSecrets.googleClientId, + fileName: "backup.xxm" + ) + ] + + private let sftpSubject = PassthroughSubject<Void, Never>() + private let hudSubject = PassthroughSubject<HUDStatus, Never>() + private let detailsSubject = PassthroughSubject<RestorationDetails, Never>() + + func setupSFTP(host: String, username: String, password: String) { + managers[.sftp] = .sftp( + host: host, + username: username, + password: password, + fileName: "backup.xxm" + ) + fetch(provider: .sftp) + } + + func link( + provider: RestorationProvider, + from controller: UIViewController, + onSuccess: @escaping () -> Void + ) { + if provider == .sftp { + sftpSubject.send(()) + return } - - private func didRequestSFTPAuthorization(from controller: UIViewController) { - let params = SFTPAuthorizationParams(controller, { [weak self] in - guard let self = self else { return } - controller.navigationController?.popViewController(animated: true) - - self.hudSubject.send(.on) - - self.sftpService.fetchMetadata{ result in - switch result { - case .success(let settings): - self.hudSubject.send(.none) - - if let settings = settings { - self.backupSubject.send(settings) - } else { - self.backupSubject.send(.init(cloudService: .sftp)) - } - case .failure(let error): - self.hudSubject.send(.error(.init(with: error))) - } - } - }) - - sftpService.authorizeFlow(params) + guard let manager = managers[provider] else { + return } - - private func didRequestDriveAuthorization(from controller: UIViewController) { - googleDriveService.authorize(presenting: controller) { authResult in - switch authResult { - case .success: - self.hudSubject.send(.on) - self.googleDriveService.downloadMetadata { downloadResult in - switch downloadResult { - case .success(let metadata): - var backup: BackupModel? - - if let metadata = metadata { - backup = .init(id: metadata.identifier, date: metadata.modifiedDate, size: metadata.size) - } - - self.hudSubject.send(.none) - self.backupSubject.send(RestoreSettings(backup: backup, cloudService: .drive)) - - case .failure(let error): - self.hudSubject.send(.error(.init(with: error))) - } - } - case .failure(let error): - self.hudSubject.send(.error(.init(with: error))) - } + do { + try manager.link(controller) { [weak self] in + guard let self else {return } + + switch $0 { + case .success: + onSuccess() + case .failure(let error): + self.hudSubject.send(.error(.init(with: error))) } + } + } catch { + hudSubject.send(.error(.init(with: error))) } + } - private func didRequestICloudAuthorization() { - if icloudService.isAuthorized() { - self.hudSubject.send(.on) - - icloudService.downloadMetadata { result in - switch result { - case .success(let metadata): - var backup: BackupModel? - - if let metadata = metadata { - backup = .init(id: metadata.path, date: metadata.modifiedDate, size: metadata.size) - } - - self.hudSubject.send(.none) - self.backupSubject.send(RestoreSettings(backup: backup, cloudService: .icloud)) - case .failure(let error): - self.hudSubject.send(.error(.init(with: error))) - } - } - } else { - /// This could be an alert controller asking if user wants to enable/deeplink - /// - icloudService.openSettings() - } + func fetch(provider: RestorationProvider) { + guard let manager = managers[provider] else { + return } - - private func didRequestDropboxAuthorization(from controller: UIViewController) { - dropboxAuthCancellable = dropboxService.authorize(presenting: controller) - .receive(on: DispatchQueue.main) - .sink { [unowned self] authResult in - switch authResult { - case .success(let bool): - guard bool == true else { return } - - self.hudSubject.send(.on) - dropboxService.downloadMetadata { metadataResult in - switch metadataResult { - case .success(let metadata): - var backup: BackupModel? - - if let metadata = metadata { - backup = .init(id: metadata.path, date: metadata.modifiedDate, size: metadata.size) - } - - self.hudSubject.send(.none) - self.backupSubject.send(RestoreSettings(backup: backup, cloudService: .dropbox)) - - case .failure(let error): - self.hudSubject.send(.error(.init(with: error))) - } - } - case .failure(let error): - self.hudSubject.send(.error(.init(with: error))) - } - } + do { + try manager.fetch { [weak self] in + guard let self else { return } + + switch $0 { + case .success(let metadata): + DependencyInjection.Container.shared.register(manager) + + self.detailsSubject.send(.init( + provider: provider, + metadata: metadata + )) + case .failure(let error): + self.hudSubject.send(.error(.init(with: error))) + } + } + } catch { + hudSubject.send(.error(.init(with: error))) } + } } diff --git a/Sources/RestoreFeature/ViewModels/RestoreSFTPViewModel.swift b/Sources/RestoreFeature/ViewModels/RestoreSFTPViewModel.swift new file mode 100644 index 0000000000000000000000000000000000000000..a24baf97da13697938f17a59a26aa4dbce2f1a99 --- /dev/null +++ b/Sources/RestoreFeature/ViewModels/RestoreSFTPViewModel.swift @@ -0,0 +1,86 @@ +import UIKit + +import HUD +import Combine +import Foundation +import CloudFiles +import CloudFilesSFTP + +struct SFTPViewState { + var host: String = "" + var username: String = "" + var password: String = "" + var isButtonEnabled: Bool = false +} + +final class RestoreSFTPViewModel { + var hudPublisher: AnyPublisher<HUDStatus, Never> { + hudSubject.eraseToAnyPublisher() + } + + var statePublisher: AnyPublisher<SFTPViewState, Never> { + stateSubject.eraseToAnyPublisher() + } + + var authPublisher: AnyPublisher<(String, String, String), Never> { + authSubject.eraseToAnyPublisher() + } + + private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none) + private let stateSubject = CurrentValueSubject<SFTPViewState, Never>(.init()) + private let authSubject = PassthroughSubject<(String, String, String), Never>() + + func didEnterHost(_ string: String) { + stateSubject.value.host = string + validate() + } + + func didEnterUsername(_ string: String) { + stateSubject.value.username = string + validate() + } + + func didEnterPassword(_ string: String) { + stateSubject.value.password = string + validate() + } + + func didTapLogin() { + hudSubject.send(.on) + + let host = stateSubject.value.host + let username = stateSubject.value.username + let password = stateSubject.value.password + + let anyController = UIViewController() + + DispatchQueue.global().async { [weak self] in + guard let self = self else { return } + do { + try CloudFilesManager.sftp( + host: host, + username: username, + password: password, + fileName: "backup.xxm" + ).link(anyController) { + switch $0 { + case .success: + self.hudSubject.send(.none) + self.authSubject.send((host, username, password)) + case .failure(let error): + self.hudSubject.send(.error(.init(with: error))) + } + } + } catch { + self.hudSubject.send(.error(.init(with: error))) + } + } + } + + private func validate() { + stateSubject.value.isButtonEnabled = + !stateSubject.value.host.isEmpty && + !stateSubject.value.username.isEmpty && + !stateSubject.value.password.isEmpty + } +} diff --git a/Sources/RestoreFeature/ViewModels/RestoreViewModel.swift b/Sources/RestoreFeature/ViewModels/RestoreViewModel.swift index d28bb3e6574f0fda353fe24fccc23cf3d1e65d91..bda1797a4f2d800de73290ce4238bb53d675da31 100644 --- a/Sources/RestoreFeature/ViewModels/RestoreViewModel.swift +++ b/Sources/RestoreFeature/ViewModels/RestoreViewModel.swift @@ -3,27 +3,25 @@ import Models import Shared import Combine import Defaults -import Foundation -import BackupFeature +import CloudFiles import DependencyInjection +import XXClient import XXModels import XXDatabase - -import XXClient import XXMessengerClient -enum RestorationStep { - case idle(CloudService, BackupModel?) - case downloading(Float, Float) - case failDownload(Error) +enum Step { + case done case wrongPass case parsingData - case done + case failDownload(Error) + case downloading(Float, Float) + case idle(RestorationProvider, CloudFiles.Fetch.Metadata?) } -extension RestorationStep: Equatable { - static func ==(lhs: RestorationStep, rhs: RestorationStep) -> Bool { +extension Step: Equatable { + static func ==(lhs: Step, rhs: Step) -> Bool { switch (lhs, rhs) { case (.done, .done), (.wrongPass, .wrongPass): return true @@ -41,30 +39,27 @@ extension RestorationStep: Equatable { final class RestoreViewModel { @Dependency var database: Database @Dependency var messenger: Messenger - @Dependency var sftpService: SFTPService - @Dependency var iCloudService: iCloudInterface - @Dependency var dropboxService: DropboxInterface - @Dependency var googleService: GoogleDriveInterface + @Dependency var manager: CloudFilesManager - @KeyObject(.username, defaultValue: nil) var username: String? @KeyObject(.phone, defaultValue: nil) var phone: String? @KeyObject(.email, defaultValue: nil) var email: String? + @KeyObject(.username, defaultValue: nil) var username: String? - var step: AnyPublisher<RestorationStep, Never> { - stepRelay.eraseToAnyPublisher() + var stepPublisher: AnyPublisher<Step, Never> { + stepSubject.eraseToAnyPublisher() } - // TO REFACTOR: - // private var pendingData: Data? - private var passphrase: String! - private let settings: RestoreSettings - private let stepRelay: CurrentValueSubject<RestorationStep, Never> - - init(settings: RestoreSettings) { - self.settings = settings - self.stepRelay = .init(.idle(settings.cloudService, settings.backup)) + private let details: RestorationDetails + private let stepSubject: CurrentValueSubject<Step, Never> + + init(details: RestorationDetails) { + self.details = details + self.stepSubject = .init(.idle( + details.provider, + details.metadata + )) } func retryWith(passphrase: String) { @@ -75,83 +70,33 @@ final class RestoreViewModel { func didTapRestore(passphrase: String) { self.passphrase = passphrase - guard let backup = settings.backup else { fatalError() } - - stepRelay.send(.downloading(0.0, backup.size)) - - switch settings.cloudService { - case .drive: - downloadBackupForDrive(backup) - case .dropbox: - downloadBackupForDropbox(backup) - case .icloud: - downloadBackupForiCloud(backup) - case .sftp: - downloadBackupForSFTP(backup) - } - } - - private func downloadBackupForSFTP(_ backup: BackupModel) { - sftpService.downloadBackup(path: backup.id) { [weak self] in - guard let self = self else { return } - self.stepRelay.send(.downloading(backup.size, backup.size)) - - switch $0 { - case .success(let data): - self.continueRestoring(data: data) - case .failure(let error): - self.stepRelay.send(.failDownload(error)) - } - } - } - - private func downloadBackupForDropbox(_ backup: BackupModel) { - dropboxService.downloadBackup(backup.id) { [weak self] in - guard let self = self else { return } - self.stepRelay.send(.downloading(backup.size, backup.size)) - - switch $0 { - case .success(let data): - self.continueRestoring(data: data) - case .failure(let error): - self.stepRelay.send(.failDownload(error)) - } + guard let metadata = details.metadata else { + fatalError() } - } - private func downloadBackupForiCloud(_ backup: BackupModel) { - iCloudService.downloadBackup(backup.id) { [weak self] in - guard let self = self else { return } - self.stepRelay.send(.downloading(backup.size, backup.size)) + stepSubject.send(.downloading(0.0, metadata.size)) - switch $0 { - case .success(let data): - self.continueRestoring(data: data) - case .failure(let error): - self.stepRelay.send(.failDownload(error)) - } - } - } + do { + try manager.download { [weak self] in + guard let self else { return } - private func downloadBackupForDrive(_ backup: BackupModel) { - googleService.downloadBackup(backup.id) { [weak self] in - if let stepRelay = self?.stepRelay { - stepRelay.send(.downloading($0, backup.size)) - } - } _: { [weak self] in - guard let self = self else { return } - - switch $0 { - case .success(let data): - self.continueRestoring(data: data) - case .failure(let error): - self.stepRelay.send(.failDownload(error)) + switch $0 { + case .success(let data): + guard let data else { + fatalError("There was metadata, but not data.") + } + self.continueRestoring(data: data) + case .failure(let error): + self.stepSubject.send(.failDownload(error)) + } } + } catch { + stepSubject.send(.failDownload(error)) } } private func continueRestoring(data: Data) { - stepRelay.send(.parsingData) + stepSubject.send(.parsingData) DispatchQueue.global().async { [weak self] in guard let self = self else { return } @@ -208,11 +153,11 @@ final class RestoreViewModel { print(">>> Error: \($0.localizedDescription)") } - self.stepRelay.send(.done) + self.stepSubject.send(.done) } catch { print(">>> Error on restoration: \(error.localizedDescription)") self.pendingData = data - self.stepRelay.send(.wrongPass) + self.stepSubject.send(.wrongPass) } } } diff --git a/Sources/RestoreFeature/Views/RestoreSFTPView.swift b/Sources/RestoreFeature/Views/RestoreSFTPView.swift new file mode 100644 index 0000000000000000000000000000000000000000..5c12026bf8eda8865a226df8886c11a05108740a --- /dev/null +++ b/Sources/RestoreFeature/Views/RestoreSFTPView.swift @@ -0,0 +1,83 @@ +import UIKit +import Shared +import InputField + +final class RestoreSFTPView: UIView { + let titleLabel = UILabel() + let subtitleLabel = UILabel() + let hostField = OutlinedInputField() + let usernameField = OutlinedInputField() + let passwordField = OutlinedInputField() + let loginButton = CapsuleButton() + let stackView = UIStackView() + + init() { + super.init(frame: .zero) + backgroundColor = Asset.neutralWhite.color + + titleLabel.textColor = Asset.neutralDark.color + titleLabel.text = Localized.AccountRestore.Sftp.title + titleLabel.font = Fonts.Mulish.bold.font(size: 24.0) + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .left + paragraph.lineHeightMultiple = 1.15 + + let attString = NSMutableAttributedString( + string: Localized.AccountRestore.Sftp.subtitle, + attributes: [ + .foregroundColor: Asset.neutralBody.color, + .font: Fonts.Mulish.regular.font(size: 16.0) as Any, + .paragraphStyle: paragraph + ]) + + attString.setAttributes( + attributes: [ + .foregroundColor: Asset.neutralDark.color, + .font: Fonts.Mulish.bold.font(size: 12.0) as Any, + .paragraphStyle: paragraph + ], betweenCharacters: "*") + + subtitleLabel.numberOfLines = 0 + subtitleLabel.attributedText = attString + + hostField.setup(title: Localized.AccountRestore.Sftp.host) + usernameField.setup(title: Localized.AccountRestore.Sftp.username) + passwordField.setup(title: Localized.AccountRestore.Sftp.password, sensitive: true) + + loginButton.set(style: .brandColored, title: Localized.AccountRestore.Sftp.login) + + stackView.spacing = 30 + stackView.axis = .vertical + stackView.distribution = .fillEqually + stackView.addArrangedSubview(hostField) + stackView.addArrangedSubview(usernameField) + stackView.addArrangedSubview(passwordField) + stackView.addArrangedSubview(loginButton) + + addSubview(titleLabel) + addSubview(subtitleLabel) + addSubview(stackView) + + titleLabel.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide).offset(15) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-41) + } + + subtitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(8) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-41) + } + + stackView.snp.makeConstraints { + $0.top.equalTo(subtitleLabel.snp.bottom).offset(28) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-38) + $0.bottom.lessThanOrEqualToSuperview() + } + } + + required init?(coder: NSCoder) { nil } +} diff --git a/Sources/RestoreFeature/Views/RestoreView.swift b/Sources/RestoreFeature/Views/RestoreView.swift index de4499113b1b6550e823551f1456c953f1a5c794..5f1648ff53f89bec8ad233d748b932a27511a5e9 100644 --- a/Sources/RestoreFeature/Views/RestoreView.swift +++ b/Sources/RestoreFeature/Views/RestoreView.swift @@ -1,171 +1,173 @@ import UIKit import Shared -import Models final class RestoreView: UIView { - let titleLabel = UILabel() - let subtitleLabel = UILabel() - let detailsView = RestoreDetailsView() - let progressView = RestoreProgressView() - - let bottomStackView = UIStackView() - let backButton = CapsuleButton() - let cancelButton = CapsuleButton() - let restoreButton = CapsuleButton() - - init() { - super.init(frame: .zero) - backgroundColor = Asset.neutralWhite.color - - subtitleLabel.numberOfLines = 0 - titleLabel.font = Fonts.Mulish.bold.font(size: 24.0) - subtitleLabel.font = Fonts.Mulish.regular.font(size: 16.0) - titleLabel.textColor = Asset.neutralDark.color - subtitleLabel.textColor = Asset.neutralDark.color - - restoreButton.set(style: .brandColored, title: Localized.AccountRestore.Found.restore) - cancelButton.set(style: .simplestColoredBrand, title: Localized.AccountRestore.Found.cancel) - backButton.set(style: .seeThrough, title: Localized.AccountRestore.NotFound.back) - - bottomStackView.axis = .vertical - - addSubview(titleLabel) - addSubview(subtitleLabel) - addSubview(detailsView) - addSubview(progressView) - addSubview(bottomStackView) - - bottomStackView.addArrangedSubview(restoreButton) - bottomStackView.addArrangedSubview(cancelButton) - bottomStackView.addArrangedSubview(backButton) - - titleLabel.snp.makeConstraints { - $0.top.equalTo(safeAreaLayoutGuide).offset(20) - $0.left.equalToSuperview().offset(38) - $0.right.equalToSuperview().offset(-38) - } - - subtitleLabel.snp.makeConstraints { - $0.top.equalTo(titleLabel.snp.bottom).offset(20) - $0.left.equalToSuperview().offset(38) - $0.right.equalToSuperview().offset(-38) - } - - detailsView.snp.makeConstraints { - $0.top.equalTo(subtitleLabel.snp.bottom).offset(40) - $0.left.equalToSuperview() - $0.right.equalToSuperview() - } - - progressView.snp.makeConstraints { - $0.top.greaterThanOrEqualTo(detailsView.snp.bottom) - $0.left.equalToSuperview() - $0.right.equalToSuperview() - $0.bottom.lessThanOrEqualTo(bottomStackView.snp.top) - } - - bottomStackView.snp.makeConstraints { - $0.top.greaterThanOrEqualTo(detailsView.snp.bottom).offset(10) - $0.left.equalToSuperview().offset(40) - $0.right.equalToSuperview().offset(-40) - $0.bottom.equalTo(safeAreaLayoutGuide).offset(-20) - } + let titleLabel = UILabel() + let subtitleLabel = UILabel() + let detailsView = RestoreDetailsView() + let progressView = RestoreProgressView() + + let bottomStackView = UIStackView() + let backButton = CapsuleButton() + let cancelButton = CapsuleButton() + let restoreButton = CapsuleButton() + + init() { + super.init(frame: .zero) + backgroundColor = Asset.neutralWhite.color + + subtitleLabel.numberOfLines = 0 + titleLabel.font = Fonts.Mulish.bold.font(size: 24.0) + subtitleLabel.font = Fonts.Mulish.regular.font(size: 16.0) + titleLabel.textColor = Asset.neutralDark.color + subtitleLabel.textColor = Asset.neutralDark.color + + restoreButton.set(style: .brandColored, title: Localized.AccountRestore.Found.restore) + cancelButton.set(style: .simplestColoredBrand, title: Localized.AccountRestore.Found.cancel) + backButton.set(style: .seeThrough, title: Localized.AccountRestore.NotFound.back) + + bottomStackView.axis = .vertical + + addSubview(titleLabel) + addSubview(subtitleLabel) + addSubview(detailsView) + addSubview(progressView) + addSubview(bottomStackView) + + bottomStackView.addArrangedSubview(restoreButton) + bottomStackView.addArrangedSubview(cancelButton) + bottomStackView.addArrangedSubview(backButton) + + titleLabel.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide).offset(20) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-38) } - required init?(coder: NSCoder) { nil } + subtitleLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(20) + $0.left.equalToSuperview().offset(38) + $0.right.equalToSuperview().offset(-38) + } + + detailsView.snp.makeConstraints { + $0.top.equalTo(subtitleLabel.snp.bottom).offset(40) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + } - func updateFor(step: RestorationStep) { - switch step { - case .idle(let cloudService, let backup): - guard let backup = backup else { - showNoBackupForCloud(named: cloudService.name()) - return - } + progressView.snp.makeConstraints { + $0.top.greaterThanOrEqualTo(detailsView.snp.bottom) + $0.left.equalToSuperview() + $0.right.equalToSuperview() + $0.bottom.lessThanOrEqualTo(bottomStackView.snp.top) + } - showBackup(backup, fromCloud: cloudService) + bottomStackView.snp.makeConstraints { + $0.top.greaterThanOrEqualTo(detailsView.snp.bottom).offset(10) + $0.left.equalToSuperview().offset(40) + $0.right.equalToSuperview().offset(-40) + $0.bottom.equalTo(safeAreaLayoutGuide).offset(-20) + } + } - case .downloading(let downloaded, let total): - restoreButton.isHidden = true - cancelButton.isHidden = true - progressView.isHidden = false + required init?(coder: NSCoder) { nil } - progressView.update(downloaded: downloaded, total: total) - case .wrongPass: - progressView.descriptiveProgressLabel.text = "Incorrect password" + func updateFor(step: Step) { + switch step { + case .idle(let provider, let metadata): + guard let metadata = metadata else { + missingMetadataFor(provider) + return + } - case .failDownload(let error): - progressView.descriptiveProgressLabel.text = error.localizedDescription + displayDetailsFrom(provider, size: metadata.size, lastDate: metadata.lastModified) - case .parsingData: - progressView.descriptiveProgressLabel.text = "Parsing backup data" + case .downloading(let downloaded, let total): + restoreButton.isHidden = true + cancelButton.isHidden = true + progressView.isHidden = false - case .done: - progressView.descriptiveProgressLabel.text = "Done" - } - } + progressView.update(downloaded: downloaded, total: total) + case .wrongPass: + progressView.descriptiveProgressLabel.text = "Incorrect password" - private func showBackup(_ backup: BackupModel, fromCloud cloud: CloudService) { - titleLabel.text = Localized.AccountRestore.Found.title - subtitleLabel.text = Localized.AccountRestore.Found.subtitle - - detailsView.titleLabel.text = cloud.name() - detailsView.imageView.image = cloud.asset() - - detailsView.dateView.setup( - title: Localized.AccountRestore.Found.date, - value: backup.date.backupStyle(), - hasArrow: false - ) - - detailsView.sizeView.setup( - title: Localized.AccountRestore.Found.size, - value: String(format: "%.1f kb", backup.size/1000), - hasArrow: false - ) - - detailsView.isHidden = false - backButton.isHidden = true - restoreButton.isHidden = false - cancelButton.isHidden = false - progressView.isHidden = true - } + case .failDownload(let error): + progressView.descriptiveProgressLabel.text = error.localizedDescription - private func showNoBackupForCloud(named cloud: String) { - titleLabel.text = Localized.AccountRestore.NotFound.title - subtitleLabel.text = Localized.AccountRestore.NotFound.subtitle(cloud) + case .parsingData: + progressView.descriptiveProgressLabel.text = "Parsing backup data" - restoreButton.isHidden = true - cancelButton.isHidden = true - detailsView.isHidden = true - backButton.isHidden = false - progressView.isHidden = true + case .done: + progressView.descriptiveProgressLabel.text = "Done" } + } + + private func displayDetailsFrom( + _ provider: RestorationProvider, + size: Float, + lastDate: Date + ) { + titleLabel.text = Localized.AccountRestore.Found.title + subtitleLabel.text = Localized.AccountRestore.Found.subtitle + detailsView.titleLabel.text = provider.name() + detailsView.imageView.image = provider.asset() + + detailsView.dateView.setup( + title: Localized.AccountRestore.Found.date, + value: lastDate.backupStyle(), + hasArrow: false + ) + + detailsView.sizeView.setup( + title: Localized.AccountRestore.Found.size, + value: String(format: "%.1f kb", size/1000), + hasArrow: false + ) + + detailsView.isHidden = false + backButton.isHidden = true + restoreButton.isHidden = false + cancelButton.isHidden = false + progressView.isHidden = true + } + + private func missingMetadataFor(_ provider: RestorationProvider) { + titleLabel.text = Localized.AccountRestore.NotFound.title + subtitleLabel.text = Localized.AccountRestore.NotFound.subtitle(provider.name()) + + restoreButton.isHidden = true + cancelButton.isHidden = true + detailsView.isHidden = true + backButton.isHidden = false + progressView.isHidden = true + } } -private extension CloudService { - func name() -> String { - switch self { - case .drive: - return Localized.Backup.googleDrive - case .icloud: - return Localized.Backup.iCloud - case .dropbox: - return Localized.Backup.dropbox - case .sftp: - return Localized.Backup.sftp - } +private extension RestorationProvider { + func name() -> String { + switch self { + case .drive: + return Localized.Backup.googleDrive + case .icloud: + return Localized.Backup.iCloud + case .dropbox: + return Localized.Backup.dropbox + case .sftp: + return Localized.Backup.sftp } - - func asset() -> UIImage { - switch self { - case .drive: - return Asset.restoreDrive.image - case .icloud: - return Asset.restoreIcloud.image - case .dropbox: - return Asset.restoreDropbox.image - case .sftp: - return Asset.restoreSFTP.image - } + } + + func asset() -> UIImage { + switch self { + case .drive: + return Asset.restoreDrive.image + case .icloud: + return Asset.restoreIcloud.image + case .dropbox: + return Asset.restoreDropbox.image + case .sftp: + return Asset.restoreSFTP.image } + } }