migrating to device
This commit is contained in:
parent
ac7a3e5b96
commit
fa2bdaf477
@ -276,8 +276,8 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th
|
||||
try await sendCommandOkResp(.apiStorageEncryption(config: DBEncryptionConfig(currentKey: currentKey, newKey: newKey)))
|
||||
}
|
||||
|
||||
func testStorageEncryption(key: String) async throws {
|
||||
try await sendCommandOkResp(.testStorageEncryption(key: key))
|
||||
func testStorageEncryption(key: String, _ ctrl: chat_ctrl? = nil) async throws {
|
||||
try await sendCommandOkResp(.testStorageEncryption(key: key), ctrl)
|
||||
}
|
||||
|
||||
func apiGetChats() throws -> [ChatData] {
|
||||
@ -878,8 +878,8 @@ func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl?
|
||||
}
|
||||
}
|
||||
|
||||
func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile) async -> (RcvFileTransfer?, String?) {
|
||||
let r = await chatSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file))
|
||||
func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (RcvFileTransfer?, String?) {
|
||||
let r = await chatSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file), ctrl)
|
||||
if case let .rcvStandaloneFileCreated(_, rcvFileTransfer) = r {
|
||||
return (rcvFileTransfer, nil)
|
||||
} else {
|
||||
@ -1106,8 +1106,8 @@ func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
|
||||
}
|
||||
}
|
||||
|
||||
private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
|
||||
let r = await chatSendCmd(cmd)
|
||||
private func sendCommandOkResp(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) async throws {
|
||||
let r = await chatSendCmd(cmd, ctrl)
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
515
apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift
Normal file
515
apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift
Normal file
@ -0,0 +1,515 @@
|
||||
//
|
||||
// MigrateFromAnotherDevice.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by Avently on 23.02.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
private enum MigrationState: Equatable {
|
||||
case pasteOrScanLink(link: String)
|
||||
case linkDownloading(link: String)
|
||||
case downloadProgress(downloadedBytes: Int64, totalBytes: Int64, fileId: Int64, link: String, archivePath: URL, ctrl: chat_ctrl?)
|
||||
case downloadFailed(totalBytes: Int64, link: String, archivePath: URL)
|
||||
case archiveImport(archivePath: String)
|
||||
case passphraseEntering(passphrase: String)
|
||||
case migration(passphrase: String)
|
||||
}
|
||||
|
||||
private enum MigrateFromAnotherDeviceViewAlert: 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 MigrateFromAnotherDevice: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State private var migrationState: MigrationState = .pasteOrScanLink(link: "")
|
||||
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
||||
@State private var alert: MigrateFromAnotherDeviceViewAlert?
|
||||
private let tempDatabaseUrl = urlForTemporaryDatabase()
|
||||
@State private var chatReceiver: MigrationChatReceiver? = nil
|
||||
@State private var backDisabled: Bool = false
|
||||
@State private var showQRCodeScanner: Bool = true
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
switch migrationState {
|
||||
case let .pasteOrScanLink(link):
|
||||
pasteOrScanLinkView(link)
|
||||
case let .linkDownloading(link):
|
||||
linkDownloadingView(link)
|
||||
case let .downloadProgress(downloaded, total, _, link, archivePath, _):
|
||||
downloadProgressView(downloaded, totalBytes: total, link, archivePath)
|
||||
case let .downloadFailed(total, link, archivePath):
|
||||
downloadFailedView(totalBytes: total, link, archivePath)
|
||||
case let .archiveImport(archivePath):
|
||||
archiveImportView(archivePath)
|
||||
case let .passphraseEntering(passphrase):
|
||||
PassphraseEnteringView(migrationState: $migrationState, currentKey: passphrase, alert: $alert)
|
||||
case let .migration(passphrase):
|
||||
migrationView(passphrase)
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(label: "Back") {
|
||||
if !backDisabled {
|
||||
dismiss()
|
||||
}
|
||||
})
|
||||
.onChange(of: migrationState) { state in
|
||||
backDisabled = switch migrationState {
|
||||
case .passphraseEntering: true
|
||||
case .migration: true
|
||||
default: false
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
Task {
|
||||
if case let .downloadProgress(_, _, fileId, _, _, ctrl) = migrationState, let ctrl {
|
||||
await stopArchiveDownloading(fileId, ctrl)
|
||||
}
|
||||
chatReceiver?.stop()
|
||||
try? FileManager.default.removeItem(atPath: "\(tempDatabaseUrl.path)_chat.db")
|
||||
try? FileManager.default.removeItem(atPath: "\(tempDatabaseUrl.path)_agent.db")
|
||||
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
|
||||
}
|
||||
}
|
||||
.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))
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(backDisabled)
|
||||
}
|
||||
|
||||
private func pasteOrScanLinkView(_ link: String) -> some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section("Paste link to an archive") {
|
||||
pasteLinkView()
|
||||
}
|
||||
Section("Or scan QR code") {
|
||||
ScannerInView(showQRCodeScanner: $showQRCodeScanner) { resp in
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
let link = r.string
|
||||
if strHasSimplexFileLink(link.trimmingCharacters(in: .whitespaces)) {
|
||||
migrationState = .linkDownloading(link: link.trimmingCharacters(in: .whitespaces))
|
||||
} else {
|
||||
alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.")
|
||||
}
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pasteLinkView() -> some View {
|
||||
Button {
|
||||
if let str = UIPasteboard.general.string {
|
||||
if strHasSimplexFileLink(str.trimmingCharacters(in: .whitespaces)) {
|
||||
migrationState = .linkDownloading(link: str.trimmingCharacters(in: .whitespaces))
|
||||
} else {
|
||||
alert = .error(title: "Invalid link", error: "The text you pasted is not a SimpleX link.")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("Tap to paste link")
|
||||
}
|
||||
.disabled(!ChatModel.shared.pasteboardHasStrings)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
private func linkDownloadingView(_ link: String) -> some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section {} header: {
|
||||
Text("Downloading link details…")
|
||||
}
|
||||
}
|
||||
progressView()
|
||||
}
|
||||
.onAppear {
|
||||
downloadLinkDetails(link)
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadProgressView(_ downloadedBytes: Int64, totalBytes: Int64, _ link: String, _ archivePath: URL) -> some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section {} header: {
|
||||
Text("Downloading archive…")
|
||||
}
|
||||
}
|
||||
let ratio = Float(downloadedBytes) / Float(totalBytes)
|
||||
largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: downloadedBytes, countStyle: .binary)) downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadFailedView(totalBytes: Int64, _ link: String, _ archivePath: URL) -> some View {
|
||||
List {
|
||||
Section {
|
||||
Button(action: {
|
||||
migrationState = .downloadProgress(downloadedBytes: 0, totalBytes: totalBytes, fileId: 0, link: link, archivePath: archivePath, ctrl: nil)
|
||||
}) {
|
||||
settingsRow("tray.and.arrow.down") {
|
||||
Text("Repeat download").foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Download failed")
|
||||
} footer: {
|
||||
Text("You can give another try")
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
chatReceiver?.stop()
|
||||
try? FileManager.default.removeItem(atPath: "\(tempDatabaseUrl.path)_chat.db")
|
||||
try? FileManager.default.removeItem(atPath: "\(tempDatabaseUrl.path)_agent.db")
|
||||
}
|
||||
}
|
||||
|
||||
private func archiveImportView(_ archivePath: String) -> some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section {} header: {
|
||||
Text("Importing archive…")
|
||||
}
|
||||
}
|
||||
progressView()
|
||||
}
|
||||
.onAppear {
|
||||
importArchive(archivePath)
|
||||
}
|
||||
}
|
||||
|
||||
private func migrationView(_ passphrase: String) -> some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section {} header: {
|
||||
Text("Migrating…")
|
||||
}
|
||||
}
|
||||
progressView()
|
||||
}
|
||||
.onAppear {
|
||||
startChat(passphrase)
|
||||
}
|
||||
}
|
||||
|
||||
private func largeProgressView(_ value: Float, _ title: String, _ description: LocalizedStringKey) -> some View {
|
||||
ZStack {
|
||||
VStack {
|
||||
Text(description)
|
||||
.font(.title3)
|
||||
.hidden()
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
Text(description)
|
||||
.font(.title3)
|
||||
}
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(value))
|
||||
.stroke(
|
||||
Color.accentColor,
|
||||
style: StrokeStyle(lineWidth: 30)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.linear, value: value)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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: URL) {
|
||||
Task {
|
||||
guard let ctrlAndUser = initTemporaryDatabase() else {
|
||||
return migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
|
||||
}
|
||||
let (ctrl, user) = ctrlAndUser
|
||||
chatReceiver = MigrationChatReceiver(ctrl: ctrl) { msg in
|
||||
Task {
|
||||
await TerminalItems.shared.add(.resp(.now, msg))
|
||||
}
|
||||
logger.debug("processReceivedMsg: \(msg.responseType)")
|
||||
await MainActor.run {
|
||||
switch msg {
|
||||
case let .rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer):
|
||||
migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl)
|
||||
case .rcvStandaloneFileComplete:
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
migrationState = .archiveImport(archivePath: archivePath.path)
|
||||
}
|
||||
default:
|
||||
logger.debug("unsupported event: \(msg.responseType)")
|
||||
}
|
||||
}
|
||||
}
|
||||
chatReceiver?.start()
|
||||
|
||||
let (res, error) = await downloadStandaloneFile(user: user, url: link, file: CryptoFile.plain(archivePath.lastPathComponent), ctrl: ctrl)
|
||||
if res == nil {
|
||||
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 {
|
||||
try await apiDeleteStorage()
|
||||
do {
|
||||
let config = ArchiveConfig(archivePath: archivePath)
|
||||
let archiveErrors = try await apiImportArchive(config: config)
|
||||
if !archiveErrors.isEmpty {
|
||||
alert = .chatImportedWithErrors()
|
||||
}
|
||||
migrationState = .passphraseEntering(passphrase: "")
|
||||
} catch let error {
|
||||
alert = .error(title: "Error importing chat database", error: responseError(error))
|
||||
}
|
||||
} catch let error {
|
||||
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 cancelMigration(_ fileId: Int64, _ ctrl: chat_ctrl) {
|
||||
Task {
|
||||
await stopArchiveDownloading(fileId, ctrl)
|
||||
await MainActor.run {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startChat(_ passphrase: String) {
|
||||
_ = kcDatabasePassword.set(passphrase)
|
||||
storeDBPassphraseGroupDefault.set(true)
|
||||
initialRandomDBPassphraseGroupDefault.set(false)
|
||||
AppChatState.shared.set(.active)
|
||||
Task {
|
||||
do {
|
||||
// resetChatCtrl()
|
||||
try initializeChat(start: true, confirmStart: false, dbKey: passphrase, refreshInvitations: true)
|
||||
await MainActor.run {
|
||||
hideView()
|
||||
AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Notify another device")
|
||||
}
|
||||
} catch let error {
|
||||
hideView()
|
||||
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func hideView() {
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func strHasSimplexFileLink(_ text: String) -> Bool {
|
||||
text.starts(with: "simplex:/file") || text.starts(with: "https://simplex.chat/file")
|
||||
}
|
||||
|
||||
private static func urlForTemporaryDatabase() -> URL {
|
||||
URL(fileURLWithPath: generateNewFileName(getMigrationTempFilesDirectory().path + "/" + "migration", "db", fullPath: true))
|
||||
}
|
||||
}
|
||||
|
||||
private struct PassphraseEnteringView: View {
|
||||
@Binding var migrationState: MigrationState
|
||||
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
|
||||
@State var currentKey: String
|
||||
@State private var verifyingPassphrase: Bool = false
|
||||
@Binding var alert: MigrateFromAnotherDeviceViewAlert?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
List {
|
||||
Section {
|
||||
PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey))
|
||||
Button(action: {
|
||||
verifyingPassphrase = true
|
||||
hideKeyboard()
|
||||
Task {
|
||||
let (status, ctrl) = chatInitTemporaryDatabase(url: getAppDatabasePath(), key: currentKey)
|
||||
let success = switch status {
|
||||
case .ok, .invalidConfirmation: true
|
||||
default: false
|
||||
}
|
||||
if success {
|
||||
// if let ctrl {
|
||||
// chat_close_store(ctrl)
|
||||
// }
|
||||
applyChatCtrl(ctrl: ctrl, result: (currentKey != "", status))
|
||||
migrationState = .migration(passphrase: currentKey)
|
||||
} else {
|
||||
showErrorOnMigrationIfNeeded(status, $alert)
|
||||
}
|
||||
verifyingPassphrase = false
|
||||
}
|
||||
}) {
|
||||
settingsRow("key", color: .secondary) {
|
||||
Text("Open chat")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Enter passphrase")
|
||||
} footer: {
|
||||
Text("Passphrase will be stored on device in Keychain. It's required for notifications to work. You can change it later in settings")
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
if verifyingPassphrase {
|
||||
progressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding<MigrateFromAnotherDeviceViewAlert?>) {
|
||||
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 processReceivedMsg: (ChatResponse) async -> Void
|
||||
private var receiveLoop: Task<Void, Never>?
|
||||
private var receiveMessages = true
|
||||
|
||||
init(ctrl: chat_ctrl, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) {
|
||||
self.ctrl = ctrl
|
||||
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) {
|
||||
await processReceivedMsg(msg)
|
||||
}
|
||||
if self.receiveMessages {
|
||||
_ = try? await Task.sleep(nanoseconds: 7_500_000)
|
||||
await receiveMsgLoop()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
logger.debug("MigrationChatReceiver.stop")
|
||||
receiveMessages = false
|
||||
receiveLoop?.cancel()
|
||||
receiveLoop = nil
|
||||
chat_close_store(ctrl)
|
||||
}
|
||||
}
|
||||
|
||||
struct MigrateFromAnotherDevice_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
MigrateFromAnotherDevice()
|
||||
}
|
||||
}
|
@ -9,7 +9,7 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
public enum MigrationState: Equatable {
|
||||
private enum MigrationState: Equatable {
|
||||
case initial
|
||||
case chatStopInProgress
|
||||
case chatStopFailed(reason: String)
|
||||
@ -24,7 +24,7 @@ public enum MigrationState: Equatable {
|
||||
case finished
|
||||
}
|
||||
|
||||
enum MigrateToAnotherDeviceViewAlert: Identifiable {
|
||||
private enum MigrateToAnotherDeviceViewAlert: 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")
|
||||
|
||||
@ -132,6 +132,7 @@ struct MigrateToAnotherDevice: View {
|
||||
.onDisappear {
|
||||
if case .linkShown = migrationState {} else if case .finished = migrationState {} else if !chatWasStoppedInitially {
|
||||
Task {
|
||||
AppChatState.shared.set(.active)
|
||||
try? startChat(refreshInvitations: true)
|
||||
}
|
||||
}
|
||||
@ -535,6 +536,7 @@ struct MigrateToAnotherDevice: View {
|
||||
|
||||
private func startChatAndDismiss() {
|
||||
Task {
|
||||
AppChatState.shared.set(.active)
|
||||
try? startChat(refreshInvitations: true)
|
||||
dismiss()
|
||||
}
|
||||
@ -623,7 +625,7 @@ func chatStoppedView() -> some View {
|
||||
}
|
||||
}
|
||||
|
||||
class MigrationChatReceiver {
|
||||
private class MigrationChatReceiver {
|
||||
let ctrl: chat_ctrl
|
||||
let processReceivedMsg: (ChatResponse) async -> Void
|
||||
private var receiveLoop: Task<Void, Never>?
|
@ -86,7 +86,7 @@ struct NewChatView: View {
|
||||
}
|
||||
}
|
||||
if case .connect = selection {
|
||||
ConnectView(showQRCodeScanner: showQRCodeScanner, pastedLink: $pastedLink, alert: $alert)
|
||||
ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert)
|
||||
.transition(.move(edge: .trailing))
|
||||
}
|
||||
}
|
||||
@ -284,8 +284,7 @@ private struct InviteView: View {
|
||||
|
||||
private struct ConnectView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@State var showQRCodeScanner = false
|
||||
@State private var cameraAuthorizationStatus: AVAuthorizationStatus?
|
||||
@Binding var showQRCodeScanner: Bool
|
||||
@Binding var pastedLink: String
|
||||
@Binding var alert: NewChatViewAlert?
|
||||
@State private var sheet: PlanAndConnectActionSheet?
|
||||
@ -295,32 +294,13 @@ private struct ConnectView: View {
|
||||
Section("Paste the link you received") {
|
||||
pasteLinkView()
|
||||
}
|
||||
|
||||
scanCodeView()
|
||||
Section("Or scan QR code") {
|
||||
ScannerInView(showQRCodeScanner: $showQRCodeScanner, processQRCode: processQRCode)
|
||||
}
|
||||
}
|
||||
.actionSheet(item: $sheet) { s in
|
||||
planAndConnectActionSheet(s, dismiss: true, cleanup: { pastedLink = "" })
|
||||
}
|
||||
.onAppear {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
cameraAuthorizationStatus = status
|
||||
if showQRCodeScanner {
|
||||
switch status {
|
||||
case .notDetermined: askCameraAuthorization()
|
||||
case .restricted: showQRCodeScanner = false
|
||||
case .denied: showQRCodeScanner = false
|
||||
case .authorized: ()
|
||||
@unknown default: askCameraAuthorization()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
|
||||
AVCaptureDevice.requestAccess(for: .video) { allowed in
|
||||
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
if allowed { cb?() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func pasteLinkView() -> some View {
|
||||
@ -351,8 +331,45 @@ private struct ConnectView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func scanCodeView() -> some View {
|
||||
Section("Or scan QR code") {
|
||||
private func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
let link = r.string
|
||||
if strIsSimplexLink(r.string) {
|
||||
connect(link)
|
||||
} else {
|
||||
alert = .newChatSomeAlert(alert: .someAlert(
|
||||
alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."),
|
||||
id: "processQRCode: code is not a SimpleX link"
|
||||
))
|
||||
}
|
||||
case let .failure(e):
|
||||
logger.error("processQRCode QR code error: \(e.localizedDescription)")
|
||||
alert = .newChatSomeAlert(alert: .someAlert(
|
||||
alert: mkAlert(title: "Invalid QR code", message: "Error scanning code: \(e.localizedDescription)"),
|
||||
id: "processQRCode: failure"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func connect(_ link: String) {
|
||||
planAndConnect(
|
||||
link,
|
||||
showAlert: { alert = .planAndConnectAlert(alert: $0) },
|
||||
showActionSheet: { sheet = $0 },
|
||||
dismiss: true,
|
||||
incognito: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ScannerInView: View {
|
||||
@Binding var showQRCodeScanner: Bool
|
||||
let processQRCode: (_ resp: Result<ScanResult, ScanError>) -> Void
|
||||
@State private var cameraAuthorizationStatus: AVAuthorizationStatus?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if showQRCodeScanner, case .authorized = cameraAuthorizationStatus {
|
||||
CodeScannerView(codeTypes: [.qr], scanMode: .continuous, completion: processQRCode)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
@ -396,37 +413,26 @@ private struct ConnectView: View {
|
||||
.disabled(cameraAuthorizationStatus == .restricted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processQRCode(_ resp: Result<ScanResult, ScanError>) {
|
||||
switch resp {
|
||||
case let .success(r):
|
||||
let link = r.string
|
||||
if strIsSimplexLink(r.string) {
|
||||
connect(link)
|
||||
} else {
|
||||
alert = .newChatSomeAlert(alert: .someAlert(
|
||||
alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."),
|
||||
id: "processQRCode: code is not a SimpleX link"
|
||||
))
|
||||
.onAppear {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
cameraAuthorizationStatus = status
|
||||
if showQRCodeScanner {
|
||||
switch status {
|
||||
case .notDetermined: askCameraAuthorization()
|
||||
case .restricted: showQRCodeScanner = false
|
||||
case .denied: showQRCodeScanner = false
|
||||
case .authorized: ()
|
||||
@unknown default: askCameraAuthorization()
|
||||
}
|
||||
}
|
||||
case let .failure(e):
|
||||
logger.error("processQRCode QR code error: \(e.localizedDescription)")
|
||||
alert = .newChatSomeAlert(alert: .someAlert(
|
||||
alert: mkAlert(title: "Invalid QR code", message: "Error scanning code: \(e.localizedDescription)"),
|
||||
id: "processQRCode: failure"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func connect(_ link: String) {
|
||||
planAndConnect(
|
||||
link,
|
||||
showAlert: { alert = .planAndConnectAlert(alert: $0) },
|
||||
showActionSheet: { sheet = $0 },
|
||||
dismiss: true,
|
||||
incognito: nil
|
||||
)
|
||||
func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
|
||||
AVCaptureDevice.requestAccess(for: .video) { allowed in
|
||||
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
if allowed { cb?() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -37,7 +37,7 @@ struct HowItWorks: View {
|
||||
Spacer()
|
||||
|
||||
if onboarding {
|
||||
OnboardingActionButton()
|
||||
OnboardingActionButton(hideMigrate: true)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
@ -42,7 +42,7 @@ struct SimpleXInfo: View {
|
||||
|
||||
Spacer()
|
||||
if onboarding {
|
||||
OnboardingActionButton()
|
||||
OnboardingActionButton(hideMigrate: false)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
@ -87,10 +87,28 @@ struct SimpleXInfo: View {
|
||||
|
||||
struct OnboardingActionButton: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
let hideMigrate: Bool
|
||||
@State private var migrateFromAnotherDevice: Bool = false
|
||||
|
||||
var body: some View {
|
||||
if m.currentUser == nil {
|
||||
actionButton("Create your profile", onboarding: .step2_CreateProfile)
|
||||
|
||||
if !hideMigrate {
|
||||
actionButton("Migrate from another device") {
|
||||
migrateFromAnotherDevice = true
|
||||
}
|
||||
.sheet(isPresented: $migrateFromAnotherDevice) {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Migrate here")
|
||||
.font(.largeTitle)
|
||||
.padding([.leading, .top, .trailing])
|
||||
.padding(.top)
|
||||
MigrateFromAnotherDevice()
|
||||
}
|
||||
.background(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
actionButton("Make a private connection", onboarding: .onboardingComplete)
|
||||
}
|
||||
@ -111,6 +129,21 @@ struct OnboardingActionButton: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
private func actionButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
|
||||
Button {
|
||||
withAnimation {
|
||||
action()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(label).font(.title2)
|
||||
Image(systemName: "greaterthan")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
struct SimpleXInfo_Previews: PreviewProvider {
|
||||
|
@ -185,6 +185,7 @@
|
||||
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; };
|
||||
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; };
|
||||
8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C05382D2B39887E006436DC /* VideoUtils.swift */; };
|
||||
8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */; };
|
||||
8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */; };
|
||||
D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; };
|
||||
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
|
||||
@ -474,6 +475,7 @@
|
||||
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; };
|
||||
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; };
|
||||
8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = "<group>"; };
|
||||
8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateFromAnotherDevice.swift; sourceTree = "<group>"; };
|
||||
8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAnotherDevice.swift; sourceTree = "<group>"; };
|
||||
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; };
|
||||
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; };
|
||||
@ -555,6 +557,7 @@
|
||||
5CB924DD27A8622200ACCCDD /* NewChat */,
|
||||
5CFA59C22860B04D00863A68 /* Database */,
|
||||
5CB634AB29E46CDB0066AD6B /* LocalAuth */,
|
||||
8C7D94982B8894D300B7B9E1 /* Migration */,
|
||||
5CA8D01B2AD9B076001FD661 /* RemoteAccess */,
|
||||
5CB924DF27A8678B00ACCCDD /* UserSettings */,
|
||||
5C2E261127A30FEA00F70299 /* TerminalView.swift */,
|
||||
@ -768,7 +771,6 @@
|
||||
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */,
|
||||
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */,
|
||||
5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */,
|
||||
8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */,
|
||||
);
|
||||
path = UserSettings;
|
||||
sourceTree = "<group>";
|
||||
@ -896,6 +898,15 @@
|
||||
path = Group;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8C7D94982B8894D300B7B9E1 /* Migration */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */,
|
||||
8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */,
|
||||
);
|
||||
path = Migration;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXHeadersBuildPhase section */
|
||||
@ -1127,6 +1138,7 @@
|
||||
5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */,
|
||||
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */,
|
||||
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
|
||||
8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */,
|
||||
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
|
||||
5C029EAA283942EA004A9677 /* CallController.swift in Sources */,
|
||||
5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */,
|
||||
|
@ -54,9 +54,9 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
|
||||
return result
|
||||
}
|
||||
|
||||
public func chatInitTemporaryDatabase(url: URL) -> (DBMigrationResult, chat_ctrl?) {
|
||||
public func chatInitTemporaryDatabase(url: URL, key: String? = nil) -> (DBMigrationResult, chat_ctrl?) {
|
||||
let dbPath = url.path
|
||||
let dbKey = randomDatabasePassword()
|
||||
let dbKey = key ?? randomDatabasePassword()
|
||||
logger.debug("chatInitTemporaryDatabase path: \(dbPath)")
|
||||
var temporaryController: chat_ctrl? = nil
|
||||
var cPath = dbPath.cString(using: .utf8)!
|
||||
@ -85,6 +85,11 @@ public func resetChatCtrl() {
|
||||
migrationResult = nil
|
||||
}
|
||||
|
||||
public func applyChatCtrl(ctrl: chat_ctrl?, result: (Bool, DBMigrationResult)) {
|
||||
chatController = ctrl
|
||||
migrationResult = result
|
||||
}
|
||||
|
||||
public func sendSimpleXCmd(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) -> ChatResponse {
|
||||
var c = cmd.cmdString.cString(using: .utf8)!
|
||||
let cjson = chat_send_cmd(ctrl ?? getChatCtrl(), &c)!
|
||||
|
@ -3378,7 +3378,7 @@ public struct SndFileTransfer: Decodable {
|
||||
}
|
||||
|
||||
public struct RcvFileTransfer: Decodable {
|
||||
|
||||
public let fileId: Int64
|
||||
}
|
||||
|
||||
public struct FileTransferMeta: Decodable {
|
||||
|
Loading…
Reference in New Issue
Block a user