ios: migration via link (#3808)

* ios: migration via link

* changes in UI

* UI

* UI and API changes

* UI and logic

* simplify statement

* UI, API, logic

* formatting

* animation fix

* better animation

* test

* changed directory

* changes

* migrating to device

* migrate settings

* more state updates on main thread

* texts

* continue migration after restart

* toggle for saving passphrase and footer text

* no visual arthefacts when deleting a chat after migration

* saving settings before changing passphrase

* back button is looking disabled when it's disabled

* fixed starting chat issues when migrating to device

* paste and share link elements

* proper import process and refactoring UI in SimpleXInfo

* show progress on settings while starting chat

* title bold font

* changes as in Android

* brace

* changes as in Android

* rename to prevent confusion

* fixes and adapted to Android

* unused param

* comment

* don't allow going back on Archiving step

* update core library

* changes as in Android

* correction

* correction

* change

* qr code

* update network settings view

* update progress

* changes

* navigation view and focus in text field

* texts

---------

Co-authored-by: Avently <avently@local>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko
2024-03-11 21:17:28 +07:00
committed by GitHub
parent 80690326cb
commit a56bc6760b
27 changed files with 2142 additions and 188 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()
}
@@ -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)
@@ -185,6 +185,9 @@
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; };
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; };
8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C05382D2B39887E006436DC /* VideoUtils.swift */; };
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; };
8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7D94992B88952700B7B9E1 /* MigrateFromAnotherDevice.swift */; };
8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7DF31F2B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift */; };
D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; };
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; };
@@ -473,6 +476,9 @@
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<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; };
@@ -553,6 +559,7 @@
5CB924DD27A8622200ACCCDD /* NewChat */,
5CFA59C22860B04D00863A68 /* Database */,
5CB634AB29E46CDB0066AD6B /* LocalAuth */,
8C7D94982B8894D300B7B9E1 /* Migration */,
5CA8D01B2AD9B076001FD661 /* RemoteAccess */,
5CB924DF27A8678B00ACCCDD /* UserSettings */,
5C2E261127A30FEA00F70299 /* TerminalView.swift */,
@@ -766,6 +773,7 @@
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */,
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */,
5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */,
8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */,
);
path = UserSettings;
sourceTree = "<group>";
@@ -893,6 +901,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 */
@@ -1124,6 +1141,7 @@
5CBD285A295711D700EC2CF4 /* ImageUtils.swift in Sources */,
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */,
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
8C7D949A2B88952700B7B9E1 /* MigrateFromAnotherDevice.swift in Sources */,
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
5C029EAA283942EA004A9677 /* CallController.swift in Sources */,
5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */,
@@ -1179,6 +1197,7 @@
5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */,
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */,
5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */,
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */,
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */,
@@ -1220,6 +1239,7 @@
5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */,
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */,
5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */,
8C7DF3202B7CDB0A00C886D0 /* MigrateToAnotherDevice.swift in Sources */,
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */,
5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */,
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */,
+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)
+5 -2
View File
@@ -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)
}