diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index c54e11eb78..bed5d9b2de 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -95,6 +95,7 @@ final class ChatModel: ObservableObject { @Published var remoteCtrlSession: RemoteCtrlSession? // currently showing invitation @Published var showingInvitation: ShowingInvitation? + @Published var migrationState: MigrationFromAnotherDeviceState? = MigrationFromAnotherDeviceState.transform() // audio recording and playback @Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source @Published var draft: ComposeState? diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 57dab12a87..7318a54e92 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -90,12 +90,12 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> T) -> T { return r } -func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) -> ChatResponse { +func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) -> ChatResponse { logger.debug("chatSendCmd \(cmd.cmdType)") let start = Date.now let resp = bgTask - ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd) } - : sendSimpleXCmd(cmd) + ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) } + : sendSimpleXCmd(cmd, ctrl) logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") if case let .response(_, json) = resp { logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") @@ -106,24 +106,24 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = return resp } -func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) async -> ChatResponse { +func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, _ ctrl: chat_ctrl? = nil) async -> ChatResponse { await withCheckedContinuation { cont in - cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay)) + cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl)) } } -func chatRecvMsg() async -> ChatResponse? { +func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatResponse? { await withCheckedContinuation { cont in _ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in - let resp = recvSimpleXMsg() + let resp = recvSimpleXMsg(ctrl) cont.resume(returning: resp) return resp } } } -func apiGetActiveUser() throws -> User? { - let r = chatSendCmdSync(.showActiveUser) +func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? { + let r = chatSendCmdSync(.showActiveUser, ctrl) switch r { case let .activeUser(user): return user case .chatCmdError(_, .error(.noActiveUser)): return nil @@ -131,8 +131,8 @@ func apiGetActiveUser() throws -> User? { } } -func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false) throws -> User { - let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp)) +func apiCreateActiveUser(_ p: Profile?, sameServers: Bool = false, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User { + let r = chatSendCmdSync(.createActiveUser(profile: p, sameServers: sameServers, pastTimestamp: pastTimestamp), ctrl) if case let .activeUser(user) = r { return user } throw r } @@ -210,8 +210,8 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn throw r } -func apiStartChat() throws -> Bool { - let r = chatSendCmdSync(.startChat(mainApp: true)) +func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool { + let r = chatSendCmdSync(.startChat(mainApp: true), ctrl) switch r { case .chatStarted: return true case .chatRunning: return false @@ -240,14 +240,14 @@ func apiSuspendChat(timeoutMicroseconds: Int) { logger.error("apiSuspendChat error: \(String(describing: r))") } -func apiSetTempFolder(tempFolder: String) throws { - let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder)) +func apiSetTempFolder(tempFolder: String, ctrl: chat_ctrl? = nil) throws { + let r = chatSendCmdSync(.setTempFolder(tempFolder: tempFolder), ctrl) if case .cmdOk = r { return } throw r } -func apiSetFilesFolder(filesFolder: String) throws { - let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder)) +func apiSetFilesFolder(filesFolder: String, ctrl: chat_ctrl? = nil) throws { + let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder), ctrl) if case .cmdOk = r { return } throw r } @@ -258,6 +258,18 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws { throw r } +func apiSaveAppSettings(settings: AppSettings) throws { + let r = chatSendCmdSync(.apiSaveSettings(settings: settings)) + if case .cmdOk = r { return } + throw r +} + +func apiGetAppSettings(settings: AppSettings) throws -> AppSettings { + let r = chatSendCmdSync(.apiGetSettings(settings: settings)) + if case let .appSettings(settings) = r { return settings } + throw r +} + func apiSetPQEncryption(_ enable: Bool) throws { let r = chatSendCmdSync(.apiSetPQEncryption(enable: enable)) if case .cmdOk = r { return } @@ -288,6 +300,10 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th try await sendCommandOkResp(.apiStorageEncryption(config: DBEncryptionConfig(currentKey: currentKey, newKey: newKey))) } +func testStorageEncryption(key: String, _ ctrl: chat_ctrl? = nil) async throws { + try await sendCommandOkResp(.testStorageEncryption(key: key), ctrl) +} + func apiGetChats() throws -> [ChatData] { let userId = try currentUserId("apiGetChats") return try apiChatsResponse(chatSendCmdSync(.apiGetChats(userId: userId))) @@ -510,8 +526,8 @@ func getNetworkConfig() async throws -> NetCfg? { throw r } -func setNetworkConfig(_ cfg: NetCfg) throws { - let r = chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg)) +func setNetworkConfig(_ cfg: NetCfg, ctrl: chat_ctrl? = nil) throws { + let r = chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl) if case .cmdOk = r { return } throw r } @@ -876,6 +892,36 @@ func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat)) } +func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (FileTransferMeta?, String?) { + let r = await chatSendCmd(.apiUploadStandaloneFile(userId: user.userId, file: file), ctrl) + if case let .sndStandaloneFileCreated(_, fileTransferMeta) = r { + return (fileTransferMeta, nil) + } else { + logger.error("uploadStandaloneFile error: \(String(describing: r))") + return (nil, String(describing: r)) + } +} + +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 { + logger.error("downloadStandaloneFile error: \(String(describing: r))") + return (nil, String(describing: r)) + } +} + +func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationFileLinkData? { + let r = await chatSendCmd(.apiStandaloneFileInfo(url: url), ctrl) + if case let .standaloneFileInfo(fileMeta) = r { + return fileMeta + } else { + logger.error("standaloneFileInfo error: \(String(describing: r))") + return nil + } +} + func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async { if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get(), auto: auto) { await chatItemSimpleUpdate(user, chatItem) @@ -921,8 +967,8 @@ func cancelFile(user: User, fileId: Int64) async { } } -func apiCancelFile(fileId: Int64) async -> AChatItem? { - let r = await chatSendCmd(.cancelFile(fileId: fileId)) +func apiCancelFile(fileId: Int64, ctrl: chat_ctrl? = nil) async -> AChatItem? { + let r = await chatSendCmd(.cancelFile(fileId: fileId), ctrl) switch r { case let .sndFileCancelled(_, chatItem, _, _) : return chatItem case let .rcvFileCancelled(_, chatItem, _) : return chatItem @@ -1094,8 +1140,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 } @@ -1336,6 +1382,16 @@ func startChat(refreshInvitations: Bool = true) throws { chatLastStartGroupDefault.set(Date.now) } +func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? { + logger.debug("startChatWithTemporaryDatabase") + let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl) + try setNetworkConfig(getNetCfg(), ctrl: ctrl) + try apiSetTempFolder(tempFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl) + try apiSetFilesFolder(filesFolder: getMigrationTempFilesDirectory().path, ctrl: ctrl) + _ = try apiStartChat(ctrl: ctrl) + return migrationActiveUser +} + func changeActiveUser(_ userId: Int64, viewPwd: String?) { do { try changeActiveUser_(userId, viewPwd: viewPwd) @@ -1714,27 +1770,37 @@ func processReceivedMsg(_ res: ChatResponse) async { case let .rcvFileSndCancelled(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) Task { cleanupFile(aChatItem) } - case let .rcvFileProgressXFTP(user, aChatItem, _, _): - await chatItemSimpleUpdate(user, aChatItem) - case let .rcvFileError(user, aChatItem): - await chatItemSimpleUpdate(user, aChatItem) - Task { cleanupFile(aChatItem) } + case let .rcvFileProgressXFTP(user, aChatItem, _, _, _): + if let aChatItem = aChatItem { + await chatItemSimpleUpdate(user, aChatItem) + } + case let .rcvFileError(user, aChatItem, _): + if let aChatItem = aChatItem { + await chatItemSimpleUpdate(user, aChatItem) + Task { cleanupFile(aChatItem) } + } case let .sndFileStart(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) case let .sndFileComplete(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) Task { cleanupDirectFile(aChatItem) } case let .sndFileRcvCancelled(user, aChatItem, _): - await chatItemSimpleUpdate(user, aChatItem) - Task { cleanupDirectFile(aChatItem) } + if let aChatItem = aChatItem { + await chatItemSimpleUpdate(user, aChatItem) + Task { cleanupDirectFile(aChatItem) } + } case let .sndFileProgressXFTP(user, aChatItem, _, _, _): - await chatItemSimpleUpdate(user, aChatItem) + if let aChatItem = aChatItem { + await chatItemSimpleUpdate(user, aChatItem) + } case let .sndFileCompleteXFTP(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) Task { cleanupFile(aChatItem) } - case let .sndFileError(user, aChatItem): - await chatItemSimpleUpdate(user, aChatItem) - Task { cleanupFile(aChatItem) } + case let .sndFileError(user, aChatItem, _): + if let aChatItem = aChatItem { + await chatItemSimpleUpdate(user, aChatItem) + Task { cleanupFile(aChatItem) } + } case let .callInvitation(invitation): await MainActor.run { m.callInvitations[invitation.contact.id] = invitation diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index e5b98589a0..7d69466c07 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -44,7 +44,12 @@ struct SimpleXApp: App { chatModel.appOpenUrl = url } .onAppear() { - if kcAppPassword.get() == nil || kcSelfDestructPassword.get() == nil { + // Present screen for continue migration if it wasn't finished yet + if chatModel.migrationState != nil { + // It's important, otherwise, user may be locked in undefined state + onboardingStageDefault.set(.step1_SimpleXInfo) + chatModel.onboardingStage = onboardingStageDefault.get() + } else if kcAppPassword.get() == nil || kcSelfDestructPassword.get() == nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { initChatAndMigrate() } diff --git a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift index 57007fff3f..86acbf6d54 100644 --- a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift @@ -35,7 +35,7 @@ struct ContactPreferencesView: View { .disabled(currentFeaturesAllowed == featuresAllowed) } } - .modifier(BackButton { + .modifier(BackButton(disabled: Binding.constant(false)) { if currentFeaturesAllowed == featuresAllowed { dismiss() } else { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index d88bdfa4a4..7ab4bf4ece 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -48,7 +48,7 @@ struct GroupPreferencesView: View { preferences.timedMessages.ttl = currentPreferences.timedMessages.ttl } } - .modifier(BackButton { + .modifier(BackButton(disabled: Binding.constant(false)) { if currentPreferences == preferences { dismiss() } else { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index d6dbf06efc..00d4f8c37b 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -24,7 +24,7 @@ struct GroupWelcomeView: View { VStack { if groupInfo.canEdit { editorView() - .modifier(BackButton { + .modifier(BackButton(disabled: Binding.constant(false)) { if welcomeTextUnchanged() { dismiss() } else { diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 90cd17fbb3..4031c3e00a 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -36,6 +36,7 @@ enum DatabaseEncryptionAlert: Identifiable { struct DatabaseEncryptionView: View { @EnvironmentObject private var m: ChatModel @Binding var useKeychain: Bool + var migration: Bool @State private var alert: DatabaseEncryptionAlert? = nil @State private var progressIndicator = false @State private var useKeychainToggle = storeDBPassphraseGroupDefault.get() @@ -48,7 +49,12 @@ struct DatabaseEncryptionView: View { var body: some View { ZStack { - databaseEncryptionView() + List { + if migration { + chatStoppedView() + } + databaseEncryptionView() + } if progressIndicator { ProgressView().scaleEffect(2) } @@ -56,72 +62,71 @@ struct DatabaseEncryptionView: View { } private func databaseEncryptionView() -> some View { - List { - Section { - settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .secondary) { - Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle) + Section { + settingsRow(storedKey ? "key.fill" : "key", color: storedKey ? .green : .secondary) { + Toggle("Save passphrase in Keychain", isOn: $useKeychainToggle) .onChange(of: useKeychainToggle) { _ in if useKeychainToggle { setUseKeychain(true) - } else if storedKey { + } else if storedKey && !migration { + // Don't show in migration process since it will remove the key after successfull encryption alert = .keychainRemoveKey } else { setUseKeychain(false) } } - .disabled(initialRandomDBPassphrase) - } + .disabled(initialRandomDBPassphrase && !migration) + } - if !initialRandomDBPassphrase && m.chatDbEncrypted == true { - PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey)) - } + if !initialRandomDBPassphrase && m.chatDbEncrypted == true { + PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey)) + } - PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true) - PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey) + PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true) + PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey) - settingsRow("lock.rotation") { - Button("Update database passphrase") { - alert = currentKey == "" - ? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase) - : (useKeychain ? .changeDatabaseKeySaved : .changeDatabaseKey) - } + settingsRow("lock.rotation") { + Button(migration ? "Set passphrase" : "Update database passphrase") { + alert = currentKey == "" + ? (useKeychain ? .encryptDatabaseSaved : .encryptDatabase) + : (useKeychain ? .changeDatabaseKeySaved : .changeDatabaseKey) } - .disabled( - (m.chatDbEncrypted == true && currentKey == "") || - currentKey == newKey || - newKey != confirmNewKey || - newKey == "" || - !validKey(currentKey) || - !validKey(newKey) - ) - } header: { - Text("") - } footer: { - VStack(alignment: .leading, spacing: 16) { - if m.chatDbEncrypted == false { - Text("Your chat database is not encrypted - set passphrase to encrypt it.") - } else if useKeychain { - if storedKey { - Text("iOS Keychain is used to securely store passphrase - it allows receiving push notifications.") - if initialRandomDBPassphrase { - Text("Database is encrypted using a random passphrase, you can change it.") - } else { - Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.") - } + } + .disabled( + (m.chatDbEncrypted == true && currentKey == "") || + currentKey == newKey || + newKey != confirmNewKey || + newKey == "" || + !validKey(currentKey) || + !validKey(newKey) + ) + } header: { + Text(migration ? "Database passphrase" : "") + } footer: { + VStack(alignment: .leading, spacing: 16) { + if m.chatDbEncrypted == false { + Text("Your chat database is not encrypted - set passphrase to encrypt it.") + } else if useKeychain { + if storedKey { + Text("iOS Keychain is used to securely store passphrase - it allows receiving push notifications.") + if initialRandomDBPassphrase && !migration { + Text("Database is encrypted using a random passphrase, you can change it.") } else { - Text("iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications.") + Text("**Please note**: you will NOT be able to recover or change passphrase if you lose it.") } } 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.") - if m.notificationMode == .instant && m.notificationPreview != .hidden { - Text("**Warning**: Instant push notifications require passphrase saved in Keychain.") - } + Text("iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow 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.") + if m.notificationMode == .instant && m.notificationPreview != .hidden && !migration { + Text("**Warning**: Instant push notifications require passphrase saved in Keychain.") } } - .padding(.top, 1) - .font(.callout) } + .padding(.top, 1) + .font(.callout) } .onAppear { if initialRandomDBPassphrase { currentKey = kcDatabasePassword.get() ?? "" } @@ -136,9 +141,15 @@ struct DatabaseEncryptionView: View { do { encryptionStartedDefault.set(true) encryptionStartedAtDefault.set(Date.now) + if !m.chatDbChanged { + try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) + } try await apiStorageEncryption(currentKey: currentKey, newKey: newKey) encryptionStartedDefault.set(false) initialRandomDBPassphraseGroupDefault.set(false) + if migration { + storeDBPassphraseGroupDefault.set(useKeychain) + } if useKeychain { if kcDatabasePassword.set(newKey) { await resetFormAfterEncryption(true) @@ -148,6 +159,9 @@ struct DatabaseEncryptionView: View { await operationEnded(.error(title: "Keychain error", error: "Error saving passphrase to keychain")) } } else { + if migration { + removePassphraseFromKeyChain() + } await resetFormAfterEncryption() await operationEnded(.databaseEncrypted) } @@ -174,7 +188,10 @@ struct DatabaseEncryptionView: View { private func setUseKeychain(_ value: Bool) { useKeychain = value - storeDBPassphraseGroupDefault.set(value) + // Postpone it when migrating to the end of encryption process + if !migration { + storeDBPassphraseGroupDefault.set(value) + } } private func databaseEncryptionAlert(_ alertItem: DatabaseEncryptionAlert) -> Alert { @@ -184,13 +201,7 @@ struct DatabaseEncryptionView: View { title: Text("Remove passphrase from keychain?"), message: Text("Instant push notifications will be hidden!\n") + storeSecurelyDanger(), primaryButton: .destructive(Text("Remove")) { - if kcDatabasePassword.remove() { - logger.debug("passphrase removed from keychain") - setUseKeychain(false) - storedKey = false - } else { - alert = .error(title: "Keychain error", error: "Failed to remove passphrase") - } + removePassphraseFromKeyChain() }, secondaryButton: .cancel() { withAnimation { useKeychainToggle = true } @@ -236,6 +247,16 @@ struct DatabaseEncryptionView: View { } } + private func removePassphraseFromKeyChain() { + if kcDatabasePassword.remove() { + logger.debug("passphrase removed from keychain") + setUseKeychain(false) + storedKey = false + } else { + alert = .error(title: "Keychain error", error: "Failed to remove passphrase") + } + } + private func storeSecurelySaved() -> Text { Text("Please store passphrase securely, you will NOT be able to change it if you lose it.") } @@ -346,6 +367,6 @@ func validKey(_ s: String) -> Bool { struct DatabaseEncryptionView_Previews: PreviewProvider { static var previews: some View { - DatabaseEncryptionView(useKeychain: Binding.constant(true)) + DatabaseEncryptionView(useKeychain: Binding.constant(true), migration: false) } } diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index 52ded44782..f8d282a6d1 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -64,7 +64,7 @@ struct DatabaseErrorView: View { case let .migrationError(mtrError): titleText("Incompatible database version") fileNameText(dbFile) - Text("Error: ") + Text(mtrErrorDescription(mtrError)) + Text("Error: ") + Text(DatabaseErrorView.mtrErrorDescription(mtrError)) } case let .errorSQL(dbFile, migrationSQLError): titleText("Database error") @@ -105,7 +105,7 @@ struct DatabaseErrorView: View { Text("Migrations: \(ms.joined(separator: ", "))") } - private func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey { + static func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey { switch err { case let .noDown(dbMigrations): return "database version is newer than the app, but no down migration for: \(dbMigrations.joined(separator: ", "))" diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 31b1f618e3..2e0cd7738f 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -116,7 +116,7 @@ struct DatabaseView: View { let color: Color = unencrypted ? .orange : .secondary settingsRow(unencrypted ? "lock.open" : useKeychain ? "key" : "lock", color: color) { NavigationLink { - DatabaseEncryptionView(useKeychain: $useKeychain) + DatabaseEncryptionView(useKeychain: $useKeychain, migration: false) .navigationTitle("Database passphrase") } label: { Text("Database passphrase") @@ -485,6 +485,10 @@ func deleteChatAsync() async throws { _ = kcDatabasePassword.remove() storeDBPassphraseGroupDefault.set(true) deleteAppDatabaseAndFiles() + // Clean state so when creating new user the app will start chat automatically (see CreateProfile:createProfile()) + DispatchQueue.main.async { + ChatModel.shared.users = [] + } } struct DatabaseView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift index 046929a9d0..ae6af24f53 100644 --- a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift +++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift @@ -188,6 +188,7 @@ struct MigrateToAppGroupView: View { let config = ArchiveConfig(archivePath: getDocumentsDirectory().appendingPathComponent(archiveName).path) Task { do { + try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) try await apiExportArchive(config: config) await MainActor.run { setV3DBMigration(.exported) } } catch let error { @@ -204,7 +205,11 @@ struct MigrateToAppGroupView: View { resetChatCtrl() try await MainActor.run { try initializeChat(start: false) } let _ = try await apiImportArchive(config: config) - await MainActor.run { setV3DBMigration(.migrated) } + let appSettings = try apiGetAppSettings(settings: AppSettings.current.prepareForExport()) + await MainActor.run { + appSettings.importIntoApp() + setV3DBMigration(.migrated) + } } catch let error { dbContainerGroupDefault.set(.documents) await MainActor.run { @@ -216,16 +221,22 @@ struct MigrateToAppGroupView: View { } } -func exportChatArchive() async throws -> URL { +func exportChatArchive(_ storagePath: URL? = nil) async throws -> URL { let archiveTime = Date.now let ts = archiveTime.ISO8601Format(Date.ISO8601FormatStyle(timeSeparator: .omitted)) let archiveName = "simplex-chat.\(ts).zip" - let archivePath = getDocumentsDirectory().appendingPathComponent(archiveName) + let archivePath = (storagePath ?? getDocumentsDirectory()).appendingPathComponent(archiveName) let config = ArchiveConfig(archivePath: archivePath.path) + // Settings should be saved before changing a passphrase, otherwise the database needs to be migrated first + if !ChatModel.shared.chatDbChanged { + try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) + } try await apiExportArchive(config: config) - deleteOldArchive() - UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME) - chatArchiveTimeDefault.set(archiveTime) + if storagePath == nil { + deleteOldArchive() + UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME) + chatArchiveTimeDefault.set(archiveTime) + } return archivePath } diff --git a/apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift new file mode 100644 index 0000000000..9022beebd3 --- /dev/null +++ b/apps/ios/Shared/Views/Migration/MigrateFromAnotherDevice.swift @@ -0,0 +1,720 @@ +// +// MigrateFromAnotherDevice.swift +// SimpleX (iOS) +// +// Created by Avently on 23.02.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +enum MigrationFromAnotherDeviceState: 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) + case let .archiveImport(archiveName): + let archivePath = getMigrationTempFilesDirectory().path + "/" + archiveName + initial = .archiveImportFailed(archivePath: archivePath) + case .passphrase: + initial = .passphrase(passphrase: "") + } + 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) { + if let state { + UserDefaults.standard.setValue(encodeJSON(state), forKey: DEFAULT_MIGRATION_STAGE) + } else { + UserDefaults.standard.removeObject(forKey: DEFAULT_MIGRATION_STAGE) + } + apply(state) + } +} + +enum MigrationFromState: 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 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 + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @State var migrationState: MigrationFromState + @State private var useKeychain = storeDBPassphraseGroupDefault.get() + @State private var alert: MigrateFromAnotherDeviceViewAlert? + 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 + + var body: some View { + VStack { + switch migrationState { + 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 .linkDownloading: false + case .downloadProgress: false + case .archiveImportFailed: false + default: m.migrationState != nil + } + } + .onChange(of: migrationState) { state in + backDisabled = switch state { + case .linkDownloading: false + case .downloadProgress: false + case .archiveImportFailed: false + default: m.migrationState != nil + } + } + .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()) + MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 } + } + } + } + .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() -> some View { + ZStack { + List { + Section("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.") + } + } + } + if developerTools { + Section("Or paste archive link") { + pasteLinkView() + } + } + } + } + } + + 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) -> some View { + ZStack { + List { + Section {} header: { + Text("Downloading archive") + } + } + let ratio = Float(downloadedBytes) / Float(max(totalBytes, 1)) + MigrateToAnotherDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: downloadedBytes, countStyle: .binary)) downloaded") + } + } + + 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") { + Text("Repeat download").foregroundColor(.accentColor) + } + } + } header: { + Text("Download failed") + } footer: { + Text("You can give another try.") + .font(.callout) + } + } + .onAppear { + chatReceiver?.stopAndCleanUp() + try? FileManager.default.removeItem(atPath: archivePath) + MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 } + } + } + + private func archiveImportView(_ archivePath: String) -> some View { + ZStack { + List { + Section {} header: { + Text("Importing archive") + } + } + progressView() + } + .onAppear { + importArchive(archivePath) + } + } + + private func archiveImportFailedView(_ archivePath: String) -> some View { + List { + Section { + Button(action: { + migrationState = .archiveImport(archivePath: archivePath) + }) { + settingsRow("square.and.arrow.down") { + Text("Repeat import").foregroundColor(.accentColor) + } + } + } header: { + Text("Import failed") + } footer: { + Text("You can give another try.") + .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 .downgrade: + ("Database downgrade", + "Downgrade and open chat", + NSLocalizedString("Warning: you may lose some data!", comment: ""), + .yesUpDown) + case let .migrationError(mtrError): + ("Incompatible database version", + nil, + "\(NSLocalizedString("Error: ", comment: "")) \(DatabaseErrorView.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") { + Text(button).foregroundColor(.accentColor) + } + } + } else { + EmptyView() + } + } header: { + Text(header) + } footer: { + Text(footer) + .font(.callout) + } + } + } + + private func migrationView(_ passphrase: String, _ confirmation: MigrationConfirmation, _ useKeychain: Bool) -> some View { + ZStack { + List { + Section {} header: { + Text("Migrating") + } + } + progressView() + } + .onAppear { + startChat(passphrase, confirmation, useKeychain) + } + } + + struct OnionView: View { + @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") { + Text("Apply").foregroundColor(.accentColor) + } + } + } header: { + Text("Confirm network settings") + } footer: { + Text("Please confirm that network settings are correct for this device.") + .font(.callout) + } + + Section("Network settings") { + 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 .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 } + case .rcvStandaloneFileComplete: + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + migrationState = .archiveImport(archivePath: archivePath) + MigrationFromAnotherDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) { m.migrationState = $0 } + } + case .rcvFileError: + 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() + } + try await apiDeleteStorage() + do { + let config = ArchiveConfig(archivePath: archivePath) + let archiveErrors = try await apiImportArchive(config: config) + if !archiveErrors.isEmpty { + alert = .chatImportedWithErrors() + } + await MainActor.run { + migrationState = .passphrase(passphrase: "") + MigrationFromAnotherDeviceState.save(.passphrase) { m.migrationState = $0 } + } + } 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()) + 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 + 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()) + MigrationFromAnotherDeviceState.save(nil) { m.migrationState = $0 } + appSettings.importIntoApp() + 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)))) + } + hideView() + } + + 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: MigrationFromState + @State private var useKeychain = true + @State var currentKey: String + @State private var verifyingPassphrase: Bool = false + @FocusState private var keyboardVisible: Bool + @Binding var alert: MigrateFromAnotherDeviceViewAlert? + + var body: some View { + ZStack { + List { + Section { + settingsRow("key", color: .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: .secondary) { + Text("Open chat") + } + } + .disabled(verifyingPassphrase || currentKey.isEmpty) + } header: { + Text("Enter passphrase") + } 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.") + } + } + .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) { + 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: (ChatResponse) async -> Void + private var receiveLoop: Task? + private var receiveMessages = true + + init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) 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.add(.resp(.now, 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 MigrateFromAnotherDevice_Previews: PreviewProvider { + static var previews: some View { + MigrateFromAnotherDevice(migrationState: .pasteOrScanLink) + } +} diff --git a/apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift new file mode 100644 index 0000000000..01a85aa6db --- /dev/null +++ b/apps/ios/Shared/Views/Migration/MigrateToAnotherDevice.swift @@ -0,0 +1,727 @@ +// +// MigrateToAnotherDevice.swift +// SimpleX (iOS) +// +// Created by Avently on 14.02.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private enum MigrationToState: Equatable { + case chatStopInProgress + case chatStopFailed(reason: String) + case passphraseNotSet + case passphraseConfirmation + case uploadConfirmation + case archiving + case uploadProgress(uploadedBytes: Int64, totalBytes: Int64, fileId: Int64, archivePath: URL, ctrl: chat_ctrl?) + case uploadFailed(totalBytes: Int64, archivePath: URL) + case linkCreation + case linkShown(fileId: Int64, link: String, archivePath: URL, ctrl: chat_ctrl) + case finished(chatDeletion: Bool) +} + +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") + + 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 let .deleteChat(title, text): return "\(title) \(text)" + case let .startChat(title, text): return "\(title) \(text)" + + 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 MigrateToAnotherDevice: 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 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 authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) + private let tempDatabaseUrl = urlForTemporaryDatabase() + @State private var chatReceiver: MigrationChatReceiver? = nil + @State private var backDisabled: Bool = false + + var body: some View { + if authorized { + migrateView() + } else { + Button(action: runAuth) { Label("Unlock", systemImage: "lock") } + .onAppear(perform: runAuth) + } + } + + private func runAuth() { authorize(NSLocalizedString("Open migration to another device", comment: "authentication reason"), $authorized) } + + func migrateView() -> some View { + VStack { + switch migrationState { + case .chatStopInProgress: + chatStopInProgressView() + case let .chatStopFailed(reason): + chatStopFailedView(reason) + case .passphraseNotSet: + passphraseNotSetView() + case .passphraseConfirmation: + PassphraseConfirmationView(migrationState: $migrationState, alert: $alert) + case .uploadConfirmation: + uploadConfirmationView() + case .archiving: + archivingView() + case let .uploadProgress(uploaded, total, _, archivePath, _): + uploadProgressView(uploaded, totalBytes: total, archivePath) + case let .uploadFailed(total, archivePath): + uploadFailedView(totalBytes: total, archivePath) + case .linkCreation: + linkCreationView() + case let .linkShown(fileId, link, archivePath, ctrl): + linkShownView(fileId, link, archivePath, ctrl) + case let .finished(chatDeletion): + finishedView(chatDeletion) + } + } + .modifier(BackButton(label: "Back", disabled: $backDisabled) { + dismiss() + }) + .onChange(of: migrationState) { state in + backDisabled = switch migrationState { + case .archiving: true + case .linkCreation: true + case .linkShown: true + case .finished: true + default: false + } + } + .onAppear { + stopChat() + } + .onDisappear { + Task { + if case .linkCreation = migrationState {} else if case .linkShown = migrationState {} else if case .finished = migrationState {} else { + await MainActor.run { + showProgressOnSettings = true + } + await startChatAndDismiss(false) + await MainActor.run { + showProgressOnSettings = false + } + } + if case let .uploadProgress(_, _, fileId, _, ctrl) = migrationState, let ctrl { + await cancelUploadedArchive(fileId, ctrl) + } + chatReceiver?.stopAndCleanUp() + try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory()) + } + } + .alert(item: $alert) { alert in + switch alert { + case let .startChat(title, text): + return Alert( + title: Text(title), + message: Text(text), + primaryButton: .destructive(Text("Start chat")) { + Task { + await startChatAndDismiss() + } + }, + secondaryButton: .cancel() + ) + case let .deleteChat(title, text): + return Alert( + title: Text(title), + message: Text(text), + primaryButton: .destructive(Text("Delete")) { + deleteChatAndDismiss() + }, + secondaryButton: .cancel() + ) + 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 chatStopInProgressView() -> some View { + ZStack { + List { + Section {} header: { + Text("Stopping chat") + } + } + progressView() + } + } + + private func chatStopFailedView(_ reason: String) -> some View { + List { + Section { + Text(reason) + Button(action: stopChat) { + settingsRow("stop.fill") { + Text("Stop chat").foregroundColor(.red) + } + } + } header: { + Text("Error stopping chat") + } footer: { + Text("In order to continue, chat should be stopped.") + .font(.callout) + } + } + } + + private func passphraseNotSetView() -> some View { + DatabaseEncryptionView(useKeychain: $useKeychain, migration: true) + .onChange(of: initialRandomDBPassphrase) { initial in + if !initial { + migrationState = .uploadConfirmation + } + } + } + + private func uploadConfirmationView() -> some View { + List { + Section { + Button(action: { migrationState = .archiving }) { + settingsRow("tray.and.arrow.up") { + Text("Archive and upload").foregroundColor(.accentColor) + } + } + } header: { + Text("Confirm upload") + } footer: { + Text("All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays.") + .font(.callout) + } + } + } + + private func archivingView() -> some View { + ZStack { + List { + Section {} header: { + Text("Archiving database") + } + } + progressView() + } + .onAppear { + exportArchive() + } + } + + private func uploadProgressView(_ uploadedBytes: Int64, totalBytes: Int64, _ archivePath: URL) -> some View { + ZStack { + List { + Section {} header: { + Text("Uploading archive") + } + } + let ratio = Float(uploadedBytes) / Float(totalBytes) + MigrateToAnotherDevice.largeProgressView(ratio, "\(Int(ratio * 100))%", "\(ByteCountFormatter.string(fromByteCount: uploadedBytes, countStyle: .binary)) uploaded") + } + .onAppear { + startUploading(totalBytes, archivePath) + } + } + + private func uploadFailedView(totalBytes: Int64, _ archivePath: URL) -> some View { + List { + Section { + Button(action: { + migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil) + }) { + settingsRow("tray.and.arrow.up") { + Text("Repeat upload").foregroundColor(.accentColor) + } + } + } header: { + Text("Upload failed") + } footer: { + Text("You can give another try.") + .font(.callout) + } + } + .onAppear { + chatReceiver?.stopAndCleanUp() + } + } + + private func linkCreationView() -> some View { + ZStack { + List { + Section {} header: { + Text("Creating archive link") + } + } + progressView() + } + } + + private func linkShownView(_ fileId: Int64, _ link: String, _ archivePath: URL, _ ctrl: chat_ctrl) -> some View { + List { + Section { + Button(action: { cancelMigration(fileId, ctrl) }) { + settingsRow("multiply") { + Text("Cancel migration").foregroundColor(.red) + } + } + Button(action: { finishMigration(fileId, ctrl) }) { + settingsRow("checkmark") { + Text("Finalize migration").foregroundColor(.accentColor) + } + } + } footer: { + Text("Choose _Migrate from another device_ on the new device and scan QR code.") + .font(.callout) + } + Section("Show QR code") { + SimpleXLinkQRCode(uri: link) + .padding() + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground)) + ) + .padding(.horizontal) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + + Section("Or securely share this file link") { + shareLinkView(link) + } + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10)) + } + } + + private func finishedView(_ chatDeletion: Bool) -> some View { + ZStack { + List { + Section { + Button(action: { alert = .deleteChat() }) { + settingsRow("trash.fill") { + Text("Delete database from this device").foregroundColor(.accentColor) + } + } + Button(action: { alert = .startChat() }) { + settingsRow("play.fill") { + Text("Start chat").foregroundColor(.red) + } + } + } header: { + Text("Migration complete") + } footer: { + VStack(alignment: .leading, spacing: 16) { + Text("You **must not** use the same database on two devices.") + Text("**Please note**: using the same database on two devices will break the decryption of messages from your connections, as a security protection.") + } + .font(.callout) + } + } + if chatDeletion { + progressView() + } + } + } + + private func shareLinkView(_ link: String) -> some View { + HStack { + linkTextView(link) + Button { + showShareSheet(items: [link]) + } label: { + Image(systemName: "square.and.arrow.up") + .padding(.top, -7) + } + } + .frame(maxWidth: .infinity) + } + + private func linkTextView(_ link: String) -> some View { + Text(link) + .lineLimit(1) + .font(.caption) + .truncationMode(.middle) + } + + static func largeProgressView(_ value: Float, _ title: String, _ description: LocalizedStringKey) -> some View { + ZStack { + VStack { + Text(description) + .font(.title3) + .hidden() + + Text(title) + .font(.system(size: 54)) + .bold() + .foregroundColor(.accentColor) + + Text(description) + .font(.title3) + } + + Circle() + .trim(from: 0, to: CGFloat(value)) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 27) + ) + .rotationEffect(.degrees(180)) + .animation(.linear, value: value) + .frame(maxWidth: .infinity) + .padding(.horizontal) + .padding(.horizontal) + } + .frame(maxWidth: .infinity) + } + + private func stopChat() { + Task { + do { + try await stopChatAsync() + do { + try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) + await MainActor.run { + migrationState = initialRandomDBPassphraseGroupDefault.get() ? .passphraseNotSet : .passphraseConfirmation + } + } catch let error { + alert = .error(title: "Error saving settings", error: error.localizedDescription) + migrationState = .chatStopFailed(reason: NSLocalizedString("Error saving settings", comment: "when migrating")) + } + } catch let e { + await MainActor.run { + migrationState = .chatStopFailed(reason: e.localizedDescription) + } + } + } + } + + private func exportArchive() { + Task { + do { + try? FileManager.default.createDirectory(at: getMigrationTempFilesDirectory(), withIntermediateDirectories: true) + let archivePath = try await exportChatArchive(getMigrationTempFilesDirectory()) + if let attrs = try? FileManager.default.attributesOfItem(atPath: archivePath.path), + let totalBytes = attrs[.size] as? Int64 { + await MainActor.run { + migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: totalBytes, fileId: 0, archivePath: archivePath, ctrl: nil) + } + } else { + await MainActor.run { + alert = .error(title: "Exported file doesn't exist") + migrationState = .uploadConfirmation + } + } + } catch let error { + await MainActor.run { + alert = .error(title: "Error exporting chat database", error: responseError(error)) + migrationState = .uploadConfirmation + } + } + } + } + + 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 startUploading(_ totalBytes: Int64, _ archivePath: URL) { + Task { + guard let ctrlAndUser = initTemporaryDatabase() else { + return migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath) + } + let (ctrl, user) = ctrlAndUser + chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in + await MainActor.run { + switch msg { + case let .sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize): + if case let .uploadProgress(uploaded, total, _, _, _) = migrationState, uploaded != total { + migrationState = .uploadProgress(uploadedBytes: sentSize, totalBytes: totalSize, fileId: fileTransferMeta.fileId, archivePath: archivePath, ctrl: ctrl) + } + case .sndFileRedirectStartXFTP: + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + migrationState = .linkCreation + } + case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs): + let cfg = getNetCfg() + let data = MigrationFileLinkData.init( + networkConfig: MigrationFileLinkData.NetworkConfig( + socksProxy: cfg.socksProxy, + hostMode: cfg.hostMode, + requiredHostMode: cfg.requiredHostMode + ) + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl) + } + default: + logger.debug("unsupported event: \(msg.responseType)") + } + } + } + chatReceiver?.start() + + let (res, error) = await uploadStandaloneFile(user: user, file: CryptoFile.plain(archivePath.lastPathComponent), ctrl: ctrl) + await MainActor.run { + guard let res = res else { + migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath) + return alert = .error(title: "Error uploading the archive", error: error ?? "") + } + migrationState = .uploadProgress(uploadedBytes: 0, totalBytes: res.fileSize, fileId: res.fileId, archivePath: archivePath, ctrl: ctrl) + } + } + } + + private func cancelUploadedArchive(_ fileId: Int64, _ ctrl: chat_ctrl) async { + _ = await apiCancelFile(fileId: fileId, ctrl: ctrl) + } + + private func cancelMigration(_ fileId: Int64, _ ctrl: chat_ctrl) { + Task { + await cancelUploadedArchive(fileId, ctrl) + await startChatAndDismiss() + } + } + + private func finishMigration(_ fileId: Int64, _ ctrl: chat_ctrl) { + Task { + await cancelUploadedArchive(fileId, ctrl) + await MainActor.run { + migrationState = .finished(chatDeletion: false) + } + } + } + + private func deleteChatAndDismiss() { + Task { + do { + try await deleteChatAsync() + m.chatDbChanged = true + m.chatInitialized = false + migrationState = .finished(chatDeletion: true) + DispatchQueue.main.asyncAfter(deadline: .now()) { + resetChatCtrl() + do { + try initializeChat(start: false) + m.chatDbChanged = false + AppChatState.shared.set(.active) + } catch let error { + fatalError("Error starting chat \(responseError(error))") + } + showSettings = false + } + } catch let error { + alert = .error(title: "Error deleting database", error: responseError(error)) + } + } + } + + private func startChatAndDismiss(_ dismiss: Bool = true) async { + AppChatState.shared.set(.active) + do { + if m.chatDbChanged { + resetChatCtrl() + try initializeChat(start: true) + m.chatDbChanged = false + } else { + try startChat(refreshInvitations: true) + } + } catch let error { + alert = .error(title: "Error starting chat", error: responseError(error)) + } + // Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered + if dismiss || m.chatDbStatus != .ok { + await MainActor.run { + showSettings = false + } + } + } + + private static func urlForTemporaryDatabase() -> URL { + URL(fileURLWithPath: generateNewFileName(getMigrationTempFilesDirectory().path + "/" + "migration", "db", fullPath: true)) + } +} + +private struct PassphraseConfirmationView: View { + @Binding var migrationState: MigrationToState + @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? + + var body: some View { + ZStack { + List { + chatStoppedView() + Section { + PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey)) + .focused($keyboardVisible) + Button(action: { + verifyingPassphrase = true + hideKeyboard() + Task { + await verifyDatabasePassphrase(currentKey) + verifyingPassphrase = false + } + }) { + settingsRow(useKeychain ? "key" : "lock", color: .secondary) { + Text("Verify passphrase") + } + } + .disabled(verifyingPassphrase || currentKey.isEmpty) + } header: { + Text("Verify database passphrase") + } footer: { + Text("Confirm that you remember database passphrase to migrate it.") + .font(.callout) + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + keyboardVisible = true + } + } + } + if verifyingPassphrase { + progressView() + } + } + } + + private func verifyDatabasePassphrase(_ dbKey: String) async { + do { + try await testStorageEncryption(key: dbKey) + await MainActor.run { + migrationState = .uploadConfirmation + } + } catch { + showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert) + } + } +} + +private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: Binding) { + 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 ) +} + +func chatStoppedView() -> some View { + settingsRow("exclamationmark.octagon.fill", color: .red) { + Text("Chat is stopped") + } +} + +private class MigrationChatReceiver { + let ctrl: chat_ctrl + let databaseUrl: URL + let processReceivedMsg: (ChatResponse) async -> Void + private var receiveLoop: Task? + private var receiveMessages = true + + init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) 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.add(.resp(.now, 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 MigrateToAnotherDevice_Previews: PreviewProvider { + static var previews: some View { + MigrateToAnotherDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false)) + } +} diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index b78d92ffc8..7ece4fdee6 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -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) { + 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) -> 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) { - 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?() } + } } } diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index ce1d727b10..b68c1279b0 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -7,11 +7,14 @@ // import SwiftUI +import SimpleXChat 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 { @@ -44,6 +47,16 @@ struct SimpleXInfo: View { if onboarding { OnboardingActionButton() Spacer() + + Button { + migrationState = nil + migrateFromAnotherDevice = true + } label: { + Label("Migrate from another device", systemImage: "tray.and.arrow.down") + .font(.subheadline) + } + .padding(.bottom, 8) + .frame(maxWidth: .infinity) } Button { @@ -54,9 +67,25 @@ struct SimpleXInfo: View { } .padding(.bottom, 8) .frame(maxWidth: .infinity) + } .frame(minHeight: g.size.height) } + .onAppear { + if m.migrationState != nil { + migrationState = m.migrationState?.makeMigrationState() + migrateFromAnotherDevice = true + } + } + .sheet(isPresented: $migrateFromAnotherDevice) { + NavigationView { + VStack(alignment: .leading) { + MigrateFromAnotherDevice(migrationState: migrationState ?? .pasteOrScanLink) + } + .navigationTitle("Migrate here") + .background(colorScheme == .light ? Color(uiColor: .tertiarySystemGroupedBackground) : .clear) + } + } .sheet(isPresented: $showHowItWorks) { HowItWorks(onboarding: onboarding) } @@ -87,6 +116,7 @@ struct SimpleXInfo: View { struct OnboardingActionButton: View { @EnvironmentObject var m: ChatModel + @Environment(\.colorScheme) var colorScheme var body: some View { if m.currentUser == nil { @@ -111,6 +141,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 { diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 6809dc1385..3059b049a3 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -58,7 +58,7 @@ struct ConnectDesktopView: View { var body: some View { if viaSettings { viewBody - .modifier(BackButton(label: "Back") { + .modifier(BackButton(label: "Back", disabled: Binding.constant(false)) { if m.activeRemoteCtrl { alert = .disconnectDesktop(action: .back) } else { diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift new file mode 100644 index 0000000000..ba192b333c --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -0,0 +1,72 @@ +// +// AppSettings.swift +// SimpleX (iOS) +// +// Created by Avently on 26.02.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import Foundation +import SimpleXChat +import SwiftUI + +extension AppSettings { + public func importIntoApp() { + let def = UserDefaults.standard + if var val = networkConfig { + // migrating from Android/desktop BUT shouldn't be here ever because it should be changed in migration stage + if case .onionViaSocks = val.hostMode { + val.hostMode = .publicHost + val.requiredHostMode = true + } + val.socksProxy = nil + setNetCfg(val) + } + if let val = privacyEncryptLocalFiles { privacyEncryptLocalFilesGroupDefault.set(val) } + if let val = privacyAcceptImages { + privacyAcceptImagesGroupDefault.set(val) + def.setValue(val, forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) + } + if let val = privacyLinkPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) } + if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) } + if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) } + if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) } + if let val = notificationMode { ChatModel.shared.notificationMode = val.toNotificationsMode() } + if let val = notificationPreviewMode { ntfPreviewModeGroupDefault.set(val) } + if let val = webrtcPolicyRelay { def.setValue(val, forKey: DEFAULT_WEBRTC_POLICY_RELAY) } + if let val = webrtcICEServers { def.setValue(val, forKey: DEFAULT_WEBRTC_ICE_SERVERS) } + if let val = confirmRemoteSessions { def.setValue(val, forKey: DEFAULT_CONFIRM_REMOTE_SESSIONS) } + if let val = connectRemoteViaMulticast { def.setValue(val, forKey: DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) } + if let val = connectRemoteViaMulticastAuto { def.setValue(val, forKey: DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO) } + if let val = developerTools { def.setValue(val, forKey: DEFAULT_DEVELOPER_TOOLS) } + if let val = confirmDBUpgrades { confirmDBUpgradesGroupDefault.set(val) } + if let val = androidCallOnLockScreen { def.setValue(val.rawValue, forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN) } + if let val = iosCallKitEnabled { callKitEnabledGroupDefault.set(val) } + if let val = iosCallKitCallsInRecents { def.setValue(val, forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) } + } + + public static var current: AppSettings { + let def = UserDefaults.standard + var c = AppSettings.defaults + c.networkConfig = getNetCfg() + c.privacyEncryptLocalFiles = privacyEncryptLocalFilesGroupDefault.get() + c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get() + c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) + c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) + c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) + c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) + c.notificationMode = AppSettingsNotificationMode.from(ChatModel.shared.notificationMode) + c.notificationPreviewMode = ntfPreviewModeGroupDefault.get() + c.webrtcPolicyRelay = def.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) + c.webrtcICEServers = def.stringArray(forKey: DEFAULT_WEBRTC_ICE_SERVERS) + c.confirmRemoteSessions = def.bool(forKey: DEFAULT_CONFIRM_REMOTE_SESSIONS) + c.connectRemoteViaMulticast = def.bool(forKey: DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) + c.connectRemoteViaMulticastAuto = def.bool(forKey: DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO) + c.developerTools = def.bool(forKey: DEFAULT_DEVELOPER_TOOLS) + c.confirmDBUpgrades = confirmDBUpgradesGroupDefault.get() + c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!) + c.iosCallKitEnabled = callKitEnabledGroupDefault.get() + c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) + return c + } +} diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift index 48d5a66970..6702ab7ce8 100644 --- a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift @@ -31,7 +31,7 @@ struct ProtocolServerView: View { ProgressView().scaleEffect(2) } } - .modifier(BackButton(label: "Your \(proto) servers") { + .modifier(BackButton(label: "Your \(proto) servers", disabled: Binding.constant(false)) { server = serverToEdit dismiss() }) @@ -117,6 +117,7 @@ struct ProtocolServerView: View { struct BackButton: ViewModifier { var label: LocalizedStringKey = "Back" + @Binding var disabled: Bool var action: () -> Void func body(content: Content) -> some View { @@ -130,6 +131,7 @@ struct BackButton: ViewModifier { Text(label) } } + .disabled(disabled) } } } diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift index 382eaffbef..b9163d4bad 100644 --- a/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/ProtocolServersView.swift @@ -95,7 +95,7 @@ struct ProtocolServersView: View { .sheet(isPresented: $showScanProtoServer) { ScanProtocolServer(servers: $servers) } - .modifier(BackButton { + .modifier(BackButton(disabled: Binding.constant(false)) { if saveDisabled { dismiss() justOpened = false diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index a691e6afc9..842ccaab4c 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -27,7 +27,7 @@ let DEFAULT_NOTIFICATION_ALERT_SHOWN = "notificationAlertShown" let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers" let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents" -let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" +let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews" @@ -51,6 +51,7 @@ 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_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime" let DEFAULT_SHOW_UNREAD_AND_FAVORITES = "showUnreadAndFavorites" let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess" @@ -58,6 +59,8 @@ let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions" let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast" let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto" +let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen" + let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, DEFAULT_LA_NOTICE_SHOWN: false, @@ -93,6 +96,7 @@ let appDefaults: [String: Any] = [ DEFAULT_CONFIRM_REMOTE_SESSIONS: false, DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true, DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true, + ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue ] // not used anymore @@ -148,10 +152,14 @@ struct SettingsView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var sceneDelegate: SceneDelegate @Binding var showSettings: Bool + @State private var showProgress: Bool = false var body: some View { ZStack { settingsView() + if showProgress { + progressView() + } if let la = chatModel.laRequest { LocalAuthView(authRequest: la) } @@ -202,9 +210,17 @@ struct SettingsView: View { } label: { settingsRow("desktopcomputer") { Text("Use from desktop") } } + + NavigationLink { + MigrateToAnotherDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress) + .navigationTitle("Migrate device") + .navigationBarTitleDisplayMode(.large) + } label: { + settingsRow("tray.and.arrow.up") { Text("Migrate to another device") } + } } .disabled(chatModel.chatRunning != true) - + Section("Settings") { NavigationLink { NotificationsView() @@ -349,6 +365,13 @@ struct SettingsView: View { } } + private func progressView() -> some View { + VStack { + ProgressView().scaleEffect(2) + } + .frame(maxWidth: .infinity, maxHeight: .infinity ) + } + private enum NotificationAlert { case enable case error(LocalizedStringKey, String) diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index e9657961ef..96eeffd16d 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -47,7 +47,7 @@ struct UserAddressView: View { userAddressScrollView() } else { userAddressScrollView() - .modifier(BackButton { + .modifier(BackButton(disabled: Binding.constant(false)) { if savedAAS == aas { dismiss() } else { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 67536d7b78..6f76781837 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -640,7 +640,9 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { cleanupDirectFile(aChatItem) return nil case let .sndFileRcvCancelled(_, aChatItem, _): - cleanupDirectFile(aChatItem) + if let aChatItem = aChatItem { + cleanupDirectFile(aChatItem) + } return nil case let .sndFileCompleteXFTP(_, aChatItem, _): cleanupFile(aChatItem) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 09904ad4fe..5f6fe6c65a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -185,6 +185,9 @@ 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 */; }; + 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 */; }; 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 */; }; @@ -473,6 +476,9 @@ 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; 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 = ""; }; 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; }; @@ -553,6 +559,7 @@ 5CB924DD27A8622200ACCCDD /* NewChat */, 5CFA59C22860B04D00863A68 /* Database */, 5CB634AB29E46CDB0066AD6B /* LocalAuth */, + 8C7D94982B8894D300B7B9E1 /* Migration */, 5CA8D01B2AD9B076001FD661 /* RemoteAccess */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, @@ -766,6 +773,7 @@ 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */, 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */, 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */, + 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */, ); path = UserSettings; sourceTree = ""; @@ -893,6 +901,15 @@ path = Group; sourceTree = ""; }; + 8C7D94982B8894D300B7B9E1 /* Migration */ = { + isa = PBXGroup; + children = ( + 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */, + 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */, + ); + path = Migration; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1124,6 +1141,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 */, @@ -1179,6 +1197,7 @@ 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, 5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */, + 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */, 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, @@ -1220,6 +1239,7 @@ 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */, 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */, + 8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.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 c0bb298929..64249fe09b 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -54,6 +54,33 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio return result } +public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation: MigrationConfirmation = .error) -> (DBMigrationResult, chat_ctrl?) { + let dbPath = url.path + let dbKey = key ?? randomDatabasePassword() + logger.debug("chatInitTemporaryDatabase path: \(dbPath)") + var temporaryController: chat_ctrl? = nil + var cPath = dbPath.cString(using: .utf8)! + var cKey = dbKey.cString(using: .utf8)! + var cConfirm = confirmation.rawValue.cString(using: .utf8)! + let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, 0, &temporaryController)! + return (dbMigrationResult(fromCString(cjson)), temporaryController) +} + +public func chatInitControllerRemovingDatabases() { + let dbPath = getAppDatabasePath().path + 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) +} + + public func chatCloseStore() { let err = fromCString(chat_close_store(getChatCtrl())) if err != "" { @@ -73,17 +100,17 @@ public func resetChatCtrl() { migrationResult = nil } -public func sendSimpleXCmd(_ cmd: ChatCommand) -> ChatResponse { +public func sendSimpleXCmd(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) -> ChatResponse { var c = cmd.cmdString.cString(using: .utf8)! - let cjson = chat_send_cmd(getChatCtrl(), &c)! + let cjson = chat_send_cmd(ctrl ?? getChatCtrl(), &c)! return chatResponse(fromCString(cjson)) } // in microseconds let MESSAGE_TIMEOUT: Int32 = 15_000_000 -public func recvSimpleXMsg() -> ChatResponse? { - if let cjson = chat_recv_msg_wait(getChatCtrl(), MESSAGE_TIMEOUT) { +public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil) -> ChatResponse? { + if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), MESSAGE_TIMEOUT) { let s = fromCString(cjson) return s == "" ? nil : chatResponse(s) } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4df419ffef..f55c69a349 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -38,6 +38,9 @@ public enum ChatCommand { case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage case apiStorageEncryption(config: DBEncryptionConfig) + case testStorageEncryption(key: String) + case apiSaveSettings(settings: AppSettings) + case apiGetSettings(settings: AppSettings) case apiGetChats(userId: Int64) case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) @@ -132,6 +135,9 @@ public enum ChatCommand { case listRemoteCtrls case stopRemoteCtrl case deleteRemoteCtrl(remoteCtrlId: Int64) + case apiUploadStandaloneFile(userId: Int64, file: CryptoFile) + case apiDownloadStandaloneFile(userId: Int64, url: String, file: CryptoFile) + case apiStandaloneFileInfo(url: String) // misc case showVersion case string(String) @@ -170,6 +176,9 @@ public enum ChatCommand { case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))" + case let .testStorageEncryption(key): return "/db test key \(key)" + case let .apiSaveSettings(settings): return "/_save app settings \(encodeJSON(settings))" + case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))" case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") @@ -282,6 +291,9 @@ public enum ChatCommand { case .listRemoteCtrls: return "/list remote ctrls" case .stopRemoteCtrl: return "/stop remote ctrl" case let .deleteRemoteCtrl(rcId): return "/delete remote ctrl \(rcId)" + case let .apiUploadStandaloneFile(userId, file): return "/_upload \(userId) \(file.filePath)" + case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" + case let .apiStandaloneFileInfo(link): return "/_download info \(link)" case .showVersion: return "/version" case let .string(str): return str } @@ -316,6 +328,9 @@ public enum ChatCommand { case .apiImportArchive: return "apiImportArchive" case .apiDeleteStorage: return "apiDeleteStorage" case .apiStorageEncryption: return "apiStorageEncryption" + case .testStorageEncryption: return "testStorageEncryption" + case .apiSaveSettings: return "apiSaveSettings" + case .apiGetSettings: return "apiGetSettings" case .apiGetChats: return "apiGetChats" case .apiGetChat: return "apiGetChat" case .apiGetChatItemInfo: return "apiGetChatItemInfo" @@ -408,6 +423,9 @@ public enum ChatCommand { case .listRemoteCtrls: return "listRemoteCtrls" case .stopRemoteCtrl: return "stopRemoteCtrl" case .deleteRemoteCtrl: return "deleteRemoteCtrl" + case .apiUploadStandaloneFile: return "apiUploadStandaloneFile" + case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" + case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" case .showVersion: return "showVersion" case .string: return "console command" } @@ -442,6 +460,8 @@ public enum ChatCommand { return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd)) case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) + case let .testStorageEncryption(key): + return .testStorageEncryption(key: obfuscate(key)) default: return self } } @@ -590,20 +610,28 @@ public enum ChatResponse: Decodable, Error { // receiving file events case rcvFileAccepted(user: UserRef, chatItem: AChatItem) case rcvFileAcceptedSndCancelled(user: UserRef, rcvFileTransfer: RcvFileTransfer) - case rcvFileStart(user: UserRef, chatItem: AChatItem) - case rcvFileProgressXFTP(user: UserRef, chatItem: AChatItem, receivedSize: Int64, totalSize: Int64) + case standaloneFileInfo(fileMeta: MigrationFileLinkData?) + case rcvStandaloneFileCreated(user: UserRef, rcvFileTransfer: RcvFileTransfer) + case rcvFileStart(user: UserRef, chatItem: AChatItem) // send by chats + case rcvFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, receivedSize: Int64, totalSize: Int64, rcvFileTransfer: RcvFileTransfer) case rcvFileComplete(user: UserRef, chatItem: AChatItem) - case rcvFileCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) + case rcvStandaloneFileComplete(user: UserRef, targetPath: String, rcvFileTransfer: RcvFileTransfer) + case rcvFileCancelled(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer) case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) - case rcvFileError(user: UserRef, chatItem: AChatItem) + case rcvFileError(user: UserRef, chatItem_: AChatItem?, rcvFileTransfer: RcvFileTransfer) // sending file events case sndFileStart(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileCancelled(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) - case sndFileRcvCancelled(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileProgressXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) + case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) + case sndFileCancelled(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) + case sndStandaloneFileCreated(user: UserRef, fileTransferMeta: FileTransferMeta) // returned by _upload + case sndFileStartXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) // not used + case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64) + case sndFileRedirectStartXFTP(user: UserRef, fileTransferMeta: FileTransferMeta, redirectMeta: FileTransferMeta) case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta) - case sndFileError(user: UserRef, chatItem: AChatItem) + case sndStandaloneFileComplete(user: UserRef, fileTransferMeta: FileTransferMeta, rcvURIs: [String]) + case sndFileCancelledXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta) + case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta) // call events case callInvitation(callInvitation: RcvCallInvitation) case callOffer(user: UserRef, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) @@ -632,6 +660,7 @@ public enum ChatResponse: Decodable, Error { case chatCmdError(user_: UserRef?, chatError: ChatError) case chatError(user_: UserRef?, chatError: ChatError) case archiveImported(archiveErrors: [ArchiveError]) + case appSettings(appSettings: AppSettings) public var responseType: String { get { @@ -744,18 +773,26 @@ public enum ChatResponse: Decodable, Error { case .newMemberContactReceivedInv: return "newMemberContactReceivedInv" case .rcvFileAccepted: return "rcvFileAccepted" case .rcvFileAcceptedSndCancelled: return "rcvFileAcceptedSndCancelled" + case .standaloneFileInfo: return "standaloneFileInfo" + case .rcvStandaloneFileCreated: return "rcvStandaloneFileCreated" case .rcvFileStart: return "rcvFileStart" case .rcvFileProgressXFTP: return "rcvFileProgressXFTP" case .rcvFileComplete: return "rcvFileComplete" + case .rcvStandaloneFileComplete: return "rcvStandaloneFileComplete" case .rcvFileCancelled: return "rcvFileCancelled" case .rcvFileSndCancelled: return "rcvFileSndCancelled" case .rcvFileError: return "rcvFileError" case .sndFileStart: return "sndFileStart" case .sndFileComplete: return "sndFileComplete" case .sndFileCancelled: return "sndFileCancelled" - case .sndFileRcvCancelled: return "sndFileRcvCancelled" + case .sndStandaloneFileCreated: return "sndStandaloneFileCreated" + case .sndFileStartXFTP: return "sndFileStartXFTP" case .sndFileProgressXFTP: return "sndFileProgressXFTP" + case .sndFileRedirectStartXFTP: return "sndFileRedirectStartXFTP" + case .sndFileRcvCancelled: return "sndFileRcvCancelled" case .sndFileCompleteXFTP: return "sndFileCompleteXFTP" + case .sndStandaloneFileComplete: return "sndStandaloneFileComplete" + case .sndFileCancelledXFTP: return "sndFileCancelledXFTP" case .sndFileError: return "sndFileError" case .callInvitation: return "callInvitation" case .callOffer: return "callOffer" @@ -781,6 +818,7 @@ public enum ChatResponse: Decodable, Error { case .chatCmdError: return "chatCmdError" case .chatError: return "chatError" case .archiveImported: return "archiveImported" + case .appSettings: return "appSettings" } } } @@ -896,19 +934,27 @@ public enum ChatResponse: Decodable, Error { case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case .rcvFileAcceptedSndCancelled: return noDetails + case let .standaloneFileInfo(fileMeta): return String(describing: fileMeta) + case .rcvStandaloneFileCreated: return noDetails case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) - case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)") + case let .rcvFileProgressXFTP(u, chatItem, receivedSize, totalSize, _): return withUser(u, "chatItem: \(String(describing: chatItem))\nreceivedSize: \(receivedSize)\ntotalSize: \(totalSize)") + case let .rcvStandaloneFileComplete(u, targetPath, _): return withUser(u, targetPath) case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) case let .rcvFileCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .rcvFileError(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileError(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) + case .sndStandaloneFileCreated: return noDetails + case let .sndFileStartXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)") + case let .sndFileRedirectStartXFTP(u, _, redirectMeta): return withUser(u, String(describing: redirectMeta)) case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) - case let .sndFileError(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .sndStandaloneFileComplete(u, _, rcvURIs): return withUser(u, String(rcvURIs.count)) + case let .sndFileCancelledXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileError(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .callInvitation(inv): return String(describing: inv) case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") @@ -933,6 +979,7 @@ public enum ChatResponse: Decodable, Error { case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) case let .archiveImported(archiveErrors): return String(describing: archiveErrors) + case let .appSettings(appSettings): return String(describing: appSettings) } } } @@ -1534,7 +1581,7 @@ public enum NotificationsMode: String, Decodable, SelectableItem { public static var values: [NotificationsMode] = [.instant, .periodic, .off] } -public enum NotificationPreviewMode: String, SelectableItem { +public enum NotificationPreviewMode: String, SelectableItem, Codable { case hidden case contact case message @@ -1734,6 +1781,7 @@ public enum StoreError: Decodable { case fileIdNotFoundBySharedMsgId(sharedMsgId: String) case sndFileNotFoundXFTP(agentSndFileId: String) case rcvFileNotFoundXFTP(agentRcvFileId: String) + case extraFileDescrNotFoundXFTP(fileId: Int64) case connectionNotFound(agentConnId: String) case connectionNotFoundById(connId: Int64) case connectionNotFoundByMemberId(groupMemberId: Int64) @@ -1895,3 +1943,147 @@ public enum RemoteCtrlError: Decodable { case badVersion(appVersion: String) // case protocolError(protocolError: RemoteProtocolError) } + +public struct MigrationFileLinkData: Codable { + let networkConfig: NetworkConfig? + + public init(networkConfig: NetworkConfig) { + self.networkConfig = networkConfig + } + + public struct NetworkConfig: Codable { + let socksProxy: String? + let hostMode: HostMode? + let requiredHostMode: Bool? + + public init(socksProxy: String?, hostMode: HostMode?, requiredHostMode: Bool?) { + self.socksProxy = socksProxy + self.hostMode = hostMode + self.requiredHostMode = requiredHostMode + } + + public func transformToPlatformSupported() -> NetworkConfig { + return if let hostMode, let requiredHostMode { + NetworkConfig( + socksProxy: nil, + hostMode: hostMode == .onionViaSocks ? .onionHost : hostMode, + requiredHostMode: requiredHostMode + ) + } else { self } + } + } + + public func addToLink(link: String) -> String { + "\(link)&data=\(encodeJSON(self).addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)" + } + + public static func readFromLink(link: String) -> MigrationFileLinkData? { +// standaloneFileInfo(link) + nil + } +} + +public struct AppSettings: Codable, Equatable { + public var networkConfig: NetCfg? = nil + public var privacyEncryptLocalFiles: Bool? = nil + public var privacyAcceptImages: Bool? = nil + public var privacyLinkPreviews: Bool? = nil + public var privacyShowChatPreviews: Bool? = nil + public var privacySaveLastDraft: Bool? = nil + public var privacyProtectScreen: Bool? = nil + public var notificationMode: AppSettingsNotificationMode? = nil + public var notificationPreviewMode: NotificationPreviewMode? = nil + public var webrtcPolicyRelay: Bool? = nil + public var webrtcICEServers: [String]? = nil + public var confirmRemoteSessions: Bool? = nil + public var connectRemoteViaMulticast: Bool? = nil + public var connectRemoteViaMulticastAuto: Bool? = nil + public var developerTools: Bool? = nil + public var confirmDBUpgrades: Bool? = nil + public var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil + public var iosCallKitEnabled: Bool? = nil + public var iosCallKitCallsInRecents: Bool? = nil + + public func prepareForExport() -> AppSettings { + var empty = AppSettings() + let def = AppSettings.defaults + if networkConfig != def.networkConfig { empty.networkConfig = networkConfig } + if privacyEncryptLocalFiles != def.privacyEncryptLocalFiles { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles } + if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages } + if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews } + if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews } + if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft } + if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen } + if notificationMode != def.notificationMode { empty.notificationMode = notificationMode } + if notificationPreviewMode != def.notificationPreviewMode { empty.notificationPreviewMode = notificationPreviewMode } + if webrtcPolicyRelay != def.webrtcPolicyRelay { empty.webrtcPolicyRelay = webrtcPolicyRelay } + if webrtcICEServers != def.webrtcICEServers { empty.webrtcICEServers = webrtcICEServers } + if confirmRemoteSessions != def.confirmRemoteSessions { empty.confirmRemoteSessions = confirmRemoteSessions } + if connectRemoteViaMulticast != def.connectRemoteViaMulticast {empty.connectRemoteViaMulticast = connectRemoteViaMulticast } + if connectRemoteViaMulticastAuto != def.connectRemoteViaMulticastAuto { empty.connectRemoteViaMulticastAuto = connectRemoteViaMulticastAuto } + if developerTools != def.developerTools { empty.developerTools = developerTools } + if confirmDBUpgrades != def.confirmDBUpgrades { empty.confirmDBUpgrades = confirmDBUpgrades } + if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen } + if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled } + if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents } + return empty + } + + public static var defaults: AppSettings { + AppSettings ( + networkConfig: NetCfg.defaults, + privacyEncryptLocalFiles: true, + privacyAcceptImages: true, + privacyLinkPreviews: true, + privacyShowChatPreviews: true, + privacySaveLastDraft: true, + privacyProtectScreen: false, + notificationMode: AppSettingsNotificationMode.instant, + notificationPreviewMode: NotificationPreviewMode.message, + webrtcPolicyRelay: true, + webrtcICEServers: [], + confirmRemoteSessions: false, + connectRemoteViaMulticast: true, + connectRemoteViaMulticastAuto: true, + developerTools: false, + confirmDBUpgrades: false, + androidCallOnLockScreen: AppSettingsLockScreenCalls.show, + iosCallKitEnabled: true, + iosCallKitCallsInRecents: false + ) + } +} + +public enum AppSettingsNotificationMode: String, Codable { + case off + case periodic + case instant + + public func toNotificationsMode() -> NotificationsMode { + switch self { + case .instant: .instant + case .periodic: .periodic + case .off: .off + } + } + + public static func from(_ mode: NotificationsMode) -> AppSettingsNotificationMode { + switch mode { + case .instant: .instant + case .periodic: .periodic + case .off: .off + } + } +} + +//public enum NotificationPreviewMode: Codable { +// case hidden +// case contact +// case message +//} + +public enum AppSettingsLockScreenCalls: String, Codable { + case disable + case show + case accept +} diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 47e250b7e9..4fbe78dc7a 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -19,6 +19,7 @@ public let GROUP_DEFAULT_CHAT_LAST_BACKGROUND_RUN = "chatLastBackgroundRun" let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode" public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" // no longer used public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer used +// This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used public let GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES = "privacyEncryptLocalFiles" @@ -36,7 +37,7 @@ let GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL = "networkTCPKeepIntvl" let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt" public let GROUP_DEFAULT_INCOGNITO = "incognito" let GROUP_DEFAULT_STORE_DB_PASSPHRASE = "storeDBPassphrase" -let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase" +public let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase" public let GROUP_DEFAULT_CONFIRM_DB_UPGRADES = "confirmDBUpgrades" public let GROUP_DEFAULT_CALL_KIT_ENABLED = "callKitEnabled" public let GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED = "pqExperimentalEnabled" @@ -169,6 +170,7 @@ public let ntfPreviewModeGroupDefault = EnumDefault( public let incognitoGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INCOGNITO) +// This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES) public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 4349fc7beb..b74a2517c7 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3410,11 +3410,14 @@ public struct SndFileTransfer: Decodable { } public struct RcvFileTransfer: Decodable { - + public let fileId: Int64 } public struct FileTransferMeta: Decodable { - + public let fileId: Int64 + public let fileName: String + public let filePath: String + public let fileSize: Int64 } public enum CICallStatus: String, Decodable { diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 7496bf7215..125600f3f3 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -28,9 +28,9 @@ public let MAX_FILE_SIZE_SMP: Int64 = 8000000 public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300) -private let CHAT_DB: String = "_chat.db" +let CHAT_DB: String = "_chat.db" -private let AGENT_DB: String = "_agent.db" +let AGENT_DB: String = "_agent.db" private let CHAT_DB_BAK: String = "_chat.db.bak" @@ -83,6 +83,7 @@ public func deleteAppDatabaseAndFiles() { try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK) try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK) try? fm.removeItem(at: getTempFilesDirectory()) + try? fm.removeItem(at: getMigrationTempFilesDirectory()) try? fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true) deleteAppFiles() _ = kcDatabasePassword.remove() @@ -183,6 +184,10 @@ public func getTempFilesDirectory() -> URL { getAppDirectory().appendingPathComponent("temp_files", isDirectory: true) } +public func getMigrationTempFilesDirectory() -> URL { + getDocumentsDirectory().appendingPathComponent("migration_temp_files", isDirectory: true) +} + public func getAppFilesDirectory() -> URL { getAppDirectory().appendingPathComponent("app_files", isDirectory: true) }