From d3b255b7cb8c36e40cf3b6b260656384403bb416 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 12 Mar 2024 23:25:06 +0700 Subject: [PATCH 1/5] ios: migration enhancements (#3893) * onion check * alert and log * correction * refactor * change * refactor * enum * footer * remove non-needed directory if no migration * naming * back * rename everything --------- Co-authored-by: Avently Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 2 +- ...erDevice.swift => MigrateFromDevice.swift} | 49 ++++---- ...therDevice.swift => MigrateToDevice.swift} | 116 +++++++++--------- .../Shared/Views/Onboarding/SimpleXInfo.swift | 20 ++- .../Views/UserSettings/SettingsView.swift | 5 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 +-- apps/ios/SimpleXChat/API.swift | 7 +- 7 files changed, 109 insertions(+), 106 deletions(-) rename apps/ios/Shared/Views/Migration/{MigrateToAnotherDevice.swift => MigrateFromDevice.swift} (93%) rename apps/ios/Shared/Views/Migration/{MigrateFromAnotherDevice.swift => MigrateToDevice.swift} (87%) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index bed5d9b2de..462699e407 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -95,7 +95,7 @@ final class ChatModel: ObservableObject { @Published var remoteCtrlSession: RemoteCtrlSession? // currently showing invitation @Published var showingInvitation: ShowingInvitation? - @Published var migrationState: MigrationFromAnotherDeviceState? = MigrationFromAnotherDeviceState.transform() + @Published var migrationState: MigrationToState? = MigrationToDeviceState.makeMigrationState() // audio recording and playback @Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source @Published var draft: ComposeState? diff --git a/apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift similarity index 93% rename from apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift rename to apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 01a85aa6db..b3b7269d22 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -1,5 +1,5 @@ // -// MigrateToAnotherDevice.swift +// MigrateFromDevice.swift // SimpleX (iOS) // // Created by Avently on 14.02.2024. @@ -9,7 +9,7 @@ import SwiftUI import SimpleXChat -private enum MigrationToState: Equatable { +private enum MigrationFromState: Equatable { case chatStopInProgress case chatStopFailed(reason: String) case passphraseNotSet @@ -23,7 +23,7 @@ private enum MigrationToState: Equatable { case finished(chatDeletion: Bool) } -private enum MigrateToAnotherDeviceViewAlert: Identifiable { +private enum MigrateFromDeviceViewAlert: Identifiable { case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.") case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures") @@ -51,15 +51,15 @@ private enum MigrateToAnotherDeviceViewAlert: Identifiable { } } -struct MigrateToAnotherDevice: View { +struct MigrateFromDevice: View { @EnvironmentObject var m: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @Binding var showSettings: Bool @Binding var showProgressOnSettings: Bool - @State private var migrationState: MigrationToState = .chatStopInProgress + @State private var migrationState: MigrationFromState = .chatStopInProgress @State private var useKeychain = storeDBPassphraseGroupDefault.get() @AppStorage(GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE, store: groupDefaults) private var initialRandomDBPassphrase: Bool = false - @State private var alert: MigrateToAnotherDeviceViewAlert? + @State private var alert: MigrateFromDeviceViewAlert? @State private var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) private let tempDatabaseUrl = urlForTemporaryDatabase() @State private var chatReceiver: MigrationChatReceiver? = nil @@ -108,11 +108,8 @@ struct MigrateToAnotherDevice: View { }) .onChange(of: migrationState) { state in backDisabled = switch migrationState { - case .archiving: true - case .linkCreation: true - case .linkShown: true - case .finished: true - default: false + case .chatStopInProgress, .archiving, .linkShown, .finished: true + case .chatStopFailed, .passphraseNotSet, .passphraseConfirmation, .uploadConfirmation, .uploadProgress, .uploadFailed, .linkCreation: false } } .onAppear { @@ -120,7 +117,7 @@ struct MigrateToAnotherDevice: View { } .onDisappear { Task { - if case .linkCreation = migrationState {} else if case .linkShown = migrationState {} else if case .finished = migrationState {} else { + if !backDisabled { await MainActor.run { showProgressOnSettings = true } @@ -252,7 +249,7 @@ struct MigrateToAnotherDevice: View { } } let ratio = Float(uploadedBytes) / Float(totalBytes) - MigrateToAnotherDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: uploadedBytes, countStyle: .binary)) uploaded") + MigrateFromDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: uploadedBytes, countStyle: .binary)) uploaded") } .onAppear { startUploading(totalBytes, archivePath) @@ -306,7 +303,10 @@ struct MigrateToAnotherDevice: View { } } } footer: { - Text("Choose _Migrate from another device_ on the new device and scan QR code.") + VStack(alignment: .leading, spacing: 16) { + Text("**Warning**: the archive will be removed.") + Text("Choose _Migrate from another device_ on the new device and scan QR code.") + } .font(.callout) } Section("Show QR code") { @@ -498,6 +498,9 @@ struct MigrateToAnotherDevice: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl) } + case .sndFileError: + alert = .error(title: "Upload failed", error: "Check your internet connection and try again") + migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath) default: logger.debug("unsupported event: \(msg.responseType)") } @@ -587,12 +590,12 @@ struct MigrateToAnotherDevice: View { } private struct PassphraseConfirmationView: View { - @Binding var migrationState: MigrationToState + @Binding var migrationState: MigrationFromState @State private var useKeychain = storeDBPassphraseGroupDefault.get() @State private var currentKey: String = "" @State private var verifyingPassphrase: Bool = false @FocusState private var keyboardVisible: Bool - @Binding var alert: MigrateToAnotherDeviceViewAlert? + @Binding var alert: MigrateFromDeviceViewAlert? var body: some View { ZStack { @@ -638,13 +641,17 @@ private struct PassphraseConfirmationView: View { await MainActor.run { migrationState = .uploadConfirmation } - } catch { - showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert) + } catch let error { + if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse { + showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert) + } else { + alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(String(describing: error))) + } } } } -private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding) { +private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding) { switch status { case .invalidConfirmation: alert.wrappedValue = .invalidConfirmation() @@ -720,8 +727,8 @@ private class MigrationChatReceiver { } } -struct MigrateToAnotherDevice_Previews: PreviewProvider { +struct MigrateFromDevice_Previews: PreviewProvider { static var previews: some View { - MigrateToAnotherDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false)) + MigrateFromDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false)) } } diff --git a/apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift similarity index 87% rename from apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift rename to apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 9022beebd3..9afd0dd406 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -1,5 +1,5 @@ // -// MigrateFromAnotherDevice.swift +// MigrateToDevice.swift // SimpleX (iOS) // // Created by Avently on 23.02.2024. @@ -9,56 +9,47 @@ import SwiftUI import SimpleXChat -enum MigrationFromAnotherDeviceState: Codable, Equatable { +enum MigrationToDeviceState: Codable, Equatable { case downloadProgress(link: String, archiveName: String) case archiveImport(archiveName: String) case passphrase - func makeMigrationState() -> MigrationFromState { - var initial: MigrationFromState = .pasteOrScanLink - //logger.debug("Inited with migrationState: \(String(describing: self))") - switch self { - case let .downloadProgress(link, archiveName): - // iOS changes absolute directory every launch, check this way - let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName - initial = .downloadFailed(totalBytes: 0, link: link, archivePath: archivePath) + // Here we check whether it's needed to show migration process after app restart or not + // It's important to NOT show the process when archive was corrupted/not fully downloaded + static func makeMigrationState() -> MigrationToState? { + let state: MigrationToDeviceState? = UserDefaults.standard.string(forKey: DEFAULT_MIGRATION_TO_STAGE) != nil ? decodeJSON(UserDefaults.standard.string(forKey: DEFAULT_MIGRATION_TO_STAGE)!) : nil + var initial: MigrationToState? = .pasteOrScanLink + //logger.debug("Inited with migrationState: \(String(describing: state))") + switch state { + case nil: + initial = nil + case .downloadProgress: + // No migration happens at the moment actually since archive were not downloaded fully + logger.debug("MigrateToDevice: archive wasn't fully downloaded, removed broken file") + initial = nil case let .archiveImport(archiveName): let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName initial = .archiveImportFailed(archivePath: archivePath) case .passphrase: initial = .passphrase(passphrase: "") } + if initial == nil { + UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_TO_STAGE) + try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory()) + } return initial } - // Here we check whether it's needed to show migration process after app restart or not - // It's important to NOT show the process when archive was corrupted/not fully downloaded - static func transform() -> MigrationFromAnotherDeviceState? { - let state: MigrationFromAnotherDeviceState? = UserDefaults.standard.string(forKey: DEFAULT_MIGRATION_STAGE) != nil ? decodeJSON(UserDefaults.standard.string(forKey: DEFAULT_MIGRATION_STAGE)!) : nil - - if case let .downloadProgress(_, archiveName) = state { - // iOS changes absolute directory every launch, check this way - let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName - try? FileManager.default.removeItem(atPath: archivePath) - UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_STAGE) - // No migration happens at the moment actually since archive were not downloaded fully - logger.debug("MigrateFromDevice: archive wasn't fully downloaded, removed broken file") - return nil - } - return state - } - - static func save(_ state: MigrationFromAnotherDeviceState?, apply: (MigrationFromAnotherDeviceState?) -> Void) { + static func save(_ state: MigrationToDeviceState?) { if let state { - UserDefaults.standard.setValue(encodeJSON(state), forKey: DEFAULT_MIGRATION_STAGE) + UserDefaults.standard.setValue(encodeJSON(state), forKey: DEFAULT_MIGRATION_TO_STAGE) } else { - UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_STAGE) + UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_TO_STAGE) } - apply(state) } } -enum MigrationFromState: Equatable { +enum MigrationToState: Equatable { case pasteOrScanLink case linkDownloading(link: String) case downloadProgress(downloadedBytes: Int64, totalBytes: Int64, fileId: Int64, link: String, archivePath: String, ctrl: chat_ctrl?) @@ -71,7 +62,7 @@ enum MigrationFromState: Equatable { case onion(appSettings: AppSettings) } -private enum MigrateFromAnotherDeviceViewAlert: Identifiable { +private enum MigrateToDeviceViewAlert: Identifiable { case chatImportedWithErrors(title: LocalizedStringKey = "Chat database imported", text: LocalizedStringKey = "Some non-fatal errors occurred during import - you may see Chat console for more details.") @@ -98,13 +89,13 @@ private enum MigrateFromAnotherDeviceViewAlert: Identifiable { } } -struct MigrateFromAnotherDevice: View { +struct MigrateToDevice: View { @EnvironmentObject var m: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false - @State var migrationState: MigrationFromState + @Binding var migrationState: MigrationToState? @State private var useKeychain = storeDBPassphraseGroupDefault.get() - @State private var alert: MigrateFromAnotherDeviceViewAlert? + @State private var alert: MigrateToDeviceViewAlert? private let tempDatabaseUrl = urlForTemporaryDatabase() @State private var chatReceiver: MigrationChatReceiver? = nil // Prevent from hiding the view until migration is finished or app deleted @@ -114,6 +105,7 @@ struct MigrateFromAnotherDevice: View { var body: some View { VStack { switch migrationState { + case nil: EmptyView() case .pasteOrScanLink: pasteOrScanLinkView() case let .linkDownloading(link): @@ -138,18 +130,14 @@ struct MigrateFromAnotherDevice: View { } .onAppear { backDisabled = switch migrationState { - case .linkDownloading: false - case .downloadProgress: false - case .archiveImportFailed: false - default: m.migrationState != nil + case nil, .pasteOrScanLink, .linkDownloading, .downloadProgress, .downloadFailed, .archiveImportFailed: false + case .archiveImport, .passphrase, .migrationConfirmation, .migration, .onion: true } } .onChange(of: migrationState) { state in backDisabled = switch state { - case .linkDownloading: false - case .downloadProgress: false - case .archiveImportFailed: false - default: m.migrationState != nil + case nil, .pasteOrScanLink, .linkDownloading, .downloadProgress, .downloadFailed, .archiveImportFailed: false + case .archiveImport, .passphrase, .migrationConfirmation, .migration, .onion: true } } .onDisappear { @@ -164,7 +152,7 @@ struct MigrateFromAnotherDevice: View { chatReceiver?.stopAndCleanUp() if !backDisabled { try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory()) - MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 } + MigrationToDeviceState.save(nil) } } } @@ -255,7 +243,7 @@ struct MigrateFromAnotherDevice: View { } } let ratio = Float(downloadedBytes) / Float(max(totalBytes, 1)) - MigrateToAnotherDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: downloadedBytes, countStyle: .binary)) downloaded") + MigrateFromDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: downloadedBytes, countStyle: .binary)) downloaded") } } @@ -280,7 +268,7 @@ struct MigrateFromAnotherDevice: View { .onAppear { chatReceiver?.stopAndCleanUp() try? FileManager.default.removeItem(atPath: archivePath) - MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 } + MigrationToDeviceState.save(nil) } } @@ -446,11 +434,16 @@ struct MigrateFromAnotherDevice: View { switch msg { case let .rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer): migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl) - MigrationFromAnotherDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) { m.migrationState = $0 } + MigrationToDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) case .rcvStandaloneFileComplete: DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - migrationState = .archiveImport(archivePath: archivePath) - MigrationFromAnotherDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) { m.migrationState = $0 } + // User closed the whole screen before new state was saved + if migrationState == nil { + MigrationToDeviceState.save(nil) + } else { + migrationState = .archiveImport(archivePath: archivePath) + MigrationToDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) + } } case .rcvFileError: alert = .error(title: "Download failed", error: "File was deleted or link is invalid") @@ -487,7 +480,7 @@ struct MigrateFromAnotherDevice: View { } await MainActor.run { migrationState = .passphrase(passphrase: "") - MigrationFromAnotherDeviceState.save(.passphrase) { m.migrationState = $0 } + MigrationToDeviceState.save(.passphrase) } } catch let error { await MainActor.run { @@ -523,11 +516,12 @@ struct MigrateFromAnotherDevice: View { resetChatCtrl() try initializeChat(start: false, confirmStart: false, dbKey: passphrase, refreshInvitations: true, confirmMigrations: confirmation) var appSettings = try apiGetAppSettings(settings: AppSettings.current.prepareForExport()) + let hasOnionConfigured = appSettings.networkConfig?.socksProxy != nil || appSettings.networkConfig?.hostMode == .onionHost + appSettings.networkConfig?.socksProxy = nil + appSettings.networkConfig?.hostMode = .publicHost + appSettings.networkConfig?.requiredHostMode = true await MainActor.run { - if appSettings.networkConfig?.hostMode == .onionViaSocks || appSettings.networkConfig?.hostMode == .onionHost || appSettings.networkConfig?.socksProxy != nil { - appSettings.networkConfig?.socksProxy = nil - appSettings.networkConfig?.hostMode = .publicHost - appSettings.networkConfig?.requiredHostMode = true + if hasOnionConfigured { migrationState = .onion(appSettings: appSettings) } else { finishMigration(appSettings) @@ -543,7 +537,7 @@ struct MigrateFromAnotherDevice: View { private func finishMigration(_ appSettings: AppSettings) { do { try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory()) - MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 } + MigrationToDeviceState.save(nil) appSettings.importIntoApp() try SimpleX.startChat(refreshInvitations: true) AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.") @@ -569,12 +563,12 @@ struct MigrateFromAnotherDevice: View { } private struct PassphraseEnteringView: View { - @Binding var migrationState: MigrationFromState + @Binding var migrationState: MigrationToState? @State private var useKeychain = true @State var currentKey: String @State private var verifyingPassphrase: Bool = false @FocusState private var keyboardVisible: Bool - @Binding var alert: MigrateFromAnotherDeviceViewAlert? + @Binding var alert: MigrateToDeviceViewAlert? var body: some View { ZStack { @@ -643,7 +637,7 @@ private struct PassphraseEnteringView: View { } } -private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding) { +private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding) { switch status { case .invalidConfirmation: alert.wrappedValue = .invalidConfirmation() @@ -713,8 +707,8 @@ private class MigrationChatReceiver { } } -struct MigrateFromAnotherDevice_Previews: PreviewProvider { +struct MigrateToDevice_Previews: PreviewProvider { static var previews: some View { - MigrateFromAnotherDevice(migrationState: .pasteOrScanLink) + MigrateToDevice(migrationState: Binding.constant(.pasteOrScanLink)) } } diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index b68c1279b0..94e281be7d 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -13,8 +13,6 @@ struct SimpleXInfo: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme: ColorScheme @State private var showHowItWorks = false - @State private var migrationState: MigrationFromState? = nil - @State private var migrateFromAnotherDevice: Bool = false var onboarding: Bool var body: some View { @@ -49,8 +47,7 @@ struct SimpleXInfo: View { Spacer() Button { - migrationState = nil - migrateFromAnotherDevice = true + m.migrationState = .pasteOrScanLink } label: { Label("Migrate from another device", systemImage: "tray.and.arrow.down") .font(.subheadline) @@ -71,16 +68,15 @@ struct SimpleXInfo: View { } .frame(minHeight: g.size.height) } - .onAppear { - if m.migrationState != nil { - migrationState = m.migrationState?.makeMigrationState() - migrateFromAnotherDevice = true - } - } - .sheet(isPresented: $migrateFromAnotherDevice) { + .sheet(isPresented: Binding( + get: { m.migrationState != nil }, + set: { _ in + m.migrationState = nil + MigrationToDeviceState.save(nil) } + )) { NavigationView { VStack(alignment: .leading) { - MigrateFromAnotherDevice(migrationState: migrationState ?? .pasteOrScanLink) + MigrateToDevice(migrationState: $m.migrationState) } .navigationTitle("Migrate here") .background(colorScheme == .light ? Color(uiColor: .tertiarySystemGroupedBackground) : .clear) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 842ccaab4c..1799d8136a 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -51,7 +51,8 @@ let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice" let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert" let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion" let DEFAULT_ONBOARDING_STAGE = "onboardingStage" -let DEFAULT_MIGRATION_STAGE = "migrationStage" +let DEFAULT_MIGRATION_TO_STAGE = "migrationToStage" +let DEFAULT_MIGRATION_FROM_STAGE = "migrationFromStage" let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime" let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites" let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess" @@ -212,7 +213,7 @@ struct SettingsView: View { } NavigationLink { - MigrateToAnotherDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress) + MigrateFromDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress) .navigationTitle("Migrate device") .navigationBarTitleDisplayMode(.large) } label: { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 5f6fe6c65a..eb96807b6f 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -186,8 +186,8 @@ 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; 8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C05382D2B39887E006436DC /* VideoUtils.swift */; }; 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; }; - 8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */; }; - 8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */; }; + 8C7D949A2B88952700B7B9E1 /* MigrateToDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7D94992B88952700B7B9E1 /* MigrateToDevice.swift */; }; + 8C7DF3202B7CDB0A00C886D0 /* MigrateFromDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7DF31F2B7CDB0A00C886D0 /* MigrateFromDevice.swift */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; }; @@ -477,8 +477,8 @@ 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; 8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = ""; }; 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; - 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateFromAnotherDevice.swift; sourceTree = ""; }; - 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAnotherDevice.swift; sourceTree = ""; }; + 8C7D94992B88952700B7B9E1 /* MigrateToDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToDevice.swift; sourceTree = ""; }; + 8C7DF31F2B7CDB0A00C886D0 /* MigrateFromDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateFromDevice.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -904,8 +904,8 @@ 8C7D94982B8894D300B7B9E1 /* Migration */ = { isa = PBXGroup; children = ( - 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */, - 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */, + 8C7DF31F2B7CDB0A00C886D0 /* MigrateFromDevice.swift */, + 8C7D94992B88952700B7B9E1 /* MigrateToDevice.swift */, ); path = Migration; sourceTree = ""; @@ -1141,7 +1141,7 @@ 5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */, 6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, - 8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */, + 8C7D949A2B88952700B7B9E1 /* MigrateToDevice.swift in Sources */, 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, 5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */, @@ -1239,7 +1239,7 @@ 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */, 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */, - 8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift in Sources */, + 8C7DF3202B7CDB0A00C886D0 /* MigrateFromDevice.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */, 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 64249fe09b..3c9f77d791 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -68,14 +68,19 @@ public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation public func chatInitControllerRemovingDatabases() { let dbPath = getAppDatabasePath().path + let fm = FileManager.default + // Remove previous databases, otherwise, can be .errorNotADatabase with nil controller + try? fm.removeItem(atPath: dbPath + CHAT_DB) + try? fm.removeItem(atPath: dbPath + AGENT_DB) + let dbKey = randomDatabasePassword() logger.debug("chatInitControllerRemovingDatabases path: \(dbPath)") var cPath = dbPath.cString(using: .utf8)! var cKey = dbKey.cString(using: .utf8)! var cConfirm = MigrationConfirmation.error.rawValue.cString(using: .utf8)! chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &chatController) + // We need only controller, not databases - let fm = FileManager.default try? fm.removeItem(atPath: dbPath + CHAT_DB) try? fm.removeItem(atPath: dbPath + AGENT_DB) } From 5fd8e6e4fec6120ff6d2d35bc109d3ba8636f335 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 12 Mar 2024 17:33:28 +0000 Subject: [PATCH 2/5] ui: exclude muted chats from filtered chats (#3900) --- apps/ios/Shared/Views/ChatList/ChatListView.swift | 4 +++- .../kotlin/chat/simplex/common/views/chatlist/ChatListView.kt | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 22807f6182..38aabdc21d 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -264,7 +264,9 @@ struct ChatListView: View { } func filtered(_ chat: Chat) -> Bool { - (chat.chatInfo.chatSettings?.favorite ?? false) || chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat + (chat.chatInfo.chatSettings?.favorite ?? false) || + chat.chatStats.unreadChat || + (chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0) } func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 1380f9ccc4..417050db35 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -535,7 +535,9 @@ private fun filteredChats( } private fun filtered(chat: Chat): Boolean = - (chat.chatInfo.chatSettings?.favorite ?: false) || chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat + (chat.chatInfo.chatSettings?.favorite ?: false) || + chat.chatStats.unreadChat || + (chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0) private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean = cInfo.chatViewName.lowercase().contains(s.lowercase()) From 7fa2f2f72e71c216d71f5f510f13c7e452abeb05 Mon Sep 17 00:00:00 2001 From: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> Date: Tue, 12 Mar 2024 19:47:38 +0200 Subject: [PATCH 3/5] core: organize withAckMessage (#3889) * core: organize withAckMessage * mark critical sections * differentiate DB internal error from chat * throw CRITICALs * only CRIT on SEDatabaseError * normalize errors * shift MonadError into ExceptT * simplify * split critical handlers * names, CRITICAL error in withAckMessage, comments * only show critical alerts when database was locked or busy and message failed to process --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Chat.hs | 73 ++++++++++++++++++++------------ src/Simplex/Chat/Controller.hs | 35 +++++++-------- src/Simplex/Chat/Store/Shared.hs | 2 + 3 files changed, 62 insertions(+), 48 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index f88f1c83a9..83ca9adde6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3291,10 +3291,24 @@ processAgentMessage _ connId DEL_CONN = toView $ CRAgentConnDeleted (AgentConnId connId) processAgentMessage corrId connId msg = do vr <- chatVersionRange - withStore' (`getUserByAConnId` AgentConnId connId) >>= \case + -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here + critical (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` (toView . CRChatError (Just user)) _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) +-- CRITICAL error will be shown to the user as alert with restart button in Android/desktop apps. +-- SEDBBusyError will only be thrown on IO exceptions or SQLError during DB queries, +-- e.g. when database is locked or busy for longer than 3s. +-- In this case there is no better mitigation than showing alert: +-- - without ACK the message delivery will be stuck, +-- - with ACK message will be lost, as it failed to be saved. +-- Full app restart is likely to resolve database condition and the message will be received and processed again. +critical :: ChatMonad m => m a -> m a +critical a = + a `catchChatError` \case + ChatErrorStore SEDBBusyError {message} -> throwError $ ChatErrorAgent (CRITICAL True message) Nothing + e -> throwError e + processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent 'AENone -> m () processAgentMessageNoConn = \case CONNECT p h -> hostEvent $ CRHostConnected p h @@ -3482,9 +3496,13 @@ processAgentMsgRcvFile _corrId aFileId msg = agentXFTPDeleteRcvFile aFileId fileId toView $ CRRcvFileError user ci e ft -processAgentMessageConn :: forall m. ChatMonad m => (PQSupport -> VersionRangeChat) -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () +processAgentMessageConn :: forall m . ChatMonad m => (PQSupport -> VersionRangeChat) -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do - entity <- withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus + -- Missing connection/entity errors here will be sent to the view but not shown as CRITICAL alert, + -- as in this case no need to ACK message - we can't process messages for this connection anyway. + -- SEDBException will be re-trown as CRITICAL as it is likely to indicate a temporary database condition + -- that will be resolved with app restart. + entity <- critical $ withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus case agentMessage of END -> case entity of RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct @@ -3547,12 +3565,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processINFOpqSupport conn pqSupport _conn' <- saveConnInfo conn connInfo pure () - MSG meta _msgFlags msgBody -> do - cmdId <- createAckCmd conn + MSG meta _msgFlags msgBody -> -- TODO only acknowledge without saving message? -- probably this branch is never executed, so there should be no reason -- to save message if contact hasn't been created yet - chat item isn't created anyway - withAckMessage agentConnId cmdId meta $ do + withAckMessage agentConnId conn meta False $ \cmdId -> do (_conn', _) <- saveDirectRcvMSG conn meta cmdId msgBody pure False SENT msgId -> @@ -3584,12 +3601,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ contData $ \(hostConnId, xGrpMemIntroCont) -> sendXGrpMemInv hostConnId (Just directConnReq) xGrpMemIntroCont CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" - MSG msgMeta _msgFlags msgBody -> do - let MsgMeta {pqEncryption} = msgMeta - (ct', conn') <- updateContactPQRcv user ct conn pqEncryption - checkIntegrityCreateItem (CDDirectRcv ct') msgMeta - cmdId <- createAckCmd conn' - withAckMessage agentConnId cmdId msgMeta $ do + MSG msgMeta _msgFlags msgBody -> + withAckMessage agentConnId conn msgMeta True $ \cmdId -> do + let MsgMeta {pqEncryption} = msgMeta + (ct', conn') <- updateContactPQRcv user ct conn pqEncryption + checkIntegrityCreateItem (CDDirectRcv ct') msgMeta `catchChatError` \_ -> pure () (conn'', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveDirectRcvMSG conn' msgMeta cmdId msgBody let ct'' = ct' {activeConn = Just conn''} :: Contact assertDirectAllowed user MDRcv ct'' $ toCMEventTag event @@ -3995,10 +4011,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = void $ sendDirectMemberMessage imConn (XGrpMemCon memberId) groupId _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" MSG msgMeta _msgFlags msgBody -> do - checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta - cmdId <- createAckCmd conn - let aChatMsgs = parseChatMessages msgBody - withAckMessage agentConnId cmdId msgMeta $ do + withAckMessage agentConnId conn msgMeta True $ \cmdId -> do + checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> processEvent cmdId chatMsg `catchChatError` \e -> toView $ CRChatError (Just user) e @@ -4010,6 +4024,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = [Right (ACMsg _ chatMsg)] -> forwardMsg_ chatMsg _ -> pure () where + aChatMsgs = parseChatMessages msgBody brokerTs = metaBrokerTs msgMeta processEvent :: MsgEncodingI e => CommandId -> ChatMessage e -> m () processEvent cmdId chatMsg = do @@ -4046,12 +4061,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) checkSendRcpt :: [AChatMessage] -> m Bool - checkSendRcpt aChatMsgs = do + checkSendRcpt aMsgs = do currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo pure $ fromMaybe (sendRcptsSmallGroups user) sendRcpts - && any aChatMsgHasReceipt aChatMsgs + && any aChatMsgHasReceipt aMsgs && currentMemCount <= smallGroupsRcptsMemLimit where aChatMsgHasReceipt (ACMsg _ ChatMessage {chatMsgEvent}) = @@ -4241,6 +4256,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure () CON _ -> startReceivingFile user fileId MSG meta _ msgBody -> do + -- XXX: not all branches do ACK parseFileChunk msgBody >>= receiveFileChunk ft (Just conn) meta OK -> -- [async agent commands] continuation on receiving OK @@ -4384,19 +4400,22 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withAckMessage' :: ConnId -> Connection -> MsgMeta -> m () -> m () withAckMessage' cId conn msgMeta action = do - cmdId <- createAckCmd conn - withAckMessage cId cmdId msgMeta $ action $> False + withAckMessage cId conn msgMeta False $ \_cmdId -> action $> False - withAckMessage :: ConnId -> CommandId -> MsgMeta -> m Bool -> m () - withAckMessage cId cmdId msgMeta action = do + withAckMessage :: ConnId -> Connection -> MsgMeta -> Bool -> (CommandId -> m Bool) -> m () + withAckMessage cId conn msgMeta showCritical action = do + cmdId <- createAckCmd conn `catchChatError` \e -> throwError $ ChatErrorAgent (CRITICAL True $ show e) Nothing -- [async agent commands] command should be asynchronous, continuation is ackMsgDeliveryEvent -- TODO catching error and sending ACK after an error, particularly if it is a database error, will result in the message not processed (and no notification to the user). -- Possible solutions are: -- 1) retry processing several times -- 2) stabilize database -- 3) show screen of death to the user asking to restart - tryChatError action >>= \case + tryChatError (action cmdId) >>= \case Right withRcpt -> ackMsg cId cmdId msgMeta $ if withRcpt then Just "" else Nothing + -- If showCritical is True, then these errors don't result in ACK and show user visible alert + -- This prevents losing the message that failed to be processed. + Left (ChatErrorStore SEDBBusyError {message}) | showCritical -> throwError $ ChatErrorAgent (CRITICAL True message) Nothing Left e -> ackMsg cId cmdId msgMeta Nothing >> throwError e ackMsg :: ConnId -> CommandId -> MsgMeta -> Maybe MsgReceiptInfo -> m () @@ -4997,9 +5016,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkIntegrityCreateItem :: forall c. ChatTypeI c => ChatDirection c 'MDRcv -> MsgMeta -> m () checkIntegrityCreateItem cd MsgMeta {integrity, broker = (_, brokerTs)} = case integrity of MsgOk -> pure () - MsgError e -> - createInternalChatItem user cd (CIRcvIntegrityError e) (Just brokerTs) - `catchChatError` \_ -> pure () + MsgError e -> createInternalChatItem user cd (CIRcvIntegrityError e) (Just brokerTs) xInfo :: Contact -> Profile -> m () xInfo c p' = void $ processContactProfileUpdate c p' True @@ -5719,7 +5736,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m () directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do - checkIntegrityCreateItem (CDDirectRcv ct) msgMeta + checkIntegrityCreateItem (CDDirectRcv ct) msgMeta `catchChatError` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus updateDirectItemStatus ct conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete @@ -5731,7 +5748,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- - getChatItemIdByAgentMsgId to return [ChatItemId] groupMsgReceived :: GroupInfo -> GroupMember -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m () groupMsgReceived gInfo m conn@Connection {connId} msgMeta msgRcpts = do - checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta + checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus updateGroupItemStatus gInfo m conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ce7c952ded..62caa8a6ab 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -46,6 +46,8 @@ import Data.Time (NominalDiffTime, UTCTime) import Data.Time.Clock.System (systemToUTCTime) import Data.Version (showVersion) import Data.Word (Word16) +import Database.SQLite.Simple (SQLError) +import qualified Database.SQLite.Simple as SQL import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC @@ -80,7 +82,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), Cor import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) -import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>)) +import Simplex.Messaging.Util (allFinally, catchAllErrors, liftIOEither, tryAllErrors, (<$$>)) import Simplex.RemoteControl.Client import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitation) import Simplex.RemoteControl.Types @@ -1296,30 +1298,23 @@ withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a -withStoreCtx ctx_ action = do +withStoreCtx _ctx action = do ChatController {chatStore} <- ask - liftEitherError ChatErrorStore $ case ctx_ of - Nothing -> withTransaction chatStore (runExceptT . action) `catch` handleInternal "" - -- uncomment to debug store performance - -- Just ctx -> do - -- t1 <- liftIO getCurrentTime - -- putStrLn $ "withStoreCtx start :: " <> show t1 <> " :: " <> ctx - -- r <- withTransactionCtx ctx_ chatStore (runExceptT . action) `E.catch` handleInternal (" (" <> ctx <> ")") - -- t2 <- liftIO getCurrentTime - -- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1) - -- pure r - Just _ -> withTransaction chatStore (runExceptT . action) `catch` handleInternal "" - where - handleInternal :: String -> SomeException -> IO (Either StoreError a) - handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr + liftIOEither $ withTransaction chatStore (runExceptT . withExceptT ChatErrorStore . action) `E.catches` handleDBErrors withStoreBatch :: (ChatMonad' m, Traversable t) => (DB.Connection -> t (IO (Either ChatError a))) -> m (t (Either ChatError a)) withStoreBatch actions = do ChatController {chatStore} <- ask - liftIO $ withTransaction chatStore $ mapM (`E.catch` handleInternal) . actions - where - handleInternal :: E.SomeException -> IO (Either ChatError a) - handleInternal = pure . Left . ChatError . CEInternalError . show + liftIO $ withTransaction chatStore $ mapM (`E.catches` handleDBErrors) . actions + +handleDBErrors :: [E.Handler IO (Either ChatError a)] +handleDBErrors = + [ E.Handler $ \(e :: SQLError) -> + let se = SQL.sqlError e + busy = se == SQL.ErrorBusy || se == SQL.ErrorLocked + in pure . Left . ChatErrorStore $ if busy then SEDBBusyError $ show se else SEDBException $ show e, + E.Handler $ \(E.SomeException e) -> pure . Left . ChatErrorStore . SEDBException $ show e + ] withStoreBatch' :: (ChatMonad' m, Traversable t) => (DB.Connection -> t (IO a)) -> m (t (Either ChatError a)) withStoreBatch' actions = withStoreBatch $ fmap (fmap Right) . actions diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 6d5c41c1a5..fd628d09ee 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -95,6 +95,8 @@ data StoreError | SEUniqueID | SELargeMsg | SEInternalError {message :: String} + | SEDBException {message :: String} + | SEDBBusyError {message :: String} | SEBadChatItem {itemId :: ChatItemId, itemTs :: Maybe ChatItemTs} | SEChatItemNotFound {itemId :: ChatItemId} | SEChatItemNotFoundByText {text :: Text} From e75be71d9ac9153b0e6389e4bb16b41e58148b5c Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Wed, 13 Mar 2024 01:25:02 +0700 Subject: [PATCH 4/5] android, desktop: migration enhancements (#3901) * adapted iOS logic * rename everything * title * title * text * rename * rename * alert on strange error --- .../chat/simplex/common/model/ChatModel.kt | 5 +- .../chat/simplex/common/model/SimpleXAPI.kt | 12 +- .../chat/simplex/common/platform/Core.kt | 4 + ...oAnotherDevice.kt => MigrateFromDevice.kt} | 264 ++++++++------- ...romAnotherDevice.kt => MigrateToDevice.kt} | 316 +++++++++--------- .../common/views/onboarding/SimpleXInfo.kt | 12 +- .../common/views/usersettings/SettingsView.kt | 5 +- .../commonMain/resources/MR/base/strings.xml | 113 ++++--- 8 files changed, 392 insertions(+), 339 deletions(-) rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/{MigrateToAnotherDevice.kt => MigrateFromDevice.kt} (64%) rename apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/{MigrateFromAnotherDevice.kt => MigrateToDevice.kt} (64%) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index faa4200555..a8a5797d71 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -13,7 +13,8 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.migration.MigrationFromAnotherDeviceState +import chat.simplex.common.views.migration.MigrationToDeviceState +import chat.simplex.common.views.migration.MigrationToState import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -105,7 +106,7 @@ object ChatModel { // currently showing invitation val showingInvitation = mutableStateOf(null as ShowingInvitation?) - val migrationState: MutableState by lazy { mutableStateOf(MigrationFromAnotherDeviceState.transform()) } + val migrationState: MutableState by lazy { mutableStateOf(MigrationToDeviceState.makeMigrationState()) } var draft = mutableStateOf(null as ComposeState?) var draftChatId = mutableStateOf(null as String?) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 08d30fe086..85ce8dc00b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -147,7 +147,8 @@ class AppPreferences { val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null) val onboardingStage = mkEnumPreference(SHARED_PREFS_ONBOARDING_STAGE, OnboardingStage.OnboardingComplete) { OnboardingStage.values().firstOrNull { it.name == this } } - val migrationStage = mkStrPreference(SHARED_PREFS_MIGRATION_STAGE, null) + val migrationToStage = mkStrPreference(SHARED_PREFS_MIGRATION_TO_STAGE, null) + val migrationFromStage = mkStrPreference(SHARED_PREFS_MIGRATION_FROM_STAGE, null) val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true) val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false) val encryptedDBPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE, null) @@ -286,7 +287,8 @@ class AppPreferences { private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime" private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage" private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage" - const val SHARED_PREFS_MIGRATION_STAGE = "MigrationStage" + const val SHARED_PREFS_MIGRATION_TO_STAGE = "MigrationToStage" + const val SHARED_PREFS_MIGRATION_FROM_STAGE = "MigrationFromStage" private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_CHAT_STOPPED = "ChatStopped" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" @@ -704,9 +706,11 @@ object ChatController { throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}") } - suspend fun testStorageEncryption(key: String, ctrl: ChatCtrl? = null): Boolean { + suspend fun testStorageEncryption(key: String, ctrl: ChatCtrl? = null): CR.ChatCmdError? { val r = sendCmd(null, CC.TestStorageEncryption(key), ctrl) - return r is CR.CmdOk + if (r is CR.CmdOk) return null + else if (r is CR.ChatCmdError) return r + throw Exception("failed to test storage encryption: ${r.responseType} ${r.details}") } suspend fun apiGetChats(rh: Long?): List { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 1a186ce8ad..f1a6d35e45 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -152,6 +152,10 @@ fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: fun chatInitControllerRemovingDatabases() { val dbPath = dbAbsolutePrefixPath + // Remove previous databases, otherwise, can be .errorNotADatabase with null controller + File(dbPath + "_chat.db").delete() + File(dbPath + "_agent.db").delete() + val dbKey = randomDatabasePassword() Log.d(TAG, "chatInitControllerRemovingDatabases path: $dbPath") val migrated = chatMigrateInit(dbPath, dbKey, MigrationConfirmation.Error.value) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt similarity index 64% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index 5db6daf6de..da6e7181d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -79,39 +79,49 @@ data class MigrationFileLinkData( @Serializable -private sealed class MigrationToState { - @Serializable object ChatStopInProgress: MigrationToState() - @Serializable data class ChatStopFailed(val reason: String): MigrationToState() - @Serializable object PassphraseNotSet: MigrationToState() - @Serializable object PassphraseConfirmation: MigrationToState() - @Serializable object UploadConfirmation: MigrationToState() - @Serializable object Archiving: MigrationToState() - @Serializable data class DatabaseInit(val totalBytes: Long, val archivePath: String): MigrationToState() - @Serializable data class UploadProgress(val uploadedBytes: Long, val totalBytes: Long, val fileId: Long, val archivePath: String, val ctrl: ChatCtrl, val user: User): MigrationToState() - @Serializable data class UploadFailed(val totalBytes: Long, val archivePath: String): MigrationToState() - @Serializable object LinkCreation: MigrationToState() - @Serializable data class LinkShown(val fileId: Long, val link: String, val ctrl: ChatCtrl): MigrationToState() - @Serializable data class Finished(val chatDeletion: Boolean): MigrationToState() +private sealed class MigrationFromState { + @Serializable object ChatStopInProgress: MigrationFromState() + @Serializable data class ChatStopFailed(val reason: String): MigrationFromState() + @Serializable object PassphraseNotSet: MigrationFromState() + @Serializable object PassphraseConfirmation: MigrationFromState() + @Serializable object UploadConfirmation: MigrationFromState() + @Serializable object Archiving: MigrationFromState() + @Serializable data class DatabaseInit(val totalBytes: Long, val archivePath: String): MigrationFromState() + @Serializable data class UploadProgress(val uploadedBytes: Long, val totalBytes: Long, val fileId: Long, val archivePath: String, val ctrl: ChatCtrl, val user: User): MigrationFromState() + @Serializable data class UploadFailed(val totalBytes: Long, val archivePath: String): MigrationFromState() + @Serializable object LinkCreation: MigrationFromState() + @Serializable data class LinkShown(val fileId: Long, val link: String, val ctrl: ChatCtrl): MigrationFromState() + @Serializable data class Finished(val chatDeletion: Boolean): MigrationFromState() } -private var MutableState.state: MigrationToState +private var MutableState.state: MigrationFromState get() = value set(v) { value = v } @Composable -fun MigrateToAnotherDeviceView(close: () -> Unit) { - val migrationState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(MigrationToState.ChatStopInProgress) } +fun MigrateFromDeviceView(close: () -> Unit) { + val migrationState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(MigrationFromState.ChatStopInProgress) } // Prevent from hiding the view until migration is finished or app deleted val backDisabled = remember { derivedStateOf { - migrationState.value is MigrationToState.DatabaseInit || - migrationState.value is MigrationToState.Archiving || - migrationState.value is MigrationToState.LinkCreation || - migrationState.value is MigrationToState.LinkShown || - migrationState.value is MigrationToState.Finished + when (migrationState.value) { + is MigrationFromState.ChatStopInProgress, + is MigrationFromState.DatabaseInit, + is MigrationFromState.Archiving, + is MigrationFromState.LinkShown, + is MigrationFromState.Finished -> true + + is MigrationFromState.ChatStopFailed, + is MigrationFromState.PassphraseNotSet, + is MigrationFromState.PassphraseConfirmation, + is MigrationFromState.UploadConfirmation, + is MigrationFromState.UploadProgress, + is MigrationFromState.UploadFailed, + is MigrationFromState.LinkCreation -> false + } } } - val chatReceiver = remember { mutableStateOf(null as MigrationToChatReceiver?) } + val chatReceiver = remember { mutableStateOf(null as MigrationFromChatReceiver?) } ModalView( enableClose = !backDisabled.value, close = { @@ -121,7 +131,7 @@ fun MigrateToAnotherDeviceView(close: () -> Unit) { close() }, ) { - MigrateToAnotherDeviceLayout( + MigrateFromDeviceLayout( migrationState = migrationState, chatReceiver = chatReceiver ) @@ -129,16 +139,16 @@ fun MigrateToAnotherDeviceView(close: () -> Unit) { } @Composable -private fun MigrateToAnotherDeviceLayout( - migrationState: MutableState, - chatReceiver: MutableState +private fun MigrateFromDeviceLayout( + migrationState: MutableState, + chatReceiver: MutableState ) { val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) } Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()).height(IntrinsicSize.Max), ) { - AppBarTitle(stringResource(MR.strings.migrate_to_device)) + AppBarTitle(stringResource(MR.strings.migrate_from_device_title)) SectionByState(migrationState, tempDatabaseFile.value, chatReceiver) SectionBottomSpacer() } @@ -147,30 +157,30 @@ private fun MigrateToAnotherDeviceLayout( @Composable private fun SectionByState( - migrationState: MutableState, + migrationState: MutableState, tempDatabaseFile: File, - chatReceiver: MutableState + chatReceiver: MutableState ) { when (val s = migrationState.value) { - is MigrationToState.ChatStopInProgress -> migrationState.ChatStopInProgressView() - is MigrationToState.ChatStopFailed -> migrationState.ChatStopFailedView(s.reason) - is MigrationToState.PassphraseNotSet -> migrationState.PassphraseNotSetView() - is MigrationToState.PassphraseConfirmation -> migrationState.PassphraseConfirmationView() - is MigrationToState.UploadConfirmation -> migrationState.UploadConfirmationView() - is MigrationToState.Archiving -> migrationState.ArchivingView() - is MigrationToState.DatabaseInit -> migrationState.DatabaseInitView(tempDatabaseFile, s.totalBytes, s.archivePath) - is MigrationToState.UploadProgress -> migrationState.UploadProgressView(s.uploadedBytes, s.totalBytes, s.ctrl, s.user, tempDatabaseFile, chatReceiver, s.archivePath) - is MigrationToState.UploadFailed -> migrationState.UploadFailedView(s.totalBytes, s.archivePath, chatReceiver.value) - is MigrationToState.LinkCreation -> LinkCreationView() - is MigrationToState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl) - is MigrationToState.Finished -> migrationState.FinishedView(s.chatDeletion) + is MigrationFromState.ChatStopInProgress -> migrationState.ChatStopInProgressView() + is MigrationFromState.ChatStopFailed -> migrationState.ChatStopFailedView(s.reason) + is MigrationFromState.PassphraseNotSet -> migrationState.PassphraseNotSetView() + is MigrationFromState.PassphraseConfirmation -> migrationState.PassphraseConfirmationView() + is MigrationFromState.UploadConfirmation -> migrationState.UploadConfirmationView() + is MigrationFromState.Archiving -> migrationState.ArchivingView() + is MigrationFromState.DatabaseInit -> migrationState.DatabaseInitView(tempDatabaseFile, s.totalBytes, s.archivePath) + is MigrationFromState.UploadProgress -> migrationState.UploadProgressView(s.uploadedBytes, s.totalBytes, s.ctrl, s.user, tempDatabaseFile, chatReceiver, s.archivePath) + is MigrationFromState.UploadFailed -> migrationState.UploadFailedView(s.totalBytes, s.archivePath, chatReceiver.value) + is MigrationFromState.LinkCreation -> LinkCreationView() + is MigrationFromState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl) + is MigrationFromState.Finished -> migrationState.FinishedView(s.chatDeletion) } } @Composable -private fun MutableState.ChatStopInProgressView() { +private fun MutableState.ChatStopInProgressView() { Box { - SectionView(stringResource(MR.strings.migration_to_device_stopping_chat).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_stopping_chat).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -179,7 +189,7 @@ private fun MutableState.ChatStopInProgressView() { } @Composable -private fun MutableState.ChatStopFailedView(reason: String) { +private fun MutableState.ChatStopFailedView(reason: String) { SectionView(stringResource(MR.strings.error_stopping_chat).uppercase()) { Text(reason) SectionSpacer() @@ -189,22 +199,22 @@ private fun MutableState.ChatStopFailedView(reason: String) { textColor = MaterialTheme.colors.error, click = ::stopChat ){} - SectionTextFooter(stringResource(MR.strings.migration_to_device_chat_should_be_stopped)) + SectionTextFooter(stringResource(MR.strings.migrate_from_device_chat_should_be_stopped)) } } @Composable -private fun MutableState.PassphraseNotSetView() { +private fun MutableState.PassphraseNotSetView() { DatabaseEncryptionView(chatModel, true) KeyChangeEffect(appPreferences.initialRandomDBPassphrase.state.value) { if (!appPreferences.initialRandomDBPassphrase.get()) { - state = MigrationToState.UploadConfirmation + state = MigrationFromState.UploadConfirmation } } } @Composable -private fun MutableState.PassphraseConfirmationView() { +private fun MutableState.PassphraseConfirmationView() { val useKeychain = remember { appPreferences.storeDBPassphrase.get() } val currentKey = rememberSaveable { mutableStateOf("") } val verifyingPassphrase = rememberSaveable { mutableStateOf(false) } @@ -214,12 +224,12 @@ private fun MutableState.PassphraseConfirmationView() { ChatStoppedView() SectionSpacer() - SectionView(stringResource(MR.strings.migration_to_device_verify_database_passphrase).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_verify_database_passphrase).uppercase()) { PassphraseField(currentKey, placeholder = stringResource(MR.strings.current_passphrase), Modifier.padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, requestFocus = true) SettingsActionItemWithContent( icon = painterResource(if (useKeychain) MR.images.ic_vpn_key_filled else MR.images.ic_lock), - text = stringResource(MR.strings.migration_to_device_verify_passphrase), + text = stringResource(MR.strings.migrate_from_device_verify_passphrase), textColor = MaterialTheme.colors.primary, disabled = verifyingPassphrase.value || currentKey.value.isEmpty(), click = { @@ -231,7 +241,7 @@ private fun MutableState.PassphraseConfirmationView() { } } ) {} - SectionTextFooter(stringResource(MR.strings.migration_to_device_confirm_you_remember_passphrase)) + SectionTextFooter(stringResource(MR.strings.migrate_from_device_confirm_you_remember_passphrase)) } } if (verifyingPassphrase.value) { @@ -241,22 +251,22 @@ private fun MutableState.PassphraseConfirmationView() { } @Composable -private fun MutableState.UploadConfirmationView() { - SectionView(stringResource(MR.strings.migration_to_device_confirm_upload).uppercase()) { +private fun MutableState.UploadConfirmationView() { + SectionView(stringResource(MR.strings.migrate_from_device_confirm_upload).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_ios_share), - text = stringResource(MR.strings.migration_to_device_archive_and_upload), + text = stringResource(MR.strings.migrate_from_device_archive_and_upload), textColor = MaterialTheme.colors.primary, - click = { state = MigrationToState.Archiving } + click = { state = MigrationFromState.Archiving } ){} - SectionTextFooter(stringResource(MR.strings.migration_to_device_all_data_will_be_uploaded)) + SectionTextFooter(stringResource(MR.strings.migrate_from_device_all_data_will_be_uploaded)) } } @Composable -private fun MutableState.ArchivingView() { +private fun MutableState.ArchivingView() { Box { - SectionView(stringResource(MR.strings.migration_to_device_archiving_database).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_archiving_database).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -265,9 +275,9 @@ private fun MutableState.ArchivingView() { } @Composable -private fun MutableState.DatabaseInitView(tempDatabaseFile: File, totalBytes: Long, archivePath: String) { +private fun MutableState.DatabaseInitView(tempDatabaseFile: File, totalBytes: Long, archivePath: String) { Box { - SectionView(stringResource(MR.strings.migration_to_device_database_init).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_database_init).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -276,19 +286,19 @@ private fun MutableState.DatabaseInitView(tempDatabaseFile: Fi } @Composable -private fun MutableState.UploadProgressView( +private fun MutableState.UploadProgressView( uploadedBytes: Long, totalBytes: Long, ctrl: ChatCtrl, user: User, tempDatabaseFile: File, - chatReceiver: MutableState, + chatReceiver: MutableState, archivePath: String, ) { Box { - SectionView(stringResource(MR.strings.migration_to_device_uploading_archive).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_uploading_archive).uppercase()) { val ratio = uploadedBytes.toFloat() / max(totalBytes, 1) - LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migration_to_device_bytes_uploaded).format(formatBytes(uploadedBytes))) + LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migrate_from_device_bytes_uploaded).format(formatBytes(uploadedBytes))) } } LaunchedEffect(Unit) { @@ -297,17 +307,17 @@ private fun MutableState.UploadProgressView( } @Composable -private fun MutableState.UploadFailedView(totalBytes: Long, archivePath: String, chatReceiver: MigrationToChatReceiver?) { - SectionView(stringResource(MR.strings.migration_to_device_upload_failed).uppercase()) { +private fun MutableState.UploadFailedView(totalBytes: Long, archivePath: String, chatReceiver: MigrationFromChatReceiver?) { + SectionView(stringResource(MR.strings.migrate_from_device_upload_failed).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_ios_share), - text = stringResource(MR.strings.migration_to_device_repeat_upload), + text = stringResource(MR.strings.migrate_from_device_repeat_upload), textColor = MaterialTheme.colors.primary, click = { - state = MigrationToState.DatabaseInit(totalBytes, archivePath) + state = MigrationFromState.DatabaseInit(totalBytes, archivePath) } ) {} - SectionTextFooter(stringResource(MR.strings.migration_to_device_try_again)) + SectionTextFooter(stringResource(MR.strings.migrate_from_device_try_again)) } LaunchedEffect(Unit) { chatReceiver?.stopAndCleanUp() @@ -317,17 +327,17 @@ private fun MutableState.UploadFailedView(totalBytes: Long, ar @Composable private fun LinkCreationView() { Box { - SectionView(stringResource(MR.strings.migration_to_device_creating_archive_link).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_creating_archive_link).uppercase()) {} ProgressView() } } @Composable -private fun MutableState.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl) { +private fun MutableState.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl) { SectionView { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_close), - text = stringResource(MR.strings.migration_to_device_cancel_migration), + text = stringResource(MR.strings.migrate_from_device_cancel_migration), textColor = MaterialTheme.colors.error, click = { cancelMigration(fileId, ctrl) @@ -335,31 +345,32 @@ private fun MutableState.LinkShownView(fileId: Long, link: Str ) {} SettingsActionItemWithContent( icon = painterResource(MR.images.ic_check), - text = stringResource(MR.strings.migration_to_device_finalize_migration), + text = stringResource(MR.strings.migrate_from_device_finalize_migration), textColor = MaterialTheme.colors.primary, click = { finishMigration(fileId, ctrl) } ) {} - SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_choose_migrate_from_another_device)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_archive_will_be_deleted)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_choose_migrate_from_another_device)) } SectionSpacer() SectionView(stringResource(MR.strings.show_QR_code).uppercase()) { SimpleXLinkQRCode(link, onShare = {}) } SectionSpacer() - SectionView(stringResource(MR.strings.migration_to_device_or_share_this_file_link).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_or_share_this_file_link).uppercase()) { LinkTextView(link, true) } } @Composable -private fun MutableState.FinishedView(chatDeletion: Boolean) { +private fun MutableState.FinishedView(chatDeletion: Boolean) { Box { - SectionView(stringResource(MR.strings.migration_to_device_migration_complete).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_migration_complete).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_delete_forever), - text = stringResource(MR.strings.migration_to_device_delete_database_from_device), + text = stringResource(MR.strings.migrate_from_device_delete_database_from_device), textColor = MaterialTheme.colors.primary, click = { AlertManager.shared.showAlertDialog( @@ -375,21 +386,21 @@ private fun MutableState.FinishedView(chatDeletion: Boolean) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_play_arrow_filled), - text = stringResource(MR.strings.migration_to_device_start_chat), + text = stringResource(MR.strings.migrate_from_device_start_chat), textColor = MaterialTheme.colors.error, click = { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.start_chat_question), - text = generalGetString(MR.strings.migration_to_device_starting_chat_on_multiple_devices_unsupported), - confirmText = generalGetString(MR.strings.migration_to_device_start_chat), + text = generalGetString(MR.strings.migrate_from_device_starting_chat_on_multiple_devices_unsupported), + confirmText = generalGetString(MR.strings.migrate_from_device_start_chat), onConfirm = { withLongRunningApi { startChatAndDismiss() } } ) } ) {} - SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_you_must_not_start_database_on_two_device)) - SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_using_on_two_device_breaks_encryption)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) } if (chatDeletion) { ProgressView() @@ -420,52 +431,58 @@ fun LargeProgressView(value: Float, title: String, description: String) { } } -private fun MutableState.stopChat() { +private fun MutableState.stopChat() { withBGApi { try { stopChatAsync(chatModel) try { controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) - state = if (appPreferences.initialRandomDBPassphrase.get()) MigrationToState.PassphraseNotSet else MigrationToState.PassphraseConfirmation + state = if (appPreferences.initialRandomDBPassphrase.get()) MigrationFromState.PassphraseNotSet else MigrationFromState.PassphraseConfirmation } catch (e: Exception) { AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.migrate_to_device_error_saving_settings), + title = generalGetString(MR.strings.migrate_from_device_error_saving_settings), text = e.stackTraceToString() ) - state = MigrationToState.ChatStopFailed(reason = generalGetString(MR.strings.migrate_to_device_error_saving_settings)) + state = MigrationFromState.ChatStopFailed(reason = generalGetString(MR.strings.migrate_from_device_error_saving_settings)) } } catch (e: Exception) { - state = MigrationToState.ChatStopFailed(reason = e.stackTraceToString().take(10)) + state = MigrationFromState.ChatStopFailed(reason = e.stackTraceToString().take(10)) } } } -private suspend fun MutableState.verifyDatabasePassphrase(dbKey: String) { - if (controller.testStorageEncryption(dbKey)) { - state = MigrationToState.UploadConfirmation - } else { +private suspend fun MutableState.verifyDatabasePassphrase(dbKey: String) { + val error = controller.testStorageEncryption(dbKey) + if (error == null) { + state = MigrationFromState.UploadConfirmation + } else if (((error.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorOpen)?.sqliteError is SQLiteError.ErrorNotADatabase) { showErrorOnMigrationIfNeeded(DBMigrationResult.ErrorNotADatabase("")) + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error), + text = generalGetString(MR.strings.migrate_from_device_error_verifying_passphrase) + " " + error.details + ) } } -private fun MutableState.exportArchive() { +private fun MutableState.exportArchive() { withLongRunningApi { try { getMigrationTempFilesDirectory().mkdir() val archivePath = exportChatArchive(chatModel, getMigrationTempFilesDirectory(), mutableStateOf(""), mutableStateOf(Instant.DISTANT_PAST), mutableStateOf("")) val totalBytes = File(archivePath).length() if (totalBytes > 0L) { - state = MigrationToState.DatabaseInit(totalBytes, archivePath) + state = MigrationFromState.DatabaseInit(totalBytes, archivePath) } else { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_to_device_exported_file_doesnt_exist)) - state = MigrationToState.UploadConfirmation + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_from_device_exported_file_doesnt_exist)) + state = MigrationFromState.UploadConfirmation } } catch (e: Exception) { AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.migrate_to_device_error_exporting_archive), + title = generalGetString(MR.strings.migrate_from_device_error_exporting_archive), text = e.stackTraceToString() ) - state = MigrationToState.UploadConfirmation + state = MigrationFromState.UploadConfirmation } } } @@ -484,7 +501,7 @@ suspend fun initTemporaryDatabase(tempDatabaseFile: File, netCfg: NetCfg): Pair< return null } -private fun MutableState.prepareDatabase( +private fun MutableState.prepareDatabase( tempDatabaseFile: File, totalBytes: Long, archivePath: String, @@ -492,35 +509,35 @@ private fun MutableState.prepareDatabase( withLongRunningApi { val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, getNetCfg()) if (ctrlAndUser == null) { - state = MigrationToState.UploadFailed(totalBytes, archivePath) + state = MigrationFromState.UploadFailed(totalBytes, archivePath) return@withLongRunningApi } val (ctrl, user) = ctrlAndUser - state = MigrationToState.UploadProgress(0L, totalBytes, 0L, archivePath, ctrl, user) + state = MigrationFromState.UploadProgress(0L, totalBytes, 0L, archivePath, ctrl, user) } } -private fun MutableState.startUploading( +private fun MutableState.startUploading( totalBytes: Long, ctrl: ChatCtrl, user: User, tempDatabaseFile: File, - chatReceiver: MutableState, + chatReceiver: MutableState, archivePath: String, ) { withBGApi { - chatReceiver.value = MigrationToChatReceiver(ctrl, tempDatabaseFile) { msg -> + chatReceiver.value = MigrationFromChatReceiver(ctrl, tempDatabaseFile) { msg -> when (msg) { is CR.SndFileProgressXFTP -> { val s = state - if (s is MigrationToState.UploadProgress && s.uploadedBytes != s.totalBytes) { - state = MigrationToState.UploadProgress(msg.sentSize, msg.totalSize, msg.fileTransferMeta.fileId, archivePath, ctrl, user) + if (s is MigrationFromState.UploadProgress && s.uploadedBytes != s.totalBytes) { + state = MigrationFromState.UploadProgress(msg.sentSize, msg.totalSize, msg.fileTransferMeta.fileId, archivePath, ctrl, user) } } is CR.SndFileRedirectStartXFTP -> { delay(500) - state = MigrationToState.LinkCreation + state = MigrationFromState.LinkCreation } is CR.SndStandaloneFileComplete -> { delay(500) @@ -532,7 +549,14 @@ private fun MutableState.startUploading( requiredHostMode = cfg.requiredHostMode ) ) - state = MigrationToState.LinkShown(msg.fileTransferMeta.fileId, data.addToLink(msg.rcvURIs[0]), ctrl) + state = MigrationFromState.LinkShown(msg.fileTransferMeta.fileId, data.addToLink(msg.rcvURIs[0]), ctrl) + } + is CR.SndFileError -> { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.migrate_from_device_upload_failed), + generalGetString(MR.strings.migrate_from_device_check_connection_and_try_again) + ) + state = MigrationFromState.UploadFailed(totalBytes, archivePath) } else -> { Log.d(TAG, "unsupported event: ${msg.responseType}") @@ -544,13 +568,13 @@ private fun MutableState.startUploading( val (res, error) = controller.uploadStandaloneFile(user, CryptoFile.plain(File(archivePath).name), ctrl) if (res == null) { - state = MigrationToState.UploadFailed(totalBytes, archivePath) + state = MigrationFromState.UploadFailed(totalBytes, archivePath) return@withBGApi AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.migration_to_device_error_uploading_archive), + generalGetString(MR.strings.migrate_from_device_error_uploading_archive), error ) } - state = MigrationToState.UploadProgress(0, res.fileSize, res.fileId, archivePath, ctrl, user) + state = MigrationFromState.UploadProgress(0, res.fileSize, res.fileId, archivePath, ctrl, user) } } @@ -565,19 +589,19 @@ private fun cancelMigration(fileId: Long, ctrl: ChatCtrl) { } } -private fun MutableState.finishMigration(fileId: Long, ctrl: ChatCtrl) { +private fun MutableState.finishMigration(fileId: Long, ctrl: ChatCtrl) { withBGApi { cancelUploadedArchive(fileId, ctrl) - state = MigrationToState.Finished(false) + state = MigrationFromState.Finished(false) } } -private fun MutableState.deleteChatAndDismiss() { +private fun MutableState.deleteChatAndDismiss() { withBGApi { try { deleteChatAsync(chatModel) chatModel.chatDbChanged.value = true - state = MigrationToState.Finished(true) + state = MigrationFromState.Finished(true) try { initChatController(startChat = { CompletableDeferred(false) }) chatModel.chatDbChanged.value = false @@ -587,7 +611,7 @@ private fun MutableState.deleteChatAndDismiss() { } } catch (e: Exception) { AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.migration_to_device_error_deleting_database), + title = generalGetString(MR.strings.migrate_from_device_error_deleting_database), text = e.stackTraceToString() ) } @@ -615,14 +639,14 @@ private suspend fun startChatAndDismiss(dismiss: Boolean = true) { } } -private suspend fun MutableState.cleanUpOnBack(chatReceiver: MigrationToChatReceiver?) { +private suspend fun MutableState.cleanUpOnBack(chatReceiver: MigrationFromChatReceiver?) { val s = state - if (s !is MigrationToState.LinkShown && s !is MigrationToState.Finished) { + if (s !is MigrationFromState.LinkShown && s !is MigrationFromState.Finished) { chatModel.switchingUsersAndHosts.value = true startChatAndDismiss(false) chatModel.switchingUsersAndHosts.value = false } - if (s is MigrationToState.UploadProgress) { + if (s is MigrationFromState.UploadProgress) { cancelUploadedArchive(s.fileId, s.ctrl) } chatReceiver?.stopAndCleanUp() @@ -632,7 +656,7 @@ private suspend fun MutableState.cleanUpOnBack(chatReceiver: M private fun fileForTemporaryDatabase(): File = File(getMigrationTempFilesDirectory(), generateNewFileName("migration", "db", getMigrationTempFilesDirectory())) -private class MigrationToChatReceiver( +private class MigrationFromChatReceiver( val ctrl: ChatCtrl, val databaseUrl: File, var receiveMessages: Boolean = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt similarity index 64% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 52698a6e47..e24570ba8b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -13,7 +13,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import chat.simplex.common.model.* -import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_MIGRATION_STAGE +import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_MIGRATION_TO_STAGE import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.startChat import chat.simplex.common.model.ChatCtrl @@ -39,100 +39,97 @@ import java.util.* import kotlin.math.max @Serializable -sealed class MigrationFromAnotherDeviceState { - @Serializable @SerialName("onion") data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationFromAnotherDeviceState() - @Serializable @SerialName("downloadProgress") data class DownloadProgress(val link: String, val archiveName: String, val netCfg: NetCfg): MigrationFromAnotherDeviceState() - @Serializable @SerialName("archiveImport") data class ArchiveImport(val archiveName: String, val netCfg: NetCfg): MigrationFromAnotherDeviceState() - @Serializable @SerialName("passphrase") data class Passphrase(val netCfg: NetCfg): MigrationFromAnotherDeviceState() +sealed class MigrationToDeviceState { + @Serializable @SerialName("onion") data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationToDeviceState() + @Serializable @SerialName("downloadProgress") data class DownloadProgress(val link: String, val archiveName: String, val netCfg: NetCfg): MigrationToDeviceState() + @Serializable @SerialName("archiveImport") data class ArchiveImport(val archiveName: String, val netCfg: NetCfg): MigrationToDeviceState() + @Serializable @SerialName("passphrase") data class Passphrase(val netCfg: NetCfg): MigrationToDeviceState() companion object { // Here we check whether it's needed to show migration process after app restart or not // It's important to NOT show the process when archive was corrupted/not fully downloaded - fun transform(): MigrationFromAnotherDeviceState? { - val stage = settings.getStringOrNull(SHARED_PREFS_MIGRATION_STAGE) - var state: MigrationFromAnotherDeviceState? = if (stage != null) json.decodeFromString(stage) else null - if (state is DownloadProgress) { - // No migration happens at the moment actually since archive were not downloaded fully - Log.e(TAG, "MigrateFromDevice: archive wasn't fully downloaded, removed broken file") - state = null - } else if (state is Onion) { - state = null - } else if (state is ArchiveImport && !File(getMigrationTempFilesDirectory(), state.archiveName).exists()) { - Log.e(TAG, "MigrateFromDevice: archive was removed unintentionally or state is broken, dropping migration") - state = null + fun makeMigrationState(): MigrationToState? { + val stage = settings.getStringOrNull(SHARED_PREFS_MIGRATION_TO_STAGE) + val state: MigrationToDeviceState? = if (stage != null) json.decodeFromString(stage) else null + val initial: MigrationToState? = when(state) { + null -> null + is DownloadProgress -> { + // No migration happens at the moment actually since archive were not downloaded fully + Log.e(TAG, "MigrateToDevice: archive wasn't fully downloaded, removed broken file") + null + } + is Onion -> null + is ArchiveImport -> { + if (!File(getMigrationTempFilesDirectory(), state.archiveName).exists()) { + Log.e(TAG, "MigrateToDevice: archive was removed unintentionally or state is broken, dropping migration") + null + } else { + val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName) + MigrationToState.ArchiveImportFailed(archivePath.absolutePath, state.netCfg) + } + } + is Passphrase -> MigrationToState.Passphrase("", state.netCfg) } - if (state == null) { - settings.remove(SHARED_PREFS_MIGRATION_STAGE) + if (initial == null) { + settings.remove(SHARED_PREFS_MIGRATION_TO_STAGE) getMigrationTempFilesDirectory().deleteRecursively() } - return state + return initial } - fun save(state: MigrationFromAnotherDeviceState?) { + fun save(state: MigrationToDeviceState?) { if (state != null) { - appPreferences.migrationStage.set(json.encodeToString(state)) + appPreferences.migrationToStage.set(json.encodeToString(state)) } else { - appPreferences.migrationStage.set(null) + appPreferences.migrationToStage.set(null) } - chatModel.migrationState.value = state } } } @Serializable -private sealed class MigrationState { - @Serializable object PasteOrScanLink: MigrationState() - @Serializable data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationState() - @Serializable data class DatabaseInit(val link: String, val netCfg: NetCfg): MigrationState() - @Serializable data class LinkDownloading(val link: String, val ctrl: ChatCtrl, val user: User, val archivePath: String, val netCfg: NetCfg): MigrationState() - @Serializable data class DownloadProgress(val downloadedBytes: Long, val totalBytes: Long, val fileId: Long, val link: String, val archivePath: String, val netCfg: NetCfg, val ctrl: ChatCtrl?): MigrationState() - @Serializable data class DownloadFailed(val totalBytes: Long, val link: String, val archivePath: String, val netCfg: NetCfg): MigrationState() - @Serializable data class ArchiveImport(val archivePath: String, val netCfg: NetCfg): MigrationState() - @Serializable data class ArchiveImportFailed(val archivePath: String, val netCfg: NetCfg): MigrationState() - @Serializable data class Passphrase(val passphrase: String, val netCfg: NetCfg): MigrationState() - @Serializable data class MigrationConfirmation(val status: DBMigrationResult, val passphrase: String, val useKeychain: Boolean, val netCfg: NetCfg): MigrationState() - @Serializable data class Migration(val passphrase: String, val confirmation: chat.simplex.common.views.helpers.MigrationConfirmation, val useKeychain: Boolean, val netCfg: NetCfg): MigrationState() +sealed class MigrationToState { + @Serializable object PasteOrScanLink: MigrationToState() + @Serializable data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationToState() + @Serializable data class DatabaseInit(val link: String, val netCfg: NetCfg): MigrationToState() + @Serializable data class LinkDownloading(val link: String, val ctrl: ChatCtrl, val user: User, val archivePath: String, val netCfg: NetCfg): MigrationToState() + @Serializable data class DownloadProgress(val downloadedBytes: Long, val totalBytes: Long, val fileId: Long, val link: String, val archivePath: String, val netCfg: NetCfg, val ctrl: ChatCtrl?): MigrationToState() + @Serializable data class DownloadFailed(val totalBytes: Long, val link: String, val archivePath: String, val netCfg: NetCfg): MigrationToState() + @Serializable data class ArchiveImport(val archivePath: String, val netCfg: NetCfg): MigrationToState() + @Serializable data class ArchiveImportFailed(val archivePath: String, val netCfg: NetCfg): MigrationToState() + @Serializable data class Passphrase(val passphrase: String, val netCfg: NetCfg): MigrationToState() + @Serializable data class MigrationConfirmation(val status: DBMigrationResult, val passphrase: String, val useKeychain: Boolean, val netCfg: NetCfg): MigrationToState() + @Serializable data class Migration(val passphrase: String, val confirmation: chat.simplex.common.views.helpers.MigrationConfirmation, val useKeychain: Boolean, val netCfg: NetCfg): MigrationToState() } -private var MutableState.state: MigrationState +private var MutableState.state: MigrationToState? get() = value set(v) { value = v } @Composable -fun ModalData.MigrateFromAnotherDeviceView(state: MigrationFromAnotherDeviceState? = null, close: () -> Unit) { - val migrationState = rememberSaveable(stateSaver = serializableSaver()) { - mutableStateOf( - when (state) { - null -> MigrationState.PasteOrScanLink - is MigrationFromAnotherDeviceState.Onion -> { - MigrationState.Onion(state.link, state.socksProxy, state.hostMode, state.requiredHostMode) - } - is MigrationFromAnotherDeviceState.DownloadProgress -> { - val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName) - // SHOULDN'T BE HERE because the app checks this before opening migration screen and will not open it in this case. - // See analyzeMigrationState() - MigrationState.DownloadFailed(totalBytes = 0, link = state.link, archivePath = archivePath.absolutePath, state.netCfg) - } - is MigrationFromAnotherDeviceState.ArchiveImport -> { - val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName) - MigrationState.ArchiveImportFailed(archivePath.absolutePath, state.netCfg) - } - is MigrationFromAnotherDeviceState.Passphrase -> { - MigrationState.Passphrase("", state.netCfg) - } - } - ) - } +fun ModalData.MigrateToDeviceView(close: () -> Unit) { + val migrationState = remember { chatModel.migrationState } // Prevent from hiding the view until migration is finished or app deleted val backDisabled = remember { derivedStateOf { - val s = chatModel.migrationState.value - s is MigrationFromAnotherDeviceState.ArchiveImport || - s is MigrationFromAnotherDeviceState.Passphrase || - migrationState.value is MigrationState.DatabaseInit + when (chatModel.migrationState.value) { + null, + is MigrationToState.PasteOrScanLink, + is MigrationToState.Onion, + is MigrationToState.LinkDownloading, + is MigrationToState.DownloadProgress, + is MigrationToState.DownloadFailed, + is MigrationToState.ArchiveImportFailed -> false + + is MigrationToState.ArchiveImport, + is MigrationToState.DatabaseInit, + is MigrationToState.Migration, + is MigrationToState.MigrationConfirmation, + is MigrationToState.Passphrase -> true + } } } - val chatReceiver = remember { mutableStateOf(null as MigrationFromChatReceiver?) } + val chatReceiver = remember { mutableStateOf(null as MigrationToChatReceiver?) } ModalView( enableClose = !backDisabled.value, close = { @@ -142,7 +139,7 @@ fun ModalData.MigrateFromAnotherDeviceView(state: MigrationFromAnotherDeviceStat } }, ) { - MigrateFromAnotherDeviceLayout( + MigrateToDeviceLayout( migrationState = migrationState, chatReceiver = chatReceiver, close = close, @@ -151,9 +148,9 @@ fun ModalData.MigrateFromAnotherDeviceView(state: MigrationFromAnotherDeviceStat } @Composable -private fun ModalData.MigrateFromAnotherDeviceLayout( - migrationState: MutableState, - chatReceiver: MutableState, +private fun ModalData.MigrateToDeviceLayout( + migrationState: MutableState, + chatReceiver: MutableState, close: () -> Unit, ) { val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) } @@ -161,7 +158,7 @@ private fun ModalData.MigrateFromAnotherDeviceLayout( Column( Modifier.fillMaxSize().verticalScroll(rememberScrollState()).height(IntrinsicSize.Max), ) { - AppBarTitle(stringResource(MR.strings.migrate_here)) + AppBarTitle(stringResource(MR.strings.migrate_to_device_title)) SectionByState(migrationState, tempDatabaseFile.value, chatReceiver, close) SectionBottomSpacer() } @@ -170,28 +167,29 @@ private fun ModalData.MigrateFromAnotherDeviceLayout( @Composable private fun ModalData.SectionByState( - migrationState: MutableState, + migrationState: MutableState, tempDatabaseFile: File, - chatReceiver: MutableState, + chatReceiver: MutableState, close: () -> Unit ) { when (val s = migrationState.value) { - is MigrationState.PasteOrScanLink -> migrationState.PasteOrScanLinkView() - is MigrationState.Onion -> OnionView(s.link, s.socksProxy, s.hostMode, s.requiredHostMode, migrationState) - is MigrationState.DatabaseInit -> migrationState.DatabaseInitView(s.link, tempDatabaseFile, s.netCfg) - is MigrationState.LinkDownloading -> migrationState.LinkDownloadingView(s.link, s.ctrl, s.user, s.archivePath, tempDatabaseFile, chatReceiver, s.netCfg) - is MigrationState.DownloadProgress -> DownloadProgressView(s.downloadedBytes, totalBytes = s.totalBytes) - is MigrationState.DownloadFailed -> migrationState.DownloadFailedView(s.link, chatReceiver.value, s.archivePath, s.netCfg) - is MigrationState.ArchiveImport -> migrationState.ArchiveImportView(s.archivePath, s.netCfg) - is MigrationState.ArchiveImportFailed -> migrationState.ArchiveImportFailedView(s.archivePath, s.netCfg) - is MigrationState.Passphrase -> migrationState.PassphraseEnteringView(currentKey = s.passphrase, s.netCfg) - is MigrationState.MigrationConfirmation -> migrationState.MigrationConfirmationView(s.status, s.passphrase, s.useKeychain, s.netCfg) - is MigrationState.Migration -> MigrationView(s.passphrase, s.confirmation, s.useKeychain, s.netCfg, close) + null -> {} + is MigrationToState.PasteOrScanLink -> migrationState.PasteOrScanLinkView() + is MigrationToState.Onion -> OnionView(s.link, s.socksProxy, s.hostMode, s.requiredHostMode, migrationState) + is MigrationToState.DatabaseInit -> migrationState.DatabaseInitView(s.link, tempDatabaseFile, s.netCfg) + is MigrationToState.LinkDownloading -> migrationState.LinkDownloadingView(s.link, s.ctrl, s.user, s.archivePath, tempDatabaseFile, chatReceiver, s.netCfg) + is MigrationToState.DownloadProgress -> DownloadProgressView(s.downloadedBytes, totalBytes = s.totalBytes) + is MigrationToState.DownloadFailed -> migrationState.DownloadFailedView(s.link, chatReceiver.value, s.archivePath, s.netCfg) + is MigrationToState.ArchiveImport -> migrationState.ArchiveImportView(s.archivePath, s.netCfg) + is MigrationToState.ArchiveImportFailed -> migrationState.ArchiveImportFailedView(s.archivePath, s.netCfg) + is MigrationToState.Passphrase -> migrationState.PassphraseEnteringView(currentKey = s.passphrase, s.netCfg) + is MigrationToState.MigrationConfirmation -> migrationState.MigrationConfirmationView(s.status, s.passphrase, s.useKeychain, s.netCfg) + is MigrationToState.Migration -> MigrationView(s.passphrase, s.confirmation, s.useKeychain, s.netCfg, close) } } @Composable -private fun MutableState.PasteOrScanLinkView() { +private fun MutableState.PasteOrScanLinkView() { if (appPlatform.isAndroid) { SectionView(stringResource(MR.strings.scan_QR_code).replace('\n', ' ').uppercase()) { QRCodeScanner(showQRCodeScanner = remember { mutableStateOf(true) }) { text -> @@ -209,7 +207,7 @@ private fun MutableState.PasteOrScanLinkView() { } @Composable -private fun MutableState.PasteLinkView() { +private fun MutableState.PasteLinkView() { val clipboard = LocalClipboardManager.current SectionItemView({ val str = clipboard.getText()?.text ?: return@SectionItemView @@ -220,7 +218,7 @@ private fun MutableState.PasteLinkView() { } @Composable -private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: HostMode, requiredHostMode: Boolean, state: MutableState) { +private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: HostMode, requiredHostMode: Boolean, state: MutableState) { val onionHosts = remember { stateGetOrPut("onionHosts") { getNetCfg().copy(socksProxy = socksProxy, hostMode = hostMode, requiredHostMode = requiredHostMode).onionHosts } } @@ -238,10 +236,10 @@ private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: Hos mutableStateOf(getNetCfg().withOnionHosts(onionHosts.value).copy(socksProxy = socksProxy, sessionMode = sessionMode.value)) } - SectionView(stringResource(MR.strings.migration_from_device_confirm_network_settings).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_confirm_network_settings).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_check), - text = stringResource(MR.strings.migration_from_device_apply_onion), + text = stringResource(MR.strings.migrate_to_device_apply_onion), textColor = MaterialTheme.colors.primary, click = { val updated = netCfg.value @@ -251,11 +249,11 @@ private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: Hos sessionMode = sessionMode.value ) withBGApi { - state.value = MigrationState.DatabaseInit(link, updated) + state.value = MigrationToState.DatabaseInit(link, updated) } } ){} - SectionTextFooter(stringResource(MR.strings.migration_from_device_confirm_network_settings_footer)) + SectionTextFooter(stringResource(MR.strings.migrate_to_device_confirm_network_settings_footer)) } SectionSpacer() @@ -285,9 +283,9 @@ private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: Hos } @Composable -private fun MutableState.DatabaseInitView(link: String, tempDatabaseFile: File, netCfg: NetCfg) { +private fun MutableState.DatabaseInitView(link: String, tempDatabaseFile: File, netCfg: NetCfg) { Box { - SectionView(stringResource(MR.strings.migration_from_device_database_init).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_database_init).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -296,17 +294,17 @@ private fun MutableState.DatabaseInitView(link: String, tempData } @Composable -private fun MutableState.LinkDownloadingView( +private fun MutableState.LinkDownloadingView( link: String, ctrl: ChatCtrl, user: User, archivePath: String, tempDatabaseFile: File, - chatReceiver: MutableState, + chatReceiver: MutableState, netCfg: NetCfg ) { Box { - SectionView(stringResource(MR.strings.migration_from_device_downloading_details).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_downloading_details).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -317,37 +315,37 @@ private fun MutableState.LinkDownloadingView( @Composable private fun DownloadProgressView(downloadedBytes: Long, totalBytes: Long) { Box { - SectionView(stringResource(MR.strings.migration_from_device_downloading_archive).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_downloading_archive).uppercase()) { val ratio = downloadedBytes.toFloat() / max(totalBytes, 1) - LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migration_from_device_bytes_downloaded).format(formatBytes(downloadedBytes))) + LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migrate_to_device_bytes_downloaded).format(formatBytes(downloadedBytes))) } } } @Composable -private fun MutableState.DownloadFailedView(link: String, chatReceiver: MigrationFromChatReceiver?, archivePath: String, netCfg: NetCfg) { - SectionView(stringResource(MR.strings.migration_from_device_download_failed).uppercase()) { +private fun MutableState.DownloadFailedView(link: String, chatReceiver: MigrationToChatReceiver?, archivePath: String, netCfg: NetCfg) { + SectionView(stringResource(MR.strings.migrate_to_device_download_failed).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_download), - text = stringResource(MR.strings.migration_from_device_repeat_download), + text = stringResource(MR.strings.migrate_to_device_repeat_download), textColor = MaterialTheme.colors.primary, click = { - state = MigrationState.DatabaseInit(link, netCfg) + state = MigrationToState.DatabaseInit(link, netCfg) } ) {} - SectionTextFooter(stringResource(MR.strings.migration_from_device_try_again)) + SectionTextFooter(stringResource(MR.strings.migrate_to_device_try_again)) } LaunchedEffect(Unit) { chatReceiver?.stopAndCleanUp() File(archivePath).delete() - MigrationFromAnotherDeviceState.save(null) + MigrationToDeviceState.save(null) } } @Composable -private fun MutableState.ArchiveImportView(archivePath: String, netCfg: NetCfg) { +private fun MutableState.ArchiveImportView(archivePath: String, netCfg: NetCfg) { Box { - SectionView(stringResource(MR.strings.migration_from_device_importing_archive).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_importing_archive).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -356,29 +354,29 @@ private fun MutableState.ArchiveImportView(archivePath: String, } @Composable -private fun MutableState.ArchiveImportFailedView(archivePath: String, netCfg: NetCfg) { - SectionView(stringResource(MR.strings.migration_from_device_import_failed).uppercase()) { +private fun MutableState.ArchiveImportFailedView(archivePath: String, netCfg: NetCfg) { + SectionView(stringResource(MR.strings.migrate_to_device_import_failed).uppercase()) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_download), - text = stringResource(MR.strings.migration_from_device_repeat_import), + text = stringResource(MR.strings.migrate_to_device_repeat_import), textColor = MaterialTheme.colors.primary, click = { - state = MigrationState.ArchiveImport(archivePath, netCfg) + state = MigrationToState.ArchiveImport(archivePath, netCfg) } ) {} - SectionTextFooter(stringResource(MR.strings.migration_from_device_try_again)) + SectionTextFooter(stringResource(MR.strings.migrate_to_device_try_again)) } } @Composable -private fun MutableState.PassphraseEnteringView(currentKey: String, netCfg: NetCfg) { +private fun MutableState.PassphraseEnteringView(currentKey: String, netCfg: NetCfg) { val currentKey = rememberSaveable { mutableStateOf(currentKey) } val verifyingPassphrase = rememberSaveable { mutableStateOf(false) } val useKeychain = rememberSaveable { mutableStateOf(appPreferences.storeDBPassphrase.get()) } Box { val view = LocalMultiplatformView() - SectionView(stringResource(MR.strings.migration_from_device_enter_passphrase).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_enter_passphrase).uppercase()) { SavePassphraseSetting( useKeychain.value, false, @@ -401,9 +399,9 @@ private fun MutableState.PassphraseEnteringView(currentKey: Stri val (status, _) = chatInitTemporaryDatabase(dbAbsolutePrefixPath, key = currentKey.value, confirmation = MigrationConfirmation.YesUp) val success = status == DBMigrationResult.OK || status == DBMigrationResult.InvalidConfirmation if (success) { - state = MigrationState.Migration(currentKey.value, MigrationConfirmation.YesUp, useKeychain.value, netCfg) + state = MigrationToState.Migration(currentKey.value, MigrationConfirmation.YesUp, useKeychain.value, netCfg) } else if (status is DBMigrationResult.ErrorMigration) { - state = MigrationState.MigrationConfirmation(status, currentKey.value, useKeychain.value, netCfg) + state = MigrationToState.MigrationConfirmation(status, currentKey.value, useKeychain.value, netCfg) } else { showErrorOnMigrationIfNeeded(status) } @@ -420,7 +418,7 @@ private fun MutableState.PassphraseEnteringView(currentKey: Stri } @Composable -private fun MutableState.MigrationConfirmationView(status: DBMigrationResult, passphrase: String, useKeychain: Boolean, netCfg: NetCfg) { +private fun MutableState.MigrationConfirmationView(status: DBMigrationResult, passphrase: String, useKeychain: Boolean, netCfg: NetCfg) { data class Tuple4(val a: A, val b: B, val c: C, val d: D) val (header: String, button: String?, footer: String, confirmation: MigrationConfirmation?) = when (status) { is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) { @@ -455,7 +453,7 @@ private fun MutableState.MigrationConfirmationView(status: DBMig text = button, textColor = MaterialTheme.colors.primary, click = { - state = MigrationState.Migration(passphrase, confirmation, useKeychain, netCfg) + state = MigrationToState.Migration(passphrase, confirmation, useKeychain, netCfg) } ) {} } @@ -466,7 +464,7 @@ private fun MutableState.MigrationConfirmationView(status: DBMig @Composable private fun MigrationView(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, close: () -> Unit) { Box { - SectionView(stringResource(MR.strings.migration_from_device_migrating).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_migrating).uppercase()) {} ProgressView() } LaunchedEffect(Unit) { @@ -479,18 +477,18 @@ private fun ProgressView() { DefaultProgressView(null) } -private suspend fun MutableState.checkUserLink(link: String) { +private suspend fun MutableState.checkUserLink(link: String) { if (strHasSimplexFileLink(link.trim())) { val data = MigrationFileLinkData.readFromLink(link) val hasOnionConfigured = data?.networkConfig?.hasOnionConfigured() ?: false val networkConfig = data?.networkConfig?.transformToPlatformSupported() // If any of iOS or Android had onion enabled, show onion screen if (hasOnionConfigured && networkConfig?.hostMode != null && networkConfig.requiredHostMode != null) { - state = MigrationState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode) - MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode)) + state = MigrationToState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode) + MigrationToDeviceState.save(MigrationToDeviceState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode)) } else { val current = getNetCfg() - state = MigrationState.DatabaseInit(link.trim(), current.copy( + state = MigrationToState.DatabaseInit(link.trim(), current.copy( socksProxy = networkConfig?.socksProxy, hostMode = networkConfig?.hostMode ?: current.hostMode, requiredHostMode = networkConfig?.requiredHostMode ?: current.requiredHostMode @@ -504,7 +502,7 @@ private suspend fun MutableState.checkUserLink(link: String) { } } -private fun MutableState.prepareDatabase( +private fun MutableState.prepareDatabase( link: String, tempDatabaseFile: File, netCfg: NetCfg, @@ -512,43 +510,59 @@ private fun MutableState.prepareDatabase( withLongRunningApi { val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, netCfg) if (ctrlAndUser == null) { - state = MigrationState.DownloadFailed(0, link, archivePath(), netCfg) + state = MigrationToState.DownloadFailed(0, link, archivePath(), netCfg) return@withLongRunningApi } val (ctrl, user) = ctrlAndUser - state = MigrationState.LinkDownloading(link, ctrl, user, archivePath(), netCfg) + state = MigrationToState.LinkDownloading(link, ctrl, user, archivePath(), netCfg) } } -private fun MutableState.startDownloading( +private fun MutableState.startDownloading( totalBytes: Long, ctrl: ChatCtrl, user: User, tempDatabaseFile: File, - chatReceiver: MutableState, + chatReceiver: MutableState, link: String, archivePath: String, netCfg: NetCfg, ) { withBGApi { - chatReceiver.value = MigrationFromChatReceiver(ctrl, tempDatabaseFile) { msg -> + chatReceiver.value = MigrationToChatReceiver(ctrl, tempDatabaseFile) { msg -> when (msg) { is CR.RcvFileProgressXFTP -> { - state = MigrationState.DownloadProgress(msg.receivedSize, msg.totalSize, msg.rcvFileTransfer.fileId, link, archivePath, netCfg, ctrl) - MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.DownloadProgress(link, File(archivePath).name, netCfg)) + state = MigrationToState.DownloadProgress(msg.receivedSize, msg.totalSize, msg.rcvFileTransfer.fileId, link, archivePath, netCfg, ctrl) + MigrationToDeviceState.save(MigrationToDeviceState.DownloadProgress(link, File(archivePath).name, netCfg)) } is CR.RcvStandaloneFileComplete -> { delay(500) - state = MigrationState.ArchiveImport(archivePath, netCfg) - MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.ArchiveImport(File(archivePath).name, netCfg)) + // User closed the whole screen before new state was saved + if (state == null) { + MigrationToDeviceState.save(null) + } else { + state = MigrationToState.ArchiveImport(archivePath, netCfg) + MigrationToDeviceState.save(MigrationToDeviceState.ArchiveImport(File(archivePath).name, netCfg)) + } } is CR.RcvFileError -> { AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.migration_from_device_download_failed), - generalGetString(MR.strings.migration_from_device_file_delete_or_link_invalid) + generalGetString(MR.strings.migrate_to_device_download_failed), + generalGetString(MR.strings.migrate_to_device_file_delete_or_link_invalid) ) - state = MigrationState.DownloadFailed(totalBytes, link, archivePath, netCfg) + state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg) + } + is CR.ChatRespError -> { + if (msg.chatError is ChatError.ChatErrorChat && msg.chatError.errorType is ChatErrorType.NoRcvFileUser) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.migrate_to_device_download_failed), + generalGetString(MR.strings.migrate_to_device_file_delete_or_link_invalid) + ) + state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg) + } else { + Log.d(TAG, "unsupported error: ${msg.responseType}") + } } else -> Log.d(TAG, "unsupported event: ${msg.responseType}") } @@ -557,16 +571,16 @@ private fun MutableState.startDownloading( val (res, error) = controller.downloadStandaloneFile(user, link, CryptoFile.plain(File(archivePath).path), ctrl) if (res == null) { - state = MigrationState.DownloadFailed(totalBytes, link, archivePath, netCfg) + state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg) AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.migration_from_device_error_downloading_archive), + generalGetString(MR.strings.migrate_to_device_error_downloading_archive), error ) } } } -private fun MutableState.importArchive(archivePath: String, netCfg: NetCfg) { +private fun MutableState.importArchive(archivePath: String, netCfg: NetCfg) { withLongRunningApi { try { if (ChatController.ctrl == null || ChatController.ctrl == -1L) { @@ -582,14 +596,14 @@ private fun MutableState.importArchive(archivePath: String, netC generalGetString(MR.strings.non_fatal_errors_occured_during_import) ) } - state = MigrationState.Passphrase("", netCfg) - MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.Passphrase(netCfg)) + state = MigrationToState.Passphrase("", netCfg) + MigrationToDeviceState.save(MigrationToDeviceState.Passphrase(netCfg)) } catch (e: Exception) { - state = MigrationState.ArchiveImportFailed(archivePath, netCfg) + state = MigrationToState.ArchiveImportFailed(archivePath, netCfg) AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_importing_database), e.stackTraceToString()) } } catch (e: Exception) { - state = MigrationState.ArchiveImportFailed(archivePath, netCfg) + state = MigrationToState.ArchiveImportFailed(archivePath, netCfg) AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_deleting_database), e.stackTraceToString()) } } @@ -630,30 +644,32 @@ private suspend fun finishMigration(appSettings: AppSettings, close: () -> Unit) startChat(user) } hideView(close) - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migration_from_device_chat_migrated), generalGetString(MR.strings.migration_from_device_finalize_migration)) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_to_device_chat_migrated), generalGetString(MR.strings.migrate_to_device_finalize_migration)) } catch (e: Exception) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.stackTraceToString()) } - MigrationFromAnotherDeviceState.save(null) + MigrationToDeviceState.save(null) } private fun hideView(close: () -> Unit) { appPreferences.onboardingStage.set(OnboardingStage.OnboardingComplete) + chatModel.migrationState.value = null close() } -private suspend fun MutableState.cleanUpOnBack(chatReceiver: MigrationFromChatReceiver?) { +private suspend fun MutableState.cleanUpOnBack(chatReceiver: MigrationToChatReceiver?) { val state = state - if (state is MigrationState.ArchiveImportFailed) { + if (state is MigrationToState.ArchiveImportFailed) { // Original database is not exist, nothing is set up correctly for showing to a user yet. Return to clean state deleteChatDatabaseFilesAndState() initChatControllerAndRunMigrations() - } else if (state is MigrationState.DownloadProgress && state.ctrl != null) { + } else if (state is MigrationToState.DownloadProgress && state.ctrl != null) { stopArchiveDownloading(state.fileId, state.ctrl) } chatReceiver?.stopAndCleanUp() getMigrationTempFilesDirectory().deleteRecursively() - MigrationFromAnotherDeviceState.save(null) + MigrationToDeviceState.save(null) + chatModel.migrationState.value = null } private fun strHasSimplexFileLink(text: String): Boolean = @@ -670,7 +686,7 @@ private fun archivePath(): String { return archivePath.absolutePath } -private class MigrationFromChatReceiver( +private class MigrationToChatReceiver( val ctrl: ChatCtrl, val databaseUrl: File, var receiveMessages: Boolean = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index 0fe756d8fb..b82852664b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -19,7 +19,8 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.migration.MigrateFromAnotherDeviceView +import chat.simplex.common.views.migration.MigrateToDeviceView +import chat.simplex.common.views.migration.MigrationToState import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource @@ -71,7 +72,9 @@ fun SimpleXInfoLayout( .padding(top = DEFAULT_PADDING), contentAlignment = Alignment.Center ) { SimpleButtonDecorated(text = stringResource(MR.strings.migrate_from_another_device), icon = painterResource(MR.images.ic_download), - click = { ModalManager.fullscreen.showCustomModal { close -> MigrateFromAnotherDeviceView(chatModel.migrationState.value, close) } }) + click = { + chatModel.migrationState.value = MigrationToState.PasteOrScanLink + ModalManager.fullscreen.showCustomModal { close -> MigrateToDeviceView(close) } }) } } @@ -85,9 +88,8 @@ fun SimpleXInfoLayout( } } LaunchedEffect(Unit) { - val state = chatModel.migrationState.value - if (state != null && !ModalManager.fullscreen.hasModalsOpen()) { - ModalManager.fullscreen.showCustomModal(animated = false) { close -> MigrateFromAnotherDeviceView(state, close) } + if (chatModel.migrationState.value != null && !ModalManager.fullscreen.hasModalsOpen()) { + ModalManager.fullscreen.showCustomModal(animated = false) { close -> MigrateToDeviceView(close) } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index ffa908e210..3ef02f536a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -28,8 +28,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.migration.MigrateFromAnotherDeviceView -import chat.simplex.common.views.migration.MigrateToAnotherDeviceView +import chat.simplex.common.views.migration.MigrateFromDeviceView import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView import chat.simplex.common.views.remote.ConnectDesktopView @@ -137,7 +136,7 @@ fun SettingsLayout( } else { SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal{ it, close -> ConnectDesktopView(close) }, disabled = stopped, extraPadding = true) } - SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_to_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateToAnotherDeviceView(close) } }}, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } }}, disabled = stopped, extraPadding = true) } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index f3b7bf4a72..fca2adfafb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1846,63 +1846,66 @@ Please report it to the developers: \n%s Restart chat - - - Migrate here + + Migrate here Or paste archive link Paste archive link Invalid link - Migrating - Preparing download - Downloading link details - Downloading archive - %s downloaded - Download failed - Repeat download - You can give another try. - Importing archive - Import failed - Repeat import - Enter passphrase - File was deleted or link is invalid - Error downloading the archive - Chat migrated! - Finalize migration on another device. - Confirm network settings - Please confirm that network settings are correct for this device. - Apply + Migrating + Preparing download + Downloading link details + Downloading archive + %s downloaded + Download failed + Repeat download + You can give another try. + Importing archive + Import failed + Repeat import + Enter passphrase + File was deleted or link is invalid + Error downloading the archive + Chat migrated! + Finalize migration on another device. + Confirm network settings + Please confirm that network settings are correct for this device. + Apply - - Migrate to another device - Error saving settings - Exported file doesn\'t exist - Error exporting chat database - Preparing upload - Error uploading the archive - Error deleting database - Stopping chat - In order to continue, chat should be stopped. - Archive and upload - Confirm upload - All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. - Archiving database - %s uploaded - Uploading archive - Upload failed - Repeat upload - You can give another try. - Creating archive link - Cancel migration - Finalize migration - Migrate from another device on the new device and scan QR code.]]> - Or securely share this file link - Delete database from this device - Warning: starting chat on multiple devices is not supported and will cause message delivery failures - Start chat - Migration complete - must not use the same database on two devices.]]> - Please note: using the same database on two devices will break the decryption of messages from your connections, as a security protection.]]> - Verify database passphrase - Verify passphrase - Confirm that you remember database passphrase to migrate it. + + Migrate device + Migrate to another device + Error saving settings + Exported file doesn\'t exist + Error exporting chat database + Preparing upload + Error uploading the archive + Error deleting database + Stopping chat + In order to continue, chat should be stopped. + Archive and upload + Confirm upload + All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. + Archiving database + %s uploaded + Uploading archive + Upload failed + Repeat upload + You can give another try. + Creating archive link + Cancel migration + Finalize migration + Migrate from another device on the new device and scan QR code.]]> + Or securely share this file link + Delete database from this device + Warning: starting chat on multiple devices is not supported and will cause message delivery failures + Start chat + Migration complete + must not use the same database on two devices.]]> + Please note: using the same database on two devices will break the decryption of messages from your connections, as a security protection.]]> + Verify database passphrase + Verify passphrase + Confirm that you remember database passphrase to migrate it. + Check your internet connection and try again + Warning: the archive will be deleted.]]> + Error verifying passphrase: \ No newline at end of file From d53ef24bf1594068e6f9873088300a11e013b3d9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 12 Mar 2024 18:29:15 +0000 Subject: [PATCH 5/5] core: 5.6.0.2 --- package.yaml | 2 +- simplex-chat.cabal | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.yaml b/package.yaml index ab703cc9cd..7d61b0314d 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.6.0.1 +version: 5.6.0.2 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index a3a78f851f..e4daba10f4 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.6.0.1 +version: 5.6.0.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat