From 2eca3e789c9f602e112b06a74e5f49b4bd9e86f5 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Sat, 17 Sep 2022 16:41:20 +0400 Subject: [PATCH] ios: restore db (#1063) * wip * wip * wip * refactor * clean up * simplify * simplify * refactor * rename * rename consts * refactor Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 ++ .../Database/DatabaseEncryptionView.swift | 3 + .../Views/Database/DatabaseErrorView.swift | 50 +++++++++++++++-- .../Views/UserSettings/SettingsView.swift | 7 +++ apps/ios/SimpleXChat/FileUtils.swift | 55 +++++++++++++++++-- 5 files changed, 107 insertions(+), 12 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f5a866b2b4..a7e85777e3 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -663,6 +663,10 @@ func initializeChat(start: Bool, dbKey: String? = nil) throws { let m = ChatModel.shared (m.chatDbEncrypted, m.chatDbStatus) = migrateChatDatabase(dbKey) if m.chatDbStatus != .ok { return } + // If we migrated successfully means previous re-encryption process on database level finished successfully too + if encryptionStartedDefault.get() { + encryptionStartedDefault.set(false) + } let _ = getChatCtrl(dbKey) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try apiSetIncognito(incognito: incognitoGroupDefault.get()) diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 3423f8db9b..0f4b0a14c9 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -133,7 +133,10 @@ struct DatabaseEncryptionView: View { progressIndicator = true Task { do { + encryptionStartedDefault.set(true) + encryptionStartedAtDefault.set(Date.now) try await apiStorageEncryption(currentKey: currentKey, newKey: newKey) + encryptionStartedDefault.set(false) initialRandomDBPassphraseGroupDefault.set(false) if useKeychain { if setDatabaseKey(newKey) { diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index 9b3c8c0fb0..32b0d87353 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -15,6 +15,7 @@ struct DatabaseErrorView: View { @State private var dbKey = "" @State private var storedDBKey = getDatabaseKey() @State private var useKeychain = storeDBPassphraseGroupDefault.get() + @State private var showRestoreDbButton = false var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -25,7 +26,6 @@ struct DatabaseErrorView: View { Text("Database passphrase is different from saved in the keychain.") databaseKeyField(onSubmit: saveAndRunChat) saveAndOpenButton() - Spacer() Text("File: \(dbFile)") } else { Text("Encrypted database").font(.title) @@ -37,30 +37,32 @@ struct DatabaseErrorView: View { databaseKeyField(onSubmit: runChat) openChatButton() } - Spacer() } case let .error(dbFile, migrationError): Text("Database error") .font(.title) Text("File: \(dbFile)") Text("Error: \(migrationError)") - Spacer() case .errorKeychain: Text("Keychain error") .font(.title) Text("Cannot access keychain to save database password") - Spacer() case let .unknown(json): Text("Database error") .font(.title) Text("Unknown database error: \(json)") - Spacer() case .ok: EmptyView() } + if showRestoreDbButton { + Spacer().frame(height: 10) + Text("The attempt to change database passphrase was not completed.") + restoreDbButton() + } } .padding() - .frame(maxHeight: .infinity) + .frame(maxHeight: .infinity, alignment: .topLeading) + .onAppear() { showRestoreDbButton = shouldShowRestoreDbButton() } } private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View { @@ -118,6 +120,42 @@ struct DatabaseErrorView: View { logger.error("initializeChat \(responseError(error))") } } + + private func shouldShowRestoreDbButton() -> Bool { + if !encryptionStartedDefault.get() { return false } + let startedAt = encryptionStartedAtDefault.get() + // In case there is a small difference between saved encryptionStartedAt time and last modified timestamp on a file + let safeDiffInTime = TimeInterval(10) + return hasBackup(newerThan: startedAt - safeDiffInTime) + } + + private func restoreDbButton() -> some View { + Button() { + AlertManager.shared.showAlert(Alert( + title: Text("Restore database backup?"), + message: Text("Please enter the previous password after restoring database backup. This action can not be undone."), + primaryButton: .destructive(Text("Restore")) { + restoreDb() + }, + secondaryButton: .cancel() + )) + } label: { + Text("Restore database backup").foregroundColor(.red) + } + } + + private func restoreDb() { + do { + try restoreBackup() + showRestoreDbButton = false + encryptionStartedDefault.set(false) + } catch { + AlertManager.shared.showAlert(Alert( + title: Text("Restore database error"), + message: Text(error.localizedDescription) + )) + } + } } struct DatabaseErrorView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 2fff1adc92..846d9b800d 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -26,6 +26,8 @@ let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName" let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime" let DEFAULT_CHAT_V3_DB_MIGRATION = "chatV3DBMigration" let DEFAULT_DEVELOPER_TOOLS = "developerTools" +let DEFAULT_ENCRYPTION_STARTED = "encryptionStarted" +let DEFAULT_ENCRYPTION_STARTED_AT = "encryptionStartedAt" let DEFAULT_ACCENT_COLOR_RED = "accentColorRed" let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" @@ -41,6 +43,7 @@ let appDefaults: [String: Any] = [ DEFAULT_EXPERIMENTAL_CALLS: false, DEFAULT_CHAT_V3_DB_MIGRATION: "offer", DEFAULT_DEVELOPER_TOOLS: false, + DEFAULT_ENCRYPTION_STARTED: false, DEFAULT_ACCENT_COLOR_RED: 0.000, DEFAULT_ACCENT_COLOR_GREEN: 0.533, DEFAULT_ACCENT_COLOR_BLUE: 1.000, @@ -51,6 +54,10 @@ private var indent: CGFloat = 36 let chatArchiveTimeDefault = DateDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CHAT_ARCHIVE_TIME) +let encryptionStartedDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_ENCRYPTION_STARTED) + +let encryptionStartedAtDefault = DateDefault(defaults: UserDefaults.standard, forKey: DEFAULT_ENCRYPTION_STARTED_AT) + func setGroupDefaults() { privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)) } diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 326741d121..57c5a2537f 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -17,6 +17,14 @@ public let maxImageSize: Int64 = 236700 public let maxFileSize: Int64 = 8000000 +private let CHAT_DB: String = "_chat.db" + +private let AGENT_DB: String = "_agent.db" + +private let CHAT_DB_BAK: String = "_chat.db.bak" + +private let AGENT_DB_BAK: String = "_agent.db.bak" + public func getDocumentsDirectory() -> URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! } @@ -44,6 +52,41 @@ public func getAppDatabasePath() -> URL { : getLegacyDatabasePath() } +func fileModificationDate(_ path: String) -> Date? { + do { + let attr = try FileManager.default.attributesOfItem(atPath: path) + return attr[FileAttributeKey.modificationDate] as? Date + } catch { + return nil + } +} + +public func hasBackup(newerThan date: Date) -> Bool { + let dbPath = getAppDatabasePath().path + return hasBackupFile(dbPath + AGENT_DB_BAK, newerThan: date) + && hasBackupFile(dbPath + CHAT_DB_BAK, newerThan: date) +} + +private func hasBackupFile(_ path: String, newerThan date: Date) -> Bool { + let fm = FileManager.default + return fm.fileExists(atPath: path) + && date <= (fileModificationDate(path) ?? Date.distantPast) +} + +public func restoreBackup() throws { + let dbPath = getAppDatabasePath().path + try restoreBackupFile(fromPath: dbPath + AGENT_DB_BAK, toPath: dbPath + AGENT_DB) + try restoreBackupFile(fromPath: dbPath + CHAT_DB_BAK, toPath: dbPath + CHAT_DB) +} + +private func restoreBackupFile(fromPath: String, toPath: String) throws { + let fm = FileManager.default + if fm.fileExists(atPath: toPath) { + try fm.removeItem(atPath: toPath) + } + try fm.copyItem(atPath: fromPath, toPath: toPath) +} + public func hasLegacyDatabase() -> Bool { hasDatabaseAtPath(getLegacyDatabasePath()) } @@ -54,18 +97,18 @@ public func hasDatabase() -> Bool { func hasDatabaseAtPath(_ dbPath: URL) -> Bool { let fm = FileManager.default - return fm.isReadableFile(atPath: dbPath.path + "_agent.db") && - fm.isReadableFile(atPath: dbPath.path + "_chat.db") + return fm.isReadableFile(atPath: dbPath.path + AGENT_DB) && + fm.isReadableFile(atPath: dbPath.path + CHAT_DB) } public func removeLegacyDatabaseAndFiles() -> Bool { let dbPath = getLegacyDatabasePath() let appFiles = getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true) let fm = FileManager.default - let r1 = nil != (try? fm.removeItem(atPath: dbPath.path + "_agent.db")) - let r2 = nil != (try? fm.removeItem(atPath: dbPath.path + "_chat.db")) - try? fm.removeItem(atPath: dbPath.path + "_agent.db.bak") - try? fm.removeItem(atPath: dbPath.path + "_chat.db.bak") + let r1 = nil != (try? fm.removeItem(atPath: dbPath.path + AGENT_DB)) + let r2 = nil != (try? fm.removeItem(atPath: dbPath.path + CHAT_DB)) + try? fm.removeItem(atPath: dbPath.path + AGENT_DB_BAK) + try? fm.removeItem(atPath: dbPath.path + CHAT_DB_BAK) try? fm.removeItem(at: appFiles) return r1 && r2 }