Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2024-03-12 15:21:02 +00:00
61 changed files with 4428 additions and 427 deletions
+1
View File
@@ -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?
+100 -34
View File
@@ -90,12 +90,12 @@ private func withBGTask<T>(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
+6 -1
View File
@@ -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()
}
+20 -5
View File
@@ -110,12 +110,11 @@ struct ChatItemContentView<Content: View>: 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<Content: View>: 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 {
@@ -35,7 +35,7 @@ struct ContactPreferencesView: View {
.disabled(currentFeaturesAllowed == featuresAllowed)
}
}
.modifier(BackButton {
.modifier(BackButton(disabled: Binding.constant(false)) {
if currentFeaturesAllowed == featuresAllowed {
dismiss()
} else {
@@ -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 {
@@ -24,7 +24,7 @@ struct GroupWelcomeView: View {
VStack {
if groupInfo.canEdit {
editorView()
.modifier(BackButton {
.modifier(BackButton(disabled: Binding.constant(false)) {
if welcomeTextUnchanged() {
dismiss()
} else {
@@ -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)
}
}
@@ -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: ", "))"
@@ -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 {
@@ -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
}
@@ -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<MigrateFromAnotherDeviceViewAlert?>) {
switch status {
case .invalidConfirmation:
alert.wrappedValue = .invalidConfirmation()
case .errorNotADatabase:
alert.wrappedValue = .wrongPassphrase()
case .errorKeychain:
alert.wrappedValue = .keychainError()
case let .errorSQL(_, error):
alert.wrappedValue = .databaseError(message: error)
case let .unknown(error):
alert.wrappedValue = .unknownError(message: error)
case .errorMigration: ()
case .ok: ()
}
}
private func progressView() -> some View {
VStack {
ProgressView().scaleEffect(2)
}
.frame(maxWidth: .infinity, maxHeight: .infinity )
}
private class MigrationChatReceiver {
let ctrl: chat_ctrl
let databaseUrl: URL
let processReceivedMsg: (ChatResponse) async -> Void
private var receiveLoop: Task<Void, Never>?
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)
}
}
@@ -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<MigrateToAnotherDeviceViewAlert?>) {
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<Void, Never>?
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))
}
}
+60 -54
View File
@@ -86,7 +86,7 @@ struct NewChatView: View {
}
}
if case .connect = selection {
ConnectView(showQRCodeScanner: showQRCodeScanner, pastedLink: $pastedLink, alert: $alert)
ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert)
.transition(.move(edge: .trailing))
}
}
@@ -284,8 +284,7 @@ private struct InviteView: View {
private struct ConnectView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@State var showQRCodeScanner = false
@State private var cameraAuthorizationStatus: AVAuthorizationStatus?
@Binding var showQRCodeScanner: Bool
@Binding var pastedLink: String
@Binding var alert: NewChatViewAlert?
@State private var sheet: PlanAndConnectActionSheet?
@@ -295,32 +294,13 @@ private struct ConnectView: View {
Section("Paste the link you received") {
pasteLinkView()
}
scanCodeView()
Section("Or scan QR code") {
ScannerInView(showQRCodeScanner: $showQRCodeScanner, processQRCode: processQRCode)
}
}
.actionSheet(item: $sheet) { s in
planAndConnectActionSheet(s, dismiss: true, cleanup: { pastedLink = "" })
}
.onAppear {
let status = AVCaptureDevice.authorizationStatus(for: .video)
cameraAuthorizationStatus = status
if showQRCodeScanner {
switch status {
case .notDetermined: askCameraAuthorization()
case .restricted: showQRCodeScanner = false
case .denied: showQRCodeScanner = false
case .authorized: ()
@unknown default: askCameraAuthorization()
}
}
}
}
func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
AVCaptureDevice.requestAccess(for: .video) { allowed in
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
if allowed { cb?() }
}
}
@ViewBuilder private func pasteLinkView() -> some View {
@@ -351,8 +331,45 @@ private struct ConnectView: View {
}
}
private func scanCodeView() -> some View {
Section("Or scan QR code") {
private func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp {
case let .success(r):
let link = r.string
if strIsSimplexLink(r.string) {
connect(link)
} else {
alert = .newChatSomeAlert(alert: .someAlert(
alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."),
id: "processQRCode: code is not a SimpleX link"
))
}
case let .failure(e):
logger.error("processQRCode QR code error: \(e.localizedDescription)")
alert = .newChatSomeAlert(alert: .someAlert(
alert: mkAlert(title: "Invalid QR code", message: "Error scanning code: \(e.localizedDescription)"),
id: "processQRCode: failure"
))
}
}
private func connect(_ link: String) {
planAndConnect(
link,
showAlert: { alert = .planAndConnectAlert(alert: $0) },
showActionSheet: { sheet = $0 },
dismiss: true,
incognito: nil
)
}
}
struct ScannerInView: View {
@Binding var showQRCodeScanner: Bool
let processQRCode: (_ resp: Result<ScanResult, ScanError>) -> Void
@State private var cameraAuthorizationStatus: AVAuthorizationStatus?
var body: some View {
Group {
if showQRCodeScanner, case .authorized = cameraAuthorizationStatus {
CodeScannerView(codeTypes: [.qr], scanMode: .continuous, completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
@@ -396,37 +413,26 @@ private struct ConnectView: View {
.disabled(cameraAuthorizationStatus == .restricted)
}
}
}
private func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp {
case let .success(r):
let link = r.string
if strIsSimplexLink(r.string) {
connect(link)
} else {
alert = .newChatSomeAlert(alert: .someAlert(
alert: mkAlert(title: "Invalid QR code", message: "The code you scanned is not a SimpleX link QR code."),
id: "processQRCode: code is not a SimpleX link"
))
.onAppear {
let status = AVCaptureDevice.authorizationStatus(for: .video)
cameraAuthorizationStatus = status
if showQRCodeScanner {
switch status {
case .notDetermined: askCameraAuthorization()
case .restricted: showQRCodeScanner = false
case .denied: showQRCodeScanner = false
case .authorized: ()
@unknown default: askCameraAuthorization()
}
}
case let .failure(e):
logger.error("processQRCode QR code error: \(e.localizedDescription)")
alert = .newChatSomeAlert(alert: .someAlert(
alert: mkAlert(title: "Invalid QR code", message: "Error scanning code: \(e.localizedDescription)"),
id: "processQRCode: failure"
))
}
}
private func connect(_ link: String) {
planAndConnect(
link,
showAlert: { alert = .planAndConnectAlert(alert: $0) },
showActionSheet: { sheet = $0 },
dismiss: true,
incognito: nil
)
func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
AVCaptureDevice.requestAccess(for: .video) { allowed in
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
if allowed { cb?() }
}
}
}
@@ -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 {
@@ -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 {
@@ -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
}
}
@@ -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)
}
}
}
@@ -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
@@ -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)
@@ -47,7 +47,7 @@ struct UserAddressView: View {
userAddressScrollView()
} else {
userAddressScrollView()
.modifier(BackButton {
.modifier(BackButton(disabled: Binding.constant(false)) {
if savedAAS == aas {
dismiss()
} else {
@@ -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)
+40 -20
View File
@@ -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 = "<group>"; };
5C6D183329E93FBA00D430B3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFeaturePreferenceView.swift; sourceTree = "<group>"; };
5C746D9F2B9F09AD0049D734 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C746DA02B9F09AD0049D734 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C746DA12B9F09AD0049D734 /* libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.0.0-5ERcEUaz80vDh3tyJ6GR42.a"; sourceTree = "<group>"; };
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 = "<group>"; };
5C746DA32B9F09AD0049D734 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = "<group>"; };
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
5C777BD32B99B38B00C72EFF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C777BD42B99B38B00C72EFF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C777BD52B99B38B00C72EFF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
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 = "<group>"; };
5C777BD72B99B38B00C72EFF /* libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.6.0-AiwFoGVZWFALIHlLc8SJrg.a"; sourceTree = "<group>"; };
5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
5C84FE9429A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -483,6 +486,9 @@
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; };
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; };
8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = "<group>"; };
8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateFromAnotherDevice.swift; sourceTree = "<group>"; };
8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAnotherDevice.swift; sourceTree = "<group>"; };
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; };
D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
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 = "<group>";
@@ -776,6 +783,7 @@
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */,
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */,
5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */,
8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */,
);
path = UserSettings;
sourceTree = "<group>";
@@ -903,6 +911,15 @@
path = Group;
sourceTree = "<group>";
};
8C7D94982B8894D300B7B9E1 /* Migration */ = {
isa = PBXGroup;
children = (
8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */,
8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */,
);
path = Migration;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
@@ -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 */,
+31 -4
View File
@@ -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)
}
+205 -13
View File
@@ -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
}
+3 -1
View File
@@ -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<NotificationPreviewMode>(
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)
+14 -11
View File
@@ -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 {
+7 -2
View File
@@ -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)
}
@@ -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<Boolean> = CompletableDeferred()
@@ -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<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
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))
@@ -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 -> {
@@ -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<MigrationFromAnotherDeviceState?> 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 {
@@ -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<Chat> {
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<FileTransferMeta?, String?> {
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<RcvFileTransfer?, String?> {
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<T>(val get: () -> T, set: (T) -> Unit) {
val set: (T) -> Unit
private val _state: MutableState<T> by lazy { mutableStateOf(get()) }
val state: State<T> by lazy { _state }
private val _state: MutableState<T> = mutableStateOf(get())
val state: State<T> = _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<SndFileTransfer>): 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<SndFileTransfer>): 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<String>): 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<RcvCallInvitation>): 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<ArchiveError>): 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<String>? = 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
}
}
}
@@ -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<DBMigrationResult, ChatCtrl?> {
val dbKey = key ?: randomDatabasePassword()
Log.d(TAG, "chatInitTemporaryDatabase path: $dbPath")
val migrated = chatMigrateInit(dbPath, dbKey, confirmation.value)
val res = runCatching {
json.decodeFromString<DBMigrationResult>(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<DBMigrationResult>(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<Boolean> {
val deferred = CompletableDeferred<Boolean>()
AlertManager.shared.showAlertDialog(
@@ -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
@@ -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
}
/**
@@ -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)
}
}
@@ -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<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
progressIndicator: MutableState<Boolean>,
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<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
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<Boolean>,
@@ -242,9 +265,18 @@ fun resetFormAfterEncryption(
m.controller.appPrefs.initialRandomDBPassphrase.set(false)
}
fun setUseKeychain(value: Boolean, useKeychain: MutableState<Boolean>, prefs: AppPreferences) {
fun setUseKeychain(value: Boolean, useKeychain: MutableState<Boolean>, 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<Boolean>, prefs: AppPreferences, storedKey: MutableState<Boolean>, 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<Any?>? = 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<Boolean>,
useKeychain: MutableState<Boolean>,
storedKey: MutableState<Boolean>,
progressIndicator: MutableState<Boolean>
progressIndicator: MutableState<Boolean>,
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 = {},
)
}
@@ -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<Boolean>, 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(", "))
@@ -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<String?>,
chatArchiveTime: MutableState<Instant?>,
chatArchiveFile: MutableState<String?>
@@ -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
@@ -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", "")
@@ -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
@@ -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() }
}
}
@@ -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<MigrationState>.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<MigrationState>,
chatReceiver: MutableState<MigrationFromChatReceiver?>,
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<MigrationState>,
tempDatabaseFile: File,
chatReceiver: MutableState<MigrationFromChatReceiver?>,
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<MigrationState>.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<MigrationState>.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<MigrationState>) {
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<MigrationState>.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<MigrationState>.LinkDownloadingView(
link: String,
ctrl: ChatCtrl,
user: User,
archivePath: String,
tempDatabaseFile: File,
chatReceiver: MutableState<MigrationFromChatReceiver?>,
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<MigrationState>.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<MigrationState>.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<MigrationState>.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<MigrationState>.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<MigrationState>.MigrationConfirmationView(status: DBMigrationResult, passphrase: String, useKeychain: Boolean, netCfg: NetCfg) {
data class Tuple4<A,B,C,D>(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<MigrationState>.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<MigrationState>.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<MigrationState>.startDownloading(
totalBytes: Long,
ctrl: ChatCtrl,
user: User,
tempDatabaseFile: File,
chatReceiver: MutableState<MigrationFromChatReceiver?>,
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<MigrationState>.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<MigrationState>.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()
}
}
@@ -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<MigrationToState>.state: MigrationToState
get() = value
set(v) { value = v }
@Composable
fun MigrateToAnotherDeviceView(close: () -> Unit) {
val migrationState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf<MigrationToState>(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<MigrationToState>,
chatReceiver: MutableState<MigrationToChatReceiver?>
) {
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<MigrationToState>,
tempDatabaseFile: File,
chatReceiver: MutableState<MigrationToChatReceiver?>
) {
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<MigrationToState>.ChatStopInProgressView() {
Box {
SectionView(stringResource(MR.strings.migration_to_device_stopping_chat).uppercase()) {}
ProgressView()
}
LaunchedEffect(Unit) {
stopChat()
}
}
@Composable
private fun MutableState<MigrationToState>.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<MigrationToState>.PassphraseNotSetView() {
DatabaseEncryptionView(chatModel, true)
KeyChangeEffect(appPreferences.initialRandomDBPassphrase.state.value) {
if (!appPreferences.initialRandomDBPassphrase.get()) {
state = MigrationToState.UploadConfirmation
}
}
}
@Composable
private fun MutableState<MigrationToState>.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<MigrationToState>.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<MigrationToState>.ArchivingView() {
Box {
SectionView(stringResource(MR.strings.migration_to_device_archiving_database).uppercase()) {}
ProgressView()
}
LaunchedEffect(Unit) {
exportArchive()
}
}
@Composable
private fun MutableState<MigrationToState>.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<MigrationToState>.UploadProgressView(
uploadedBytes: Long,
totalBytes: Long,
ctrl: ChatCtrl,
user: User,
tempDatabaseFile: File,
chatReceiver: MutableState<MigrationToChatReceiver?>,
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<MigrationToState>.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<MigrationToState>.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<MigrationToState>.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<MigrationToState>.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<MigrationToState>.verifyDatabasePassphrase(dbKey: String) {
if (controller.testStorageEncryption(dbKey)) {
state = MigrationToState.UploadConfirmation
} else {
showErrorOnMigrationIfNeeded(DBMigrationResult.ErrorNotADatabase(""))
}
}
private fun MutableState<MigrationToState>.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<ChatCtrl, User>? {
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<MigrationToState>.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<MigrationToState>.startUploading(
totalBytes: Long,
ctrl: ChatCtrl,
user: User,
tempDatabaseFile: File,
chatReceiver: MutableState<MigrationToChatReceiver?>,
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<MigrationToState>.finishMigration(fileId: Long, ctrl: ChatCtrl) {
withBGApi {
cancelUploadedArchive(fileId, ctrl)
state = MigrationToState.Finished(false)
}
}
private fun MutableState<MigrationToState>.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<MigrationToState>.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()
}
}
@@ -301,7 +301,7 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState<String>, 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 {
@@ -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()
@@ -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
@@ -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<OnionHosts>,
sessionMode: MutableState<TransportSessionMode>,
proxyPort: State<Int>,
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<Boolean>,
onionHosts: MutableState<OnionHosts>,
sessionMode: MutableState<TransportSessionMode>,
networkProxyHostPort: SharedPreference<String?>,
proxyPort: State<Int>,
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<Boolean>,
proxyPort: State<Int>,
toggleSocksProxy: (Boolean) -> Unit,
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)
showModal: (@Composable ModalData.() -> Unit) -> Unit,
networkProxyHostPort: SharedPreference<String?> = 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<String?> = 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<OnionHosts>,
enabled: State<Boolean>,
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<TransportSessionMode>,
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) },
@@ -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()
@@ -54,8 +54,10 @@
<string name="decryption_error">Decryption error</string>
<string name="encryption_renegotiation_error">Encryption re-negotiation error</string>
<string name="e2ee_info_no_pq">This conversation is protected by end-to-end encryption with perfect forward secrecy, repudiation and break-in recovery.</string>
<string name="e2ee_info_pq">This conversation is protected by quantum resistant end-to-end encryption. It has perfect forward secrecy, repudiation and quantum resistant break-in recovery.</string>
<string name="e2ee_info_no_pq"><![CDATA[Messages, files and calls are protected by <b>end-to-end encryption</b> with perfect forward secrecy, repudiation and break-in recovery.]]></string>
<string name="e2ee_info_pq"><![CDATA[Messages, files and calls are protected by <b>quantum resistant e2e encryption</b> with perfect forward secrecy, repudiation and break-in recovery.]]></string>
<string name="e2ee_info_no_pq_short">This chat is protected by end-to-end encryption.</string>
<string name="e2ee_info_pq_short">This chat is protected by quantum resistant end-to-end encryption.</string>
<!-- NoteFolder - ChatModel.kt -->
<string name="note_folder_local_display_name">Private notes</string>
@@ -243,6 +245,7 @@
<string name="auth_stop_chat">Stop chat</string>
<string name="auth_open_chat_console">Open chat console</string>
<string name="auth_open_chat_profiles">Open chat profiles</string>
<string name="auth_open_migration_to_another_device">Open migration screen</string>
<string name="lock_not_enabled">SimpleX Lock not enabled!</string>
<string name="you_can_turn_on_lock">You can turn on SimpleX Lock via Settings.</string>
@@ -821,6 +824,7 @@
<string name="opensource_protocol_and_code_anybody_can_run_servers">Open-source protocol and code anybody can run the servers.</string>
<string name="create_your_profile">Create your profile</string>
<string name="make_private_connection">Make a private connection</string>
<string name="migrate_from_another_device">Migrate from another device</string>
<string name="how_it_works">How it works</string>
<!-- How SimpleX Works -->
@@ -1079,6 +1083,7 @@
<string name="confirm_new_passphrase">Confirm new passphrase…</string>
<string name="update_database_passphrase">Update database passphrase</string>
<string name="set_database_passphrase">Set database passphrase</string>
<string name="set_passphrase">Set passphrase</string>
<string name="enter_correct_current_passphrase">Please enter correct current passphrase.</string>
<string name="database_is_not_encrypted">Your chat database is not encrypted - set passphrase to protect it.</string>
<string name="keychain_is_storing_securely">Android Keystore is used to securely store passphrase - it allows notification service to work.</string>
@@ -1840,4 +1845,64 @@
<string name="agent_internal_error_title">Internal error</string>
<string name="agent_internal_error_desc">Please report it to the developers: \n%s</string>
<string name="restart_chat_button">Restart chat</string>
<!-- MigrateFromAnotherDevice.kt -->
<string name="migrate_here">Migrate here</string>
<string name="or_paste_archive_link">Or paste archive link</string>
<string name="paste_archive_link">Paste archive link</string>
<string name="invalid_file_link">Invalid link</string>
<string name="migration_from_device_migrating">Migrating</string>
<string name="migration_from_device_database_init">Preparing download</string>
<string name="migration_from_device_downloading_details">Downloading link details</string>
<string name="migration_from_device_downloading_archive">Downloading archive</string>
<string name="migration_from_device_bytes_downloaded">%s downloaded</string>
<string name="migration_from_device_download_failed">Download failed</string>
<string name="migration_from_device_repeat_download">Repeat download</string>
<string name="migration_from_device_try_again">You can give another try.</string>
<string name="migration_from_device_importing_archive">Importing archive</string>
<string name="migration_from_device_import_failed">Import failed</string>
<string name="migration_from_device_repeat_import">Repeat import</string>
<string name="migration_from_device_enter_passphrase">Enter passphrase</string>
<string name="migration_from_device_file_delete_or_link_invalid">File was deleted or link is invalid</string>
<string name="migration_from_device_error_downloading_archive">Error downloading the archive</string>
<string name="migration_from_device_chat_migrated">Chat migrated!</string>
<string name="migration_from_device_finalize_migration">Finalize migration on another device.</string>
<string name="migration_from_device_confirm_network_settings">Confirm network settings</string>
<string name="migration_from_device_confirm_network_settings_footer">Please confirm that network settings are correct for this device.</string>
<string name="migration_from_device_apply_onion">Apply</string>
<!-- MigrateToAnotherDevice.kt -->
<string name="migrate_to_device">Migrate to another device</string>
<string name="migrate_to_device_error_saving_settings">Error saving settings</string>
<string name="migrate_to_device_exported_file_doesnt_exist">Exported file doesn\'t exist</string>
<string name="migrate_to_device_error_exporting_archive">Error exporting chat database</string>
<string name="migration_to_device_database_init">Preparing upload</string>
<string name="migration_to_device_error_uploading_archive">Error uploading the archive</string>
<string name="migration_to_device_error_deleting_database">Error deleting database</string>
<string name="migration_to_device_stopping_chat">Stopping chat</string>
<string name="migration_to_device_chat_should_be_stopped">In order to continue, chat should be stopped.</string>
<string name="migration_to_device_archive_and_upload">Archive and upload</string>
<string name="migration_to_device_confirm_upload">Confirm upload</string>
<string name="migration_to_device_all_data_will_be_uploaded">All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays.</string>
<string name="migration_to_device_archiving_database">Archiving database</string>
<string name="migration_to_device_bytes_uploaded">%s uploaded</string>
<string name="migration_to_device_uploading_archive">Uploading archive</string>
<string name="migration_to_device_upload_failed">Upload failed</string>
<string name="migration_to_device_repeat_upload">Repeat upload</string>
<string name="migration_to_device_try_again">You can give another try.</string>
<string name="migration_to_device_creating_archive_link">Creating archive link</string>
<string name="migration_to_device_cancel_migration">Cancel migration</string>
<string name="migration_to_device_finalize_migration">Finalize migration</string>
<string name="migration_to_device_choose_migrate_from_another_device"><![CDATA[Choose <i>Migrate from another device</i> on the new device and scan QR code.]]></string>
<string name="migration_to_device_or_share_this_file_link">Or securely share this file link</string>
<string name="migration_to_device_delete_database_from_device">Delete database from this device</string>
<string name="migration_to_device_starting_chat_on_multiple_devices_unsupported">Warning: starting chat on multiple devices is not supported and will cause message delivery failures</string>
<string name="migration_to_device_start_chat">Start chat</string>
<string name="migration_to_device_migration_complete">Migration complete</string>
<string name="migration_to_device_you_must_not_start_database_on_two_device"><![CDATA[You <b>must not</b> use the same database on two devices.]]></string>
<string name="migration_to_device_using_on_two_device_breaks_encryption"><![CDATA[<b>Please note</b>: using the same database on two devices will break the decryption of messages from your connections, as a security protection.]]></string>
<string name="migration_to_device_verify_database_passphrase">Verify database passphrase</string>
<string name="migration_to_device_verify_passphrase">Verify passphrase</string>
<string name="migration_to_device_confirm_you_remember_passphrase">Confirm that you remember database passphrase to migrate it.</string>
</resources>
@@ -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<Boolean>,
initialRandomDBPassphrase: MutableState<Boolean>,
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))
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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}
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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_