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/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index d9404547e2..da9dc523e1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -110,12 +110,11 @@ struct ChatItemContentView: View { case .sndModerated: deletedItemView() case .rcvModerated: deletedItemView() case .rcvBlocked: deletedItemView() + case let .sndDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo)) + case let .rcvDirectE2EEInfo(e2eeInfo): CIEventView(eventText: directE2EEInfoText(e2eeInfo)) + case .sndGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText()) + case .rcvGroupE2EEInfo: CIEventView(eventText: e2eeInfoNoPQText()) case let .invalidJSON(json): CIInvalidJSONView(json: json) - // TODO proper items - case .sndDirectE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) - case .rcvDirectE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) - case .sndGroupE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) - case .rcvGroupE2EEInfo: CIEventView(eventText: Text(chatItem.content.text)) } } @@ -175,6 +174,22 @@ struct ChatItemContentView: View { Text(members) } } + + private func directE2EEInfoText(_ info: E2EEInfo) -> Text { + info.pqEnabled + ? Text("Messages, files and calls are protected by **quantum resistant e2e encryption** with perfect forward secrecy, repudiation and break-in recovery.") + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + : e2eeInfoNoPQText() + } + + private func e2eeInfoNoPQText() -> Text { + Text("Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery.") + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + } } func chatEventText(_ text: Text) -> Text { 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 c8bdd92b25..b2b04f6657 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -57,15 +57,15 @@ 5C65F343297D45E100B67AF3 /* VersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C65F341297D3F3600B67AF3 /* VersionView.swift */; }; 5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6BA666289BD954009B8ECC /* DismissSheets.swift */; }; 5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */; }; + 5C746DA42B9F09AD0049D734 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746D9F2B9F09AD0049D734 /* libffi.a */; }; + 5C746DA52B9F09AD0049D734 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA02B9F09AD0049D734 /* libgmpxx.a */; }; + 5C746DA62B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */; }; + 5C746DA72B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA22B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a */; }; + 5C746DA82B9F09AD0049D734 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C746DA32B9F09AD0049D734 /* libgmp.a */; }; 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; - 5C777BD82B99B38B00C72EFF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD32B99B38B00C72EFF /* libgmp.a */; }; - 5C777BD92B99B38B00C72EFF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD42B99B38B00C72EFF /* libgmpxx.a */; }; - 5C777BDA2B99B38B00C72EFF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD52B99B38B00C72EFF /* libffi.a */; }; - 5C777BDB2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */; }; - 5C777BDC2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; }; 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */; }; @@ -190,6 +190,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 */; }; @@ -326,15 +329,15 @@ 5C6D183229E93FBA00D430B3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = "pl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C6D183329E93FBA00D430B3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; 5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFeaturePreferenceView.swift; sourceTree = ""; }; + 5C746D9F2B9F09AD0049D734 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C746DA02B9F09AD0049D734 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a"; sourceTree = ""; }; + 5C746DA22B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a"; sourceTree = ""; }; + 5C746DA32B9F09AD0049D734 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; - 5C777BD32B99B38B00C72EFF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C777BD42B99B38B00C72EFF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C777BD52B99B38B00C72EFF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a"; sourceTree = ""; }; - 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a"; sourceTree = ""; }; 5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C84FE9429A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -483,6 +486,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; }; @@ -524,13 +530,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C777BD92B99B38B00C72EFF /* libgmpxx.a in Frameworks */, - 5C777BDB2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a in Frameworks */, + 5C746DA52B9F09AD0049D734 /* libgmpxx.a in Frameworks */, + 5C746DA82B9F09AD0049D734 /* libgmp.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C777BD82B99B38B00C72EFF /* libgmp.a in Frameworks */, - 5C777BDA2B99B38B00C72EFF /* libffi.a in Frameworks */, + 5C746DA72B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a in Frameworks */, + 5C746DA42B9F09AD0049D734 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5C777BDC2B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a in Frameworks */, + 5C746DA62B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -563,6 +569,7 @@ 5CB924DD27A8622200ACCCDD /* NewChat */, 5CFA59C22860B04D00863A68 /* Database */, 5CB634AB29E46CDB0066AD6B /* LocalAuth */, + 8C7D94982B8894D300B7B9E1 /* Migration */, 5CA8D01B2AD9B076001FD661 /* RemoteAccess */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, @@ -592,11 +599,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C777BD52B99B38B00C72EFF /* libffi.a */, - 5C777BD32B99B38B00C72EFF /* libgmp.a */, - 5C777BD42B99B38B00C72EFF /* libgmpxx.a */, - 5C777BD62B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg-ghc9.6.3.a */, - 5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */, + 5C746D9F2B9F09AD0049D734 /* libffi.a */, + 5C746DA32B9F09AD0049D734 /* libgmp.a */, + 5C746DA02B9F09AD0049D734 /* libgmpxx.a */, + 5C746DA22B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42-ghc9.6.3.a */, + 5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */, ); path = Libraries; sourceTree = ""; @@ -776,6 +783,7 @@ 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */, 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */, 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */, + 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */, ); path = UserSettings; sourceTree = ""; @@ -903,6 +911,15 @@ path = Group; sourceTree = ""; }; + 8C7D94982B8894D300B7B9E1 /* Migration */ = { + isa = PBXGroup; + children = ( + 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */, + 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */, + ); + path = Migration; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1134,6 +1151,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 */, @@ -1189,6 +1207,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 */, @@ -1230,6 +1249,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 3463bfca18..b74a2517c7 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2784,23 +2784,23 @@ public enum CIContent: Decodable, ItemContent { case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item") case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item") - case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoToText(e2eeInfo) - case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoToText(e2eeInfo) - case .sndGroupE2EEInfo: return e2eeInfoNoPQText - case .rcvGroupE2EEInfo: return e2eeInfoNoPQText + case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) + case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) + case .sndGroupE2EEInfo: return e2eeInfoNoPQStr + case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item") } } } - private func directE2EEInfoToText(_ e2eeInfo: E2EEInfo) -> String { + private func directE2EEInfoStr(_ e2eeInfo: E2EEInfo) -> String { e2eeInfo.pqEnabled - ? NSLocalizedString("This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery.", comment: "E2EE info chat item") - : e2eeInfoNoPQText + ? NSLocalizedString("This chat is protected by quantum resistant end-to-end encryption.", comment: "E2EE info chat item") + : e2eeInfoNoPQStr } - private var e2eeInfoNoPQText: String { - NSLocalizedString("This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery.", comment: "E2EE info chat item") + private var e2eeInfoNoPQStr: String { + NSLocalizedString("This chat is protected by end-to-end encryption.", comment: "E2EE info chat item") } static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?) -> String { @@ -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) } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index b76b18fd21..f29aa39607 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -1,10 +1,16 @@ package chat.simplex.app +import android.annotation.SuppressLint import android.app.* import android.content.Context import chat.simplex.common.platform.Log import android.content.Intent +import android.content.pm.ActivityInfo +import android.media.AudioManager import android.os.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.* import androidx.work.* import chat.simplex.app.model.NtfManager @@ -18,8 +24,7 @@ import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.DefaultTheme -import chat.simplex.common.views.call.RcvCallInvitation -import chat.simplex.common.views.call.activeCallDestroyWebView +import chat.simplex.common.views.call.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import com.jakewharton.processphoenix.ProcessPhoenix @@ -65,7 +70,11 @@ class SimplexApp: Application(), LifecycleEventObserver { tmpDir.deleteRecursively() tmpDir.mkdir() - if (DatabaseUtils.ksSelfDestructPassword.get() == null) { + // Present screen for continue migration if it wasn't finished yet + if (chatModel.migrationState.value != null) { + // It's important, otherwise, user may be locked in undefined state + appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) + } else if (DatabaseUtils.ksAppPassword.get() == null || DatabaseUtils.ksSelfDestructPassword.get() == null) { initChatControllerAndRunMigrations() } ProcessLifecycleOwner.get().lifecycle.addObserver(this@SimplexApp) @@ -282,6 +291,21 @@ class SimplexApp: Application(), LifecycleEventObserver { activeCallDestroyWebView() } + @SuppressLint("SourceLockedOrientationActivity") + @Composable + override fun androidLockPortraitOrientation() { + val context = LocalContext.current + DisposableEffect(Unit) { + val activity = context as? Activity ?: return@DisposableEffect onDispose {} + // Lock orientation to portrait in order to have good experience with calls + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + onDispose { + // Unlock orientation + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + } + override suspend fun androidAskToAllowBackgroundCalls(): Boolean { if (SimplexService.isBackgroundRestricted()) { val userChoice: CompletableDeferred = CompletableDeferred() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt index df2499926f..83677f3318 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.database import SectionItemView import SectionTextFooter +import TextIconSpaced import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.Composable @@ -22,8 +23,9 @@ actual fun SavePassphraseSetting( useKeychain: Boolean, initialRandomDBPassphrase: Boolean, storedKey: Boolean, - progressIndicator: Boolean, minHeight: Dp, + enabled: Boolean, + smallPadding: Boolean, onCheckedChange: (Boolean) -> Unit, ) { SectionItemView(minHeight = minHeight) { @@ -33,7 +35,11 @@ actual fun SavePassphraseSetting( stringResource(MR.strings.save_passphrase_in_keychain), tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary ) - Spacer(Modifier.padding(horizontal = 4.dp)) + if (smallPadding) { + Spacer(Modifier.padding(horizontal = 4.dp)) + } else { + TextIconSpaced(false) + } Text( stringResource(MR.strings.save_passphrase_in_keychain), Modifier.padding(end = 24.dp), @@ -43,7 +49,7 @@ actual fun SavePassphraseSetting( DefaultSwitch( checked = useKeychain, onCheckedChange = onCheckedChange, - enabled = !initialRandomDBPassphrase && !progressIndicator + enabled = enabled ) } } @@ -55,13 +61,14 @@ actual fun DatabaseEncryptionFooter( chatDbEncrypted: Boolean?, storedKey: MutableState, initialRandomDBPassphrase: MutableState, + migration: Boolean, ) { if (chatDbEncrypted == false) { SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) } else if (useKeychain.value) { if (storedKey.value) { SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely)) - if (initialRandomDBPassphrase.value) { + if (initialRandomDBPassphrase.value && !migration) { SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) } else { SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 0213350916..e7dda42ade 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -110,6 +110,13 @@ fun MainScreen() { val localUserCreated = chatModel.localUserCreated.value var showInitializationView by remember { mutableStateOf(false) } when { + onboarding == OnboardingStage.Step1_SimpleXInfo && chatModel.migrationState.value != null -> { + // In migration process. Nothing should interrupt it, that's why it's the first branch in when() + SimpleXInfo(chatModel, onboarding = true) + if (appPlatform.isDesktop) { + ModalManager.fullscreen.showInView() + } + } chatModel.dbMigrationInProgress.value -> DefaultProgressView(stringResource(MR.strings.database_migration_in_progress)) chatModel.chatDbStatus.value == null && showInitializationView -> DefaultProgressView(stringResource(MR.strings.opening_database)) showChatDatabaseError -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index df1dec330d..faa4200555 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -13,6 +13,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.migration.MigrationFromAnotherDeviceState import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -104,6 +105,8 @@ object ChatModel { // currently showing invitation val showingInvitation = mutableStateOf(null as ShowingInvitation?) + val migrationState: MutableState by lazy { mutableStateOf(MigrationFromAnotherDeviceState.transform()) } + var draft = mutableStateOf(null as ComposeState?) var draftChatId = mutableStateOf(null as String?) @@ -2328,10 +2331,10 @@ sealed class CIContent: ItemContent { is SndModerated -> generalGetString(MR.strings.moderated_description) is RcvModerated -> generalGetString(MR.strings.moderated_description) is RcvBlocked -> generalGetString(MR.strings.blocked_by_admin_item_description) - is SndDirectE2EEInfo -> directE2EEInfoToText(e2eeInfo) - is RcvDirectE2EEInfo -> directE2EEInfoToText(e2eeInfo) - is SndGroupE2EEInfo -> e2eeInfoNoPQText - is RcvGroupE2EEInfo -> e2eeInfoNoPQText + is SndDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo) + is RcvDirectE2EEInfo -> directE2EEInfoStr(e2eeInfo) + is SndGroupE2EEInfo -> e2eeInfoNoPQStr + is RcvGroupE2EEInfo -> e2eeInfoNoPQStr is InvalidJSON -> "invalid data" } @@ -2350,14 +2353,14 @@ sealed class CIContent: ItemContent { } companion object { - fun directE2EEInfoToText(e2EEInfo: E2EEInfo): String = + fun directE2EEInfoStr(e2EEInfo: E2EEInfo): String = if (e2EEInfo.pqEnabled) { - generalGetString(MR.strings.e2ee_info_pq) + generalGetString(MR.strings.e2ee_info_pq_short) } else { - e2eeInfoNoPQText + e2eeInfoNoPQStr } - private val e2eeInfoNoPQText: String = generalGetString(MR.strings.e2ee_info_no_pq) + private val e2eeInfoNoPQStr: String = generalGetString(MR.strings.e2ee_info_no_pq_short) fun featureText(feature: Feature, enabled: String, param: Int?): String = if (feature.hasParam) { @@ -2973,10 +2976,17 @@ enum class FormatColor(val color: String) { class SndFileTransfer() {} @Serializable -class RcvFileTransfer() {} +data class RcvFileTransfer( + val fileId: Long, +) @Serializable -class FileTransferMeta() {} +data class FileTransferMeta( + val fileId: Long, + val fileName: String, + val filePath: String, + val fileSize: Long, +) @Serializable enum class CICallStatus { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index d695b2c608..08d30fe086 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -4,12 +4,15 @@ import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import chat.simplex.common.model.ChatController.getNetCfg +import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.model.ChatModel.changingActiveUserMutex import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* +import chat.simplex.common.views.migration.MigrationFileLinkData import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* import com.charleskorn.kaml.Yaml @@ -144,6 +147,7 @@ class AppPreferences { val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null) val onboardingStage = mkEnumPreference(SHARED_PREFS_ONBOARDING_STAGE, OnboardingStage.OnboardingComplete) { OnboardingStage.values().firstOrNull { it.name == this } } + val migrationStage = mkStrPreference(SHARED_PREFS_MIGRATION_STAGE, null) val storeDBPassphrase = mkBoolPreference(SHARED_PREFS_STORE_DB_PASSPHRASE, true) val initialRandomDBPassphrase = mkBoolPreference(SHARED_PREFS_INITIAL_RANDOM_DB_PASSPHRASE, false) val encryptedDBPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_DB_PASSPHRASE, null) @@ -177,6 +181,11 @@ class AppPreferences { val offerRemoteMulticast = mkBoolPreference(SHARED_PREFS_OFFER_REMOTE_MULTICAST, true) val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null) + + + val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true) + val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false) + private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( @@ -277,6 +286,7 @@ class AppPreferences { private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime" private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage" private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage" + const val SHARED_PREFS_MIGRATION_STAGE = "MigrationStage" private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_CHAT_STOPPED = "ChatStopped" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" @@ -326,6 +336,9 @@ class AppPreferences { private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState" + + private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled" + private const val SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS = "iOSCallKitCallsInRecents" } } @@ -402,6 +415,16 @@ object ChatController { } } + suspend fun startChatWithTemporaryDatabase(ctrl: ChatCtrl, netCfg: NetCfg): User? { + Log.d(TAG, "startChatWithTemporaryDatabase") + val migrationActiveUser = apiGetActiveUser(null, ctrl) ?: apiCreateActiveUser(null, Profile(displayName = "Temp", fullName = ""), ctrl = ctrl) + apiSetNetworkConfig(netCfg, ctrl) + apiSetTempFolder(getMigrationTempFilesDirectory().absolutePath, ctrl) + apiSetFilesFolder(getMigrationTempFilesDirectory().absolutePath, ctrl) + apiStartChat(ctrl) + return migrationActiveUser + } + suspend fun changeActiveUser(rhId: Long?, toUserId: Long, viewPwd: String?) { try { changeActiveUser_(rhId, toUserId, viewPwd) @@ -478,8 +501,8 @@ object ChatController { } } - suspend fun sendCmd(rhId: Long?, cmd: CC): CR { - val ctrl = ctrl ?: throw Exception("Controller is not initialized") + suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null): CR { + val ctrl = otherCtrl ?: ctrl ?: throw Exception("Controller is not initialized") return withContext(Dispatchers.IO) { val c = cmd.cmdString @@ -496,7 +519,7 @@ object ChatController { } } - private fun recvMsg(ctrl: ChatCtrl): APIResponse? { + fun recvMsg(ctrl: ChatCtrl): APIResponse? { val json = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) return if (json == "") { null @@ -509,8 +532,8 @@ object ChatController { } } - suspend fun apiGetActiveUser(rh: Long?): User? { - val r = sendCmd(rh, CC.ShowActiveUser()) + suspend fun apiGetActiveUser(rh: Long?, ctrl: ChatCtrl? = null): User? { + val r = sendCmd(rh, CC.ShowActiveUser(), ctrl) if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}") if (rh == null) { @@ -519,8 +542,8 @@ object ChatController { return null } - suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false): User? { - val r = sendCmd(rh, CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp)) + suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, sameServers: Boolean = false, pastTimestamp: Boolean = false, ctrl: ChatCtrl? = null): User? { + val r = sendCmd(rh, CC.CreateActiveUser(p, sameServers = sameServers, pastTimestamp = pastTimestamp), ctrl) if (r is CR.ActiveUser) return r.user.updateRemoteHostId(rh) else if ( r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName || @@ -598,8 +621,8 @@ object ChatController { throw Exception("failed to delete the user ${r.responseType} ${r.details}") } - suspend fun apiStartChat(): Boolean { - val r = sendCmd(null, CC.StartChat(mainApp = true)) + suspend fun apiStartChat(ctrl: ChatCtrl? = null): Boolean { + val r = sendCmd(null, CC.StartChat(mainApp = true), ctrl) when (r) { is CR.ChatStarted -> return true is CR.ChatRunning -> return false @@ -615,14 +638,14 @@ object ChatController { } } - suspend fun apiSetTempFolder(tempFolder: String) { - val r = sendCmd(null, CC.SetTempFolder(tempFolder)) + suspend fun apiSetTempFolder(tempFolder: String, ctrl: ChatCtrl? = null) { + val r = sendCmd(null, CC.SetTempFolder(tempFolder), ctrl) if (r is CR.CmdOk) return throw Error("failed to set temp folder: ${r.responseType} ${r.details}") } - suspend fun apiSetFilesFolder(filesFolder: String) { - val r = sendCmd(null, CC.SetFilesFolder(filesFolder)) + suspend fun apiSetFilesFolder(filesFolder: String, ctrl: ChatCtrl? = null) { + val r = sendCmd(null, CC.SetFilesFolder(filesFolder), ctrl) if (r is CR.CmdOk) return throw Error("failed to set files folder: ${r.responseType} ${r.details}") } @@ -635,6 +658,18 @@ object ChatController { suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetEncryptLocalFiles(enable)) + suspend fun apiSaveAppSettings(settings: AppSettings) { + val r = sendCmd(null, CC.ApiSaveSettings(settings)) + if (r is CR.CmdOk) return + throw Error("failed to set app settings: ${r.responseType} ${r.details}") + } + + suspend fun apiGetAppSettings(settings: AppSettings): AppSettings { + val r = sendCmd(null, CC.ApiGetSettings(settings)) + if (r is CR.AppSettingsR) return r.appSettings + throw Error("failed to get app settings: ${r.responseType} ${r.details}") + } + suspend fun apiSetPQEncryption(enable: Boolean) = sendCommandOkResp(null, CC.ApiSetPQEncryption(enable)) suspend fun apiSetContactPQ(rh: Long?, contactId: Long, enable: Boolean): Contact? { @@ -669,6 +704,11 @@ object ChatController { throw Exception("failed to set storage encryption: ${r.responseType} ${r.details}") } + suspend fun testStorageEncryption(key: String, ctrl: ChatCtrl? = null): Boolean { + val r = sendCmd(null, CC.TestStorageEncryption(key), ctrl) + return r is CR.CmdOk + } + suspend fun apiGetChats(rh: Long?): List { val userId = kotlin.runCatching { currentUserId("apiGetChats") }.getOrElse { return emptyList() } val r = sendCmd(rh, CC.ApiGetChats(userId)) @@ -805,8 +845,8 @@ object ChatController { throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") } - suspend fun apiSetNetworkConfig(cfg: NetCfg): Boolean { - val r = sendCmd(null, CC.APISetNetworkConfig(cfg)) + suspend fun apiSetNetworkConfig(cfg: NetCfg, ctrl: ChatCtrl? = null): Boolean { + val r = sendCmd(null, CC.APISetNetworkConfig(cfg), ctrl) return when (r) { is CR.CmdOk -> true else -> { @@ -1236,6 +1276,36 @@ object ChatController { return false } + suspend fun uploadStandaloneFile(user: UserLike, file: CryptoFile, ctrl: ChatCtrl? = null): Pair { + val r = sendCmd(null, CC.ApiUploadStandaloneFile(user.userId, file), ctrl) + return if (r is CR.SndStandaloneFileCreated) { + r.fileTransferMeta to null + } else { + Log.e(TAG, "uploadStandaloneFile error: $r") + null to r.toString() + } + } + + suspend fun downloadStandaloneFile(user: UserLike, url: String, file: CryptoFile, ctrl: ChatCtrl? = null): Pair { + val r = sendCmd(null, CC.ApiDownloadStandaloneFile(user.userId, url, file), ctrl) + return if (r is CR.RcvStandaloneFileCreated) { + r.rcvFileTransfer to null + } else { + Log.e(TAG, "downloadStandaloneFile error: $r") + null to r.toString() + } + } + + suspend fun standaloneFileInfo(url: String, ctrl: ChatCtrl? = null): MigrationFileLinkData? { + val r = sendCmd(null, CC.ApiStandaloneFileInfo(url), ctrl) + return if (r is CR.StandaloneFileInfo) { + r.fileMeta + } else { + Log.e(TAG, "standaloneFileInfo error: $r") + null + } + } + suspend fun apiReceiveFile(rh: Long?, fileId: Long, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { // -1 here is to override default behavior of providing current remote host id because file can be asked by local device while remote is connected val r = sendCmd(rh, CC.ReceiveFile(fileId, encrypted, inline)) @@ -1274,11 +1344,11 @@ object ChatController { } } - suspend fun apiCancelFile(rh: Long?, fileId: Long): AChatItem? { - val r = sendCmd(rh, CC.CancelFile(fileId)) + suspend fun apiCancelFile(rh: Long?, fileId: Long, ctrl: ChatCtrl? = null): AChatItem? { + val r = sendCmd(rh, CC.CancelFile(fileId), ctrl) return when (r) { - is CR.SndFileCancelled -> r.chatItem - is CR.RcvFileCancelled -> r.chatItem + is CR.SndFileCancelled -> r.chatItem_ + is CR.RcvFileCancelled -> r.chatItem_ else -> { Log.d(TAG, "apiCancelFile bad response: ${r.responseType} ${r.details}") null @@ -1565,8 +1635,8 @@ object ChatController { suspend fun deleteRemoteCtrl(rcId: Long): Boolean = sendCommandOkResp(null, CC.DeleteRemoteCtrl(rcId)) - private suspend fun sendCommandOkResp(rh: Long?, cmd: CC): Boolean { - val r = sendCmd(rh, cmd) + private suspend fun sendCommandOkResp(rh: Long?, cmd: CC, ctrl: ChatCtrl? = null): Boolean { + val r = sendCmd(rh, cmd, ctrl) val ok = r is CR.CmdOk if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r) return ok @@ -1856,11 +1926,16 @@ object ChatController { chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupFile(r.chatItem) } - is CR.RcvFileProgressXFTP -> - chatItemSimpleUpdate(rhId, r.user, r.chatItem) + is CR.RcvFileProgressXFTP -> { + if (r.chatItem_ != null) { + chatItemSimpleUpdate(rhId, r.user, r.chatItem_) + } + } is CR.RcvFileError -> { - chatItemSimpleUpdate(rhId, r.user, r.chatItem) - cleanupFile(r.chatItem) + if (r.chatItem_ != null) { + chatItemSimpleUpdate(rhId, r.user, r.chatItem_) + cleanupFile(r.chatItem_) + } } is CR.SndFileStart -> chatItemSimpleUpdate(rhId, r.user, r.chatItem) @@ -1869,18 +1944,25 @@ object ChatController { cleanupDirectFile(r.chatItem) } is CR.SndFileRcvCancelled -> { - chatItemSimpleUpdate(rhId, r.user, r.chatItem) - cleanupDirectFile(r.chatItem) + if (r.chatItem_ != null) { + chatItemSimpleUpdate(rhId, r.user, r.chatItem_) + cleanupDirectFile(r.chatItem_) + } + } + is CR.SndFileProgressXFTP -> { + if (r.chatItem_ != null) { + chatItemSimpleUpdate(rhId, r.user, r.chatItem_) + } } - is CR.SndFileProgressXFTP -> - chatItemSimpleUpdate(rhId, r.user, r.chatItem) is CR.SndFileCompleteXFTP -> { chatItemSimpleUpdate(rhId, r.user, r.chatItem) cleanupFile(r.chatItem) } is CR.SndFileError -> { - chatItemSimpleUpdate(rhId, r.user, r.chatItem) - cleanupFile(r.chatItem) + if (r.chatItem_ != null) { + chatItemSimpleUpdate(rhId, r.user, r.chatItem_) + cleanupFile(r.chatItem_) + } } is CR.CallInvitation -> { chatModel.callManager.reportNewIncomingCall(r.callInvitation.copy(remoteHostId = rhId)) @@ -2249,21 +2331,13 @@ object ChatController { class SharedPreference(val get: () -> T, set: (T) -> Unit) { val set: (T) -> Unit - private val _state: MutableState by lazy { mutableStateOf(get()) } - val state: State by lazy { _state } + private val _state: MutableState = mutableStateOf(get()) + val state: State = _state init { this.set = { value -> set(value) - try { - _state.value = value - } catch (e: IllegalStateException) { - // Can be `Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied` - Log.i(TAG, e.stackTraceToString()) - withApi { - _state.value = value - } - } + _state.value = value } } } @@ -2295,6 +2369,9 @@ sealed class CC { class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() class ApiStorageEncryption(val config: DBEncryptionConfig): CC() + class TestStorageEncryption(val key: String): CC() + class ApiSaveSettings(val settings: AppSettings): CC() + class ApiGetSettings(val settings: AppSettings): CC() class ApiGetChats(val userId: Long): CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() @@ -2388,6 +2465,9 @@ sealed class CC { class ListRemoteCtrls(): CC() class StopRemoteCtrl(): CC() class DeleteRemoteCtrl(val remoteCtrlId: Long): CC() + class ApiUploadStandaloneFile(val userId: Long, val file: CryptoFile): CC() + class ApiDownloadStandaloneFile(val userId: Long, val url: String, val file: CryptoFile): CC() + class ApiStandaloneFileInfo(val url: String): CC() // misc class ShowVersion(): CC() @@ -2426,6 +2506,9 @@ sealed class CC { is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}" + is TestStorageEncryption -> "/db test key $key" + is ApiSaveSettings -> "/_save app settings ${json.encodeToString(settings)}" + is ApiGetSettings -> "/_get app settings ${json.encodeToString(settings)}" is ApiGetChats -> "/_get chats $userId pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId" @@ -2533,6 +2616,9 @@ sealed class CC { is ListRemoteCtrls -> "/list remote ctrls" is StopRemoteCtrl -> "/stop remote ctrl" is DeleteRemoteCtrl -> "/delete remote ctrl $remoteCtrlId" + is ApiUploadStandaloneFile -> "/_upload $userId ${file.filePath}" + is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}" + is ApiStandaloneFileInfo -> "/_download info $url" is ShowVersion -> "/version" } @@ -2562,6 +2648,9 @@ sealed class CC { is ApiImportArchive -> "apiImportArchive" is ApiDeleteStorage -> "apiDeleteStorage" is ApiStorageEncryption -> "apiStorageEncryption" + is TestStorageEncryption -> "testStorageEncryption" + is ApiSaveSettings -> "apiSaveSettings" + is ApiGetSettings -> "apiGetSettings" is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" is ApiGetChatItemInfo -> "apiGetChatItemInfo" @@ -2654,6 +2743,9 @@ sealed class CC { is ListRemoteCtrls -> "listRemoteCtrls" is StopRemoteCtrl -> "stopRemoteCtrl" is DeleteRemoteCtrl -> "deleteRemoteCtrl" + is ApiUploadStandaloneFile -> "apiUploadStandaloneFile" + is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile" + is ApiStandaloneFileInfo -> "apiStandaloneFileInfo" is ShowVersion -> "showVersion" } @@ -2671,6 +2763,7 @@ sealed class CC { is ApiHideUser -> ApiHideUser(userId, obfuscate(viewPwd)) is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd)) is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd)) + is TestStorageEncryption -> TestStorageEncryption(obfuscate(key)) else -> this } @@ -3796,6 +3889,13 @@ val json = Json { explicitNulls = false } +val jsonShort = Json { + prettyPrint = false + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false +} + val yaml = Yaml(configuration = YamlConfiguration( strictMode = false, encodeDefaults = false, @@ -3985,20 +4085,28 @@ sealed class CR { // receiving file events @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val user: UserRef, val rcvFileTransfer: RcvFileTransfer): CR() - @Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: UserRef, val chatItem: AChatItem): CR() + @Serializable @SerialName("standaloneFileInfo") class StandaloneFileInfo(val fileMeta: MigrationFileLinkData?): CR() + @Serializable @SerialName("rcvStandaloneFileCreated") class RcvStandaloneFileCreated(val user: UserRef, val rcvFileTransfer: RcvFileTransfer): CR() + @Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: UserRef, val chatItem: AChatItem): CR() // send by chats + @Serializable @SerialName("rcvFileProgressXFTP") class RcvFileProgressXFTP(val user: UserRef, val chatItem_: AChatItem?, val receivedSize: Long, val totalSize: Long, val rcvFileTransfer: RcvFileTransfer): CR() @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val user: UserRef, val chatItem: AChatItem): CR() - @Serializable @SerialName("rcvFileCancelled") class RcvFileCancelled(val user: UserRef, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR() + @Serializable @SerialName("rcvStandaloneFileComplete") class RcvStandaloneFileComplete(val user: UserRef, val targetPath: String, val rcvFileTransfer: RcvFileTransfer): CR() + @Serializable @SerialName("rcvFileCancelled") class RcvFileCancelled(val user: UserRef, val chatItem_: AChatItem?, val rcvFileTransfer: RcvFileTransfer): CR() @Serializable @SerialName("rcvFileSndCancelled") class RcvFileSndCancelled(val user: UserRef, val chatItem: AChatItem, val rcvFileTransfer: RcvFileTransfer): CR() - @Serializable @SerialName("rcvFileProgressXFTP") class RcvFileProgressXFTP(val user: UserRef, val chatItem: AChatItem, val receivedSize: Long, val totalSize: Long): CR() - @Serializable @SerialName("rcvFileError") class RcvFileError(val user: UserRef, val chatItem: AChatItem): CR() + @Serializable @SerialName("rcvFileError") class RcvFileError(val user: UserRef, val chatItem_: AChatItem?, val rcvFileTransfer: RcvFileTransfer): CR() // sending file events @Serializable @SerialName("sndFileStart") class SndFileStart(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() @Serializable @SerialName("sndFileComplete") class SndFileComplete(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() - @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() - @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: UserRef, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() - @Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR() + @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: UserRef, val chatItem_: AChatItem?, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() + @Serializable @SerialName("sndStandaloneFileCreated") class SndStandaloneFileCreated(val user: UserRef, val fileTransferMeta: FileTransferMeta): CR() // returned by _upload + @Serializable @SerialName("sndFileStartXFTP") class SndFileStartXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta): CR() // not used + @Serializable @SerialName("sndFileProgressXFTP") class SndFileProgressXFTP(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta, val sentSize: Long, val totalSize: Long): CR() + @Serializable @SerialName("sndFileRedirectStartXFTP") class SndFileRedirectStartXFTP(val user: UserRef, val fileTransferMeta: FileTransferMeta, val redirectMeta: FileTransferMeta): CR() @Serializable @SerialName("sndFileCompleteXFTP") class SndFileCompleteXFTP(val user: UserRef, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta): CR() - @Serializable @SerialName("sndFileError") class SndFileError(val user: UserRef, val chatItem: AChatItem): CR() + @Serializable @SerialName("sndStandaloneFileComplete") class SndStandaloneFileComplete(val user: UserRef, val fileTransferMeta: FileTransferMeta, val rcvURIs: List): CR() + @Serializable @SerialName("sndFileCancelledXFTP") class SndFileCancelledXFTP(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta): CR() + @Serializable @SerialName("sndFileError") class SndFileError(val user: UserRef, val chatItem_: AChatItem?, val fileTransferMeta: FileTransferMeta): CR() // call events @Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR() @Serializable @SerialName("callInvitations") class CallInvitations(val callInvitations: List): CR() @@ -4032,6 +4140,7 @@ sealed class CR { @Serializable @SerialName("chatCmdError") class ChatCmdError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR() @Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List): CR() + @Serializable @SerialName("appSettings") class AppSettingsR(val appSettings: AppSettings): CR() // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -4141,19 +4250,27 @@ sealed class CR { is NewMemberContactSentInv -> "newMemberContactSentInv" is NewMemberContactReceivedInv -> "newMemberContactReceivedInv" is RcvFileAcceptedSndCancelled -> "rcvFileAcceptedSndCancelled" + is StandaloneFileInfo -> "standaloneFileInfo" + is RcvStandaloneFileCreated -> "rcvStandaloneFileCreated" is RcvFileAccepted -> "rcvFileAccepted" is RcvFileStart -> "rcvFileStart" is RcvFileComplete -> "rcvFileComplete" + is RcvStandaloneFileComplete -> "rcvStandaloneFileComplete" is RcvFileCancelled -> "rcvFileCancelled" + is SndStandaloneFileCreated -> "sndStandaloneFileCreated" + is SndFileStartXFTP -> "sndFileStartXFTP" is RcvFileSndCancelled -> "rcvFileSndCancelled" is RcvFileProgressXFTP -> "rcvFileProgressXFTP" + is SndFileRedirectStartXFTP -> "sndFileRedirectStartXFTP" is RcvFileError -> "rcvFileError" - is SndFileCancelled -> "sndFileCancelled" + is SndFileStart -> "sndFileStart" is SndFileComplete -> "sndFileComplete" is SndFileRcvCancelled -> "sndFileRcvCancelled" - is SndFileStart -> "sndFileStart" + is SndFileCancelled -> "sndFileCancelled" is SndFileProgressXFTP -> "sndFileProgressXFTP" is SndFileCompleteXFTP -> "sndFileCompleteXFTP" + is SndStandaloneFileComplete -> "sndStandaloneFileComplete" + is SndFileCancelledXFTP -> "sndFileCancelledXFTP" is SndFileError -> "sndFileError" is CallInvitations -> "callInvitations" is CallInvitation -> "callInvitation" @@ -4183,6 +4300,7 @@ sealed class CR { is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" is ArchiveImported -> "archiveImported" + is AppSettingsR -> "appSettings" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -4195,7 +4313,7 @@ sealed class CR { is ChatStopped -> noDetails() is ApiChats -> withUser(user, json.encodeToString(chats)) is ApiChat -> withUser(user, json.encodeToString(chat)) - is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(AChatItem)}\n${json.encodeToString(chatItemInfo)}") + is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is UserProtoServers -> withUser(user, "servers: ${json.encodeToString(servers)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL)) @@ -4292,20 +4410,28 @@ sealed class CR { is NewMemberContactSentInv -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member") is NewMemberContactReceivedInv -> withUser(user, "contact: $contact\ngroupInfo: $groupInfo\nmember: $member") is RcvFileAcceptedSndCancelled -> withUser(user, noDetails()) + is StandaloneFileInfo -> json.encodeToString(fileMeta) + is RcvStandaloneFileCreated -> noDetails() is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem)) is RcvFileStart -> withUser(user, json.encodeToString(chatItem)) is RcvFileComplete -> withUser(user, json.encodeToString(chatItem)) - is RcvFileCancelled -> withUser(user, json.encodeToString(chatItem)) + is RcvFileCancelled -> withUser(user, json.encodeToString(chatItem_)) is RcvFileSndCancelled -> withUser(user, json.encodeToString(chatItem)) - is RcvFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nreceivedSize: $receivedSize\ntotalSize: $totalSize") - is RcvFileError -> withUser(user, json.encodeToString(chatItem)) - is SndFileCancelled -> json.encodeToString(chatItem) + is RcvFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem_)}\nreceivedSize: $receivedSize\ntotalSize: $totalSize") + is RcvStandaloneFileComplete -> withUser(user, targetPath) + is RcvFileError -> withUser(user, json.encodeToString(chatItem_)) + is SndFileCancelled -> json.encodeToString(chatItem_) + is SndStandaloneFileCreated -> noDetails() + is SndFileStartXFTP -> withUser(user, json.encodeToString(chatItem)) is SndFileComplete -> withUser(user, json.encodeToString(chatItem)) - is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem)) + is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem_)) is SndFileStart -> withUser(user, json.encodeToString(chatItem)) - is SndFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\nsentSize: $sentSize\ntotalSize: $totalSize") + is SndFileProgressXFTP -> withUser(user, "chatItem: ${json.encodeToString(chatItem_)}\nsentSize: $sentSize\ntotalSize: $totalSize") + is SndFileRedirectStartXFTP -> withUser(user, json.encodeToString(redirectMeta)) is SndFileCompleteXFTP -> withUser(user, json.encodeToString(chatItem)) - is SndFileError -> withUser(user, json.encodeToString(chatItem)) + is SndStandaloneFileComplete -> withUser(user, rcvURIs.size.toString()) + is SndFileCancelledXFTP -> withUser(user, json.encodeToString(chatItem_)) + is SndFileError -> withUser(user, json.encodeToString(chatItem_)) is CallInvitations -> "callInvitations: ${json.encodeToString(callInvitations)}" is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}" is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}") @@ -4351,6 +4477,7 @@ sealed class CR { is ChatCmdError -> withUser(user_, chatError.string) is ChatRespError -> withUser(user_, chatError.string) is ArchiveImported -> "${archiveErrors.map { it.string } }" + is AppSettingsR -> json.encodeToString(appSettings) is Response -> json is Invalid -> str } @@ -4764,6 +4891,7 @@ sealed class StoreError { is FileIdNotFoundBySharedMsgId -> "fileIdNotFoundBySharedMsgId" is SndFileNotFoundXFTP -> "sndFileNotFoundXFTP" is RcvFileNotFoundXFTP -> "rcvFileNotFoundXFTP" + is ExtraFileDescrNotFoundXFTP -> "extraFileDescrNotFoundXFTP" is ConnectionNotFound -> "connectionNotFound" is ConnectionNotFoundById -> "connectionNotFoundById" is ConnectionNotFoundByMemberId -> "connectionNotFoundByMemberId" @@ -4822,6 +4950,7 @@ sealed class StoreError { @Serializable @SerialName("fileIdNotFoundBySharedMsgId") class FileIdNotFoundBySharedMsgId(val sharedMsgId: String): StoreError() @Serializable @SerialName("sndFileNotFoundXFTP") class SndFileNotFoundXFTP(val agentSndFileId: String): StoreError() @Serializable @SerialName("rcvFileNotFoundXFTP") class RcvFileNotFoundXFTP(val agentRcvFileId: String): StoreError() + @Serializable @SerialName("extraFileDescrNotFoundXFTP") class ExtraFileDescrNotFoundXFTP(val fileId: Long): StoreError() @Serializable @SerialName("connectionNotFound") class ConnectionNotFound(val agentConnId: String): StoreError() @Serializable @SerialName("connectionNotFoundById") class ConnectionNotFoundById(val connId: Long): StoreError() @Serializable @SerialName("connectionNotFoundByMemberId") class ConnectionNotFoundByMemberId(val groupMemberId: Long): StoreError() @@ -5167,3 +5296,205 @@ enum class NotificationsMode() { val default: NotificationsMode = SERVICE } } + +@Serializable +data class AppSettings( + var networkConfig: NetCfg? = null, + var privacyEncryptLocalFiles: Boolean? = null, + var privacyAcceptImages: Boolean? = null, + var privacyLinkPreviews: Boolean? = null, + var privacyShowChatPreviews: Boolean? = null, + var privacySaveLastDraft: Boolean? = null, + var privacyProtectScreen: Boolean? = null, + var notificationMode: AppSettingsNotificationMode? = null, + var notificationPreviewMode: AppSettingsNotificationPreviewMode? = null, + var webrtcPolicyRelay: Boolean? = null, + var webrtcICEServers: List? = null, + var confirmRemoteSessions: Boolean? = null, + var connectRemoteViaMulticast: Boolean? = null, + var connectRemoteViaMulticastAuto: Boolean? = null, + var developerTools: Boolean? = null, + var confirmDBUpgrades: Boolean? = null, + var androidCallOnLockScreen: AppSettingsLockScreenCalls? = null, + var iosCallKitEnabled: Boolean? = null, + var iosCallKitCallsInRecents: Boolean? = null, +) { + fun prepareForExport(): AppSettings { + val empty = AppSettings() + val def = 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 + } + + fun importIntoApp() { + val def = appPreferences + var net = networkConfig?.copy() + if (net != null) { + // migrating from iOS BUT shouldn't be here ever because it should be changed on migration stage + if (net.hostMode == HostMode.Onion) { + net = net.copy(hostMode = HostMode.Public, requiredHostMode = true) + } + setNetCfg(net) + } + privacyEncryptLocalFiles?.let { def.privacyEncryptLocalFiles.set(it) } + privacyAcceptImages?.let { def.privacyAcceptImages.set(it) } + privacyLinkPreviews?.let { def.privacyLinkPreviews.set(it) } + privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) } + privacySaveLastDraft?.let { def.privacySaveLastDraft.set(it) } + privacyProtectScreen?.let { def.privacyProtectScreen.set(it) } + notificationMode?.let { def.notificationsMode.set(it.toNotificationsMode()) } + notificationPreviewMode?.let { def.notificationPreviewMode.set(it.toNotificationPreviewMode().name) } + webrtcPolicyRelay?.let { def.webrtcPolicyRelay.set(it) } + webrtcICEServers?.let { def.webrtcIceServers.set(it.joinToString(separator = "\n")) } + confirmRemoteSessions?.let { def.confirmRemoteSessions.set(it) } + connectRemoteViaMulticast?.let { def.connectRemoteViaMulticast.set(it) } + connectRemoteViaMulticastAuto?.let { def.connectRemoteViaMulticastAuto.set(it) } + developerTools?.let { def.developerTools.set(it) } + confirmDBUpgrades?.let { def.confirmDBUpgrades.set(it) } + androidCallOnLockScreen?.let { def.callOnLockScreen.set(it.toCallOnLockScreen()) } + iosCallKitEnabled?.let { def.iosCallKitEnabled.set(it) } + iosCallKitCallsInRecents?.let { def.iosCallKitCallsInRecents.set(it) } + } + + companion object { + val defaults: AppSettings + get() = AppSettings( + networkConfig = NetCfg.defaults, + privacyEncryptLocalFiles = true, + privacyAcceptImages = true, + privacyLinkPreviews = true, + privacyShowChatPreviews = true, + privacySaveLastDraft = true, + privacyProtectScreen = false, + notificationMode = AppSettingsNotificationMode.INSTANT, + notificationPreviewMode = AppSettingsNotificationPreviewMode.MESSAGE, + webrtcPolicyRelay = true, + webrtcICEServers = emptyList(), + confirmRemoteSessions = false, + connectRemoteViaMulticast = true, + connectRemoteViaMulticastAuto = true, + developerTools = false, + confirmDBUpgrades = false, + androidCallOnLockScreen = AppSettingsLockScreenCalls.SHOW, + iosCallKitEnabled = true, + iosCallKitCallsInRecents = false + ) + + val current: AppSettings + get() { + val def = appPreferences + return defaults.copy( + networkConfig = getNetCfg(), + privacyEncryptLocalFiles = def.privacyEncryptLocalFiles.get(), + privacyAcceptImages = def.privacyAcceptImages.get(), + privacyLinkPreviews = def.privacyLinkPreviews.get(), + privacyShowChatPreviews = def.privacyShowChatPreviews.get(), + privacySaveLastDraft = def.privacySaveLastDraft.get(), + privacyProtectScreen = def.privacyProtectScreen.get(), + notificationMode = AppSettingsNotificationMode.from(def.notificationsMode.get()), + notificationPreviewMode = AppSettingsNotificationPreviewMode.from(NotificationPreviewMode.valueOf(def.notificationPreviewMode.get()!!)), + webrtcPolicyRelay = def.webrtcPolicyRelay.get(), + webrtcICEServers = def.webrtcIceServers.get()?.lines(), + confirmRemoteSessions = def.confirmRemoteSessions.get(), + connectRemoteViaMulticast = def.connectRemoteViaMulticast.get(), + connectRemoteViaMulticastAuto = def.connectRemoteViaMulticastAuto.get(), + developerTools = def.developerTools.get(), + confirmDBUpgrades = def.confirmDBUpgrades.get(), + androidCallOnLockScreen = AppSettingsLockScreenCalls.from(def.callOnLockScreen.get()), + iosCallKitEnabled = def.iosCallKitEnabled.get(), + iosCallKitCallsInRecents = def.iosCallKitCallsInRecents.get(), + ) + } + } +} + +@Serializable +enum class AppSettingsNotificationMode { + @SerialName("off") OFF, + @SerialName("periodic") PERIODIC, + @SerialName("instant") INSTANT; + + fun toNotificationsMode(): NotificationsMode = + when (this) { + INSTANT -> NotificationsMode.SERVICE + PERIODIC -> NotificationsMode.PERIODIC + OFF -> NotificationsMode.OFF + } + + companion object { + fun from(mode: NotificationsMode): AppSettingsNotificationMode = + when (mode) { + NotificationsMode.SERVICE -> INSTANT + NotificationsMode.PERIODIC -> PERIODIC + NotificationsMode.OFF -> OFF + } + } +} + +@Serializable +enum class AppSettingsNotificationPreviewMode { + @SerialName("message") MESSAGE, + @SerialName("contact") CONTACT, + @SerialName("hidden") HIDDEN; + + fun toNotificationPreviewMode(): NotificationPreviewMode = + when (this) { + MESSAGE -> NotificationPreviewMode.MESSAGE + CONTACT -> NotificationPreviewMode.CONTACT + HIDDEN -> NotificationPreviewMode.HIDDEN + } + + companion object { + val default: AppSettingsNotificationPreviewMode = MESSAGE + + fun from(mode: NotificationPreviewMode): AppSettingsNotificationPreviewMode = + when (mode) { + NotificationPreviewMode.MESSAGE -> MESSAGE + NotificationPreviewMode.CONTACT -> CONTACT + NotificationPreviewMode.HIDDEN -> HIDDEN + } + } +} + +@Serializable +enum class AppSettingsLockScreenCalls { + @SerialName("disable") DISABLE, + @SerialName("show") SHOW, + @SerialName("accept") ACCEPT; + + fun toCallOnLockScreen(): CallOnLockScreen = + when (this) { + DISABLE -> CallOnLockScreen.DISABLE + SHOW -> CallOnLockScreen.SHOW + ACCEPT -> CallOnLockScreen.ACCEPT + } + + companion object { + val default = SHOW + + fun from(mode: CallOnLockScreen): AppSettingsLockScreenCalls = + when (mode) { + CallOnLockScreen.DISABLE -> DISABLE + CallOnLockScreen.SHOW -> SHOW + CallOnLockScreen.ACCEPT -> ACCEPT + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 7e2ba462c9..1a186ce8ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -5,10 +5,12 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.currentUser import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword +import chat.simplex.common.views.helpers.DatabaseUtils.randomDatabasePassword import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.serialization.decodeFromString +import java.io.File import java.nio.ByteBuffer // ghc's rts @@ -137,6 +139,33 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } } +fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: MigrationConfirmation = MigrationConfirmation.Error): Pair { + val dbKey = key ?: randomDatabasePassword() + Log.d(TAG, "chatInitTemporaryDatabase path: $dbPath") + val migrated = chatMigrateInit(dbPath, dbKey, confirmation.value) + val res = runCatching { + json.decodeFromString(migrated[0] as String) + }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } + + return res to migrated[1] as ChatCtrl +} + +fun chatInitControllerRemovingDatabases() { + val dbPath = dbAbsolutePrefixPath + val dbKey = randomDatabasePassword() + Log.d(TAG, "chatInitControllerRemovingDatabases path: $dbPath") + val migrated = chatMigrateInit(dbPath, dbKey, MigrationConfirmation.Error.value) + val res = runCatching { + json.decodeFromString(migrated[0] as String) + }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } + + val ctrl = migrated[1] as Long + chatController.ctrl = ctrl + // We need only controller, not databases + File(dbPath + "_chat.db").delete() + File(dbPath + "_agent.db").delete() +} + fun showStartChatAfterRestartAlert(): CompletableDeferred { val deferred = CompletableDeferred() AlertManager.shared.showAlertDialog( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index a6c93cc2f3..7ae2ab23dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -66,6 +66,8 @@ fun copyBytesToFile(bytes: ByteArrayInputStream, to: URI, finally: () -> Unit) { } } +fun getMigrationTempFilesDirectory(): File = File(dataDir, "migration_temp_files") + fun getAppFilePath(fileName: String): String { val rh = chatModel.currentRemoteHost.value val s = File.separator diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 8ce92f6154..03878a19d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -1,5 +1,6 @@ package chat.simplex.common.platform +import androidx.compose.runtime.Composable import chat.simplex.common.model.ChatId import chat.simplex.common.model.NotificationsMode @@ -16,6 +17,7 @@ interface PlatformInterface { fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {} fun androidPictureInPictureAllowed(): Boolean = true fun androidCallEnded() {} + @Composable fun androidLockPortraitOrientation() {} suspend fun androidAskToAllowBackgroundCalls(): Boolean = true } /** diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index cce4307d1f..64741f7466 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -379,6 +379,30 @@ fun ChatItemView( } } + @Composable + fun E2EEInfoNoPQText() { + Text( + buildAnnotatedString { + withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_no_pq)) } + }, + Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + ) + } + + @Composable + fun DirectE2EEInfoText(e2EEInfo: E2EEInfo) { + if (e2EEInfo.pqEnabled) { + Text( + buildAnnotatedString { + withStyle(chatEventStyle) { append(annotatedStringResource(MR.strings.e2ee_info_pq)) } + }, + Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + ) + } else { + E2EEInfoNoPQText() + } + } + when (val c = cItem.content) { is CIContent.SndMsgContent -> ContentItem() is CIContent.RcvMsgContent -> ContentItem() @@ -452,11 +476,10 @@ fun ChatItemView( is CIContent.SndModerated -> DeletedItem() is CIContent.RcvModerated -> DeletedItem() is CIContent.RcvBlocked -> DeletedItem() - // TODO proper items - is CIContent.SndDirectE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) - is CIContent.RcvDirectE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) - is CIContent.SndGroupE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) - is CIContent.RcvGroupE2EEInfo -> CIEventView(buildAnnotatedString { append(cItem.content.text) }) + is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() is CIContent.InvalidJSON -> CIInvalidJSONView(c.json) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index 7bd9fbc66f..7ee9442b11 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -1,9 +1,8 @@ package chat.simplex.common.views.database import SectionBottomSpacer -import SectionItemView import SectionItemViewSpaceBetween -import SectionTextFooter +import SectionSpacer import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource @@ -24,20 +23,22 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.* import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.* import chat.simplex.common.model.* +import chat.simplex.common.platform.appPreferences import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.model.* -import chat.simplex.common.platform.appPlatform +import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.datetime.Clock import kotlin.math.log2 @Composable -fun DatabaseEncryptionView(m: ChatModel) { +fun DatabaseEncryptionView(m: ChatModel, migration: Boolean) { val progressIndicator = remember { mutableStateOf(false) } val prefs = m.controller.appPrefs val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) } @@ -61,9 +62,10 @@ fun DatabaseEncryptionView(m: ChatModel) { storedKey, initialRandomDBPassphrase, progressIndicator, + migration, onConfirmEncrypt = { withLongRunningApi { - encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator) + encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator, migration) } } ) @@ -95,24 +97,34 @@ fun DatabaseEncryptionLayout( storedKey: MutableState, initialRandomDBPassphrase: MutableState, progressIndicator: MutableState, + migration: Boolean, onConfirmEncrypt: () -> Unit, ) { Column( - Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + if (!migration) Modifier.fillMaxWidth().verticalScroll(rememberScrollState()) else Modifier.fillMaxWidth(), ) { - AppBarTitle(stringResource(MR.strings.database_passphrase)) - SectionView(null) { - SavePassphraseSetting(useKeychain.value, initialRandomDBPassphrase.value, storedKey.value, progressIndicator.value) { checked -> + if (!migration) { + AppBarTitle(stringResource(MR.strings.database_passphrase)) + } else { + ChatStoppedView() + SectionSpacer() + } + SectionView(if (migration) generalGetString(MR.strings.database_passphrase).uppercase() else null) { + SavePassphraseSetting( + useKeychain.value, + initialRandomDBPassphrase.value, + storedKey.value, + enabled = (!initialRandomDBPassphrase.value && !progressIndicator.value) || migration + ) { checked -> if (checked) { - setUseKeychain(true, useKeychain, prefs) - } else if (storedKey.value) { + setUseKeychain(true, useKeychain, prefs, migration) + } else if (storedKey.value && !migration) { + // Don't show in migration process since it will remove the key after successful encryption removePassphraseAlert { - DatabaseUtils.ksDatabasePassword.remove() - setUseKeychain(false, useKeychain, prefs) - storedKey.value = false + removePassphraseFromKeyChain(useKeychain, prefs, storedKey, false) } } else { - setUseKeychain(false, useKeychain, prefs) + setUseKeychain(false, useKeychain, prefs, migration) } } @@ -169,12 +181,12 @@ fun DatabaseEncryptionLayout( ) SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) { - Text(generalGetString(MR.strings.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) + Text(generalGetString(if (migration) MR.strings.set_passphrase else MR.strings.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } } Column { - DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase) + DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase, migration) } SectionBottomSpacer() } @@ -211,8 +223,9 @@ expect fun SavePassphraseSetting( useKeychain: Boolean, initialRandomDBPassphrase: Boolean, storedKey: Boolean, - progressIndicator: Boolean, minHeight: Dp = TextFieldDefaults.MinHeight, + enabled: Boolean, + smallPadding: Boolean = true, onCheckedChange: (Boolean) -> Unit, ) @@ -222,8 +235,18 @@ expect fun DatabaseEncryptionFooter( chatDbEncrypted: Boolean?, storedKey: MutableState, initialRandomDBPassphrase: MutableState, + migration: Boolean, ) +@Composable +fun ChatStoppedView() { + SettingsActionItem( + icon = painterResource(MR.images.ic_report_filled), + text = stringResource(MR.strings.chat_is_stopped), + iconColor = Color.Red, + ) +} + fun resetFormAfterEncryption( m: ChatModel, initialRandomDBPassphrase: MutableState, @@ -242,9 +265,18 @@ fun resetFormAfterEncryption( m.controller.appPrefs.initialRandomDBPassphrase.set(false) } -fun setUseKeychain(value: Boolean, useKeychain: MutableState, prefs: AppPreferences) { +fun setUseKeychain(value: Boolean, useKeychain: MutableState, prefs: AppPreferences, migration: Boolean) { useKeychain.value = value - prefs.storeDBPassphrase.set(value) + // Postpone it when migrating to the end of encryption process + if (!migration) { + prefs.storeDBPassphrase.set(value) + } +} + +private fun removePassphraseFromKeyChain(useKeychain: MutableState, prefs: AppPreferences, storedKey: MutableState, migration: Boolean) { + DatabaseUtils.ksDatabasePassword.remove() + setUseKeychain(false, useKeychain, prefs, migration) + storedKey.value = false } fun storeSecurelySaved() = generalGetString(MR.strings.store_passphrase_securely) @@ -267,6 +299,7 @@ fun PassphraseField( isValid: (String) -> Boolean, keyboardActions: KeyboardActions = KeyboardActions(), dependsOn: State? = null, + requestFocus: Boolean = false, ) { var valid by remember { mutableStateOf(validKey(key.value)) } var showKey by remember { mutableStateOf(false) } @@ -295,6 +328,7 @@ fun PassphraseField( val color = MaterialTheme.colors.onBackground val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize) val interactionSource = remember { MutableInteractionSource() } + val focusRequester = remember { FocusRequester() } BasicTextField( value = state.value, modifier = modifier @@ -304,7 +338,8 @@ fun PassphraseField( .defaultMinSize( minWidth = TextFieldDefaults.MinWidth, minHeight = TextFieldDefaults.MinHeight - ), + ) + .focusRequester(focusRequester), onValueChange = { state.value = it key.value = it.text @@ -347,6 +382,12 @@ fun PassphraseField( ) } ) + LaunchedEffect(Unit) { + if (requestFocus) { + delay(200) + focusRequester.requestFocus() + } + } LaunchedEffect(Unit) { snapshotFlow { dependsOn?.value } .distinctUntilChanged() @@ -363,13 +404,17 @@ suspend fun encryptDatabase( initialRandomDBPassphrase: MutableState, useKeychain: MutableState, storedKey: MutableState, - progressIndicator: MutableState + progressIndicator: MutableState, + migration: Boolean, ): Boolean { val m = ChatModel val prefs = ChatController.appPrefs progressIndicator.value = true return try { prefs.encryptionStartedAt.set(Clock.System.now()) + if (!m.chatDbChanged.value) { + m.controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) + } val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value) prefs.encryptionStartedAt.set(null) val sqliteError = ((error?.chatError as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError @@ -393,9 +438,14 @@ suspend fun encryptDatabase( } else -> { val new = newKey.value + if (migration) { + appPreferences.storeDBPassphrase.set(useKeychain.value) + } resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) if (useKeychain.value) { DatabaseUtils.ksDatabasePassword.set(new) + } else if (migration) { + removePassphraseFromKeyChain(useKeychain, prefs, storedKey, true) } operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted)) @@ -474,6 +524,7 @@ fun PreviewDatabaseEncryptionLayout() { storedKey = remember { mutableStateOf(true) }, initialRandomDBPassphrase = remember { mutableStateOf(true) }, progressIndicator = remember { mutableStateOf(false) }, + migration = false, onConfirmEncrypt = {}, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index 0c208c06e8..a22e6399f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -206,6 +206,14 @@ private fun runChat( is DBMigrationResult.OK -> { platform.androidChatStartedAfterBeingOff() } + null -> {} + else -> showErrorOnMigrationIfNeeded(status) + } +} + +fun showErrorOnMigrationIfNeeded(status: DBMigrationResult) = + when (status) { + is DBMigrationResult.OK -> {} is DBMigrationResult.ErrorNotADatabase -> AlertManager.shared.showAlertMsg(generalGetString(MR.strings.wrong_passphrase_title), generalGetString(MR.strings.enter_correct_passphrase)) is DBMigrationResult.ErrorSQL -> @@ -217,9 +225,7 @@ private fun runChat( is DBMigrationResult.InvalidConfirmation -> AlertManager.shared.showAlertMsg(generalGetString(MR.strings.invalid_migration_confirmation)) is DBMigrationResult.ErrorMigration -> {} - null -> {} } -} private fun shouldShowRestoreDbButton(prefs: AppPreferences): Boolean { val startedAt = prefs.encryptionStartedAt.get() ?: return false @@ -246,7 +252,7 @@ private fun restoreDb(restoreDbFromBackup: MutableState, prefs: AppPref } } -private fun mtrErrorDescription(err: MTRError): String = +fun mtrErrorDescription(err: MTRError): String = when (err) { is MTRError.NoDown -> String.format(generalGetString(MR.strings.mtr_error_no_down_migration), err.dbMigrations.joinToString(", ")) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 8680c98d46..8d7d9f8166 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -211,7 +211,7 @@ fun DatabaseLayout( if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_lock), stringResource(MR.strings.database_passphrase), - click = showSettingsModal() { DatabaseEncryptionView(it) }, + click = showSettingsModal() { DatabaseEncryptionView(it, false) }, iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, disabled = operationsDisabled ) @@ -486,6 +486,7 @@ fun deleteChatDatabaseFilesAndState() { filesDir.mkdir() remoteHostsDir.deleteRecursively() tmpDir.deleteRecursively() + getMigrationTempFilesDirectory().deleteRecursively() tmpDir.mkdir() DatabaseUtils.ksDatabasePassword.remove() controller.appPrefs.storeDBPassphrase.set(true) @@ -509,7 +510,7 @@ private fun exportArchive( progressIndicator.value = true withLongRunningApi { try { - val archiveFile = exportChatArchive(m, chatArchiveName, chatArchiveTime, chatArchiveFile) + val archiveFile = exportChatArchive(m, null, chatArchiveName, chatArchiveTime, chatArchiveFile) chatArchiveFile.value = archiveFile saveArchiveLauncher.launch(archiveFile.substringAfterLast(File.separator)) progressIndicator.value = false @@ -520,8 +521,9 @@ private fun exportArchive( } } -private suspend fun exportChatArchive( +suspend fun exportChatArchive( m: ChatModel, + storagePath: File?, chatArchiveName: MutableState, chatArchiveTime: MutableState, chatArchiveFile: MutableState @@ -529,13 +531,19 @@ private suspend fun exportChatArchive( val archiveTime = Clock.System.now() val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant())) val archiveName = "simplex-chat.$ts.zip" - val archivePath = "${filesDir.absolutePath}${File.separator}$archiveName" + val archivePath = "${(storagePath ?: filesDir).absolutePath}${File.separator}$archiveName" val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString()) + // Settings should be saved before changing a passphrase, otherwise the database needs to be migrated first + if (!m.chatDbChanged.value) { + controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) + } m.controller.apiExportArchive(config) - deleteOldArchive(m) - m.controller.appPrefs.chatArchiveName.set(archiveName) + if (storagePath == null) { + deleteOldArchive(m) + m.controller.appPrefs.chatArchiveName.set(archiveName) + m.controller.appPrefs.chatArchiveTime.set(archiveTime) + } chatArchiveName.value = archiveName - m.controller.appPrefs.chatArchiveTime.set(archiveTime) chatArchiveTime.value = archiveTime chatArchiveFile.value = archivePath return archivePath diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index 52dc2c0658..0ad7af439f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -64,7 +64,7 @@ object DatabaseUtils { return dbKey } - private fun randomDatabasePassword(): String { + fun randomDatabasePassword(): String { val s = ByteArray(32) SecureRandom().nextBytes(s) return s.toBase64StringForPassphrase().replace("\n", "") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt index 104a01150f..675584ae13 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt @@ -15,7 +15,7 @@ fun DefaultProgressView(description: String?) { Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator( Modifier - .padding(bottom = DEFAULT_PADDING) + .padding(bottom = if (description != null) DEFAULT_PADDING else 0.dp) .size(30.dp), color = MaterialTheme.colors.secondary, strokeWidth = 2.5.dp diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index ce4d8da47f..887a5bfdd9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -19,17 +19,18 @@ import kotlin.math.min fun ModalView( close: () -> Unit, showClose: Boolean = true, + enableClose: Boolean = true, background: Color = MaterialTheme.colors.background, modifier: Modifier = Modifier, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit, ) { if (showClose) { - BackHandler(onBack = close) + BackHandler(enabled = enableClose, onBack = close) } Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) { Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) { - CloseSheetBar(close, showClose, endButtons = endButtons) + CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons) Box(modifier) { content() } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt new file mode 100644 index 0000000000..52698a6e47 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromAnotherDevice.kt @@ -0,0 +1,721 @@ +package chat.simplex.common.views.migration + +import SectionBottomSpacer +import SectionItemView +import SectionSpacer +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import chat.simplex.common.model.* +import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_MIGRATION_STAGE +import chat.simplex.common.model.ChatController.getNetCfg +import chat.simplex.common.model.ChatController.startChat +import chat.simplex.common.model.ChatCtrl +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.database.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword +import chat.simplex.common.views.newchat.QRCodeScanner +import chat.simplex.common.views.onboarding.OnboardingStage +import chat.simplex.common.views.usersettings.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* +import kotlinx.datetime.Clock +import kotlinx.datetime.toJavaInstant +import kotlinx.serialization.* +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.max + +@Serializable +sealed class MigrationFromAnotherDeviceState { + @Serializable @SerialName("onion") data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationFromAnotherDeviceState() + @Serializable @SerialName("downloadProgress") data class DownloadProgress(val link: String, val archiveName: String, val netCfg: NetCfg): MigrationFromAnotherDeviceState() + @Serializable @SerialName("archiveImport") data class ArchiveImport(val archiveName: String, val netCfg: NetCfg): MigrationFromAnotherDeviceState() + @Serializable @SerialName("passphrase") data class Passphrase(val netCfg: NetCfg): MigrationFromAnotherDeviceState() + + companion object { + // Here we check whether it's needed to show migration process after app restart or not + // It's important to NOT show the process when archive was corrupted/not fully downloaded + fun transform(): MigrationFromAnotherDeviceState? { + val stage = settings.getStringOrNull(SHARED_PREFS_MIGRATION_STAGE) + var state: MigrationFromAnotherDeviceState? = if (stage != null) json.decodeFromString(stage) else null + if (state is DownloadProgress) { + // No migration happens at the moment actually since archive were not downloaded fully + Log.e(TAG, "MigrateFromDevice: archive wasn't fully downloaded, removed broken file") + state = null + } else if (state is Onion) { + state = null + } else if (state is ArchiveImport && !File(getMigrationTempFilesDirectory(), state.archiveName).exists()) { + Log.e(TAG, "MigrateFromDevice: archive was removed unintentionally or state is broken, dropping migration") + state = null + } + if (state == null) { + settings.remove(SHARED_PREFS_MIGRATION_STAGE) + getMigrationTempFilesDirectory().deleteRecursively() + } + return state + } + + fun save(state: MigrationFromAnotherDeviceState?) { + if (state != null) { + appPreferences.migrationStage.set(json.encodeToString(state)) + } else { + appPreferences.migrationStage.set(null) + } + chatModel.migrationState.value = state + } + } +} + +@Serializable +private sealed class MigrationState { + @Serializable object PasteOrScanLink: MigrationState() + @Serializable data class Onion(val link: String, val socksProxy: String?, val hostMode: HostMode, val requiredHostMode: Boolean): MigrationState() + @Serializable data class DatabaseInit(val link: String, val netCfg: NetCfg): MigrationState() + @Serializable data class LinkDownloading(val link: String, val ctrl: ChatCtrl, val user: User, val archivePath: String, val netCfg: NetCfg): MigrationState() + @Serializable data class DownloadProgress(val downloadedBytes: Long, val totalBytes: Long, val fileId: Long, val link: String, val archivePath: String, val netCfg: NetCfg, val ctrl: ChatCtrl?): MigrationState() + @Serializable data class DownloadFailed(val totalBytes: Long, val link: String, val archivePath: String, val netCfg: NetCfg): MigrationState() + @Serializable data class ArchiveImport(val archivePath: String, val netCfg: NetCfg): MigrationState() + @Serializable data class ArchiveImportFailed(val archivePath: String, val netCfg: NetCfg): MigrationState() + @Serializable data class Passphrase(val passphrase: String, val netCfg: NetCfg): MigrationState() + @Serializable data class MigrationConfirmation(val status: DBMigrationResult, val passphrase: String, val useKeychain: Boolean, val netCfg: NetCfg): MigrationState() + @Serializable data class Migration(val passphrase: String, val confirmation: chat.simplex.common.views.helpers.MigrationConfirmation, val useKeychain: Boolean, val netCfg: NetCfg): MigrationState() +} + +private var MutableState.state: MigrationState + get() = value + set(v) { value = v } + +@Composable +fun ModalData.MigrateFromAnotherDeviceView(state: MigrationFromAnotherDeviceState? = null, close: () -> Unit) { + val migrationState = rememberSaveable(stateSaver = serializableSaver()) { + mutableStateOf( + when (state) { + null -> MigrationState.PasteOrScanLink + is MigrationFromAnotherDeviceState.Onion -> { + MigrationState.Onion(state.link, state.socksProxy, state.hostMode, state.requiredHostMode) + } + is MigrationFromAnotherDeviceState.DownloadProgress -> { + val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName) + // SHOULDN'T BE HERE because the app checks this before opening migration screen and will not open it in this case. + // See analyzeMigrationState() + MigrationState.DownloadFailed(totalBytes = 0, link = state.link, archivePath = archivePath.absolutePath, state.netCfg) + } + is MigrationFromAnotherDeviceState.ArchiveImport -> { + val archivePath = File(getMigrationTempFilesDirectory(), state.archiveName) + MigrationState.ArchiveImportFailed(archivePath.absolutePath, state.netCfg) + } + is MigrationFromAnotherDeviceState.Passphrase -> { + MigrationState.Passphrase("", state.netCfg) + } + } + ) + } + // Prevent from hiding the view until migration is finished or app deleted + val backDisabled = remember { + derivedStateOf { + val s = chatModel.migrationState.value + s is MigrationFromAnotherDeviceState.ArchiveImport || + s is MigrationFromAnotherDeviceState.Passphrase || + migrationState.value is MigrationState.DatabaseInit + } + } + val chatReceiver = remember { mutableStateOf(null as MigrationFromChatReceiver?) } + ModalView( + enableClose = !backDisabled.value, + close = { + withBGApi { + migrationState.cleanUpOnBack(chatReceiver.value) + close() + } + }, + ) { + MigrateFromAnotherDeviceLayout( + migrationState = migrationState, + chatReceiver = chatReceiver, + close = close, + ) + } +} + +@Composable +private fun ModalData.MigrateFromAnotherDeviceLayout( + migrationState: MutableState, + chatReceiver: MutableState, + close: () -> Unit, +) { + val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) } + + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).height(IntrinsicSize.Max), + ) { + AppBarTitle(stringResource(MR.strings.migrate_here)) + SectionByState(migrationState, tempDatabaseFile.value, chatReceiver, close) + SectionBottomSpacer() + } + platform.androidLockPortraitOrientation() +} + +@Composable +private fun ModalData.SectionByState( + migrationState: MutableState, + tempDatabaseFile: File, + chatReceiver: MutableState, + close: () -> Unit +) { + when (val s = migrationState.value) { + is MigrationState.PasteOrScanLink -> migrationState.PasteOrScanLinkView() + is MigrationState.Onion -> OnionView(s.link, s.socksProxy, s.hostMode, s.requiredHostMode, migrationState) + is MigrationState.DatabaseInit -> migrationState.DatabaseInitView(s.link, tempDatabaseFile, s.netCfg) + is MigrationState.LinkDownloading -> migrationState.LinkDownloadingView(s.link, s.ctrl, s.user, s.archivePath, tempDatabaseFile, chatReceiver, s.netCfg) + is MigrationState.DownloadProgress -> DownloadProgressView(s.downloadedBytes, totalBytes = s.totalBytes) + is MigrationState.DownloadFailed -> migrationState.DownloadFailedView(s.link, chatReceiver.value, s.archivePath, s.netCfg) + is MigrationState.ArchiveImport -> migrationState.ArchiveImportView(s.archivePath, s.netCfg) + is MigrationState.ArchiveImportFailed -> migrationState.ArchiveImportFailedView(s.archivePath, s.netCfg) + is MigrationState.Passphrase -> migrationState.PassphraseEnteringView(currentKey = s.passphrase, s.netCfg) + is MigrationState.MigrationConfirmation -> migrationState.MigrationConfirmationView(s.status, s.passphrase, s.useKeychain, s.netCfg) + is MigrationState.Migration -> MigrationView(s.passphrase, s.confirmation, s.useKeychain, s.netCfg, close) + } +} + +@Composable +private fun MutableState.PasteOrScanLinkView() { + if (appPlatform.isAndroid) { + SectionView(stringResource(MR.strings.scan_QR_code).replace('\n', ' ').uppercase()) { + QRCodeScanner(showQRCodeScanner = remember { mutableStateOf(true) }) { text -> + withBGApi { checkUserLink(text) } + } + } + SectionSpacer() + } + + if (appPlatform.isDesktop || appPreferences.developerTools.get()) { + SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link).uppercase()) { + PasteLinkView() + } + } +} + +@Composable +private fun MutableState.PasteLinkView() { + val clipboard = LocalClipboardManager.current + SectionItemView({ + val str = clipboard.getText()?.text ?: return@SectionItemView + withBGApi { checkUserLink(str) } + }) { + Text(stringResource(MR.strings.tap_to_paste_link)) + } +} + +@Composable +private fun ModalData.OnionView(link: String, socksProxy: String?, hostMode: HostMode, requiredHostMode: Boolean, state: MutableState) { + val onionHosts = remember { stateGetOrPut("onionHosts") { + getNetCfg().copy(socksProxy = socksProxy, hostMode = hostMode, requiredHostMode = requiredHostMode).onionHosts + } } + val networkUseSocksProxy = remember { stateGetOrPut("networkUseSocksProxy") { socksProxy != null } } + val sessionMode = remember { stateGetOrPut("sessionMode") { TransportSessionMode.User} } + val networkProxyHostPort = remember { stateGetOrPut("networkHostProxyPort") { + var proxy = (socksProxy ?: chatModel.controller.appPrefs.networkProxyHostPort.get()) + if (proxy?.startsWith(":") == true) proxy = "localhost$proxy" + proxy + } + } + val proxyPort = remember { derivedStateOf { networkProxyHostPort.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } } + + val netCfg = rememberSaveable(stateSaver = serializableSaver()) { + mutableStateOf(getNetCfg().withOnionHosts(onionHosts.value).copy(socksProxy = socksProxy, sessionMode = sessionMode.value)) + } + + SectionView(stringResource(MR.strings.migration_from_device_confirm_network_settings).uppercase()) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_check), + text = stringResource(MR.strings.migration_from_device_apply_onion), + textColor = MaterialTheme.colors.primary, + click = { + val updated = netCfg.value + .withOnionHosts(onionHosts.value) + .withHostPort(if (networkUseSocksProxy.value) networkProxyHostPort.value else null, null) + .copy( + sessionMode = sessionMode.value + ) + withBGApi { + state.value = MigrationState.DatabaseInit(link, updated) + } + } + ){} + SectionTextFooter(stringResource(MR.strings.migration_from_device_confirm_network_settings_footer)) + } + + SectionSpacer() + + val networkProxyHostPortPref = SharedPreference(get = { networkProxyHostPort.value }, set = { + networkProxyHostPort.value = it + }) + SectionView(stringResource(MR.strings.network_settings_title).uppercase()) { + OnionRelatedLayout( + appPreferences.developerTools.get(), + networkUseSocksProxy, + onionHosts, + sessionMode, + networkProxyHostPortPref, + proxyPort, + toggleSocksProxy = { enable -> + networkUseSocksProxy.value = enable + }, + useOnion = { + onionHosts.value = it + }, + updateSessionMode = { + sessionMode.value = it + } + ) + } +} + +@Composable +private fun MutableState.DatabaseInitView(link: String, tempDatabaseFile: File, netCfg: NetCfg) { + Box { + SectionView(stringResource(MR.strings.migration_from_device_database_init).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + prepareDatabase(link, tempDatabaseFile, netCfg) + } +} + +@Composable +private fun MutableState.LinkDownloadingView( + link: String, + ctrl: ChatCtrl, + user: User, + archivePath: String, + tempDatabaseFile: File, + chatReceiver: MutableState, + netCfg: NetCfg +) { + Box { + SectionView(stringResource(MR.strings.migration_from_device_downloading_details).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + startDownloading(0, ctrl, user, tempDatabaseFile, chatReceiver, link, archivePath, netCfg) + } +} + +@Composable +private fun DownloadProgressView(downloadedBytes: Long, totalBytes: Long) { + Box { + SectionView(stringResource(MR.strings.migration_from_device_downloading_archive).uppercase()) { + val ratio = downloadedBytes.toFloat() / max(totalBytes, 1) + LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migration_from_device_bytes_downloaded).format(formatBytes(downloadedBytes))) + } + } +} + +@Composable +private fun MutableState.DownloadFailedView(link: String, chatReceiver: MigrationFromChatReceiver?, archivePath: String, netCfg: NetCfg) { + SectionView(stringResource(MR.strings.migration_from_device_download_failed).uppercase()) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_download), + text = stringResource(MR.strings.migration_from_device_repeat_download), + textColor = MaterialTheme.colors.primary, + click = { + state = MigrationState.DatabaseInit(link, netCfg) + } + ) {} + SectionTextFooter(stringResource(MR.strings.migration_from_device_try_again)) + } + LaunchedEffect(Unit) { + chatReceiver?.stopAndCleanUp() + File(archivePath).delete() + MigrationFromAnotherDeviceState.save(null) + } +} + +@Composable +private fun MutableState.ArchiveImportView(archivePath: String, netCfg: NetCfg) { + Box { + SectionView(stringResource(MR.strings.migration_from_device_importing_archive).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + importArchive(archivePath, netCfg) + } +} + +@Composable +private fun MutableState.ArchiveImportFailedView(archivePath: String, netCfg: NetCfg) { + SectionView(stringResource(MR.strings.migration_from_device_import_failed).uppercase()) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_download), + text = stringResource(MR.strings.migration_from_device_repeat_import), + textColor = MaterialTheme.colors.primary, + click = { + state = MigrationState.ArchiveImport(archivePath, netCfg) + } + ) {} + SectionTextFooter(stringResource(MR.strings.migration_from_device_try_again)) + } +} + +@Composable +private fun MutableState.PassphraseEnteringView(currentKey: String, netCfg: NetCfg) { + val currentKey = rememberSaveable { mutableStateOf(currentKey) } + val verifyingPassphrase = rememberSaveable { mutableStateOf(false) } + val useKeychain = rememberSaveable { mutableStateOf(appPreferences.storeDBPassphrase.get()) } + + Box { + val view = LocalMultiplatformView() + SectionView(stringResource(MR.strings.migration_from_device_enter_passphrase).uppercase()) { + SavePassphraseSetting( + useKeychain.value, + false, + false, + enabled = !verifyingPassphrase.value, + smallPadding = false + ) { checked -> useKeychain.value = checked } + + PassphraseField(currentKey, placeholder = stringResource(MR.strings.current_passphrase), Modifier.padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, requestFocus = true) + + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_vpn_key_filled), + text = stringResource(MR.strings.open_chat), + textColor = MaterialTheme.colors.primary, + disabled = verifyingPassphrase.value || currentKey.value.isEmpty(), + click = { + verifyingPassphrase.value = true + hideKeyboard(view) + withBGApi { + val (status, _) = chatInitTemporaryDatabase(dbAbsolutePrefixPath, key = currentKey.value, confirmation = MigrationConfirmation.YesUp) + val success = status == DBMigrationResult.OK || status == DBMigrationResult.InvalidConfirmation + if (success) { + state = MigrationState.Migration(currentKey.value, MigrationConfirmation.YesUp, useKeychain.value, netCfg) + } else if (status is DBMigrationResult.ErrorMigration) { + state = MigrationState.MigrationConfirmation(status, currentKey.value, useKeychain.value, netCfg) + } else { + showErrorOnMigrationIfNeeded(status) + } + verifyingPassphrase.value = false + } + } + ) {} + DatabaseEncryptionFooter(useKeychain, chatDbEncrypted = true, remember { mutableStateOf(false) }, remember { mutableStateOf(false) }, true) + } + if (verifyingPassphrase.value) { + ProgressView() + } + } +} + +@Composable +private fun MutableState.MigrationConfirmationView(status: DBMigrationResult, passphrase: String, useKeychain: Boolean, netCfg: NetCfg) { + data class Tuple4(val a: A, val b: B, val c: C, val d: D) + val (header: String, button: String?, footer: String, confirmation: MigrationConfirmation?) = when (status) { + is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) { + is MigrationError.Upgrade -> + Tuple4( + generalGetString(MR.strings.database_upgrade), + generalGetString(MR.strings.upgrade_and_open_chat), + "", + MigrationConfirmation.YesUp + ) + is MigrationError.Downgrade -> + Tuple4( + generalGetString(MR.strings.database_downgrade), + generalGetString(MR.strings.downgrade_and_open_chat), + generalGetString(MR.strings.database_downgrade_warning), + MigrationConfirmation.YesUpDown + ) + is MigrationError.Error -> + Tuple4( + generalGetString(MR.strings.incompatible_database_version), + null, + mtrErrorDescription(err.mtrError), + null + ) + } + else -> Tuple4(generalGetString(MR.strings.error), null, generalGetString(MR.strings.unknown_error), null) + } + SectionView(header.uppercase()) { + if (button != null && confirmation != null) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_download), + text = button, + textColor = MaterialTheme.colors.primary, + click = { + state = MigrationState.Migration(passphrase, confirmation, useKeychain, netCfg) + } + ) {} + } + SectionTextFooter(footer) + } +} + +@Composable +private fun MigrationView(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, close: () -> Unit) { + Box { + SectionView(stringResource(MR.strings.migration_from_device_migrating).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + startChat(passphrase, confirmation, useKeychain, netCfg, close) + } +} + +@Composable +private fun ProgressView() { + DefaultProgressView(null) +} + +private suspend fun MutableState.checkUserLink(link: String) { + if (strHasSimplexFileLink(link.trim())) { + val data = MigrationFileLinkData.readFromLink(link) + val hasOnionConfigured = data?.networkConfig?.hasOnionConfigured() ?: false + val networkConfig = data?.networkConfig?.transformToPlatformSupported() + // If any of iOS or Android had onion enabled, show onion screen + if (hasOnionConfigured && networkConfig?.hostMode != null && networkConfig.requiredHostMode != null) { + state = MigrationState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode) + MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.Onion(link.trim(), networkConfig.socksProxy, networkConfig.hostMode, networkConfig.requiredHostMode)) + } else { + val current = getNetCfg() + state = MigrationState.DatabaseInit(link.trim(), current.copy( + socksProxy = networkConfig?.socksProxy, + hostMode = networkConfig?.hostMode ?: current.hostMode, + requiredHostMode = networkConfig?.requiredHostMode ?: current.requiredHostMode + )) + } + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_file_link), + text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link) + ) + } +} + +private fun MutableState.prepareDatabase( + link: String, + tempDatabaseFile: File, + netCfg: NetCfg, +) { + withLongRunningApi { + val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, netCfg) + if (ctrlAndUser == null) { + state = MigrationState.DownloadFailed(0, link, archivePath(), netCfg) + return@withLongRunningApi + } + + val (ctrl, user) = ctrlAndUser + state = MigrationState.LinkDownloading(link, ctrl, user, archivePath(), netCfg) + } +} + +private fun MutableState.startDownloading( + totalBytes: Long, + ctrl: ChatCtrl, + user: User, + tempDatabaseFile: File, + chatReceiver: MutableState, + link: String, + archivePath: String, + netCfg: NetCfg, +) { + withBGApi { + chatReceiver.value = MigrationFromChatReceiver(ctrl, tempDatabaseFile) { msg -> + when (msg) { + is CR.RcvFileProgressXFTP -> { + state = MigrationState.DownloadProgress(msg.receivedSize, msg.totalSize, msg.rcvFileTransfer.fileId, link, archivePath, netCfg, ctrl) + MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.DownloadProgress(link, File(archivePath).name, netCfg)) + } + is CR.RcvStandaloneFileComplete -> { + delay(500) + state = MigrationState.ArchiveImport(archivePath, netCfg) + MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.ArchiveImport(File(archivePath).name, netCfg)) + } + is CR.RcvFileError -> { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.migration_from_device_download_failed), + generalGetString(MR.strings.migration_from_device_file_delete_or_link_invalid) + ) + state = MigrationState.DownloadFailed(totalBytes, link, archivePath, netCfg) + } + else -> Log.d(TAG, "unsupported event: ${msg.responseType}") + } + } + chatReceiver.value?.start() + + val (res, error) = controller.downloadStandaloneFile(user, link, CryptoFile.plain(File(archivePath).path), ctrl) + if (res == null) { + state = MigrationState.DownloadFailed(totalBytes, link, archivePath, netCfg) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.migration_from_device_error_downloading_archive), + error + ) + } + } +} + +private fun MutableState.importArchive(archivePath: String, netCfg: NetCfg) { + withLongRunningApi { + try { + if (ChatController.ctrl == null || ChatController.ctrl == -1L) { + chatInitControllerRemovingDatabases() + } + controller.apiDeleteStorage() + try { + val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString()) + val archiveErrors = controller.apiImportArchive(config) + if (archiveErrors.isNotEmpty()) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.chat_database_imported), + generalGetString(MR.strings.non_fatal_errors_occured_during_import) + ) + } + state = MigrationState.Passphrase("", netCfg) + MigrationFromAnotherDeviceState.save(MigrationFromAnotherDeviceState.Passphrase(netCfg)) + } catch (e: Exception) { + state = MigrationState.ArchiveImportFailed(archivePath, netCfg) + AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_importing_database), e.stackTraceToString()) + } + } catch (e: Exception) { + state = MigrationState.ArchiveImportFailed(archivePath, netCfg) + AlertManager.shared.showAlertMsg (generalGetString(MR.strings.error_deleting_database), e.stackTraceToString()) + } + } +} + +private suspend fun stopArchiveDownloading(fileId: Long, ctrl: ChatCtrl) { + controller.apiCancelFile(null, fileId, ctrl) +} + +private fun startChat(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, close: () -> Unit) { + if (useKeychain) { + ksDatabasePassword.set(passphrase) + } else { + ksDatabasePassword.remove() + } + appPreferences.storeDBPassphrase.set(useKeychain) + appPreferences.initialRandomDBPassphrase.set(false) + withBGApi { + try { + initChatController(useKey = passphrase, confirmMigrations = confirmation) { CompletableDeferred(false) } + val appSettings = controller.apiGetAppSettings(AppSettings.current.prepareForExport()).copy( + networkConfig = netCfg + ) + finishMigration(appSettings, close) + } catch (e: Exception) { + hideView(close) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.stackTraceToString()) + } + } +} + +private suspend fun finishMigration(appSettings: AppSettings, close: () -> Unit) { + try { + getMigrationTempFilesDirectory().deleteRecursively() + appSettings.importIntoApp() + val user = chatModel.currentUser.value + if (user != null) { + startChat(user) + } + hideView(close) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migration_from_device_chat_migrated), generalGetString(MR.strings.migration_from_device_finalize_migration)) + } catch (e: Exception) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.stackTraceToString()) + } + MigrationFromAnotherDeviceState.save(null) +} + +private fun hideView(close: () -> Unit) { + appPreferences.onboardingStage.set(OnboardingStage.OnboardingComplete) + close() +} + +private suspend fun MutableState.cleanUpOnBack(chatReceiver: MigrationFromChatReceiver?) { + val state = state + if (state is MigrationState.ArchiveImportFailed) { + // Original database is not exist, nothing is set up correctly for showing to a user yet. Return to clean state + deleteChatDatabaseFilesAndState() + initChatControllerAndRunMigrations() + } else if (state is MigrationState.DownloadProgress && state.ctrl != null) { + stopArchiveDownloading(state.fileId, state.ctrl) + } + chatReceiver?.stopAndCleanUp() + getMigrationTempFilesDirectory().deleteRecursively() + MigrationFromAnotherDeviceState.save(null) +} + +private fun strHasSimplexFileLink(text: String): Boolean = + text.startsWith("simplex:/file") || text.startsWith("https://simplex.chat/file") + +private fun fileForTemporaryDatabase(): File = + File(getMigrationTempFilesDirectory(), generateNewFileName("migration", "db", getMigrationTempFilesDirectory())) + +private fun archivePath(): String { + val archiveTime = Clock.System.now() + val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant())) + val archiveName = "simplex-chat.$ts.zip" + val archivePath = File(getMigrationTempFilesDirectory(), archiveName) + return archivePath.absolutePath +} + +private class MigrationFromChatReceiver( + val ctrl: ChatCtrl, + val databaseUrl: File, + var receiveMessages: Boolean = true, + val processReceivedMsg: suspend (CR) -> Unit +) { + fun start() { + Log.d(TAG, "MigrationChatReceiver startReceiver") + CoroutineScope(Dispatchers.IO).launch { + while (receiveMessages) { + try { + val msg = ChatController.recvMsg(ctrl) + if (msg != null && receiveMessages) { + val r = msg.resp + val rhId = msg.remoteHostId + Log.d(TAG, "processReceivedMsg: ${r.responseType}") + chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) + val finishedWithoutTimeout = withTimeoutOrNull(60_000L) { + processReceivedMsg(r) + } + if (finishedWithoutTimeout == null) { + Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) + if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.possible_slow_function_title), + text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()), + shareText = true + ) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg exception: " + e.stackTraceToString()) + } catch (e: Exception) { + Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg throwable: " + e.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) + } + } + } + } + + fun stopAndCleanUp() { + Log.d(TAG, "MigrationChatReceiver.stop") + receiveMessages = false + chatCloseStore(ctrl) + File(databaseUrl.absolutePath + "_chat.db").delete() + File(databaseUrl.absolutePath + "_agent.db").delete() + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.kt new file mode 100644 index 0000000000..5db6daf6de --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToAnotherDevice.kt @@ -0,0 +1,683 @@ +package chat.simplex.common.views.migration + +import SectionBottomSpacer +import SectionSpacer +import SectionTextFooter +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.getNetCfg +import chat.simplex.common.model.ChatController.startChat +import chat.simplex.common.model.ChatController.startChatWithTemporaryDatabase +import chat.simplex.common.model.ChatCtrl +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.database.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.LinkTextView +import chat.simplex.common.views.newchat.SimpleXLinkQRCode +import chat.simplex.common.views.usersettings.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* +import kotlinx.datetime.* +import kotlinx.serialization.* +import java.io.File +import java.net.URLEncoder +import kotlin.math.max + +@Serializable +data class MigrationFileLinkData( + val networkConfig: NetworkConfig?, +) { + @Serializable + data class NetworkConfig( + val socksProxy: String?, + val hostMode: HostMode?, + val requiredHostMode: Boolean? + ) { + fun hasOnionConfigured(): Boolean = socksProxy != null || hostMode == HostMode.Onion + + fun transformToPlatformSupported(): NetworkConfig { + return if (hostMode != null && requiredHostMode != null) { + NetworkConfig( + socksProxy = if (hostMode == HostMode.Onion) socksProxy ?: NetCfg.proxyDefaults.socksProxy else socksProxy, + hostMode = if (hostMode == HostMode.Onion) HostMode.OnionViaSocks else hostMode, + requiredHostMode = requiredHostMode + ) + } else this + } + } + + fun addToLink(link: String) = link + "&data=" + URLEncoder.encode(jsonShort.encodeToString(this), "UTF-8") + + companion object { + suspend fun readFromLink(link: String): MigrationFileLinkData? = + try { + // val data = link.substringAfter("&data=").substringBefore("&") + // json.decodeFromString(URLDecoder.decode(data, "UTF-8")) + controller.standaloneFileInfo(link) + } catch (e: Exception) { + null + } + } +} + + + +@Serializable +private sealed class MigrationToState { + @Serializable object ChatStopInProgress: MigrationToState() + @Serializable data class ChatStopFailed(val reason: String): MigrationToState() + @Serializable object PassphraseNotSet: MigrationToState() + @Serializable object PassphraseConfirmation: MigrationToState() + @Serializable object UploadConfirmation: MigrationToState() + @Serializable object Archiving: MigrationToState() + @Serializable data class DatabaseInit(val totalBytes: Long, val archivePath: String): MigrationToState() + @Serializable data class UploadProgress(val uploadedBytes: Long, val totalBytes: Long, val fileId: Long, val archivePath: String, val ctrl: ChatCtrl, val user: User): MigrationToState() + @Serializable data class UploadFailed(val totalBytes: Long, val archivePath: String): MigrationToState() + @Serializable object LinkCreation: MigrationToState() + @Serializable data class LinkShown(val fileId: Long, val link: String, val ctrl: ChatCtrl): MigrationToState() + @Serializable data class Finished(val chatDeletion: Boolean): MigrationToState() +} + +private var MutableState.state: MigrationToState + get() = value + set(v) { value = v } + +@Composable +fun MigrateToAnotherDeviceView(close: () -> Unit) { + val migrationState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(MigrationToState.ChatStopInProgress) } + // Prevent from hiding the view until migration is finished or app deleted + val backDisabled = remember { + derivedStateOf { + migrationState.value is MigrationToState.DatabaseInit || + migrationState.value is MigrationToState.Archiving || + migrationState.value is MigrationToState.LinkCreation || + migrationState.value is MigrationToState.LinkShown || + migrationState.value is MigrationToState.Finished + } + } + val chatReceiver = remember { mutableStateOf(null as MigrationToChatReceiver?) } + ModalView( + enableClose = !backDisabled.value, + close = { + withBGApi { + migrationState.cleanUpOnBack(chatReceiver.value) + } + close() + }, + ) { + MigrateToAnotherDeviceLayout( + migrationState = migrationState, + chatReceiver = chatReceiver + ) + } +} + +@Composable +private fun MigrateToAnotherDeviceLayout( + migrationState: MutableState, + chatReceiver: MutableState +) { + val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) } + + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).height(IntrinsicSize.Max), + ) { + AppBarTitle(stringResource(MR.strings.migrate_to_device)) + SectionByState(migrationState, tempDatabaseFile.value, chatReceiver) + SectionBottomSpacer() + } + platform.androidLockPortraitOrientation() +} + +@Composable +private fun SectionByState( + migrationState: MutableState, + tempDatabaseFile: File, + chatReceiver: MutableState +) { + when (val s = migrationState.value) { + is MigrationToState.ChatStopInProgress -> migrationState.ChatStopInProgressView() + is MigrationToState.ChatStopFailed -> migrationState.ChatStopFailedView(s.reason) + is MigrationToState.PassphraseNotSet -> migrationState.PassphraseNotSetView() + is MigrationToState.PassphraseConfirmation -> migrationState.PassphraseConfirmationView() + is MigrationToState.UploadConfirmation -> migrationState.UploadConfirmationView() + is MigrationToState.Archiving -> migrationState.ArchivingView() + is MigrationToState.DatabaseInit -> migrationState.DatabaseInitView(tempDatabaseFile, s.totalBytes, s.archivePath) + is MigrationToState.UploadProgress -> migrationState.UploadProgressView(s.uploadedBytes, s.totalBytes, s.ctrl, s.user, tempDatabaseFile, chatReceiver, s.archivePath) + is MigrationToState.UploadFailed -> migrationState.UploadFailedView(s.totalBytes, s.archivePath, chatReceiver.value) + is MigrationToState.LinkCreation -> LinkCreationView() + is MigrationToState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl) + is MigrationToState.Finished -> migrationState.FinishedView(s.chatDeletion) + } +} + +@Composable +private fun MutableState.ChatStopInProgressView() { + Box { + SectionView(stringResource(MR.strings.migration_to_device_stopping_chat).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + stopChat() + } +} + +@Composable +private fun MutableState.ChatStopFailedView(reason: String) { + SectionView(stringResource(MR.strings.error_stopping_chat).uppercase()) { + Text(reason) + SectionSpacer() + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_report_filled), + text = stringResource(MR.strings.auth_stop_chat), + textColor = MaterialTheme.colors.error, + click = ::stopChat + ){} + SectionTextFooter(stringResource(MR.strings.migration_to_device_chat_should_be_stopped)) + } +} + +@Composable +private fun MutableState.PassphraseNotSetView() { + DatabaseEncryptionView(chatModel, true) + KeyChangeEffect(appPreferences.initialRandomDBPassphrase.state.value) { + if (!appPreferences.initialRandomDBPassphrase.get()) { + state = MigrationToState.UploadConfirmation + } + } +} + +@Composable +private fun MutableState.PassphraseConfirmationView() { + val useKeychain = remember { appPreferences.storeDBPassphrase.get() } + val currentKey = rememberSaveable { mutableStateOf("") } + val verifyingPassphrase = rememberSaveable { mutableStateOf(false) } + Box { + val view = LocalMultiplatformView() + Column { + ChatStoppedView() + SectionSpacer() + + SectionView(stringResource(MR.strings.migration_to_device_verify_database_passphrase).uppercase()) { + PassphraseField(currentKey, placeholder = stringResource(MR.strings.current_passphrase), Modifier.padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, requestFocus = true) + + SettingsActionItemWithContent( + icon = painterResource(if (useKeychain) MR.images.ic_vpn_key_filled else MR.images.ic_lock), + text = stringResource(MR.strings.migration_to_device_verify_passphrase), + textColor = MaterialTheme.colors.primary, + disabled = verifyingPassphrase.value || currentKey.value.isEmpty(), + click = { + verifyingPassphrase.value = true + hideKeyboard(view) + withBGApi { + verifyDatabasePassphrase(currentKey.value) + verifyingPassphrase.value = false + } + } + ) {} + SectionTextFooter(stringResource(MR.strings.migration_to_device_confirm_you_remember_passphrase)) + } + } + if (verifyingPassphrase.value) { + ProgressView() + } + } +} + +@Composable +private fun MutableState.UploadConfirmationView() { + SectionView(stringResource(MR.strings.migration_to_device_confirm_upload).uppercase()) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_ios_share), + text = stringResource(MR.strings.migration_to_device_archive_and_upload), + textColor = MaterialTheme.colors.primary, + click = { state = MigrationToState.Archiving } + ){} + SectionTextFooter(stringResource(MR.strings.migration_to_device_all_data_will_be_uploaded)) + } +} + +@Composable +private fun MutableState.ArchivingView() { + Box { + SectionView(stringResource(MR.strings.migration_to_device_archiving_database).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + exportArchive() + } +} + +@Composable +private fun MutableState.DatabaseInitView(tempDatabaseFile: File, totalBytes: Long, archivePath: String) { + Box { + SectionView(stringResource(MR.strings.migration_to_device_database_init).uppercase()) {} + ProgressView() + } + LaunchedEffect(Unit) { + prepareDatabase(tempDatabaseFile, totalBytes, archivePath) + } +} + +@Composable +private fun MutableState.UploadProgressView( + uploadedBytes: Long, + totalBytes: Long, + ctrl: ChatCtrl, + user: User, + tempDatabaseFile: File, + chatReceiver: MutableState, + archivePath: String, +) { + Box { + SectionView(stringResource(MR.strings.migration_to_device_uploading_archive).uppercase()) { + val ratio = uploadedBytes.toFloat() / max(totalBytes, 1) + LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migration_to_device_bytes_uploaded).format(formatBytes(uploadedBytes))) + } + } + LaunchedEffect(Unit) { + startUploading(totalBytes, ctrl, user, tempDatabaseFile, chatReceiver, archivePath) + } +} + +@Composable +private fun MutableState.UploadFailedView(totalBytes: Long, archivePath: String, chatReceiver: MigrationToChatReceiver?) { + SectionView(stringResource(MR.strings.migration_to_device_upload_failed).uppercase()) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_ios_share), + text = stringResource(MR.strings.migration_to_device_repeat_upload), + textColor = MaterialTheme.colors.primary, + click = { + state = MigrationToState.DatabaseInit(totalBytes, archivePath) + } + ) {} + SectionTextFooter(stringResource(MR.strings.migration_to_device_try_again)) + } + LaunchedEffect(Unit) { + chatReceiver?.stopAndCleanUp() + } +} + +@Composable +private fun LinkCreationView() { + Box { + SectionView(stringResource(MR.strings.migration_to_device_creating_archive_link).uppercase()) {} + ProgressView() + } +} + +@Composable +private fun MutableState.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl) { + SectionView { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_close), + text = stringResource(MR.strings.migration_to_device_cancel_migration), + textColor = MaterialTheme.colors.error, + click = { + cancelMigration(fileId, ctrl) + } + ) {} + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_check), + text = stringResource(MR.strings.migration_to_device_finalize_migration), + textColor = MaterialTheme.colors.primary, + click = { + finishMigration(fileId, ctrl) + } + ) {} + SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_choose_migrate_from_another_device)) + } + SectionSpacer() + SectionView(stringResource(MR.strings.show_QR_code).uppercase()) { + SimpleXLinkQRCode(link, onShare = {}) + } + SectionSpacer() + SectionView(stringResource(MR.strings.migration_to_device_or_share_this_file_link).uppercase()) { + LinkTextView(link, true) + } +} + +@Composable +private fun MutableState.FinishedView(chatDeletion: Boolean) { + Box { + SectionView(stringResource(MR.strings.migration_to_device_migration_complete).uppercase()) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_delete_forever), + text = stringResource(MR.strings.migration_to_device_delete_database_from_device), + textColor = MaterialTheme.colors.primary, + click = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.delete_chat_profile_question), + text = generalGetString(MR.strings.delete_chat_profile_action_cannot_be_undone_warning), + confirmText = generalGetString(MR.strings.delete_verb), + onConfirm = { + deleteChatAndDismiss() + } + ) + } + ) {} + + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_play_arrow_filled), + text = stringResource(MR.strings.migration_to_device_start_chat), + textColor = MaterialTheme.colors.error, + click = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.start_chat_question), + text = generalGetString(MR.strings.migration_to_device_starting_chat_on_multiple_devices_unsupported), + confirmText = generalGetString(MR.strings.migration_to_device_start_chat), + onConfirm = { + withLongRunningApi { startChatAndDismiss() } + } + ) + } + ) {} + SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_you_must_not_start_database_on_two_device)) + SectionTextFooter(annotatedStringResource(MR.strings.migration_to_device_using_on_two_device_breaks_encryption)) + } + if (chatDeletion) { + ProgressView() + } + } +} + +@Composable +private fun ProgressView() { + DefaultProgressView(null) +} + +@Composable +fun LargeProgressView(value: Float, title: String, description: String) { + Box(Modifier.padding(DEFAULT_PADDING).fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + progress = value, + (if (appPlatform.isDesktop) Modifier.size(DEFAULT_START_MODAL_WIDTH) else Modifier.size(windowWidth() - DEFAULT_PADDING * 2)) + .rotate(-90f), + color = MaterialTheme.colors.primary, + strokeWidth = 25.dp + ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(description, color = Color.Transparent) + Text(title, style = MaterialTheme.typography.h1.copy(fontSize = 50.sp, fontWeight = FontWeight.Bold), color = MaterialTheme.colors.primary) + Text(description, style = MaterialTheme.typography.subtitle1) + } + } +} + +private fun MutableState.stopChat() { + withBGApi { + try { + stopChatAsync(chatModel) + try { + controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) + state = if (appPreferences.initialRandomDBPassphrase.get()) MigrationToState.PassphraseNotSet else MigrationToState.PassphraseConfirmation + } catch (e: Exception) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.migrate_to_device_error_saving_settings), + text = e.stackTraceToString() + ) + state = MigrationToState.ChatStopFailed(reason = generalGetString(MR.strings.migrate_to_device_error_saving_settings)) + } + } catch (e: Exception) { + state = MigrationToState.ChatStopFailed(reason = e.stackTraceToString().take(10)) + } + } +} + +private suspend fun MutableState.verifyDatabasePassphrase(dbKey: String) { + if (controller.testStorageEncryption(dbKey)) { + state = MigrationToState.UploadConfirmation + } else { + showErrorOnMigrationIfNeeded(DBMigrationResult.ErrorNotADatabase("")) + } +} + +private fun MutableState.exportArchive() { + withLongRunningApi { + try { + getMigrationTempFilesDirectory().mkdir() + val archivePath = exportChatArchive(chatModel, getMigrationTempFilesDirectory(), mutableStateOf(""), mutableStateOf(Instant.DISTANT_PAST), mutableStateOf("")) + val totalBytes = File(archivePath).length() + if (totalBytes > 0L) { + state = MigrationToState.DatabaseInit(totalBytes, archivePath) + } else { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_to_device_exported_file_doesnt_exist)) + state = MigrationToState.UploadConfirmation + } + } catch (e: Exception) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.migrate_to_device_error_exporting_archive), + text = e.stackTraceToString() + ) + state = MigrationToState.UploadConfirmation + } + } +} + +suspend fun initTemporaryDatabase(tempDatabaseFile: File, netCfg: NetCfg): Pair? { + val (status, ctrl) = chatInitTemporaryDatabase(tempDatabaseFile.absolutePath) + showErrorOnMigrationIfNeeded(status) + try { + if (ctrl != null) { + val user = startChatWithTemporaryDatabase(ctrl, netCfg) + return if (user != null) ctrl to user else null + } + } catch (e: Throwable) { + Log.e(TAG, "Error while starting chat in temporary database: ${e.stackTraceToString()}") + } + return null +} + +private fun MutableState.prepareDatabase( + tempDatabaseFile: File, + totalBytes: Long, + archivePath: String, +) { + withLongRunningApi { + val ctrlAndUser = initTemporaryDatabase(tempDatabaseFile, getNetCfg()) + if (ctrlAndUser == null) { + state = MigrationToState.UploadFailed(totalBytes, archivePath) + return@withLongRunningApi + } + + val (ctrl, user) = ctrlAndUser + state = MigrationToState.UploadProgress(0L, totalBytes, 0L, archivePath, ctrl, user) + } +} + +private fun MutableState.startUploading( + totalBytes: Long, + ctrl: ChatCtrl, + user: User, + tempDatabaseFile: File, + chatReceiver: MutableState, + archivePath: String, +) { + withBGApi { + chatReceiver.value = MigrationToChatReceiver(ctrl, tempDatabaseFile) { msg -> + when (msg) { + is CR.SndFileProgressXFTP -> { + val s = state + if (s is MigrationToState.UploadProgress && s.uploadedBytes != s.totalBytes) { + state = MigrationToState.UploadProgress(msg.sentSize, msg.totalSize, msg.fileTransferMeta.fileId, archivePath, ctrl, user) + } + } + is CR.SndFileRedirectStartXFTP -> { + delay(500) + state = MigrationToState.LinkCreation + } + is CR.SndStandaloneFileComplete -> { + delay(500) + val cfg = getNetCfg() + val data = MigrationFileLinkData( + networkConfig = MigrationFileLinkData.NetworkConfig( + socksProxy = cfg.socksProxy, + hostMode = cfg.hostMode, + requiredHostMode = cfg.requiredHostMode + ) + ) + state = MigrationToState.LinkShown(msg.fileTransferMeta.fileId, data.addToLink(msg.rcvURIs[0]), ctrl) + } + else -> { + Log.d(TAG, "unsupported event: ${msg.responseType}") + } + } + } + + chatReceiver.value?.start() + + val (res, error) = controller.uploadStandaloneFile(user, CryptoFile.plain(File(archivePath).name), ctrl) + if (res == null) { + state = MigrationToState.UploadFailed(totalBytes, archivePath) + return@withBGApi AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.migration_to_device_error_uploading_archive), + error + ) + } + state = MigrationToState.UploadProgress(0, res.fileSize, res.fileId, archivePath, ctrl, user) + } +} + +private suspend fun cancelUploadedArchive(fileId: Long, ctrl: ChatCtrl) { + controller.apiCancelFile(null, fileId, ctrl) +} + +private fun cancelMigration(fileId: Long, ctrl: ChatCtrl) { + withBGApi { + cancelUploadedArchive(fileId, ctrl) + startChatAndDismiss() + } +} + +private fun MutableState.finishMigration(fileId: Long, ctrl: ChatCtrl) { + withBGApi { + cancelUploadedArchive(fileId, ctrl) + state = MigrationToState.Finished(false) + } +} + +private fun MutableState.deleteChatAndDismiss() { + withBGApi { + try { + deleteChatAsync(chatModel) + chatModel.chatDbChanged.value = true + state = MigrationToState.Finished(true) + try { + initChatController(startChat = { CompletableDeferred(false) }) + chatModel.chatDbChanged.value = false + ModalManager.fullscreen.closeModals() + } catch (e: Exception) { + throw Exception(generalGetString(MR.strings.error_starting_chat) + "\n" + e.stackTraceToString()) + } + } catch (e: Exception) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.migration_to_device_error_deleting_database), + text = e.stackTraceToString() + ) + } + } +} + +private suspend fun startChatAndDismiss(dismiss: Boolean = true) { + try { + val user = chatModel.currentUser.value + if (chatModel.chatDbChanged.value) { + initChatController() + chatModel.chatDbChanged.value = false + } else if (user != null) { + startChat(user) + } + } catch (e: Exception) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_starting_chat), + text = e.stackTraceToString() + ) + } + // Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered + if (dismiss || chatModel.chatDbStatus.value != DBMigrationResult.OK) { + ModalManager.fullscreen.closeModals() + } +} + +private suspend fun MutableState.cleanUpOnBack(chatReceiver: MigrationToChatReceiver?) { + val s = state + if (s !is MigrationToState.LinkShown && s !is MigrationToState.Finished) { + chatModel.switchingUsersAndHosts.value = true + startChatAndDismiss(false) + chatModel.switchingUsersAndHosts.value = false + } + if (s is MigrationToState.UploadProgress) { + cancelUploadedArchive(s.fileId, s.ctrl) + } + chatReceiver?.stopAndCleanUp() + getMigrationTempFilesDirectory().deleteRecursively() +} + +private fun fileForTemporaryDatabase(): File = + File(getMigrationTempFilesDirectory(), generateNewFileName("migration", "db", getMigrationTempFilesDirectory())) + +private class MigrationToChatReceiver( + val ctrl: ChatCtrl, + val databaseUrl: File, + var receiveMessages: Boolean = true, + val processReceivedMsg: suspend (CR) -> Unit +) { + fun start() { + Log.d(TAG, "MigrationChatReceiver startReceiver") + CoroutineScope(Dispatchers.IO).launch { + while (receiveMessages) { + try { + val msg = ChatController.recvMsg(ctrl) + if (msg != null && receiveMessages) { + val r = msg.resp + val rhId = msg.remoteHostId + Log.d(TAG, "processReceivedMsg: ${r.responseType}") + chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) + val finishedWithoutTimeout = withTimeoutOrNull(60_000L) { + processReceivedMsg(r) + } + if (finishedWithoutTimeout == null) { + Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) + if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.possible_slow_function_title), + text = generalGetString(MR.strings.possible_slow_function_desc).format(60, msg.resp.responseType + "\n" + Exception().stackTraceToString()), + shareText = true + ) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg exception: " + e.stackTraceToString()) + } catch (e: Exception) { + Log.e(TAG, "MigrationChatReceiver recvMsg/processReceivedMsg throwable: " + e.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.stackTraceToString()) + } + } + } + } + + fun stopAndCleanUp() { + Log.d(TAG, "MigrationChatReceiver.stop") + receiveMessages = false + chatCloseStore(ctrl) + File(databaseUrl.absolutePath + "_chat.db").delete() + File(databaseUrl.absolutePath + "_agent.db").delete() + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index 3b4bb86e66..bf154acca8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -301,7 +301,7 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState, showQRC } @Composable -private fun LinkTextView(link: String, share: Boolean) { +fun LinkTextView(link: String, share: Boolean) { val clipboard = LocalClipboardManager.current Row(Modifier.fillMaxWidth().heightIn(min = 46.dp).padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { Box(Modifier.weight(1f).clickable { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 9ae34eb180..905bf77989 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -58,7 +58,7 @@ fun SetupDatabasePassphrase(m: ChatModel) { prefs.storeDBPassphrase.set(false) val newKeyValue = newKey.value - val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator) + val success = encryptDatabase(currentKey, newKey, confirmNewKey, mutableStateOf(true), saveInPreferences, mutableStateOf(true), progressIndicator, false) if (success) { startChat(newKeyValue) nextStep() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index 2aad2556af..0fe756d8fb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -16,8 +16,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.migration.MigrateFromAnotherDeviceView import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource @@ -62,17 +64,32 @@ fun SimpleXInfoLayout( OnboardingActionButton(user, onboardingStage) } Spacer(Modifier.fillMaxHeight().weight(1f)) + + Box( + Modifier + .fillMaxWidth() + .padding(top = DEFAULT_PADDING), contentAlignment = Alignment.Center + ) { + SimpleButtonDecorated(text = stringResource(MR.strings.migrate_from_another_device), icon = painterResource(MR.images.ic_download), + click = { ModalManager.fullscreen.showCustomModal { close -> MigrateFromAnotherDeviceView(chatModel.migrationState.value, close) } }) + } } Box( Modifier .fillMaxWidth() - .padding(bottom = DEFAULT_PADDING.times(1.5f), top = DEFAULT_PADDING), contentAlignment = Alignment.Center + .padding(bottom = DEFAULT_PADDING.times(1.5f), top = if (onboardingStage == null) DEFAULT_PADDING else 0.dp), contentAlignment = Alignment.Center ) { SimpleButtonDecorated(text = stringResource(MR.strings.how_it_works), icon = painterResource(MR.images.ic_info), click = showModal { HowItWorks(user, onboardingStage) }) } } + LaunchedEffect(Unit) { + val state = chatModel.migrationState.value + if (state != null && !ModalManager.fullscreen.hasModalsOpen()) { + ModalManager.fullscreen.showCustomModal(animated = false) { close -> MigrateFromAnotherDeviceView(state, close) } + } + } } @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt index 66b4a0e839..27e5c80cde 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt @@ -33,12 +33,7 @@ import chat.simplex.common.views.helpers.annotatedStringResource import chat.simplex.res.MR @Composable -fun NetworkAndServersView( - chatModel: ChatModel, - showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit), -) { +fun NetworkAndServersView() { val currentRemoteHost by remember { chatModel.currentRemoteHost } // It's not a state, just a one-time value. Shouldn't be used in any state-related situations val netCfg = remember { chatModel.controller.getNetCfg() } @@ -55,9 +50,6 @@ fun NetworkAndServersView( onionHosts = onionHosts, sessionMode = sessionMode, proxyPort = proxyPort, - showModal = showModal, - showSettingsModal = showSettingsModal, - showCustomModal = showCustomModal, toggleSocksProxy = { enable -> if (enable) { AlertManager.shared.showAlertDialog( @@ -154,13 +146,11 @@ fun NetworkAndServersView( onionHosts: MutableState, sessionMode: MutableState, proxyPort: State, - showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit), toggleSocksProxy: (Boolean) -> Unit, useOnion: (OnionHosts) -> Unit, updateSessionMode: (TransportSessionMode) -> Unit, ) { + val m = chatModel Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp) @@ -168,17 +158,18 @@ fun NetworkAndServersView( AppBarTitle(stringResource(MR.strings.network_and_servers)) if (!chatModel.desktopNoUserNoRemote) { SectionView(generalGetString(MR.strings.settings_section_title_messages)) { - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) }) + SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.smp_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.SMP, close) } }) - SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), showCustomModal { m, close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) }) + SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } }) if (currentRemoteHost == null) { - UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showSettingsModal) - UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) + val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.start.showModal(content = it) } + UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showModal, chatModel.controller.appPrefs.networkProxyHostPort, false) + UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion) if (developerTools) { - SessionModePicker(sessionMode, showSettingsModal, updateSessionMode) + SessionModePicker(sessionMode, showModal, updateSessionMode) } - SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) + SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showModal { AdvancedNetworkSettingsView(m) } }) } } } @@ -196,18 +187,39 @@ fun NetworkAndServersView( } SectionView(generalGetString(MR.strings.settings_section_title_calls)) { - SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), showModal { RTCServersView(it) }) + SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } }) } SectionBottomSpacer() } } +@Composable fun OnionRelatedLayout( + developerTools: Boolean, + networkUseSocksProxy: MutableState, + onionHosts: MutableState, + sessionMode: MutableState, + networkProxyHostPort: SharedPreference, + proxyPort: State, + toggleSocksProxy: (Boolean) -> Unit, + useOnion: (OnionHosts) -> Unit, + updateSessionMode: (TransportSessionMode) -> Unit, +) { + val showModal = { it: @Composable ModalData.() -> Unit -> ModalManager.fullscreen.showModal(content = it) } + UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showModal, networkProxyHostPort, true) + UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion) + if (developerTools) { + SessionModePicker(sessionMode, showModal, updateSessionMode) + } +} + @Composable fun UseSocksProxySwitch( networkUseSocksProxy: MutableState, proxyPort: State, toggleSocksProxy: (Boolean) -> Unit, - showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) + showModal: (@Composable ModalData.() -> Unit) -> Unit, + networkProxyHostPort: SharedPreference = chatModel.controller.appPrefs.networkProxyHostPort, + migration: Boolean = false, ) { Row( Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING), @@ -227,8 +239,11 @@ fun UseSocksProxySwitch( val text = buildAnnotatedString { append(generalGetString(MR.strings.network_socks_toggle_use_socks_proxy) + " (") val style = SpanStyle(color = MaterialTheme.colors.primary) + val disabledStyle = SpanStyle(color = MaterialTheme.colors.onBackground) withAnnotation(tag = "PORT", annotation = generalGetString(MR.strings.network_proxy_port).format(proxyPort.value)) { - withStyle(style) { append(generalGetString(MR.strings.network_proxy_port).format(proxyPort.value)) } + withStyle(if (networkUseSocksProxy.value || !migration) style else disabledStyle) { + append(generalGetString(MR.strings.network_proxy_port).format(proxyPort.value)) + } } append(")") } @@ -238,7 +253,9 @@ fun UseSocksProxySwitch( onClick = { offset -> text.getStringAnnotations(tag = "PORT", start = offset, end = offset) .firstOrNull()?.let { _ -> - showSettingsModal { SockProxySettings(it) }() + if (networkUseSocksProxy.value || !migration) { + showModal { SockProxySettings(chatModel, networkProxyHostPort, migration) } + } } }, shouldConsumeEvent = { offset -> @@ -254,7 +271,11 @@ fun UseSocksProxySwitch( } @Composable -fun SockProxySettings(m: ChatModel) { +fun SockProxySettings( + m: ChatModel, + networkProxyHostPort: SharedPreference = m.controller.appPrefs.networkProxyHostPort, + migration: Boolean, +) { Column( Modifier .fillMaxWidth() @@ -262,17 +283,17 @@ fun SockProxySettings(m: ChatModel) { ) { val defaultHostPort = remember { "localhost:9050" } AppBarTitle(generalGetString(MR.strings.network_socks_proxy_settings)) - val hostPort by remember { m.controller.appPrefs.networkProxyHostPort.state } + val hostPortSaved by remember { networkProxyHostPort.state } val hostUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue(hostPort?.split(":")?.firstOrNull() ?: "localhost")) + mutableStateOf(TextFieldValue(hostPortSaved?.split(":")?.firstOrNull() ?: "localhost")) } val portUnsaved = rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue(hostPort?.split(":")?.lastOrNull() ?: "9050")) + mutableStateOf(TextFieldValue(hostPortSaved?.split(":")?.lastOrNull() ?: "9050")) } val save = { withBGApi { - m.controller.appPrefs.networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text) - if (m.controller.appPrefs.networkUseSocksProxy.get()) { + networkProxyHostPort.set(hostUnsaved.value.text + ":" + portUnsaved.value.text) + if (m.controller.appPrefs.networkUseSocksProxy.get() && !migration) { m.controller.apiSetNetworkConfig(m.controller.getNetCfg()) } } @@ -281,21 +302,21 @@ fun SockProxySettings(m: ChatModel) { SectionItemView { ResetToDefaultsButton({ val reset = { - m.controller.appPrefs.networkProxyHostPort.set(defaultHostPort) + networkProxyHostPort.set(defaultHostPort) val newHost = defaultHostPort.split(":").first() val newPort = defaultHostPort.split(":").last() hostUnsaved.value = hostUnsaved.value.copy(newHost, TextRange(newHost.length)) portUnsaved.value = portUnsaved.value.copy(newPort, TextRange(newPort.length)) save() } - if (m.controller.appPrefs.networkUseSocksProxy.get()) { + if (m.controller.appPrefs.networkUseSocksProxy.get() && !migration) { showUpdateNetworkSettingsDialog { reset() } } else { reset() } - }, disabled = hostPort == defaultHostPort) + }, disabled = hostPortSaved == defaultHostPort) } SectionItemView { DefaultConfigurableTextField( @@ -321,14 +342,14 @@ fun SockProxySettings(m: ChatModel) { SectionCustomFooter { NetworkSectionFooter( revert = { - val prevHost = m.controller.appPrefs.networkProxyHostPort.get()?.split(":")?.firstOrNull() ?: "localhost" - val prevPort = m.controller.appPrefs.networkProxyHostPort.get()?.split(":")?.lastOrNull() ?: "9050" + val prevHost = hostPortSaved?.split(":")?.firstOrNull() ?: "localhost" + val prevPort = hostPortSaved?.split(":")?.lastOrNull() ?: "9050" hostUnsaved.value = hostUnsaved.value.copy(prevHost, TextRange(prevHost.length)) portUnsaved.value = portUnsaved.value.copy(prevPort, TextRange(prevPort.length)) }, - save = { if (m.controller.appPrefs.networkUseSocksProxy.get()) showUpdateNetworkSettingsDialog { save() } else save() }, - revertDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text), - saveDisabled = hostPort == (hostUnsaved.value.text + ":" + portUnsaved.value.text) || + save = { if (m.controller.appPrefs.networkUseSocksProxy.get() && !migration) showUpdateNetworkSettingsDialog { save() } else save() }, + revertDisabled = hostPortSaved == (hostUnsaved.value.text + ":" + portUnsaved.value.text), + saveDisabled = hostPortSaved == (hostUnsaved.value.text + ":" + portUnsaved.value.text) || remember { derivedStateOf { !validHost(hostUnsaved.value.text) } }.value || remember { derivedStateOf { !validPort(portUnsaved.value.text) } }.value ) @@ -341,7 +362,7 @@ fun SockProxySettings(m: ChatModel) { private fun UseOnionHosts( onionHosts: MutableState, enabled: State, - showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showModal: (@Composable ModalData.() -> Unit) -> Unit, useOnion: (OnionHosts) -> Unit, ) { val values = remember { @@ -353,29 +374,43 @@ private fun UseOnionHosts( } } } - val onSelected = showModal { - Column( - Modifier.fillMaxWidth(), - ) { - AppBarTitle(stringResource(MR.strings.network_use_onion_hosts)) - SectionViewSelectable(null, onionHosts, values, useOnion) + val onSelected = { + showModal { + Column( + Modifier.fillMaxWidth(), + ) { + AppBarTitle(stringResource(MR.strings.network_use_onion_hosts)) + SectionViewSelectable(null, onionHosts, values, useOnion) + } } } - SectionItemWithValue( - generalGetString(MR.strings.network_use_onion_hosts), - onionHosts, - values, - icon = painterResource(MR.images.ic_security), - enabled = enabled, - onSelected = onSelected - ) + if (enabled.value) { + SectionItemWithValue( + generalGetString(MR.strings.network_use_onion_hosts), + onionHosts, + values, + icon = painterResource(MR.images.ic_security), + enabled = enabled, + onSelected = onSelected + ) + } else { + // In reality, when socks proxy is disabled, this option acts like NEVER regardless of what was chosen before + SectionItemWithValue( + generalGetString(MR.strings.network_use_onion_hosts), + remember { mutableStateOf(OnionHosts.NEVER) }, + listOf(ValueTitleDesc(OnionHosts.NEVER, generalGetString(MR.strings.network_use_onion_hosts_no), AnnotatedString(generalGetString(MR.strings.network_use_onion_hosts_no_desc)))), + icon = painterResource(MR.images.ic_security), + enabled = enabled, + onSelected = {} + ) + } } @Composable private fun SessionModePicker( sessionMode: MutableState, - showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showModal: (@Composable ModalData.() -> Unit) -> Unit, updateSessionMode: (TransportSessionMode) -> Unit, ) { val density = LocalDensity.current @@ -393,12 +428,14 @@ private fun SessionModePicker( sessionMode, values, icon = painterResource(MR.images.ic_safety_divider), - onSelected = showModal { - Column( - Modifier.fillMaxWidth(), - ) { - AppBarTitle(stringResource(MR.strings.network_session_mode_transport_isolation)) - SectionViewSelectable(null, sessionMode, values, updateSessionMode) + onSelected = { + showModal { + Column( + Modifier.fillMaxWidth(), + ) { + AppBarTitle(stringResource(MR.strings.network_session_mode_transport_isolation)) + SectionViewSelectable(null, sessionMode, values, updateSessionMode) + } } } ) @@ -455,9 +492,6 @@ fun PreviewNetworkAndServersLayout() { developerTools = true, networkUseSocksProxy = remember { mutableStateOf(true) }, proxyPort = remember { mutableStateOf(9050) }, - showModal = { {} }, - showSettingsModal = { {} }, - showCustomModal = { {} }, toggleSocksProxy = {}, onionHosts = remember { mutableStateOf(OnionHosts.PREFER) }, sessionMode = remember { mutableStateOf(TransportSessionMode.User) }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index d92f2f0f13..ffa908e210 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -28,6 +28,8 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.migration.MigrateFromAnotherDeviceView +import chat.simplex.common.views.migration.MigrateToAnotherDeviceView import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView import chat.simplex.common.views.remote.ConnectDesktopView @@ -135,12 +137,13 @@ fun SettingsLayout( } else { SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal{ it, close -> ConnectDesktopView(close) }, disabled = stopped, extraPadding = true) } + SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_to_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateToAnotherDeviceView(close) } }}, disabled = stopped, extraPadding = true) } SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_settings)) { SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal, showCustomModal) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true) SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true) @@ -366,7 +369,7 @@ fun SettingsActionItem(icon: Painter, text: String, click: (() -> Unit)? = null, } @Composable -fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (() -> Unit)? = null, iconColor: Color = MaterialTheme.colors.secondary, disabled: Boolean = false, extraPadding: Boolean = false, content: @Composable RowScope.() -> Unit) { +fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (() -> Unit)? = null, iconColor: Color = MaterialTheme.colors.secondary, textColor: Color = MaterialTheme.colors.onBackground, disabled: Boolean = false, extraPadding: Boolean = false, content: @Composable RowScope.() -> Unit) { SectionItemView( click, extraPadding = extraPadding, @@ -382,7 +385,7 @@ fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: ( } if (text != null) { val padding = with(LocalDensity.current) { 6.sp.toDp() } - Text(text, Modifier.weight(1f).padding(vertical = padding), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground) + Text(text, Modifier.weight(1f).padding(vertical = padding), color = if (disabled) MaterialTheme.colors.secondary else textColor) Spacer(Modifier.width(DEFAULT_PADDING)) Row(Modifier.widthIn(max = (windowWidth() - DEFAULT_PADDING * 2) / 2)) { content() diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 49552a592d..f3b7bf4a72 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -54,8 +54,10 @@ Decryption error Encryption re-negotiation error - This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery. - This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery. + end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery.]]> + quantum resistant e2e encryption with perfect forward secrecy, repudiation and break-in recovery.]]> + This chat is protected by end-to-end encryption. + This chat is protected by quantum resistant end-to-end encryption. Private notes @@ -243,6 +245,7 @@ Stop chat Open chat console Open chat profiles + Open migration screen SimpleX Lock not enabled! You can turn on SimpleX Lock via Settings. @@ -821,6 +824,7 @@ Open-source protocol and code – anybody can run the servers. Create your profile Make a private connection + Migrate from another device How it works @@ -1079,6 +1083,7 @@ Confirm new passphrase… Update database passphrase Set database passphrase + Set passphrase Please enter correct current passphrase. Your chat database is not encrypted - set passphrase to protect it. Android Keystore is used to securely store passphrase - it allows notification service to work. @@ -1840,4 +1845,64 @@ Internal error Please report it to the developers: \n%s Restart chat + + + + Migrate here + Or paste archive link + Paste archive link + Invalid link + Migrating + Preparing download + Downloading link details + Downloading archive + %s downloaded + Download failed + Repeat download + You can give another try. + Importing archive + Import failed + Repeat import + Enter passphrase + File was deleted or link is invalid + Error downloading the archive + Chat migrated! + Finalize migration on another device. + Confirm network settings + Please confirm that network settings are correct for this device. + Apply + + + Migrate to another device + Error saving settings + Exported file doesn\'t exist + Error exporting chat database + Preparing upload + Error uploading the archive + Error deleting database + Stopping chat + In order to continue, chat should be stopped. + Archive and upload + Confirm upload + All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays. + Archiving database + %s uploaded + Uploading archive + Upload failed + Repeat upload + You can give another try. + Creating archive link + Cancel migration + Finalize migration + Migrate from another device on the new device and scan QR code.]]> + Or securely share this file link + Delete database from this device + Warning: starting chat on multiple devices is not supported and will cause message delivery failures + Start chat + Migration complete + must not use the same database on two devices.]]> + Please note: using the same database on two devices will break the decryption of messages from your connections, as a security protection.]]> + Verify database passphrase + Verify passphrase + Confirm that you remember database passphrase to migrate it. \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt index af2b269b58..eb93e7c510 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.database import SectionItemView import SectionTextFooter +import TextIconSpaced import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.Composable @@ -22,8 +23,9 @@ actual fun SavePassphraseSetting( useKeychain: Boolean, initialRandomDBPassphrase: Boolean, storedKey: Boolean, - progressIndicator: Boolean, minHeight: Dp, + enabled: Boolean, + smallPadding: Boolean, onCheckedChange: (Boolean) -> Unit, ) { SectionItemView(minHeight = minHeight) { @@ -33,7 +35,11 @@ actual fun SavePassphraseSetting( stringResource(MR.strings.save_passphrase_in_settings), tint = if (storedKey) WarningOrange else MaterialTheme.colors.secondary ) - Spacer(Modifier.padding(horizontal = 4.dp)) + if (smallPadding) { + Spacer(Modifier.padding(horizontal = 4.dp)) + } else { + TextIconSpaced(false) + } Text( stringResource(MR.strings.save_passphrase_in_settings), Modifier.padding(end = 24.dp), @@ -43,7 +49,7 @@ actual fun SavePassphraseSetting( DefaultSwitch( checked = useKeychain, onCheckedChange = onCheckedChange, - enabled = !initialRandomDBPassphrase && !progressIndicator + enabled = enabled ) } } @@ -55,13 +61,14 @@ actual fun DatabaseEncryptionFooter( chatDbEncrypted: Boolean?, storedKey: MutableState, initialRandomDBPassphrase: MutableState, + migration: Boolean, ) { if (chatDbEncrypted == false) { SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) } else if (useKeychain.value) { if (storedKey.value) { SectionTextFooter(generalGetString(MR.strings.settings_is_storing_in_clear_text)) - if (initialRandomDBPassphrase.value) { + if (initialRandomDBPassphrase.value && !migration) { SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) } else { SectionTextFooter(annotatedStringResource(MR.strings.impossible_to_recover_passphrase)) diff --git a/cabal.project b/cabal.project index 3916c9fb6d..c4f2f102e8 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 78eb4f764fd52385a8687d2605a0e6edc1808431 + tag: 0aa4ae72286237d066c3ce2bff355638523c7095 source-repository-package type: git diff --git a/package.yaml b/package.yaml index a4df72cda8..ab703cc9cd 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.6.0.0 +version: 5.6.0.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index c7678d1201..7672cde62d 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."78eb4f764fd52385a8687d2605a0e6edc1808431" = "09nmrk65nbn6mp8mwwk09d5zx9cgm38i6xgmndk6jzlhnfl5fiy6"; + "https://github.com/simplex-chat/simplexmq.git"."0aa4ae72286237d066c3ce2bff355638523c7095" = "1jcy5p8220w8ahi4fgil5rxlj83c9qy44s6mly9jh8n9a2bwdr4d"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 26300dc146..a3a78f851f 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.6.0.0 +version: 5.6.0.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 56914d2d9d..f88f1c83a9 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -3423,7 +3423,7 @@ processAgentMsgSndFile _corrId aFileId msg = liftIO $ updateFileCancelled db user fileId CIFSSndError lookupChatItemByFileId db vr user fileId withAgent (`xftpDeleteSndFileInternal` aFileId) - toView $ CRSndFileError user ci ft + toView $ CRSndFileError user ci ft err splitFileDescr :: ChatMonad m => RcvFileDescrText -> m (NonEmpty FileDescr) splitFileDescr rfdText = do diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index b2d82a0243..ce7c952ded 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -619,7 +619,7 @@ data ChatResponse | CRSndFileCompleteXFTP {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta} | CRSndStandaloneFileComplete {user :: User, fileTransferMeta :: FileTransferMeta, rcvURIs :: [Text]} | CRSndFileCancelledXFTP {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta} - | CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta} + | CRSndFileError {user :: User, chatItem_ :: Maybe AChatItem, fileTransferMeta :: FileTransferMeta, errorMessage :: Text} | CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary} | CRUserProfileImage {user :: User, profile :: Profile} | CRContactAliasUpdated {user :: User, toContact :: Contact} diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d5f66224b9..857198a109 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -72,11 +72,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExis -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [5, 5, 0, 2] +minRemoteCtrlVersion = AppVersion [5, 6, 0, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [5, 5, 0, 2] +minRemoteHostVersion = AppVersion [5, 6, 0, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 05b1a153b2..bf74add6db 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -471,7 +471,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id - LEFT JOIN chat_items i ON i.group_id = m.group_id + LEFT JOIN chat_items i ON i.user_id = m.user_id + AND i.group_id = m.group_id AND m.group_member_id = i.group_member_id AND i.shared_msg_id = :msg_id WHERE m.user_id = :user_id AND m.group_id = :group_id AND m.member_id = :member_id diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8aa917f044..50dc151aa4 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -215,8 +215,8 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRSndStandaloneFileComplete u ft uris -> ttyUser u $ standaloneUploadComplete ft uris CRSndFileCompleteXFTP u ci _ -> ttyUser u $ uploadingFile "completed" ci CRSndFileCancelledXFTP {} -> [] - CRSndFileError u Nothing ft -> ttyUser u $ uploadingFileStandalone "error" ft - CRSndFileError u (Just ci) _ -> ttyUser u $ uploadingFile "error" ci + CRSndFileError u Nothing ft e -> ttyUser u $ uploadingFileStandalone "error" ft <> [plain e] + CRSndFileError u (Just ci) _ e -> ttyUser u $ uploadingFile "error" ci <> [plain e] CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft] CRStandaloneFileInfo info_ -> maybe ["no file information in URI"] (\j -> [plain . LB.toStrict $ J.encode j]) info_