From 6a2f2a512fbdc58808fb5ef7d9b64aef28adb2d8 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 24 Jun 2022 13:52:20 +0100 Subject: [PATCH] ios: UI to export/import/delete chat database (#743) * ios: UI to export/import/delete chat database * move files * ui for database migration * migration screen layout * ios: export archive and delete chat database * import archive * refactor, update texts * database migration (almost works) * fix missing import * delete legacy database * update migration errors --- apps/ios/Shared/ContentView.swift | 47 ++- apps/ios/Shared/Model/BGManager.swift | 7 +- apps/ios/Shared/Model/ChatModel.swift | 2 + apps/ios/Shared/Model/SimpleXAPI.swift | 90 ++--- apps/ios/Shared/SimpleXApp.swift | 37 +- .../Chat/ComposeMessage/ComposeView.swift | 20 +- .../Shared/Views/ChatList/ChatListView.swift | 18 +- .../Views/Database/ChatArchiveView.swift | 65 ++++ .../Shared/Views/Database/DatabaseView.swift | 331 ++++++++++++++++++ .../Database/MigrateToAppGroupView.swift | 248 +++++++++++++ .../Views/Onboarding/CreateProfile.swift | 4 +- .../Views/Onboarding/MakeConnection.swift | 10 +- .../Views/UserSettings/SettingsView.swift | 40 ++- .../ios/SimpleX NSE/NotificationService.swift | 85 ++--- apps/ios/SimpleX.xcodeproj/project.pbxproj | 76 ++-- .../xcschemes/SimpleX (iOS).xcscheme | 2 +- apps/ios/SimpleXChat/API.swift | 57 ++- apps/ios/SimpleXChat/APITypes.swift | 25 +- apps/ios/SimpleXChat/AppGroup.swift | 72 +++- apps/ios/SimpleXChat/FileUtils.swift | 65 +++- apps/ios/SimpleXChat/SimpleX.h | 2 + 21 files changed, 1097 insertions(+), 206 deletions(-) create mode 100644 apps/ios/Shared/Views/Database/ChatArchiveView.swift create mode 100644 apps/ios/Shared/Views/Database/DatabaseView.swift create mode 100644 apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index e1e3b808b7..356292334b 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -14,6 +14,7 @@ struct ContentView: View { @Binding var doAuthenticate: Bool @Binding var userAuthorized: Bool? @State private var showChatInfo: Bool = false // TODO comprehensively close modal views on authentication + @State private var v3DBMigration = v3DBMigrationDefault.get() @AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false @AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @@ -26,35 +27,43 @@ struct ContentView: View { if let step = chatModel.onboardingStage { if case .onboardingComplete = step, chatModel.currentUser != nil { - ZStack(alignment: .top) { - ChatListView(showChatInfo: $showChatInfo) - .onAppear { - NtfManager.shared.requestAuthorization(onDeny: { - alertManager.showAlert(notificationAlert()) - }) - // Local Authentication notice is to be shown on next start after onboarding is complete - if (!prefLANoticeShown && prefShowLANotice) { - prefLANoticeShown = true - alertManager.showAlert(laNoticeAlert()) - } - prefShowLANotice = true - } - if chatModel.showCallView, let call = chatModel.activeCall { - ActiveCallView(call: call) - } - IncomingCallView() - } + mainView() } else { OnboardingView(onboarding: step) } + } else if !v3DBMigrationDefault.get().startChat { + MigrateToAppGroupView() } } } - .onAppear { if doAuthenticate { runAuthenticate() } } + .onAppear { + if doAuthenticate { runAuthenticate() } + } .onChange(of: doAuthenticate) { _ in if doAuthenticate { runAuthenticate() } } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } } + private func mainView() -> some View { + ZStack(alignment: .top) { + ChatListView(showChatInfo: $showChatInfo) + .onAppear { + NtfManager.shared.requestAuthorization(onDeny: { + alertManager.showAlert(notificationAlert()) + }) + // Local Authentication notice is to be shown on next start after onboarding is complete + if (!prefLANoticeShown && prefShowLANotice) { + prefLANoticeShown = true + alertManager.showAlert(laNoticeAlert()) + } + prefShowLANotice = true + } + if chatModel.showCallView, let call = chatModel.activeCall { + ActiveCallView(call: call) + } + IncomingCallView() + } + } + private func runAuthenticate() { if !prefPerformLA { userAuthorized = true diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index b76097bc79..e1d9e819b1 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -8,6 +8,7 @@ import Foundation import BackgroundTasks +import SimpleXChat private let receiveTaskId = "chat.simplex.app.receive" @@ -71,7 +72,11 @@ class BGManager { } self.completed = false DispatchQueue.main.async { - initializeChat() + do { + try initializeChat(start: true) + } catch let error { + fatalError("Failed to start or load chats: \(responseError(error))") + } if ChatModel.shared.currentUser == nil { completeReceiving("no current user") return diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 0696b0b6e9..e60f51d534 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -15,6 +15,8 @@ import SimpleXChat final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var currentUser: User? + @Published var chatRunning: Bool? + @Published var chatDbChanged = false // list of chat "previews" @Published var chats: [Chat] = [] // current chat diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7fca87c52a..ed40b1eb26 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -72,7 +72,7 @@ func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { let msgDelay: Double = 7.5 let maxTaskDuration: Double = 15 -private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> ChatResponse) -> ChatResponse { +private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> T) -> T { let endTask = beginBGTask() DispatchQueue.global().asyncAfter(deadline: .now() + maxTaskDuration, execute: endTask) let r = f() @@ -93,11 +93,9 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = if case let .response(_, json) = resp { logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") } - if case .apiParseMarkdown = cmd {} else { - DispatchQueue.main.async { - ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) - ChatModel.shared.terminalItems.append(.resp(.now, resp)) - } + DispatchQueue.main.async { + ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) + ChatModel.shared.terminalItems.append(.resp(.now, resp)) } return resp } @@ -108,10 +106,10 @@ func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil } } -func chatRecvMsg() async -> ChatResponse { +func chatRecvMsg() async -> ChatResponse? { await withCheckedContinuation { cont in - _ = withBGTask(bgDelay: msgDelay) { - let resp = chatResponse(chat_recv_msg(getChatCtrl())!) + _ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in + let resp = recvSimpleXMsg() cont.resume(returning: resp) return resp } @@ -143,8 +141,8 @@ func apiStartChat() throws -> Bool { } } -func apiStopChat() throws { - let r = chatSendCmdSync(.apiStopChat) +func apiStopChat() async throws { + let r = await chatSendCmd(.apiStopChat) switch r { case .chatStopped: return default: throw r @@ -163,6 +161,18 @@ func apiSetFilesFolder(filesFolder: String) throws { throw r } +func apiExportArchive(config: ArchiveConfig) async throws { + try await sendCommandOkResp(.apiExportArchive(config: config)) +} + +func apiImportArchive(config: ArchiveConfig) async throws { + try await sendCommandOkResp(.apiImportArchive(config: config)) +} + +func apiDeleteStorage() async throws { + try await sendCommandOkResp(.apiDeleteStorage) +} + func apiGetChats() throws -> [Chat] { let r = chatSendCmdSync(.apiGetChats) if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } } @@ -326,12 +336,6 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? { } } -func apiParseMarkdown(text: String) throws -> [FormattedText]? { - let r = chatSendCmdSync(.apiParseMarkdown(text: text)) - if case let .apiParsedMarkdown(formattedText) = r { return formattedText } - throw r -} - func apiCreateUserAddress() async throws -> String { let r = await chatSendCmd(.createMyAddress) if case let .userContactLinkCreated(connReq) = r { return connReq } @@ -468,42 +472,42 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { throw r } -func initializeChat() { +func initializeChat(start: Bool) throws { logger.debug("initializeChat") do { let m = ChatModel.shared + try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) m.currentUser = try apiGetActiveUser() if m.currentUser == nil { m.onboardingStage = .step1_SimpleXInfo + } else if start { + try startChat() } else { - startChat() + m.chatRunning = false } } catch { - fatalError("Failed to initialize chat controller or database: \(error)") + fatalError("Failed to initialize chat controller or database: \(responseError(error))") } } -func startChat() { +func startChat() throws { logger.debug("startChat") - do { - let m = ChatModel.shared - // TODO set file folder once, before chat is started - let justStarted = try apiStartChat() - if justStarted { - try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) - m.userAddress = try apiGetUserAddress() - m.userSMPServers = try getUserSMPServers() - m.chats = try apiGetChats() - withAnimation { - m.onboardingStage = m.chats.isEmpty - ? .step3_MakeConnection - : .onboardingComplete - } + let m = ChatModel.shared + // TODO set file folder once, before chat is started + let justStarted = try apiStartChat() + if justStarted { + m.userAddress = try apiGetUserAddress() + m.userSMPServers = try getUserSMPServers() + m.chats = try apiGetChats() + withAnimation { + m.onboardingStage = m.chats.isEmpty + ? .step3_MakeConnection + : .onboardingComplete } - ChatReceiver.shared.start() - } catch { - fatalError("Failed to start or load chats: \(error)") } + ChatReceiver.shared.start() + m.chatRunning = true + chatLastStartGroupDefault.set(Date.now) } class ChatReceiver { @@ -524,9 +528,11 @@ class ChatReceiver { } func receiveMsgLoop() async { - let msg = await chatRecvMsg() - self._lastMsgTime = .now - await processReceivedMsg(msg) + // TODO use function that has timeout + if let msg = await chatRecvMsg() { + self._lastMsgTime = .now + await processReceivedMsg(msg) + } if self.receiveMessages { do { try await Task.sleep(nanoseconds: 7_500_000) } catch { logger.error("receiveMsgLoop: Task.sleep error: \(error.localizedDescription)") } @@ -697,7 +703,7 @@ func processReceivedMsg(_ res: ChatResponse) async { // CallController.shared.reportCallRemoteEnded(call: call) } case let .appPhase(appPhase): - setAppState(AppState(appPhase: appPhase)) + appStateGroupDefault.set(AppState(appPhase: appPhase)) default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 4c46ecde5c..557ef1f532 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -25,6 +25,7 @@ struct SimpleXApp: App { init() { hs_init(0, nil) UserDefaults.standard.register(defaults: appDefaults) + setDbContainer() BGManager.shared.register() NtfManager.shared.registerCategories() } @@ -38,7 +39,11 @@ struct SimpleXApp: App { chatModel.appOpenUrl = url } .onAppear() { - initializeChat() + do { + try initializeChat(start: v3DBMigrationDefault.get().startChat) + } catch let error { + fatalError("Failed to start or load chats: \(responseError(error))") + } } .onChange(of: scenePhase) { phase in logger.debug("scenePhase \(String(describing: scenePhase))") @@ -51,7 +56,7 @@ struct SimpleXApp: App { } doAuthenticate = false case .active: - setAppState(.active) + appStateGroupDefault.set(.active) apiSetAppPhase(appPhase: .active) doAuthenticate = authenticationExpired() default: @@ -61,12 +66,34 @@ struct SimpleXApp: App { } } + private func setDbContainer() { +// Uncomment and run once to open DB in app documents folder: + // dbContainerGroupDefault.set(.documents) + // v3DBMigrationDefault.set(.offer) +// to create database in app documents folder also uncomment: + // let legacyDatabase = true + let legacyDatabase = hasLegacyDatabase() + if legacyDatabase, case .documents = dbContainerGroupDefault.get() { + dbContainerGroupDefault.set(.documents) + switch v3DBMigrationDefault.get() { + case .migrated: () + default: v3DBMigrationDefault.set(.offer) + } + logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath(), privacy: .public)*.db") + } else { + dbContainerGroupDefault.set(.group) + v3DBMigrationDefault.set(.ready) + logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath(), privacy: .public)*.db") + logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not", privacy: .public) present") + } + } + private func pauseApp() { - setAppState(.pausing) + appStateGroupDefault.set(.pausing) apiSetAppPhase(appPhase: .paused) let endTask = beginBGTask { - if getAppState() != .active { - setAppState(.suspending) + if appStateGroupDefault.get() != .active { + appStateGroupDefault.set(.suspending) apiSetAppPhase(appPhase: .suspended) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 87f6c44e39..c97719dc1c 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -210,9 +210,8 @@ struct ComposeView: View { allowedContentTypes: [.data], allowsMultipleSelection: false ) { result in - if case .success = result { + if case let .success(files) = result, let fileURL = files.first { do { - let fileURL: URL = try result.get().first! var fileSize: Int? = nil if fileURL.startAccessingSecurityScopedResource() { let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey]) @@ -392,17 +391,12 @@ struct ComposeView: View { } private func parseMessage(_ msg: String) -> URL? { - do { - let parsedMsg = try apiParseMarkdown(text: msg) - let uri = parsedMsg?.first(where: { ft in - ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) - }) - if let uri = uri { return URL(string: uri.text) } - else { return nil } - } catch { - logger.error("apiParseMarkdown error: \(error.localizedDescription)") - return nil - } + let parsedMsg = parseSimpleXMarkdown(msg) + let uri = parsedMsg?.first(where: { ft in + ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) + }) + if let uri = uri { return URL(string: uri.text) } + else { return nil } } private func isSimplexLink(_ link: String) -> Bool { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 721365c2f1..e4b3d87b18 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -23,6 +23,7 @@ struct ChatListView: View { ForEach(filteredChats()) { chat in ChatListNavLink(chat: chat, showChatInfo: $showChatInfo) .padding(.trailing, -16) + .disabled(chatModel.chatRunning != true) } } .onChange(of: chatModel.chatId) { _ in @@ -46,7 +47,11 @@ struct ChatListView: View { SettingsButton() } ToolbarItem(placement: .navigationBarTrailing) { - NewChatButton() + switch chatModel.chatRunning { + case .some(true): NewChatButton() + case .some(false): chatStoppedIcon() + case .none: EmptyView() + } } } } @@ -74,6 +79,17 @@ struct ChatListView: View { } } +func chatStoppedIcon() -> some View { + Button { + AlertManager.shared.showAlertMsg( + title: "Chat is stopped", + message: "You can start chat via app Settings / Database or by restarting the app" + ) + } label: { + Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red) + } +} + struct ChatListView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() diff --git a/apps/ios/Shared/Views/Database/ChatArchiveView.swift b/apps/ios/Shared/Views/Database/ChatArchiveView.swift new file mode 100644 index 0000000000..65913343d5 --- /dev/null +++ b/apps/ios/Shared/Views/Database/ChatArchiveView.swift @@ -0,0 +1,65 @@ +// +// ChatArchiveView.swift +// SimpleXChat +// +// Created by Evgeny on 23/06/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChatArchiveView: View { + var archiveName: String + @AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String? + @AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0 + @State private var showDeleteAlert = false + + var body: some View { + let fileUrl = getDocumentsDirectory().appendingPathComponent(archiveName) + let fileTs = chatArchiveTimeDefault.get() + List { + Section { + settingsRow("square.and.arrow.up") { + Button { + showShareSheet(items: [fileUrl]) + } label: { + Text("Save archive") + } + } + settingsRow("trash") { + Button { + showDeleteAlert = true + } label: { + Text("Delete archive").foregroundColor(.red) + } + } + } header: { + Text("Chat archive") + } footer: { + Text("Created on \(fileTs)") + } + } + .alert(isPresented: $showDeleteAlert) { + Alert( + title: Text("Delete chat archive?"), + primaryButton: .destructive(Text("Delete")) { + do { + try FileManager.default.removeItem(atPath: fileUrl.path) + chatArchiveName = nil + chatArchiveTime = 0 + } catch let error { + logger.error("removeItem error \(String(describing: error))") + } + }, + secondaryButton: .cancel() + ) + } + } +} + +struct ChatArchiveView_Previews: PreviewProvider { + static var previews: some View { + ChatArchiveView(archiveName: "") + } +} diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift new file mode 100644 index 0000000000..6ff5a10348 --- /dev/null +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -0,0 +1,331 @@ +// +// DatabaseView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 19/06/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +enum DatabaseAlert: Identifiable { + case stopChat + case importArchive + case archiveImported + case deleteChat + case chatDeleted + case deleteLegacyDatabase + case error(title: LocalizedStringKey, error: String = "") + + var id: String { + switch self { + case .stopChat: return "stopChat" + case .importArchive: return "importArchive" + case .archiveImported: return "archiveImported" + case .deleteChat: return "deleteChat" + case .chatDeleted: return "chatDeleted" + case .deleteLegacyDatabase: return "deleteLegacyDatabase" + case let .error(title, _): return "error \(title)" + } + } +} + +struct DatabaseView: View { + @EnvironmentObject var m: ChatModel + @Binding var showSettings: Bool + @State private var runChat = false + @State private var alert: DatabaseAlert? = nil + @State private var showFileImporter = false + @State private var importedArchivePath: URL? + @State private var progressIndicator = false + @AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String? + @AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0 + @State private var dbContainer = dbContainerGroupDefault.get() + @State private var legacyDatabase = hasLegacyDatabase() + + var body: some View { + ZStack { + chatDatabaseView() + if progressIndicator { + ProgressView().scaleEffect(2) + } + } + } + + private func chatDatabaseView() -> some View { + List { + let stopped = m.chatRunning == false + Section { + settingsRow( + stopped ? "exclamationmark.octagon.fill" : "play.fill", + color: stopped ? .red : .green + ) { + Toggle( + stopped ? "Chat is stopped" : "Chat is running", + isOn: $runChat + ) + .onChange(of: runChat) { _ in + if (runChat) { + startChat() + } else { + alert = .stopChat + } + } + } + } header: { + Text("Run chat") + } footer: { + if case .documents = dbContainer { + Text("Database will be migrated when the app restarts") + } + } + + Section { + settingsRow("square.and.arrow.up") { + Button { + exportArchive() + } label: { + Text("Export database") + } + } + settingsRow("square.and.arrow.down") { + Button(role: .destructive) { + showFileImporter = true + } label: { + Text("Import database") + } + } + if let archiveName = chatArchiveName { + let title: LocalizedStringKey = chatArchiveTimeDefault.get() < chatLastStartGroupDefault.get() + ? "Old database archive" + : "New database archive" + settingsRow("archivebox") { + NavigationLink { + ChatArchiveView(archiveName: archiveName) + .navigationTitle(title) + } label: { + Text(title) + } + } + } + settingsRow("trash.slash") { + Button(role: .destructive) { + alert = .deleteChat + } label: { + Text("Delete database") + } + } + } header: { + Text("Chat database") + } footer: { + Text( + stopped + ? "You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." + : "Stop chat to enable database actions" + ) + } + .disabled(!stopped) + + if case .group = dbContainer, legacyDatabase { + Section("Old database") { + settingsRow("trash") { + Button { + alert = .deleteLegacyDatabase + } label: { + Text("Delete old database") + } + } + } + } + } + .onAppear { runChat = m.chatRunning ?? true } + .alert(item: $alert) { item in databaseAlert(item) } + .fileImporter( + isPresented: $showFileImporter, + allowedContentTypes: [.zip], + allowsMultipleSelection: false + ) { result in + if case let .success(files) = result, let fileURL = files.first { + importedArchivePath = fileURL + alert = .importArchive + } + } + } + + private func databaseAlert(_ alertItem: DatabaseAlert) -> Alert { + switch alertItem { + case .stopChat: + return Alert( + title: Text("Stop chat?"), + message: Text("Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped."), + primaryButton: .destructive(Text("Stop")) { + stopChat() + }, + secondaryButton: .cancel { + withAnimation { runChat = true } + } + ) + case .importArchive: + if let fileURL = importedArchivePath { + return Alert( + title: Text("Import chat database?"), + message: Text("Your current chat database will be DELETED and REPLACED with the imported one.\n") + Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."), + primaryButton: .destructive(Text("Import")) { + importArchive(fileURL) + }, + secondaryButton: .cancel() + ) + } else { + return Alert(title: Text("Error: no database file")) + } + case .archiveImported: + return Alert( + title: Text("Chat database imported"), + message: Text("Restart the app to use imported chat database"), + primaryButton: .default(Text("Ok")), + secondaryButton: .cancel() + ) + + case .deleteChat: + return Alert( + title: Text("Delete chat profile?"), + message: Text("This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost."), + primaryButton: .destructive(Text("Delete")) { + deleteChat() + }, + secondaryButton: .cancel() + ) + case .chatDeleted: + return Alert( + title: Text("Chat database deleted"), + message: Text("Restart the app to create a new chat profile"), + primaryButton: .default(Text("Ok")), + secondaryButton: .cancel() + ) + case .deleteLegacyDatabase: + return Alert( + title: Text("Delete old database?"), + message: Text("The old database was not removed during the migration, it can be deleted."), + primaryButton: .destructive(Text("Delete")) { + deleteLegacyDatabase() + }, + secondaryButton: .cancel() + ) + case let .error(title, error): + return Alert(title: Text(title), message: Text("\(error)")) + } + } + + private func stopChat() { + Task { + do { + try await apiStopChat() + await MainActor.run { m.chatRunning = false } + } catch let error { + await MainActor.run { + runChat = true + alert = .error(title: "Error stopping chat", error: responseError(error)) + } + } + } + } + + private func exportArchive() { + progressIndicator = true + Task { + do { + let archivePath = try await exportChatArchive() + showShareSheet(items: [archivePath]) + await MainActor.run { progressIndicator = false } + } catch let error { + await MainActor.run { + alert = .error(title: "Error exporting chat database", error: responseError(error)) + progressIndicator = false + } + } + } + } + + private func importArchive(_ archivePath: URL) { + if archivePath.startAccessingSecurityScopedResource() { + progressIndicator = true + Task { + do { + try await apiDeleteStorage() + do { + let config = ArchiveConfig(archivePath: archivePath.path) + try await apiImportArchive(config: config) + await operationEnded(.archiveImported) + } catch let error { + await operationEnded(.error(title: "Error importing chat database", error: responseError(error))) + } + } catch let error { + await operationEnded(.error(title: "Error deleting chat database", error: responseError(error))) + } + archivePath.stopAccessingSecurityScopedResource() + } + } else { + alert = .error(title: "Error accessing database file") + } + } + + private func deleteChat() { + progressIndicator = true + Task { + do { + try await apiDeleteStorage() + await operationEnded(.chatDeleted) + } catch let error { + await operationEnded(.error(title: "Error deleting database", error: responseError(error))) + } + } + } + + private func deleteLegacyDatabase() { + if removeLegacyDatabaseAndFiles() { + legacyDatabase = false + } else { + alert = .error(title: "Error deleting old database") + } + } + + private func operationEnded(_ dbAlert: DatabaseAlert) async { + await MainActor.run { + m.chatDbChanged = true + progressIndicator = false + alert = dbAlert + } + } + + private func startChat() { + if m.chatDbChanged { + showSettings = false + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + resetChatCtrl() + do { + try initializeChat(start: true) + m.chatDbChanged = false + } catch let error { + fatalError("Error starting chat \(responseError(error))") + } + } + } else { + do { + _ = try apiStartChat() + runChat = true + m.chatRunning = true + chatLastStartGroupDefault.set(Date.now) + } catch let error { + runChat = false + alert = .error(title: "Error starting chat", error: responseError(error)) + } + } + } +} + +struct DatabaseView_Previews: PreviewProvider { + static var previews: some View { + DatabaseView(showSettings: Binding.constant(false)) + } +} diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift new file mode 100644 index 0000000000..3749047b3f --- /dev/null +++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift @@ -0,0 +1,248 @@ +// +// MigrateToGroupView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 20/06/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +enum V3DBMigrationState: String { + case offer + case postponed + case exporting + case export_error + case exported + case migrating + case migration_error + case migrated + case ready + + var startChat: Bool { + switch self { + case .postponed: return true + case .ready: return true + default: return false + } + } +} + +let v3DBMigrationDefault = EnumDefault( + defaults: UserDefaults.standard, + forKey: DEFAULT_CHAT_V3_DB_MIGRATION, + withDefault: .offer +) + +struct MigrateToAppGroupView: View { + @EnvironmentObject var chatModel: ChatModel + @State private var v3DBMigration = v3DBMigrationDefault.get() + @State private var migrationError = "" + @AppStorage(DEFAULT_CHAT_ARCHIVE_NAME) private var chatArchiveName: String? + @AppStorage(DEFAULT_CHAT_ARCHIVE_TIME) private var chatArchiveTime: Double = 0 + + var body: some View { + ZStack(alignment: .topLeading) { + Text("Database migration").font(.largeTitle) + + switch v3DBMigration { + case .offer: + VStack(alignment: .leading, spacing: 16) { + Text("To support instant push notifications the chat database has to be migrated.") + Text("If you need to use the chat now tap **Skip** below (you will be offered to migrate the database when you restart the app).") + } + .padding(.top, 56) + center { + Button { + migrateDatabaseToV3() + } label: { + Text("Start migration") + .font(.title) + .frame(maxWidth: .infinity) + } + } + skipMigration() + case .exporting: + center { + ProgressView(value: 0.33) + Text("Exporting database archive...") + } + migrationProgress() + case .export_error: + migrationFailed().padding(.top, 56) + center { + Text("Export error:").font(.headline) + Text(migrationError) + } + skipMigration() + case .exported: + center { + Text("Exported database archive.") + } + case .migrating: + center { + ProgressView(value: 0.67) + Text("Migrating database archive...") + } + migrationProgress() + case .migration_error: + VStack(alignment: .leading, spacing: 16) { + migrationFailed() + Text("The created archive is available via app Settings / Database / Old database archive.") + } + .padding(.top, 56) + center { + Text("Migration error:").font(.headline) + Text(migrationError) + } + skipMigration() + case .migrated: + center { + ProgressView(value: 1.0) + Text("Migration is completed") + } + VStack { + Spacer() + Spacer() + Spacer() + Button { + do { + resetChatCtrl() + try initializeChat(start: true) + setV3DBMigration(.ready) + } catch let error { + dbContainerGroupDefault.set(.documents) + setV3DBMigration(.migration_error) + migrationError = "Error starting chat: \(responseError(error))" + } + deleteOldArchive() + } label: { + Text("Start using chat") + .font(.title) + .frame(maxWidth: .infinity) + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + default: + Spacer() + Text("Unexpected migration state") + Text("\(v3DBMigration.rawValue)") + Spacer() + skipMigration() + } + } + .padding() + } + + private func center(@ViewBuilder c: @escaping () -> Content) -> some View where Content: View { + VStack(alignment: .leading, spacing: 8) { c() } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + private func migrationProgress() -> some View { + VStack { + Spacer() + ProgressView().scaleEffect(2) + Spacer() + Spacer() + Spacer() + } + .frame(maxWidth: .infinity) + } + + private func migrationFailed() -> some View { + Text("Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat).") + } + + private func skipMigration() -> some View { + ZStack { + Button { + setV3DBMigration(.postponed) + do { + try startChat() + } catch let error { + fatalError("Failed to start or load chats: \(responseError(error))") + } + } label: { + Text("Skip and start using chat") + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + } + + private func setV3DBMigration(_ value: V3DBMigrationState) { + v3DBMigration = value + v3DBMigrationDefault.set(value) + } + + func migrateDatabaseToV3() { + setV3DBMigration(.exporting) + let archiveTime = Date.now + let archiveName = "simplex-chat.\(archiveTime.ISO8601Format()).zip" + chatArchiveTime = archiveTime.timeIntervalSince1970 + chatArchiveName = archiveName + let config = ArchiveConfig(archivePath: getDocumentsDirectory().appendingPathComponent(archiveName).path) + Task { + do { + try await apiExportArchive(config: config) + await MainActor.run { setV3DBMigration(.exported) } + } catch let error { + await MainActor.run { + setV3DBMigration(.export_error) + migrationError = responseError(error) + } + return + } + + do { + await MainActor.run { setV3DBMigration(.migrating) } + dbContainerGroupDefault.set(.group) + resetChatCtrl() + try initializeChat(start: false) + try await apiImportArchive(config: config) + await MainActor.run { setV3DBMigration(.migrated) } + } catch let error { + dbContainerGroupDefault.set(.documents) + await MainActor.run { + setV3DBMigration(.migration_error) + migrationError = responseError(error) + } + } + } + } +} + +func exportChatArchive() 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 config = ArchiveConfig(archivePath: archivePath.path) + try await apiExportArchive(config: config) + deleteOldArchive() + UserDefaults.standard.set(archiveName, forKey: DEFAULT_CHAT_ARCHIVE_NAME) + chatArchiveTimeDefault.set(archiveTime) + return archivePath +} + +func deleteOldArchive() { + let d = UserDefaults.standard + if let archiveName = d.string(forKey: DEFAULT_CHAT_ARCHIVE_NAME) { + do { + try FileManager.default.removeItem(atPath: getDocumentsDirectory().appendingPathComponent(archiveName).path) + d.set(nil, forKey: DEFAULT_CHAT_ARCHIVE_NAME) + d.set(0, forKey: DEFAULT_CHAT_ARCHIVE_TIME) + } catch let error { + logger.error("removeItem error \(String(describing: error))") + } + } +} + +struct MigrateToGroupView_Previews: PreviewProvider { + static var previews: some View { + MigrateToAppGroupView() + } +} diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 578cd85d64..511ff82b36 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -95,11 +95,11 @@ struct CreateProfile: View { ) do { m.currentUser = try apiCreateActiveUser(profile) - startChat() + try startChat() withAnimation { m.onboardingStage = .step3_MakeConnection } } catch { - fatalError("Failed to create user: \(error)") + fatalError("Failed to create user or start chat: \(responseError(error))") } } diff --git a/apps/ios/Shared/Views/Onboarding/MakeConnection.swift b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift index 88676535b8..92ae376b9b 100644 --- a/apps/ios/Shared/Views/Onboarding/MakeConnection.swift +++ b/apps/ios/Shared/Views/Onboarding/MakeConnection.swift @@ -16,7 +16,14 @@ struct MakeConnection: View { var body: some View { VStack(alignment: .leading) { - SettingsButton().padding(.bottom, 1) + HStack { + SettingsButton() + if m.chatRunning == false { + Spacer() + chatStoppedIcon() + } + } + .padding(.bottom, 1) if let user = m.currentUser { Text("Welcome \(user.displayName)!") @@ -66,6 +73,7 @@ struct MakeConnection: View { } } } + .disabled(m.chatRunning != true) } Spacer() diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 017360e30c..5226e11b1e 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -24,6 +24,9 @@ let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay" let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls" +let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName" +let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime" +let DEFAULT_CHAT_V3_DB_MIGRATION = "chatV3DBMigration" let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, @@ -34,11 +37,14 @@ let appDefaults: [String: Any] = [ DEFAULT_WEBRTC_POLICY_RELAY: true, DEFAULT_PRIVACY_ACCEPT_IMAGES: true, DEFAULT_PRIVACY_LINK_PREVIEWS: true, - DEFAULT_EXPERIMENTAL_CALLS: false + DEFAULT_EXPERIMENTAL_CALLS: false, + DEFAULT_CHAT_V3_DB_MIGRATION: "offer" ] private var indent: CGFloat = 36 +let chatArchiveTimeDefault = DateDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CHAT_ARCHIVE_TIME) + struct SettingsView: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @@ -52,7 +58,7 @@ struct SettingsView: View { var body: some View { let user: User = chatModel.currentUser! - return NavigationView { + NavigationView { List { Section("You") { NavigationLink { @@ -62,12 +68,30 @@ struct SettingsView: View { ProfilePreview(profileOf: user) .padding(.leading, -8) } + .disabled(chatModel.chatRunning != true) + NavigationLink { UserAddress() .navigationTitle("Your chat address") } label: { settingsRow("qrcode") { Text("Your SimpleX contact address") } } + .disabled(chatModel.chatRunning != true) + + NavigationLink { + DatabaseView(showSettings: $showSettings) + .navigationTitle("Your chat database") + } label: { + settingsRow("internaldrive") { + HStack { + Text("Database export & import") + Spacer() + if chatModel.chatRunning == false { + Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red) + } + } + } + } } Section("Settings") { @@ -76,7 +100,9 @@ struct SettingsView: View { CallSettings() .navigationTitle("Your calls") } label: { - settingsRow("video") { Text("Audio & video calls") } + settingsRow("video") { + Text("Audio & video calls") + } } } NavigationLink { @@ -95,6 +121,7 @@ struct SettingsView: View { settingsRow("server.rack") { Text("SMP servers") } } } + .disabled(chatModel.chatRunning != true) Section("Help") { NavigationLink { @@ -128,6 +155,7 @@ struct SettingsView: View { Text("Chat with the developers") } } + .disabled(chatModel.chatRunning != true) settingsRow("envelope") { Text("[Send us email](mailto:chat@simplex.chat)") } } @@ -137,6 +165,7 @@ struct SettingsView: View { } label: { settingsRow("terminal") { Text("Chat console") } } + .disabled(chatModel.chatRunning != true) ZStack(alignment: .leading) { Image(colorScheme == .dark ? "github_light" : "github") .resizable() @@ -156,6 +185,7 @@ struct SettingsView: View { notificationsIcon() notificationsToggle(token) } + .disabled(chatModel.chatRunning != true) } Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))") } @@ -261,9 +291,9 @@ struct SettingsView: View { } } -func settingsRow(_ icon: String, content: @escaping () -> Content) -> some View { +func settingsRow(_ icon: String, color: Color = .secondary, content: @escaping () -> Content) -> some View { ZStack(alignment: .leading) { - Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.secondary) + Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(color) content().padding(.leading, indent) } } diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 91313e288f..6c1d1a6c71 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -18,9 +18,9 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("NotificationService.didReceive") - print("*** userInfo", request.content.userInfo) - let appState = getAppState() + let appState = appStateGroupDefault.get() if appState.running { + print("userInfo", request.content.userInfo) contentHandler(request.content) return } @@ -33,9 +33,10 @@ class NotificationService: UNNotificationServiceExtension { let encNtfInfo = ntfData["message"] as? String, let _ = startChat() { apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) - let content = receiveMessages() - contentHandler (content) - return + if let content = receiveMessages() { + contentHandler(content) + return + } } if let bestAttemptContent = bestAttemptContent { @@ -68,6 +69,7 @@ func startChat() -> User? { do { try apiStartChat() try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) + chatLastStartGroupDefault.set(Date.now) return user } catch { logger.error("NotificationService startChat error: \(responseError(error), privacy: .public)") @@ -78,40 +80,43 @@ func startChat() -> User? { return nil } -func receiveMessages() -> UNNotificationContent { +func receiveMessages() -> UNNotificationContent? { logger.debug("NotificationService receiveMessages started") while true { - let res = chatResponse(chat_recv_msg(getChatCtrl())!) - logger.debug("NotificationService receiveMessages: \(res.responseType)") - switch res { -// case let .newContactConnection(connection): -// case let .contactConnectionDeleted(connection): - case let .contactConnected(contact): - return createContactConnectedNtf(contact) -// case let .contactConnecting(contact): -// TODO profile update - case let .receivedContactRequest(contactRequest): - return createContactRequestNtf(contactRequest) -// case let .contactUpdated(toContact): -// TODO profile updated - case let .newChatItem(aChatItem): - let cInfo = aChatItem.chatInfo - let cItem = aChatItem.chatItem - return createMessageReceivedNtf(cInfo, cItem) -// case let .chatItemUpdated(aChatItem): -// TODO message updated -// let cInfo = aChatItem.chatInfo -// let cItem = aChatItem.chatItem -// NtfManager.shared.notifyMessageReceived(cInfo, cItem) -// case let .chatItemDeleted(_, toChatItem): -// TODO message updated -// case let .rcvFileComplete(aChatItem): -// TODO file received? -// let cInfo = aChatItem.chatInfo -// let cItem = aChatItem.chatItem -// NtfManager.shared.notifyMessageReceived(cInfo, cItem) - default: - logger.debug("NotificationService ignored event: \(res.responseType)") + if let res = recvSimpleXMsg() { + logger.debug("NotificationService receiveMessages: \(res.responseType)") + switch res { + // case let .newContactConnection(connection): + // case let .contactConnectionDeleted(connection): + case let .contactConnected(contact): + return createContactConnectedNtf(contact) + // case let .contactConnecting(contact): + // TODO profile update + case let .receivedContactRequest(contactRequest): + return createContactRequestNtf(contactRequest) + // case let .contactUpdated(toContact): + // TODO profile updated + case let .newChatItem(aChatItem): + let cInfo = aChatItem.chatInfo + let cItem = aChatItem.chatItem + return createMessageReceivedNtf(cInfo, cItem) + // case let .chatItemUpdated(aChatItem): + // TODO message updated + // let cInfo = aChatItem.chatInfo + // let cItem = aChatItem.chatItem + // NtfManager.shared.notifyMessageReceived(cInfo, cItem) + // case let .chatItemDeleted(_, toChatItem): + // TODO message updated + // case let .rcvFileComplete(aChatItem): + // TODO file received? + // let cInfo = aChatItem.chatInfo + // let cItem = aChatItem.chatItem + // NtfManager.shared.notifyMessageReceived(cInfo, cItem) + default: + logger.debug("NotificationService ignored event: \(res.responseType)") + } + } else { + return nil } } } @@ -147,9 +152,9 @@ func apiSetFilesFolder(filesFolder: String) throws { func apiGetNtfMessage(nonce: String, encNtfInfo: String) { let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) if case let .ntfMessages(connEntity, msgTs, ntfMessages) = r { - print(connEntity) - print(msgTs) - print(ntfMessages) + if let connEntity = connEntity { print("connEntity", connEntity) } + if let msgTs = msgTs { print("msgTs", msgTs) } + print("ntfMessages", ntfMessages) return } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index cea0f83938..ed5e204143 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; }; 5C3F1D5A2844B4DE00EC8A82 /* ExperimentalFeaturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */; }; + 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; @@ -40,11 +41,6 @@ 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */; }; 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; - 5C69D5B22852379F009B27A4 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C6F2A3728522C9A00103588 /* libgmp.a */; }; - 5C69D5B32852379F009B27A4 /* libHSsimplex-chat-2.2.0-3TOca6xkke4IR3YLgDepFy.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C6F2A3B28522C9A00103588 /* libHSsimplex-chat-2.2.0-3TOca6xkke4IR3YLgDepFy.a */; }; - 5C69D5B42852379F009B27A4 /* libHSsimplex-chat-2.2.0-3TOca6xkke4IR3YLgDepFy-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C6F2A3A28522C9A00103588 /* libHSsimplex-chat-2.2.0-3TOca6xkke4IR3YLgDepFy-ghc8.10.7.a */; }; - 5C69D5B52852379F009B27A4 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C6F2A3828522C9A00103588 /* libffi.a */; }; - 5C69D5B62852379F009B27A4 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C6F2A3928522C9A00103588 /* libgmpxx.a */; }; 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; @@ -102,6 +98,13 @@ 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; + 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; + 5CFA59CA2864464A00863A68 /* libHSsimplex-chat-2.2.0-40J3CUJkXRXAsiR552cpHl-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFA59C52864464A00863A68 /* libHSsimplex-chat-2.2.0-40J3CUJkXRXAsiR552cpHl-ghc8.10.7.a */; }; + 5CFA59CB2864464A00863A68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFA59C62864464A00863A68 /* libffi.a */; }; + 5CFA59CC2864464A00863A68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFA59C72864464A00863A68 /* libgmpxx.a */; }; + 5CFA59CD2864464A00863A68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFA59C82864464A00863A68 /* libgmp.a */; }; + 5CFA59CE2864464A00863A68 /* libHSsimplex-chat-2.2.0-40J3CUJkXRXAsiR552cpHl.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CFA59C92864464A00863A68 /* libHSsimplex-chat-2.2.0-40J3CUJkXRXAsiR552cpHl.a */; }; + 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; @@ -198,6 +201,7 @@ 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.swift; sourceTree = ""; }; 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalFeaturesView.swift; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; + 5C4B3B09285FB130003915F2 /* DatabaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseView.swift; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; @@ -209,11 +213,6 @@ 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = ""; }; 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = ""; }; - 5C6F2A3728522C9A00103588 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C6F2A3828522C9A00103588 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C6F2A3928522C9A00103588 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C6F2A3A28522C9A00103588 /* libHSsimplex-chat-2.2.0-3TOca6xkke4IR3YLgDepFy-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.2.0-3TOca6xkke4IR3YLgDepFy-ghc8.10.7.a"; sourceTree = ""; }; - 5C6F2A3B28522C9A00103588 /* libHSsimplex-chat-2.2.0-3TOca6xkke4IR3YLgDepFy.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.2.0-3TOca6xkke4IR3YLgDepFy.a"; sourceTree = ""; }; 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = ""; }; 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; @@ -269,6 +268,13 @@ 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; + 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; sourceTree = ""; }; + 5CFA59C52864464A00863A68 /* libHSsimplex-chat-2.2.0-40J3CUJkXRXAsiR552cpHl-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.2.0-40J3CUJkXRXAsiR552cpHl-ghc8.10.7.a"; sourceTree = ""; }; + 5CFA59C62864464A00863A68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CFA59C72864464A00863A68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CFA59C82864464A00863A68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CFA59C92864464A00863A68 /* libHSsimplex-chat-2.2.0-40J3CUJkXRXAsiR552cpHl.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-2.2.0-40J3CUJkXRXAsiR552cpHl.a"; sourceTree = ""; }; + 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; 6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = ""; }; @@ -313,13 +319,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C69D5B62852379F009B27A4 /* libgmpxx.a in Frameworks */, + 5CFA59CE2864464A00863A68 /* libHSsimplex-chat-2.2.0-40J3CUJkXRXAsiR552cpHl.a in Frameworks */, + 5CFA59CA2864464A00863A68 /* libHSsimplex-chat-2.2.0-40J3CUJkXRXAsiR552cpHl-ghc8.10.7.a in Frameworks */, + 5CFA59CC2864464A00863A68 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C69D5B32852379F009B27A4 /* libHSsimplex-chat-2.2.0-3TOca6xkke4IR3YLgDepFy.a in Frameworks */, - 5C69D5B22852379F009B27A4 /* libgmp.a in Frameworks */, - 5C69D5B42852379F009B27A4 /* libHSsimplex-chat-2.2.0-3TOca6xkke4IR3YLgDepFy-ghc8.10.7.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5C69D5B52852379F009B27A4 /* libffi.a in Frameworks */, + 5CFA59CD2864464A00863A68 /* libgmp.a in Frameworks */, + 5CFA59CB2864464A00863A68 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -349,6 +355,7 @@ 5C5F4AC227A5E9AF00B51EF1 /* Chat */, 5CB9250B27A942F300ACCCDD /* ChatList */, 5CB924DD27A8622200ACCCDD /* NewChat */, + 5CFA59C22860B04D00863A68 /* Database */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, ); @@ -372,11 +379,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C6F2A3828522C9A00103588 /* libffi.a */, - 5C6F2A3728522C9A00103588 /* libgmp.a */, - 5C6F2A3928522C9A00103588 /* libgmpxx.a */, - 5C6F2A3A28522C9A00103588 /* libHSsimplex-chat-2.2.0-3TOca6xkke4IR3YLgDepFy-ghc8.10.7.a */, - 5C6F2A3B28522C9A00103588 /* libHSsimplex-chat-2.2.0-3TOca6xkke4IR3YLgDepFy.a */, + 5CFA59C62864464A00863A68 /* libffi.a */, + 5CFA59C82864464A00863A68 /* libgmp.a */, + 5CFA59C72864464A00863A68 /* libgmpxx.a */, + 5CFA59C52864464A00863A68 /* libHSsimplex-chat-2.2.0-40J3CUJkXRXAsiR552cpHl-ghc8.10.7.a */, + 5CFA59C92864464A00863A68 /* libHSsimplex-chat-2.2.0-40J3CUJkXRXAsiR552cpHl.a */, ); path = Libraries; sourceTree = ""; @@ -584,6 +591,16 @@ path = ComposeMessage; sourceTree = ""; }; + 5CFA59C22860B04D00863A68 /* Database */ = { + isa = PBXGroup; + children = ( + 5C4B3B09285FB130003915F2 /* DatabaseView.swift */, + 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */, + 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */, + ); + path = Database; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -685,7 +702,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1330; - LastUpgradeCheck = 1330; + LastUpgradeCheck = 1340; ORGANIZATIONNAME = "SimpleX Chat"; TargetAttributes = { 5CA059C9279559F40002BEB4 = { @@ -713,6 +730,7 @@ knownRegions = ( en, ru, + Base, ); mainGroup = 5CA059BD279559F40002BEB4; packageReferences = ( @@ -786,7 +804,9 @@ 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, + 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, + 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, @@ -825,6 +845,7 @@ 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */, 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */, + 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */, @@ -948,6 +969,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -1008,6 +1030,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -1094,7 +1117,6 @@ PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1217,7 +1239,6 @@ SDKROOT = iphoneos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -1284,10 +1305,6 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( "$(inherited)", "$(PROJECT_DIR)/Libraries/ios", @@ -1305,7 +1322,6 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_INCLUDE_PATHS = ""; SWIFT_OBJC_BRIDGING_HEADER = ./SimpleXChat/SimpleX.h; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; @@ -1335,10 +1351,6 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( "$(inherited)", "$(PROJECT_DIR)/Libraries/ios", diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme index ecdec033c9..6a1d4192e6 100644 --- a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme @@ -1,6 +1,6 @@ chat_ctrl { if let controller = chatController { return controller } - let dataDir = getDocumentsDirectory().path + "/mobile_v1" - logger.debug("documents directory \(dataDir)") - var cstr = dataDir.cString(using: .utf8)! + let dbPath = getAppDatabasePath().path + logger.debug("getChatCtrl DB path: \(dbPath)") + var cstr = dbPath.cString(using: .utf8)! chatController = chat_init(&cstr) logger.debug("getChatCtrl: chat_init") return chatController! } -public func sendSimpleXCmd(_ cmd: ChatCommand) -> ChatResponse { - var c = cmd.cmdString.cString(using: .utf8)! - return chatResponse(chat_send_cmd(getChatCtrl(), &c)) +public func resetChatCtrl() { + chatController = nil } -public func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse { - let s = String.init(cString: cjson) +public func sendSimpleXCmd(_ cmd: ChatCommand) -> ChatResponse { + var c = cmd.cmdString.cString(using: .utf8)! + let cjson = chat_send_cmd(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) { + let s = fromCString(cjson) + return s == "" ? nil : chatResponse(s) + } + return nil +} + +public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? { + var c = s.cString(using: .utf8)! + if let cjson = chat_parse_markdown(&c) { + if let d = fromCString(cjson).data(using: .utf8) { + do { + let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d) + return r.formattedText + } catch { + logger.error("parseSimpleXMarkdown jsonDecoder.decode error: \(error.localizedDescription)") + } + } + } + return nil +} + +struct ParsedMarkdown: Decodable { + var formattedText: [FormattedText]? +} + +private func fromCString(_ c: UnsafeMutablePointer) -> String { + let s = String.init(cString: c) + free(c) + return s +} + +public func chatResponse(_ s: String) -> ChatResponse { let d = s.data(using: .utf8)! // TODO is there a way to do it without copying the data? e.g: // let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) @@ -46,7 +86,6 @@ public func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse { } json = prettyJSON(j) } - free(cjson) return ChatResponse.response(type: type ?? "invalid", json: json ?? s) } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 4424cf2c73..999f0531f4 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -18,6 +18,9 @@ public enum ChatCommand { case apiStopChat case apiSetAppPhase(appPhase: AgentPhase) case setFilesFolder(filesFolder: String) + case apiExportArchive(config: ArchiveConfig) + case apiImportArchive(config: ArchiveConfig) + case apiDeleteStorage case apiGetChats case apiGetChat(type: ChatType, id: Int64) case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) @@ -35,7 +38,6 @@ public enum ChatCommand { case apiDeleteChat(type: ChatType, id: Int64) case apiClearChat(type: ChatType, id: Int64) case apiUpdateProfile(profile: Profile) - case apiParseMarkdown(text: String) case createMyAddress case deleteMyAddress case showMyAddress @@ -62,6 +64,9 @@ public enum ChatCommand { case .apiStopChat: return "/_stop" case let .apiSetAppPhase(appPhase): return "/_app phase \(appPhase)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" + case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" + case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" + case .apiDeleteStorage: return "/_db delete" case .apiGetChats: return "/_get chats pcc=on" case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" case let .apiSendMessage(type, id, file, quotedItemId, mc): @@ -81,7 +86,6 @@ public enum ChatCommand { case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" - case let .apiParseMarkdown(text): return "/_parse \(text)" case .createMyAddress: return "/address" case .deleteMyAddress: return "/delete_address" case .showMyAddress: return "/show_address" @@ -110,6 +114,9 @@ public enum ChatCommand { case .apiStopChat: return "apiStopChat" case .apiSetAppPhase: return "apiSetAppPhase" case .setFilesFolder: return "setFilesFolder" + case .apiExportArchive: return "apiExportArchive" + case .apiImportArchive: return "apiImportArchive" + case .apiDeleteStorage: return "apiDeleteStorage" case .apiGetChats: return "apiGetChats" case .apiGetChat: return "apiGetChat" case .apiSendMessage: return "apiSendMessage" @@ -127,7 +134,6 @@ public enum ChatCommand { case .apiDeleteChat: return "apiDeleteChat" case .apiClearChat: return "apiClearChat" case .apiUpdateProfile: return "apiUpdateProfile" - case .apiParseMarkdown: return "apiParseMarkdown" case .createMyAddress: return "createMyAddress" case .deleteMyAddress: return "deleteMyAddress" case .showMyAddress: return "showMyAddress" @@ -178,7 +184,6 @@ public enum ChatResponse: Decodable, Error { case chatCleared(chatInfo: ChatInfo) case userProfileNoChange case userProfileUpdated(fromProfile: Profile, toProfile: Profile) - case apiParsedMarkdown(formattedText: [FormattedText]?) case userContactLink(connReqContact: String) case userContactLinkCreated(connReqContact: String) case userContactLinkDeleted @@ -243,7 +248,6 @@ public enum ChatResponse: Decodable, Error { case .chatCleared: return "chatCleared" case .userProfileNoChange: return "userProfileNoChange" case .userProfileUpdated: return "userProfileUpdated" - case .apiParsedMarkdown: return "apiParsedMarkdown" case .userContactLink: return "userContactLink" case .userContactLinkCreated: return "userContactLinkCreated" case .userContactLinkDeleted: return "userContactLinkDeleted" @@ -309,7 +313,6 @@ public enum ChatResponse: Decodable, Error { case let .chatCleared(chatInfo): return String(describing: chatInfo) case .userProfileNoChange: return noDetails case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) - case let .apiParsedMarkdown(formattedText): return String(describing: formattedText) case let .userContactLink(connReq): return connReq case let .userContactLinkCreated(connReq): return connReq case .userContactLinkDeleted: return noDetails @@ -370,6 +373,16 @@ public enum AgentPhase: String, Codable { case suspended = "SUSPENDED" } +public struct ArchiveConfig: Encodable { + var archivePath: String + var disableCompression: Bool? + + public init(archivePath: String, disableCompression: Bool? = nil) { + self.archivePath = archivePath + self.disableCompression = disableCompression + } +} + public func decodeJSON(_ json: String) -> T? { if let data = json.data(using: .utf8) { return try? jsonDecoder.decode(T.self, from: data) diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index abb996ecab..15c26d896f 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -10,12 +10,12 @@ import Foundation import SwiftUI let GROUP_DEFAULT_APP_STATE = "appState" +let GROUP_DEFAULT_DB_CONTAINER = "dbContainer" +public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart" let APP_GROUP_NAME = "group.chat.simplex.app" -func getGroupDefaults() -> UserDefaults? { - UserDefaults(suiteName: APP_GROUP_NAME) -} +public let groupDefaults = UserDefaults(suiteName: APP_GROUP_NAME)! public enum AppState: String { case active @@ -42,18 +42,66 @@ public enum AppState: String { } } -public func setAppState(_ state: AppState) { - if let defaults = getGroupDefaults() { - defaults.set(state.rawValue, forKey: GROUP_DEFAULT_APP_STATE) +public enum DBContainer: String { + case documents + case group +} + +public let appStateGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_APP_STATE, + withDefault: .active +) + +public let dbContainerGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_DB_CONTAINER, + withDefault: .documents +) + +public let chatLastStartGroupDefault = DateDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_CHAT_LAST_START) + +public class DateDefault { + var defaults: UserDefaults + var key: String + + public init(defaults: UserDefaults = UserDefaults.standard, forKey: String) { + self.defaults = defaults + self.key = forKey + } + + public func get() -> Date { + let ts = defaults.double(forKey: key) + return Date(timeIntervalSince1970: ts) + } + + public func set(_ ts: Date) { + defaults.set(ts.timeIntervalSince1970, forKey: key) defaults.synchronize() } } -public func getAppState() -> AppState { - if let defaults = getGroupDefaults(), - let rawValue = defaults.string(forKey: GROUP_DEFAULT_APP_STATE), - let state = AppState(rawValue: rawValue) { - return state +public class EnumDefault where T.RawValue == String { + var defaults: UserDefaults + var key: String + var defaultValue: T + + public init(defaults: UserDefaults = UserDefaults.standard, forKey: String, withDefault: T) { + self.defaults = defaults + self.key = forKey + self.defaultValue = withDefault + } + + public func get() -> T { + if let rawValue = defaults.string(forKey: key), + let value = T(rawValue: rawValue) { + return value + } + return defaultValue + } + + public func set(_ value: T) { + defaults.set(value.rawValue, forKey: key) + defaults.synchronize() } - return .active } diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 0393039316..b236712910 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -17,13 +17,55 @@ public let maxImageSize: Int64 = 236700 public let maxFileSize: Int64 = 8000000 -func getDocumentsDirectory() -> URL { -// FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! +public func getDocumentsDirectory() -> URL { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! +} + +func getGroupContainerDirectory() -> URL { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)! } +func getAppDirectory() -> URL { + dbContainerGroupDefault.get() == .group + ? getGroupContainerDirectory() + : getDocumentsDirectory() +// getDocumentsDirectory() +} + +let DB_FILE_PREFIX = "simplex_v1" + +func getLegacyDatabasePath() -> URL { + getDocumentsDirectory().appendingPathComponent("mobile_v1", isDirectory: false) +} + +public func getAppDatabasePath() -> URL { + dbContainerGroupDefault.get() == .group + ? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX, isDirectory: false) + : getLegacyDatabasePath() +// getLegacyDatabasePath() +} + +public func hasLegacyDatabase() -> Bool { + let dbPath = getLegacyDatabasePath() + let fm = FileManager.default + return fm.isReadableFile(atPath: dbPath.path + "_agent.db") && + fm.isReadableFile(atPath: dbPath.path + "_chat.db") +} + +public func removeLegacyDatabaseAndFiles() -> Bool { + let dbPath = getLegacyDatabasePath() + let appFiles = getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true) + let fm = FileManager.default + let r1 = nil != (try? fm.removeItem(atPath: dbPath.path + "_agent.db")) + let r2 = nil != (try? fm.removeItem(atPath: dbPath.path + "_chat.db")) + try? fm.removeItem(atPath: dbPath.path + "_agent.db.bak") + try? fm.removeItem(atPath: dbPath.path + "_chat.db.bak") + try? fm.removeItem(at: appFiles) + return r1 && r2 +} + public func getAppFilesDirectory() -> URL { - getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true) + getAppDirectory().appendingPathComponent("app_files", isDirectory: true) } func getAppFilePath(_ fileName: String) -> URL { @@ -96,8 +138,9 @@ private func saveFile(_ data: Data, _ fileName: String) -> String? { private func uniqueCombine(_ fileName: String) -> String { func tryCombine(_ fileName: String, _ n: Int) -> String { - let name = fileName.deletingPathExtension - let ext = fileName.pathExtension + let ns = fileName as NSString + let name = ns.deletingPathExtension + let ext = ns.pathExtension let suffix = (n == 0) ? "" : "_\(n)" let f = "\(name)\(suffix).\(ext)" return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f @@ -105,18 +148,6 @@ private func uniqueCombine(_ fileName: String) -> String { return tryCombine(fileName, 0) } -private extension String { - var ns: NSString { - return self as NSString - } - var pathExtension: String { - return ns.pathExtension - } - var deletingPathExtension: String { - return ns.deletingPathExtension - } -} - public func removeFile(_ fileName: String) { do { try FileManager.default.removeItem(atPath: getAppFilePath(fileName).path) diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index dab7fa3eb6..e848ad5cdd 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -18,3 +18,5 @@ typedef void* chat_ctrl; extern chat_ctrl chat_init(char *path); extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); extern char *chat_recv_msg(chat_ctrl ctl); +extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait); +extern char *chat_parse_markdown(char *str);