mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-30 00:08:44 +00:00
Follow-up to "Recognise wrong QR code type in every scanner", from an iOS review of that commit. - ScanProtocolServer: a rejected scan now alerts in place instead of dismissing, but the sheet had no way out - move its title into the nav bar and add a Cancel button. Fixes the preview, which needs AppTheme now that the themed background is applied inside the view. - MigrateToDevice: classify the pasted link off the main thread, as the Kotlin paste path already does - pasted text is unbounded and classifying a file link parses its YAML description. Guard the result, as the sheet can be dismissed while classifying. - Relay message named the Chat relay screen but not that the address is only kept once the relay is tested, so following it lost the pasted address to an "Invalid relay name!" alert. The migration message named a label that only exists on desktop. - ContentView: the /r deep link showed the relay alert without the guidance added to the other call site; both literals were identical before. - Document WrongQRCode.swift per apps/ios/CODE.md, and refresh the specs and product docs that quote the changed messages.
814 lines
35 KiB
Swift
814 lines
35 KiB
Swift
//
|
|
// MigrateToDevice.swift
|
|
// SimpleX (iOS)
|
|
//
|
|
// Created by Avently on 23.02.2024.
|
|
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
|
//
|
|
// Spec: spec/database.md
|
|
|
|
import SwiftUI
|
|
import SimpleXChat
|
|
|
|
enum MigrationToDeviceState: Codable, Equatable {
|
|
case downloadProgress(link: String, archiveName: String)
|
|
case archiveImport(archiveName: String)
|
|
case passphrase
|
|
|
|
// 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
|
|
}
|
|
|
|
static func save(_ state: MigrationToDeviceState?) {
|
|
if let state {
|
|
UserDefaults.standard.setValue(encodeJSON(state), forKey: DEFAULT_MIGRATION_TO_STAGE)
|
|
} else {
|
|
UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_TO_STAGE)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum MigrationToState: Equatable {
|
|
case pasteOrScanLink
|
|
case linkDownloading(link: String)
|
|
case downloadProgress(downloadedBytes: Int64, totalBytes: Int64, fileId: Int64, link: String, archivePath: String, ctrl: chat_ctrl?)
|
|
case downloadFailed(totalBytes: Int64, link: String, archivePath: String)
|
|
case archiveImport(archivePath: String)
|
|
case archiveImportFailed(archivePath: String)
|
|
case passphrase(passphrase: String)
|
|
case migrationConfirmation(status: DBMigrationResult, passphrase: String, useKeychain: Bool)
|
|
case migration(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Bool)
|
|
case onion(appSettings: AppSettings)
|
|
}
|
|
|
|
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.")
|
|
|
|
case wrongPassphrase(title: LocalizedStringKey = "Wrong passphrase!", message: LocalizedStringKey = "Enter correct passphrase.")
|
|
case invalidConfirmation(title: LocalizedStringKey = "Invalid migration confirmation")
|
|
case keychainError(_ title: LocalizedStringKey = "Keychain error")
|
|
case databaseError(_ title: LocalizedStringKey = "Database error", message: String)
|
|
case unknownError(_ title: LocalizedStringKey = "Unknown error", message: String)
|
|
|
|
case error(title: LocalizedStringKey, error: String = "")
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .chatImportedWithErrors: return "chatImportedWithErrors"
|
|
|
|
case .wrongPassphrase: return "wrongPassphrase"
|
|
case .invalidConfirmation: return "invalidConfirmation"
|
|
case .keychainError: return "keychainError"
|
|
case let .databaseError(title, message): return "\(title) \(message)"
|
|
case let .unknownError(title, message): return "\(title) \(message)"
|
|
|
|
case let .error(title, _): return "error \(title)"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MigrateToDevice: View {
|
|
@EnvironmentObject var m: ChatModel
|
|
@EnvironmentObject var theme: AppTheme
|
|
@Environment(\.dismiss) var dismiss: DismissAction
|
|
@Binding var migrationState: MigrationToState?
|
|
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
|
@State private var alert: MigrateToDeviceViewAlert?
|
|
@State private var databaseAlert: DatabaseAlert? = nil
|
|
private let tempDatabaseUrl = urlForTemporaryDatabase()
|
|
@State private var chatReceiver: MigrationChatReceiver? = nil
|
|
// Prevent from hiding the view until migration is finished or app deleted
|
|
@State private var backDisabled: Bool = false
|
|
@State private var showQRCodeScanner: Bool = true
|
|
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
|
|
|
|
@State private var importingArchiveFromFileProgressIndicator = false
|
|
@State private var showFileImporter = false
|
|
|
|
var body: some View {
|
|
VStack {
|
|
switch migrationState {
|
|
case nil: EmptyView()
|
|
case .pasteOrScanLink:
|
|
pasteOrScanLinkView()
|
|
case let .linkDownloading(link):
|
|
linkDownloadingView(link)
|
|
case let .downloadProgress(downloaded, total, _, _, _, _):
|
|
downloadProgressView(downloaded, totalBytes: total)
|
|
case let .downloadFailed(total, link, archivePath):
|
|
downloadFailedView(totalBytes: total, link, archivePath)
|
|
case let .archiveImport(archivePath):
|
|
archiveImportView(archivePath)
|
|
case let .archiveImportFailed(archivePath):
|
|
archiveImportFailedView(archivePath)
|
|
case let .passphrase(passphrase):
|
|
PassphraseEnteringView(migrationState: $migrationState, currentKey: passphrase, alert: $alert)
|
|
case let .migrationConfirmation(status, passphrase, useKeychain):
|
|
migrationConfirmationView(status, passphrase, useKeychain)
|
|
case let .migration(passphrase, confirmation, useKeychain):
|
|
migrationView(passphrase, confirmation, useKeychain)
|
|
case let .onion(appSettings):
|
|
OnionView(appSettings: appSettings, finishMigration: finishMigration)
|
|
}
|
|
}
|
|
.onAppear {
|
|
backDisabled = switch migrationState {
|
|
case nil, .pasteOrScanLink, .linkDownloading, .downloadProgress, .downloadFailed, .archiveImportFailed: false
|
|
case .archiveImport, .passphrase, .migrationConfirmation, .migration, .onion: true
|
|
}
|
|
}
|
|
.onChange(of: migrationState) { state in
|
|
backDisabled = switch state {
|
|
case nil, .pasteOrScanLink, .linkDownloading, .downloadProgress, .downloadFailed, .archiveImportFailed: false
|
|
case .archiveImport, .passphrase, .migrationConfirmation, .migration, .onion: true
|
|
}
|
|
}
|
|
.onDisappear {
|
|
Task {
|
|
if case .archiveImportFailed = migrationState {
|
|
// Original database is not exist, nothing is setup correctly for showing to a user yet. Return to clean state
|
|
deleteAppDatabaseAndFiles()
|
|
initChatAndMigrate()
|
|
} else if case let .downloadProgress(_, _, fileId, _, _, ctrl) = migrationState, let ctrl {
|
|
await stopArchiveDownloading(fileId, ctrl)
|
|
}
|
|
chatReceiver?.stopAndCleanUp()
|
|
if !backDisabled {
|
|
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
|
|
MigrationToDeviceState.save(nil)
|
|
}
|
|
}
|
|
}
|
|
.alert(item: $alert) { alert in
|
|
switch alert {
|
|
case let .chatImportedWithErrors(title, text):
|
|
return Alert(title: Text(title), message: Text(text))
|
|
case let .wrongPassphrase(title, message):
|
|
return Alert(title: Text(title), message: Text(message))
|
|
case let .invalidConfirmation(title):
|
|
return Alert(title: Text(title))
|
|
case let .keychainError(title):
|
|
return Alert(title: Text(title))
|
|
case let .databaseError(title, message):
|
|
return Alert(title: Text(title), message: Text(message))
|
|
case let .unknownError(title, message):
|
|
return Alert(title: Text(title), message: Text(message))
|
|
case let .error(title, error):
|
|
return Alert(title: Text(title), message: Text(error))
|
|
}
|
|
}
|
|
.alert(item: $databaseAlert) { item in
|
|
switch item {
|
|
case .archiveImported:
|
|
let (title, message) = archiveImportedAlertText()
|
|
return Alert(title: Text(title), message: Text(message))
|
|
case let .archiveImportedWithErrors(errs):
|
|
let (title, message) = archiveImportedWithErrorsAlertText(errs: errs)
|
|
return Alert(title: Text(title), message: Text(message))
|
|
case let .error(title, error):
|
|
return Alert(title: Text(title), message: Text(error))
|
|
default: // not expected this branch to be called because this alert is used only for importArchive purpose
|
|
return Alert(title: Text("Error"))
|
|
}
|
|
}
|
|
.interactiveDismissDisabled(backDisabled)
|
|
}
|
|
|
|
private func pasteOrScanLinkView() -> some View {
|
|
ZStack {
|
|
List {
|
|
Section(header: Text("Scan QR code").foregroundColor(theme.colors.secondary)) {
|
|
ScannerInView(showQRCodeScanner: $showQRCodeScanner) { resp in
|
|
switch resp {
|
|
case let .success(r):
|
|
let trimmed = r.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
switch checkLink(trimmed) {
|
|
case .some(.fileDescription):
|
|
migrationState = .linkDownloading(link: trimmed)
|
|
case nil:
|
|
alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.")
|
|
case let type?:
|
|
alert = .error(title: "Wrong link", error: wrongQRCodeMessage(type))
|
|
}
|
|
case let .failure(e):
|
|
logger.error("processQRCode QR code error: \(e.localizedDescription)")
|
|
alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.")
|
|
}
|
|
}
|
|
}
|
|
Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
|
|
pasteLinkView()
|
|
}
|
|
Section(header: Text("Or import archive file").foregroundColor(theme.colors.secondary)) {
|
|
archiveImportFromFileView()
|
|
}
|
|
}
|
|
if importingArchiveFromFileProgressIndicator {
|
|
progressView()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func pasteLinkView() -> some View {
|
|
Button {
|
|
if let str = UIPasteboard.general.string {
|
|
let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
Task {
|
|
let linkType = await checkLinkAsync(trimmed)
|
|
// the sheet can be dismissed while classifying; applying the result then
|
|
// resurrects it (SimpleXInfo) or skips the paste screen (CreateProfile)
|
|
guard case .some(.pasteOrScanLink) = migrationState else { return }
|
|
switch linkType {
|
|
case .some(.fileDescription):
|
|
migrationState = .linkDownloading(link: trimmed)
|
|
case nil:
|
|
alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.")
|
|
case let type?:
|
|
alert = .error(title: "Wrong link", error: wrongQRCodeMessage(type))
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Text("Tap to paste link")
|
|
}
|
|
.disabled(!pasteboardHasStrings)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
}
|
|
|
|
private func archiveImportFromFileView() -> some View {
|
|
Button {
|
|
showFileImporter = true
|
|
} label: {
|
|
Label("Import database", systemImage: "square.and.arrow.down")
|
|
}
|
|
.disabled(importingArchiveFromFileProgressIndicator)
|
|
.fileImporter(
|
|
isPresented: $showFileImporter,
|
|
allowedContentTypes: [.zip],
|
|
allowsMultipleSelection: false
|
|
) { result in
|
|
if case let .success(files) = result, let fileURL = files.first {
|
|
Task {
|
|
let success = await DatabaseView.importArchive(fileURL, $importingArchiveFromFileProgressIndicator, $databaseAlert, true)
|
|
if success {
|
|
DatabaseView.startChat(
|
|
Binding.constant(false),
|
|
$importingArchiveFromFileProgressIndicator
|
|
)
|
|
hideView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
private func linkDownloadingView(_ link: String) -> some View {
|
|
ZStack {
|
|
List {
|
|
Section {} header: {
|
|
Text("Downloading link details")
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
progressView()
|
|
}
|
|
.onAppear {
|
|
downloadLinkDetails(link)
|
|
}
|
|
}
|
|
|
|
private func downloadProgressView(_ downloadedBytes: Int64, totalBytes: Int64) -> some View {
|
|
ZStack {
|
|
List {
|
|
Section {} header: {
|
|
Text("Downloading archive")
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
let ratio = Float(downloadedBytes) / Float(max(totalBytes, 1))
|
|
MigrateFromDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: downloadedBytes, countStyle: .binary)) downloaded", theme.colors.primary)
|
|
}
|
|
}
|
|
|
|
private func downloadFailedView(totalBytes: Int64, _ link: String, _ archivePath: String) -> some View {
|
|
List {
|
|
Section {
|
|
Button(action: {
|
|
try? FileManager.default.removeItem(atPath: archivePath)
|
|
migrationState = .linkDownloading(link: link)
|
|
}) {
|
|
settingsRow("tray.and.arrow.down", color: theme.colors.secondary) {
|
|
Text("Repeat download").foregroundColor(theme.colors.primary)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Download failed")
|
|
.foregroundColor(theme.colors.secondary)
|
|
} footer: {
|
|
Text("You can give another try.")
|
|
.foregroundColor(theme.colors.secondary)
|
|
.font(.callout)
|
|
}
|
|
}
|
|
.onAppear {
|
|
chatReceiver?.stopAndCleanUp()
|
|
try? FileManager.default.removeItem(atPath: archivePath)
|
|
MigrationToDeviceState.save(nil)
|
|
}
|
|
}
|
|
|
|
private func archiveImportView(_ archivePath: String) -> some View {
|
|
ZStack {
|
|
List {
|
|
Section {} header: {
|
|
Text("Importing archive")
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
progressView()
|
|
}
|
|
.onAppear {
|
|
importArchive(archivePath)
|
|
}
|
|
}
|
|
|
|
private func archiveImportFailedView(_ archivePath: String) -> some View {
|
|
List {
|
|
Section {
|
|
Button(action: {
|
|
migrationState = .archiveImport(archivePath: archivePath)
|
|
}) {
|
|
settingsRow("square.and.arrow.down", color: theme.colors.secondary) {
|
|
Text("Repeat import").foregroundColor(theme.colors.primary)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Import failed")
|
|
.foregroundColor(theme.colors.secondary)
|
|
} footer: {
|
|
Text("You can give another try.")
|
|
.foregroundColor(theme.colors.secondary)
|
|
.font(.callout)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func migrationConfirmationView(_ status: DBMigrationResult, _ passphrase: String, _ useKeychain: Bool) -> some View {
|
|
List {
|
|
let (header, button, footer, confirmation): (LocalizedStringKey, LocalizedStringKey?, String, MigrationConfirmation?) = switch status {
|
|
case let .errorMigration(_, migrationError):
|
|
switch migrationError {
|
|
case .upgrade:
|
|
("Database upgrade",
|
|
"Upgrade and open chat",
|
|
"",
|
|
.yesUp)
|
|
case let .downgrade(downMigrations):
|
|
("Database downgrade",
|
|
"Downgrade and open chat",
|
|
([NSLocalizedString("Warning: you may lose some data!", comment: "")]
|
|
+ downMigrationWarnings(downMigrations).reversed())
|
|
.joined(separator: "\n"),
|
|
.yesUpDown)
|
|
case let .migrationError(mtrError):
|
|
("Incompatible database version",
|
|
nil,
|
|
"\(NSLocalizedString("Error: ", comment: "")) \(mtrErrorDescription(mtrError))",
|
|
nil)
|
|
}
|
|
default: ("Error", nil, "Unknown error", nil)
|
|
}
|
|
Section {
|
|
if let button, let confirmation {
|
|
Button(action: {
|
|
migrationState = .migration(passphrase: passphrase, confirmation: confirmation, useKeychain: useKeychain)
|
|
}) {
|
|
settingsRow("square.and.arrow.down", color: theme.colors.secondary) {
|
|
Text(button).foregroundColor(theme.colors.primary)
|
|
}
|
|
}
|
|
} else {
|
|
EmptyView()
|
|
}
|
|
} header: {
|
|
Text(header)
|
|
.foregroundColor(theme.colors.secondary)
|
|
} footer: {
|
|
Text(footer)
|
|
.foregroundColor(theme.colors.secondary)
|
|
.font(.callout)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func migrationView(_ passphrase: String, _ confirmation: MigrationConfirmation, _ useKeychain: Bool) -> some View {
|
|
ZStack {
|
|
List {
|
|
Section {} header: {
|
|
Text("Migrating")
|
|
.foregroundColor(theme.colors.secondary)
|
|
}
|
|
}
|
|
progressView()
|
|
}
|
|
.onAppear {
|
|
startChat(passphrase, confirmation, useKeychain)
|
|
}
|
|
}
|
|
|
|
struct OnionView: View {
|
|
@EnvironmentObject var theme: AppTheme
|
|
@State var appSettings: AppSettings
|
|
@State private var onionHosts: OnionHosts = .no
|
|
var finishMigration: (AppSettings) -> Void
|
|
|
|
var body: some View {
|
|
List {
|
|
Section {
|
|
Button(action: {
|
|
var updated = appSettings.networkConfig!
|
|
let (hostMode, requiredHostMode) = onionHosts.hostMode
|
|
updated.hostMode = hostMode
|
|
updated.requiredHostMode = requiredHostMode
|
|
updated.socksProxy = nil
|
|
appSettings.networkConfig = updated
|
|
finishMigration(appSettings)
|
|
}) {
|
|
settingsRow("checkmark", color: theme.colors.secondary) {
|
|
Text("Apply").foregroundColor(theme.colors.primary)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Confirm network settings")
|
|
.foregroundColor(theme.colors.secondary)
|
|
} footer: {
|
|
Text("Please confirm that network settings are correct for this device.")
|
|
.foregroundColor(theme.colors.secondary)
|
|
.font(.callout)
|
|
}
|
|
|
|
Section(header: Text("Network settings").foregroundColor(theme.colors.secondary)) {
|
|
Picker("Use .onion hosts", selection: $onionHosts) {
|
|
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
|
|
}
|
|
.frame(height: 36)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func downloadLinkDetails(_ link: String) {
|
|
let archiveTime = Date.now
|
|
let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted))
|
|
let archiveName = "simplex-chat.\(ts).zip"
|
|
let archivePath = getMigrationTempFilesDirectory().appendingPathComponent(archiveName)
|
|
|
|
startDownloading(0, link, archivePath.path)
|
|
}
|
|
|
|
private func initTemporaryDatabase() -> (chat_ctrl, User)? {
|
|
let (status, ctrl) = chatInitTemporaryDatabase(url: tempDatabaseUrl)
|
|
showErrorOnMigrationIfNeeded(status, $alert)
|
|
do {
|
|
if let ctrl, let user = try startChatWithTemporaryDatabase(ctrl: ctrl) {
|
|
return (ctrl, user)
|
|
}
|
|
} catch let error {
|
|
logger.error("Error while starting chat in temporary database: \(error.localizedDescription)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func startDownloading(_ totalBytes: Int64, _ link: String, _ archivePath: String) {
|
|
Task {
|
|
guard let ctrlAndUser = initTemporaryDatabase() else {
|
|
return migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
|
|
}
|
|
let (ctrl, user) = ctrlAndUser
|
|
chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in
|
|
await MainActor.run {
|
|
switch msg {
|
|
case let .result(.rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer)):
|
|
migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl)
|
|
MigrationToDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent))
|
|
case .result(.rcvStandaloneFileComplete):
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
// 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 .result(.rcvFileError):
|
|
alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
|
|
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
|
|
case .error(.error(.noRcvFileUser)):
|
|
alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
|
|
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
|
|
default:
|
|
logger.debug("unsupported event: \(msg.responseType)")
|
|
}
|
|
}
|
|
}
|
|
chatReceiver?.start()
|
|
|
|
let (res, error) = await downloadStandaloneFile(user: user, url: link, file: CryptoFile.plain(URL(fileURLWithPath: archivePath).lastPathComponent), ctrl: ctrl)
|
|
if res == nil {
|
|
await MainActor.run {
|
|
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
|
|
}
|
|
return alert = .error(title: "Error downloading the archive", error: error ?? "")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func importArchive(_ archivePath: String) {
|
|
Task {
|
|
do {
|
|
if !hasChatCtrl() {
|
|
chatInitControllerRemovingDatabases()
|
|
} else if ChatModel.shared.chatRunning == true {
|
|
// cannot delete storage if chat is running
|
|
try await stopChatAsync()
|
|
}
|
|
try await apiDeleteStorage()
|
|
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
|
|
do {
|
|
let config = ArchiveConfig(archivePath: archivePath)
|
|
let archiveErrors = try await apiImportArchive(config: config)
|
|
if !archiveErrors.isEmpty {
|
|
alert = .chatImportedWithErrors()
|
|
}
|
|
await MainActor.run {
|
|
migrationState = .passphrase(passphrase: "")
|
|
MigrationToDeviceState.save(.passphrase)
|
|
}
|
|
} catch let error {
|
|
await MainActor.run {
|
|
migrationState = .archiveImportFailed(archivePath: archivePath)
|
|
}
|
|
alert = .error(title: "Error importing chat database", error: responseError(error))
|
|
}
|
|
} catch let error {
|
|
await MainActor.run {
|
|
migrationState = .archiveImportFailed(archivePath: archivePath)
|
|
}
|
|
alert = .error(title: "Error deleting chat database", error: responseError(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
private func stopArchiveDownloading(_ fileId: Int64, _ ctrl: chat_ctrl) async {
|
|
_ = await apiCancelFile(fileId: fileId, ctrl: ctrl)
|
|
}
|
|
|
|
private func startChat(_ passphrase: String, _ confirmation: MigrationConfirmation, _ useKeychain: Bool) {
|
|
if useKeychain {
|
|
_ = kcDatabasePassword.set(passphrase)
|
|
} else {
|
|
_ = kcDatabasePassword.remove()
|
|
}
|
|
storeDBPassphraseGroupDefault.set(useKeychain)
|
|
initialRandomDBPassphraseGroupDefault.set(false)
|
|
AppChatState.shared.set(.active)
|
|
Task {
|
|
do {
|
|
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 hasOnionConfigured {
|
|
migrationState = .onion(appSettings: appSettings)
|
|
} else {
|
|
finishMigration(appSettings)
|
|
}
|
|
}
|
|
} catch let error {
|
|
hideView()
|
|
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func finishMigration(_ appSettings: AppSettings) {
|
|
do {
|
|
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
|
|
MigrationToDeviceState.save(nil)
|
|
try ObjC.catchException {
|
|
appSettings.importIntoApp()
|
|
}
|
|
do {
|
|
try SimpleX.startChat(refreshInvitations: true)
|
|
AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
|
|
} catch let error {
|
|
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
|
|
}
|
|
} catch let error {
|
|
logger.error("Error importing settings: \(error.localizedDescription)")
|
|
AlertManager.shared.showAlert(
|
|
Alert(
|
|
title: Text("Error migrating settings"),
|
|
message: Text ("Some app settings were not migrated.") + textNewLine + Text(responseError(error)))
|
|
)
|
|
}
|
|
hideView()
|
|
}
|
|
|
|
private func hideView() {
|
|
onboardingStageDefault.set(.onboardingComplete)
|
|
m.onboardingStage = .onboardingComplete
|
|
m.migrationState = nil
|
|
MigrationToDeviceState.save(nil)
|
|
dismiss()
|
|
}
|
|
|
|
private static func urlForTemporaryDatabase() -> URL {
|
|
URL(fileURLWithPath: generateNewFileName(getMigrationTempFilesDirectory().path + "/" + "migration", "db", fullPath: true))
|
|
}
|
|
}
|
|
|
|
private struct PassphraseEnteringView: View {
|
|
@EnvironmentObject var theme: AppTheme
|
|
@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: MigrateToDeviceViewAlert?
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
List {
|
|
Section {
|
|
settingsRow("key", color: theme.colors.secondary) {
|
|
Toggle("Save passphrase in Keychain", isOn: $useKeychain)
|
|
}
|
|
|
|
PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey))
|
|
.focused($keyboardVisible)
|
|
Button(action: {
|
|
verifyingPassphrase = true
|
|
hideKeyboard()
|
|
Task {
|
|
let (status, _) = chatInitTemporaryDatabase(url: getAppDatabasePath(), key: currentKey, confirmation: .yesUp)
|
|
let success = switch status {
|
|
case .ok, .invalidConfirmation: true
|
|
default: false
|
|
}
|
|
if success {
|
|
await MainActor.run {
|
|
migrationState = .migration(passphrase: currentKey, confirmation: .yesUp, useKeychain: useKeychain)
|
|
}
|
|
} else if case .errorMigration = status {
|
|
await MainActor.run {
|
|
migrationState = .migrationConfirmation(status: status, passphrase: currentKey, useKeychain: useKeychain)
|
|
}
|
|
} else {
|
|
showErrorOnMigrationIfNeeded(status, $alert)
|
|
}
|
|
verifyingPassphrase = false
|
|
}
|
|
}) {
|
|
settingsRow("key", color: theme.colors.secondary) {
|
|
Text("Open chat")
|
|
}
|
|
}
|
|
.disabled(verifyingPassphrase || currentKey.isEmpty)
|
|
} header: {
|
|
Text("Enter passphrase")
|
|
.foregroundColor(theme.colors.secondary)
|
|
} footer: {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
if useKeychain {
|
|
Text("iOS Keychain is used to securely store passphrase - it allows receiving push notifications.")
|
|
} else {
|
|
Text("You have to enter passphrase every time the app starts - it is not stored on the device.")
|
|
Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.")
|
|
Text("**Warning**: Instant push notifications require passphrase saved in Keychain.")
|
|
}
|
|
}
|
|
.foregroundColor(theme.colors.secondary)
|
|
.font(.callout)
|
|
.padding(.top, 1)
|
|
.onTapGesture { keyboardVisible = false }
|
|
}
|
|
.onAppear {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
keyboardVisible = true
|
|
}
|
|
}
|
|
}
|
|
if verifyingPassphrase {
|
|
progressView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding<MigrateToDeviceViewAlert?>) {
|
|
switch status {
|
|
case .invalidConfirmation:
|
|
alert.wrappedValue = .invalidConfirmation()
|
|
case .errorNotADatabase:
|
|
alert.wrappedValue = .wrongPassphrase()
|
|
case .errorKeychain:
|
|
alert.wrappedValue = .keychainError()
|
|
case let .errorSQL(_, error):
|
|
alert.wrappedValue = .databaseError(message: error)
|
|
case let .unknown(error):
|
|
alert.wrappedValue = .unknownError(message: error)
|
|
case .errorMigration: ()
|
|
case .ok: ()
|
|
}
|
|
}
|
|
|
|
private func progressView() -> some View {
|
|
VStack {
|
|
ProgressView().scaleEffect(2)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
|
}
|
|
|
|
private class MigrationChatReceiver {
|
|
let ctrl: chat_ctrl
|
|
let databaseUrl: URL
|
|
let processReceivedMsg: (APIResult<ChatEvent>) async -> Void
|
|
private var receiveLoop: Task<Void, Never>?
|
|
private var receiveMessages = true
|
|
|
|
init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult<ChatEvent>) async -> Void) {
|
|
self.ctrl = ctrl
|
|
self.databaseUrl = databaseUrl
|
|
self.processReceivedMsg = processReceivedMsg
|
|
}
|
|
|
|
func start() {
|
|
logger.debug("MigrationChatReceiver.start")
|
|
receiveMessages = true
|
|
if receiveLoop != nil { return }
|
|
receiveLoop = Task { await receiveMsgLoop() }
|
|
}
|
|
|
|
func receiveMsgLoop() async {
|
|
// TODO use function that has timeout
|
|
if let msg = await chatRecvMsg(ctrl) {
|
|
Task {
|
|
await TerminalItems.shared.addResult(msg)
|
|
}
|
|
logger.debug("processReceivedMsg: \(msg.responseType)")
|
|
await processReceivedMsg(msg)
|
|
}
|
|
if self.receiveMessages {
|
|
_ = try? await Task.sleep(nanoseconds: 7_500_000)
|
|
await receiveMsgLoop()
|
|
}
|
|
}
|
|
|
|
func stopAndCleanUp() {
|
|
logger.debug("MigrationChatReceiver.stop")
|
|
receiveMessages = false
|
|
receiveLoop?.cancel()
|
|
receiveLoop = nil
|
|
chat_close_store(ctrl)
|
|
try? FileManager.default.removeItem(atPath: "\(databaseUrl.path)_chat.db")
|
|
try? FileManager.default.removeItem(atPath: "\(databaseUrl.path)_agent.db")
|
|
}
|
|
}
|
|
|
|
struct MigrateToDevice_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
MigrateToDevice(migrationState: Binding.constant(.pasteOrScanLink))
|
|
}
|
|
}
|