diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 9e6073c10e..145e362797 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -42,6 +42,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { let m = ChatModel.shared let deviceToken = DeviceToken(pushProvider: PushProvider(env: pushEnvironment), token: token) m.deviceToken = deviceToken + // savedToken is set in startChat, when it is started before this method is called if m.savedToken != nil { registerToken(token: deviceToken) } @@ -80,7 +81,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { } } else if let checkMessages = ntfData["checkMessages"] as? Bool, checkMessages { logger.debug("AppDelegate: didReceiveRemoteNotification: checkMessages") - if appStateGroupDefault.get().inactive && m.ntfEnablePeriodic { + if m.ntfEnablePeriodic && allowBackgroundRefresh() && BGManager.shared.lastRanLongAgo { receiveMessages(completionHandler) } else { completionHandler(.noData) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index b69ccbb7c7..d7b9fef218 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -14,11 +14,14 @@ struct ContentView: View { @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared @Environment(\.colorScheme) var colorScheme - @Binding var doAuthenticate: Bool - @Binding var userAuthorized: Bool? - @Binding var canConnectCall: Bool - @Binding var lastSuccessfulUnlock: TimeInterval? - @Binding var showInitializationView: Bool + + var contentAccessAuthenticationExtended: Bool + + @Environment(\.scenePhase) var scenePhase + @State private var automaticAuthenticationAttempted = false + @State private var canConnectViewCall = false + @State private var lastSuccessfulUnlock: TimeInterval? = nil + @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 @@ -40,9 +43,19 @@ struct ContentView: View { } } + private var accessAuthenticated: Bool { + chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended + } + var body: some View { ZStack { - contentView() + // contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings. + // i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() } + if !prefPerformLA || accessAuthenticated { + contentView() + } else { + lockButton() + } if chatModel.showCallView, let call = chatModel.activeCall { callView(call) } @@ -50,6 +63,7 @@ struct ContentView: View { LocalAuthView(authRequest: la) } else if showSetPasscode { SetAppPasscodeView { + chatModel.contentViewAccessAuthenticated = true prefPerformLA = true showSetPasscode = false privacyLocalAuthModeDefault.set(.passcode) @@ -60,13 +74,9 @@ struct ContentView: View { alertManager.showAlert(laPasscodeNotSetAlert()) } } - } - .onAppear { - if prefPerformLA { requestNtfAuthorization() } - initAuthenticate() - } - .onChange(of: doAuthenticate) { _ in - initAuthenticate() + if chatModel.chatDbStatus == nil { + initializationView() + } } .alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! } .sheet(isPresented: $showSettings) { @@ -76,14 +86,44 @@ struct ContentView: View { Button("System authentication") { initialEnableLA() } Button("Passcode entry") { showSetPasscode = true } } + .onChange(of: scenePhase) { phase in + logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") + switch (phase) { + case .background: + // also see .onChange(of: scenePhase) in SimpleXApp: on entering background + // it remembers enteredBackgroundAuthenticated and sets chatModel.contentViewAccessAuthenticated to false + automaticAuthenticationAttempted = false + canConnectViewCall = false + case .active: + canConnectViewCall = !prefPerformLA || contentAccessAuthenticationExtended || unlockedRecently() + + // condition `!chatModel.contentViewAccessAuthenticated` is required for when authentication is enabled in settings or on initial notice + if prefPerformLA && !chatModel.contentViewAccessAuthenticated { + if AppChatState.shared.value != .stopped { + if contentAccessAuthenticationExtended { + chatModel.contentViewAccessAuthenticated = true + } else { + if !automaticAuthenticationAttempted { + automaticAuthenticationAttempted = true + // authenticate if call kit call is not in progress + if !(CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil) { + authenticateContentViewAccess() + } + } + } + } else { + // when app is stopped automatic authentication is not attempted + chatModel.contentViewAccessAuthenticated = contentAccessAuthenticationExtended + } + } + default: + break + } + } } @ViewBuilder private func contentView() -> some View { - if prefPerformLA && userAuthorized != true { - lockButton() - } else if chatModel.chatDbStatus == nil && showInitializationView { - initializationView() - } else if let status = chatModel.chatDbStatus, status != .ok { + if let status = chatModel.chatDbStatus, status != .ok { DatabaseErrorView(status: status) } else if !chatModel.v3DBMigration.startChat { MigrateToAppGroupView() @@ -106,11 +146,11 @@ struct ContentView: View { if CallController.useCallKit() { ActiveCallView(call: call, canConnectCall: Binding.constant(true)) .onDisappear { - if userAuthorized == false && doAuthenticate { runAuthenticate() } + if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() } } } else { - ActiveCallView(call: call, canConnectCall: $canConnectCall) - if prefPerformLA && userAuthorized != true { + ActiveCallView(call: call, canConnectCall: $canConnectViewCall) + if prefPerformLA && !accessAuthenticated { Rectangle() .fill(colorScheme == .dark ? .black : .white) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -120,22 +160,27 @@ struct ContentView: View { } private func lockButton() -> some View { - Button(action: runAuthenticate) { Label("Unlock", systemImage: "lock") } + Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") } } private func initializationView() -> some View { VStack { ProgressView().scaleEffect(2) - Text("Opening database…") + Text("Opening app…") .padding() } + .frame(maxWidth: .infinity, maxHeight: .infinity ) + .background( + Rectangle() + .fill(.background) + ) } private func mainView() -> some View { ZStack(alignment: .top) { ChatListView(showSettings: $showSettings).privacySensitive(protectScreen) .onAppear { - if !prefPerformLA { requestNtfAuthorization() } + requestNtfAuthorization() // Local Authentication notice is to be shown on next start after onboarding is complete if (!prefLANoticeShown && prefShowLANotice && !chatModel.chats.isEmpty) { prefLANoticeShown = true @@ -187,48 +232,37 @@ struct ContentView: View { } } - private func initAuthenticate() { - logger.debug("initAuthenticate") - if CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil { - userAuthorized = false - } else if doAuthenticate { - runAuthenticate() - } - } - - private func runAuthenticate() { - logger.debug("DEBUGGING: runAuthenticate") - if !prefPerformLA { - userAuthorized = true + private func unlockedRecently() -> Bool { + if let lastSuccessfulUnlock = lastSuccessfulUnlock { + return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 } else { - logger.debug("DEBUGGING: before dismissAllSheets") - dismissAllSheets(animated: false) { - logger.debug("DEBUGGING: in dismissAllSheets callback") - chatModel.chatId = nil - justAuthenticate() - } + return false } } - private func justAuthenticate() { - userAuthorized = false - let laMode = privacyLocalAuthModeDefault.get() - authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in - logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))") - switch (laResult) { - case .success: - userAuthorized = true - canConnectCall = true - lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime - case .failed: - if laMode == .passcode { - AlertManager.shared.showAlert(laFailedAlert()) + private func authenticateContentViewAccess() { + logger.debug("DEBUGGING: authenticateContentViewAccess") + dismissAllSheets(animated: false) { + logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback") + chatModel.chatId = nil + + authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in + logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))") + switch (laResult) { + case .success: + chatModel.contentViewAccessAuthenticated = true + canConnectViewCall = true + lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime + case .failed: + chatModel.contentViewAccessAuthenticated = false + if privacyLocalAuthModeDefault.get() == .passcode { + AlertManager.shared.showAlert(laFailedAlert()) + } + case .unavailable: + prefPerformLA = false + canConnectViewCall = true + AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) } - case .unavailable: - userAuthorized = true - prefPerformLA = false - canConnectCall = true - AlertManager.shared.showAlert(laUnavailableTurningOffAlert()) } } } @@ -259,6 +293,7 @@ struct ContentView: View { authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in switch laResult { case .success: + chatModel.contentViewAccessAuthenticated = true prefPerformLA = true alertManager.showAlert(laTurnedOnAlert()) case .failed: diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index 5ee52407bf..25eab6c69e 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -15,7 +15,13 @@ private let receiveTaskId = "chat.simplex.app.receive" // TCP timeout + 2 sec private let waitForMessages: TimeInterval = 6 -private let bgRefreshInterval: TimeInterval = 450 +// This is the smallest interval between refreshes, and also target interval in "off" mode +private let bgRefreshInterval: TimeInterval = 600 // 10 minutes + +// This intervals are used for background refresh in instant and periodic modes +private let periodicBgRefreshInterval: TimeInterval = 1200 // 20 minutes + +private let maxBgRefreshInterval: TimeInterval = 2400 // 40 minutes private let maxTimerCount = 9 @@ -33,14 +39,14 @@ class BGManager { } } - func schedule() { + func schedule(interval: TimeInterval? = nil) { if !ChatModel.shared.ntfEnableLocal { logger.debug("BGManager.schedule: disabled") return } logger.debug("BGManager.schedule") let request = BGAppRefreshTaskRequest(identifier: receiveTaskId) - request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval) + request.earliestBeginDate = Date(timeIntervalSinceNow: interval ?? runInterval) do { try BGTaskScheduler.shared.submit(request) } catch { @@ -48,20 +54,34 @@ class BGManager { } } + var runInterval: TimeInterval { + switch ChatModel.shared.notificationMode { + case .instant: maxBgRefreshInterval + case .periodic: periodicBgRefreshInterval + case .off: bgRefreshInterval + } + } + + var lastRanLongAgo: Bool { + Date.now.timeIntervalSince(chatLastBackgroundRunGroupDefault.get()) > runInterval + } + private func handleRefresh(_ task: BGAppRefreshTask) { if !ChatModel.shared.ntfEnableLocal { logger.debug("BGManager.handleRefresh: disabled") return } logger.debug("BGManager.handleRefresh") - schedule() - if appStateGroupDefault.get().inactive { + let shouldRun_ = lastRanLongAgo + if allowBackgroundRefresh() && shouldRun_ { + schedule() let completeRefresh = completionHandler { task.setTaskCompleted(success: true) } task.expirationHandler = { completeRefresh("expirationHandler") } receiveMessages(completeRefresh) } else { + schedule(interval: shouldRun_ ? bgRefreshInterval : runInterval) logger.debug("BGManager.completionHandler: already active, not started") task.setTaskCompleted(success: true) } @@ -90,20 +110,22 @@ class BGManager { } self.completed = false DispatchQueue.main.async { + chatLastBackgroundRunGroupDefault.set(Date.now) let m = ChatModel.shared if (!m.chatInitialized) { + setAppState(.bgRefresh) do { try initializeChat(start: true) } catch let error { fatalError("Failed to start or load chats: \(responseError(error))") } } + activateChat(appState: .bgRefresh) if m.currentUser == nil { completeReceiving("no current user") return } logger.debug("BGManager.receiveMessages: starting chat") - activateChat(appState: .bgRefresh) let cr = ChatReceiver() self.chatReceiver = cr cr.start() diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 13fe0737e2..0cc281fda9 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -54,6 +54,8 @@ final class ChatModel: ObservableObject { @Published var chatDbChanged = false @Published var chatDbEncrypted: Bool? @Published var chatDbStatus: DBMigrationResult? + // local authentication + @Published var contentViewAccessAuthenticated: Bool = false @Published var laRequest: LocalAuthRequest? // list of chat "previews" @Published var chats: [Chat] = [] @@ -104,12 +106,10 @@ final class ChatModel: ObservableObject { static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } - var ntfEnableLocal: Bool { - notificationMode == .off || ntfEnableLocalGroupDefault.get() - } + let ntfEnableLocal = true var ntfEnablePeriodic: Bool { - notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get() + notificationMode != .off } var activeRemoteCtrl: Bool { diff --git a/apps/ios/Shared/Model/ImageUtils.swift b/apps/ios/Shared/Model/ImageUtils.swift index 90070e74d3..41d741e7e6 100644 --- a/apps/ios/Shared/Model/ImageUtils.swift +++ b/apps/ios/Shared/Model/ImageUtils.swift @@ -195,18 +195,18 @@ func moveTempFileFromURL(_ url: URL) -> CryptoFile? { } } -func generateNewFileName(_ prefix: String, _ ext: String) -> String { - uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)") +func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String { + uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath) } -private func uniqueCombine(_ fileName: String) -> String { +private func uniqueCombine(_ fileName: String, fullPath: Bool = false) -> String { func tryCombine(_ fileName: String, _ n: Int) -> String { 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 + return (FileManager.default.fileExists(atPath: fullPath ? f : getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f } return tryCombine(fileName, 0) } diff --git a/apps/ios/Shared/Model/NSESubscriber.swift b/apps/ios/Shared/Model/NSESubscriber.swift new file mode 100644 index 0000000000..f52e72beab --- /dev/null +++ b/apps/ios/Shared/Model/NSESubscriber.swift @@ -0,0 +1,83 @@ +// +// NSESubscriber.swift +// SimpleXChat +// +// Created by Evgeny on 09/12/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import Foundation +import SimpleXChat + +private var nseSubscribers: [UUID:NSESubscriber] = [:] + +// timeout for active notification service extension going into "suspending" state. +// If in two seconds the state does not change, we assume that it was not running and proceed with app activation/answering call. +private let SUSPENDING_TIMEOUT: TimeInterval = 2 + +// timeout should be larger than SUSPENDING_TIMEOUT +func waitNSESuspended(timeout: TimeInterval, dispatchQueue: DispatchQueue = DispatchQueue.main, suspended: @escaping (Bool) -> Void) { + if timeout <= SUSPENDING_TIMEOUT { + logger.warning("waitNSESuspended: small timeout \(timeout), using \(SUSPENDING_TIMEOUT + 1)") + } + var state = nseStateGroupDefault.get() + if case .suspended = state { + dispatchQueue.async { suspended(true) } + return + } + let id = UUID() + var suspendedCalled = false + checkTimeout() + nseSubscribers[id] = nseMessageSubscriber { msg in + if case let .state(newState) = msg { + state = newState + logger.debug("waitNSESuspended state: \(state.rawValue)") + if case .suspended = newState { + notifySuspended(true) + } + } + } + return + + func notifySuspended(_ ok: Bool) { + logger.debug("waitNSESuspended notifySuspended: \(ok)") + if !suspendedCalled { + logger.debug("waitNSESuspended notifySuspended: calling suspended(\(ok))") + suspendedCalled = true + nseSubscribers.removeValue(forKey: id) + dispatchQueue.async { suspended(ok) } + } + } + + func checkTimeout() { + if !suspending() { + checkSuspendingTimeout() + } else if state == .suspending { + checkSuspendedTimeout() + } + } + + func suspending() -> Bool { + suspendedCalled || state == .suspended || state == .suspending + } + + func checkSuspendingTimeout() { + DispatchQueue.global().asyncAfter(deadline: .now() + SUSPENDING_TIMEOUT) { + logger.debug("waitNSESuspended check suspending timeout") + if !suspending() { + notifySuspended(false) + } else if state != .suspended { + checkSuspendedTimeout() + } + } + } + + func checkSuspendedTimeout() { + DispatchQueue.global().asyncAfter(deadline: .now() + min(timeout - SUSPENDING_TIMEOUT, 1)) { + logger.debug("waitNSESuspended check suspended timeout") + if state != .suspended { + notifySuspended(false) + } + } + } +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 19030a2842..e67dab18fa 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -228,7 +228,8 @@ func apiStopChat() async throws { } func apiActivateChat() { - let r = chatSendCmdSync(.apiActivateChat) + chatReopenStore() + let r = chatSendCmdSync(.apiActivateChat(restoreChat: true)) if case .cmdOk = r { return } logger.error("apiActivateChat error: \(String(describing: r))") } @@ -1234,6 +1235,9 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool try startChat(refreshInvitations: refreshInvitations) } else { m.chatRunning = false + try getUserChatData() + NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers()) + m.onboardingStage = onboardingStageDefault.get() } } @@ -1250,6 +1254,8 @@ func startChat(refreshInvitations: Bool = true) throws { try refreshCallInvitations() } (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() + // deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called, + // when it is called before startChat if let token = m.deviceToken { registerToken(token: token) } diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 1c8c32f8b9..9b03f38f3c 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -9,27 +9,28 @@ import Foundation import UIKit import SimpleXChat +import SwiftUI private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock") -let appSuspendTimeout: Int = 15 // seconds - let bgSuspendTimeout: Int = 5 // seconds let terminationTimeout: Int = 3 // seconds +let activationDelay: TimeInterval = 1.5 + private func _suspendChat(timeout: Int) { // this is a redundant check to prevent logical errors, like the one fixed in this PR - let state = appStateGroupDefault.get() + let state = AppChatState.shared.value if !state.canSuspend { logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)") } else if ChatModel.ok { - appStateGroupDefault.set(.suspending) + AppChatState.shared.set(.suspending) apiSuspendChat(timeoutMicroseconds: timeout * 1000000) let endTask = beginBGTask(chatSuspended) DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask) } else { - appStateGroupDefault.set(.suspended) + AppChatState.shared.set(.suspended) } } @@ -41,18 +42,16 @@ func suspendChat() { func suspendBgRefresh() { suspendLockQueue.sync { - if case .bgRefresh = appStateGroupDefault.get() { + if case .bgRefresh = AppChatState.shared.value { _suspendChat(timeout: bgSuspendTimeout) } } } -private var terminating = false - func terminateChat() { logger.debug("terminateChat") suspendLockQueue.sync { - switch appStateGroupDefault.get() { + switch AppChatState.shared.value { case .suspending: // suspend instantly if already suspending _chatSuspended() @@ -64,7 +63,6 @@ func terminateChat() { case .stopped: chatCloseStore() default: - terminating = true // the store will be closed in _chatSuspended when event is received _suspendChat(timeout: terminationTimeout) } @@ -73,7 +71,7 @@ func terminateChat() { func chatSuspended() { suspendLockQueue.sync { - if case .suspending = appStateGroupDefault.get() { + if case .suspending = AppChatState.shared.value { _chatSuspended() } } @@ -81,48 +79,108 @@ func chatSuspended() { private func _chatSuspended() { logger.debug("_chatSuspended") - appStateGroupDefault.set(.suspended) + AppChatState.shared.set(.suspended) if ChatModel.shared.chatRunning == true { ChatReceiver.shared.stop() } - if terminating { - chatCloseStore() + chatCloseStore() +} + +func setAppState(_ appState: AppState) { + suspendLockQueue.sync { + AppChatState.shared.set(appState) } } func activateChat(appState: AppState = .active) { logger.debug("DEBUGGING: activateChat") - terminating = false suspendLockQueue.sync { - appStateGroupDefault.set(appState) + AppChatState.shared.set(appState) if ChatModel.ok { apiActivateChat() } logger.debug("DEBUGGING: activateChat: after apiActivateChat") } } func initChatAndMigrate(refreshInvitations: Bool = true) { - terminating = false let m = ChatModel.shared if (!m.chatInitialized) { + m.v3DBMigration = v3DBMigrationDefault.get() + if AppChatState.shared.value == .stopped { + AlertManager.shared.showAlert(Alert( + title: Text("Start chat?"), + message: Text("Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat."), + primaryButton: .default(Text("Ok")) { + AppChatState.shared.set(.active) + initialize(start: true) + }, + secondaryButton: .cancel { + initialize(start: false) + } + )) + } else { + initialize(start: true) + } + } + + func initialize(start: Bool) { do { - m.v3DBMigration = v3DBMigrationDefault.get() - try initializeChat(start: m.v3DBMigration.startChat, refreshInvitations: refreshInvitations) + try initializeChat(start: m.v3DBMigration.startChat && start, refreshInvitations: refreshInvitations) } catch let error { - fatalError("Failed to start or load chats: \(responseError(error))") + AlertManager.shared.showAlertMsg( + title: start ? "Error starting chat" : "Error opening chat", + message: "Please contact developers.\nError: \(responseError(error))" + ) } } } -func startChatAndActivate() { - terminating = false +func startChatAndActivate(dispatchQueue: DispatchQueue = DispatchQueue.main, _ completion: @escaping () -> Void) { logger.debug("DEBUGGING: startChatAndActivate") if ChatModel.shared.chatRunning == true { ChatReceiver.shared.start() logger.debug("DEBUGGING: startChatAndActivate: after ChatReceiver.shared.start") } - if .active != appStateGroupDefault.get() { + if .active == AppChatState.shared.value { + completion() + } else if nseStateGroupDefault.get().inactive { + activate() + } else { + // setting app state to "activating" to notify NSE that it should suspend + setAppState(.activating) + waitNSESuspended(timeout: 10, dispatchQueue: dispatchQueue) { ok in + if !ok { + // if for some reason NSE failed to suspend, + // e.g., it crashed previously without setting its state to "suspended", + // set it to "suspended" state anyway, so that next time app + // does not have to wait when activating. + nseStateGroupDefault.set(.suspended) + } + if AppChatState.shared.value == .activating { + activate() + } + } + } + + func activate() { logger.debug("DEBUGGING: startChatAndActivate: before activateChat") activateChat() + completion() logger.debug("DEBUGGING: startChatAndActivate: after activateChat") } } + +// appStateGroupDefault must not be used in the app directly, only via this singleton +class AppChatState { + static let shared = AppChatState() + private var value_ = appStateGroupDefault.get() + + var value: AppState { + value_ + } + + func set(_ state: AppState) { + appStateGroupDefault.set(state) + sendAppState(state) + value_ = state + } +} diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index fd1ec9511b..f72ffcaaaf 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -16,14 +16,9 @@ struct SimpleXApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var chatModel = ChatModel.shared @ObservedObject var alertManager = AlertManager.shared + @Environment(\.scenePhase) var scenePhase - @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false - @State private var userAuthorized: Bool? - @State private var doAuthenticate = false - @State private var enteredBackground: TimeInterval? = nil - @State private var canConnectCall = false - @State private var lastSuccessfulUnlock: TimeInterval? = nil - @State private var showInitializationView = false + @State private var enteredBackgroundAuthenticated: TimeInterval? = nil init() { DispatchQueue.global(qos: .background).sync { @@ -39,22 +34,17 @@ struct SimpleXApp: App { } var body: some Scene { - return WindowGroup { - ContentView( - doAuthenticate: $doAuthenticate, - userAuthorized: $userAuthorized, - canConnectCall: $canConnectCall, - lastSuccessfulUnlock: $lastSuccessfulUnlock, - showInitializationView: $showInitializationView - ) + WindowGroup { + // contentAccessAuthenticationExtended has to be passed to ContentView on view initialization, + // so that it's computed by the time view renders, and not on event after rendering + ContentView(contentAccessAuthenticationExtended: !authenticationExpired()) .environmentObject(chatModel) .onOpenURL { url in logger.debug("ContentView.onOpenURL: \(url)") chatModel.appOpenUrl = url } .onAppear() { - showInitializationView = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { initChatAndMigrate() } } @@ -62,30 +52,35 @@ struct SimpleXApp: App { logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") switch (phase) { case .background: + // --- authentication + // see ContentView .onChange(of: scenePhase) for remaining authentication logic + if chatModel.contentViewAccessAuthenticated { + enteredBackgroundAuthenticated = ProcessInfo.processInfo.systemUptime + } + chatModel.contentViewAccessAuthenticated = false + // authentication --- + if CallController.useCallKit() && chatModel.activeCall != nil { CallController.shared.shouldSuspendChat = true } else { suspendChat() BGManager.shared.schedule() } - if userAuthorized == true { - enteredBackground = ProcessInfo.processInfo.systemUptime - } - doAuthenticate = false - canConnectCall = false NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: CallController.shared.shouldSuspendChat = false - let appState = appStateGroupDefault.get() - startChatAndActivate() - if appState.inactive && chatModel.chatRunning == true { - updateChats() - if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { - updateCallInvitations() + let appState = AppChatState.shared.value + + if appState != .stopped { + startChatAndActivate { + if appState.inactive && chatModel.chatRunning == true { + updateChats() + if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { + updateCallInvitations() + } + } } } - doAuthenticate = authenticationExpired() - canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() default: break } @@ -118,22 +113,14 @@ struct SimpleXApp: App { } private func authenticationExpired() -> Bool { - if let enteredBackground = enteredBackground { + if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated { let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY)) - return ProcessInfo.processInfo.systemUptime - enteredBackground >= delay + return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay } else { return true } } - private func unlockedRecently() -> Bool { - if let lastSuccessfulUnlock = lastSuccessfulUnlock { - return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 - } else { - return false - } - } - private func updateChats() { do { let chats = try apiGetChats() diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 9ca894ea89..fcd3a85584 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -155,31 +155,32 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse if (!ChatModel.shared.chatInitialized) { initChatAndMigrate(refreshInvitations: false) } - startChatAndActivate() - shouldSuspendChat = true - // There are no invitations in the model, as it was processed by NSE - _ = try? justRefreshCallInvitations() - // logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))") - // Extract the call information from the push notification payload - let m = ChatModel.shared - if let contactId = payload.dictionaryPayload["contactId"] as? String, - let invitation = m.callInvitations[contactId] { - let update = cxCallUpdate(invitation: invitation) - if let uuid = invitation.callkitUUID { - logger.debug("CallController: report pushkit call via CallKit") - let update = cxCallUpdate(invitation: invitation) - provider.reportNewIncomingCall(with: uuid, update: update) { error in - if error != nil { - m.callInvitations.removeValue(forKey: contactId) + startChatAndActivate(dispatchQueue: DispatchQueue.global()) { + self.shouldSuspendChat = true + // There are no invitations in the model, as it was processed by NSE + _ = try? justRefreshCallInvitations() + // logger.debug("CallController justRefreshCallInvitations: \(String(describing: m.callInvitations))") + // Extract the call information from the push notification payload + let m = ChatModel.shared + if let contactId = payload.dictionaryPayload["contactId"] as? String, + let invitation = m.callInvitations[contactId] { + let update = self.cxCallUpdate(invitation: invitation) + if let uuid = invitation.callkitUUID { + logger.debug("CallController: report pushkit call via CallKit") + let update = self.cxCallUpdate(invitation: invitation) + self.provider.reportNewIncomingCall(with: uuid, update: update) { error in + if error != nil { + m.callInvitations.removeValue(forKey: contactId) + } + // Tell PushKit that the notification is handled. + completion() } - // Tell PushKit that the notification is handled. - completion() + } else { + self.reportExpiredCall(update: update, completion) } } else { - reportExpiredCall(update: update, completion) + self.reportExpiredCall(payload: payload, completion) } - } else { - reportExpiredCall(payload: payload, completion) } } diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index acb459938f..933a3c745e 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -18,6 +18,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg }() private static let ivTagBytes: Int = 28 private static let enableEncryption: Bool = true + private var chat_ctrl = getChatCtrl() struct Call { var connection: RTCPeerConnection @@ -308,7 +309,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg memcpy(pointer, (unencrypted as NSData).bytes, unencrypted.count) let isKeyFrame = unencrypted[0] & 1 == 0 let clearTextBytesSize = mediaType.rawValue == 0 ? 1 : isKeyFrame ? 10 : 3 - logCrypto("encrypt", chat_encrypt_media(&key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize))) + logCrypto("encrypt", chat_encrypt_media(chat_ctrl, &key, pointer.advanced(by: clearTextBytesSize), Int32(unencrypted.count + WebRTCClient.ivTagBytes - clearTextBytesSize))) return Data(bytes: pointer, count: unencrypted.count + WebRTCClient.ivTagBytes) } else { return nil diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 4fb93ffe22..6e2c0c1555 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -723,9 +723,14 @@ struct ChatView: View { if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live { menu.append(replyUIAction(ci)) } - menu.append(shareUIAction(ci)) - menu.append(copyUIAction(ci)) - if let fileSource = getLoadedFileSource(ci.file) { + let fileSource = getLoadedFileSource(ci.file) + let fileExists = if let fs = fileSource, FileManager.default.fileExists(atPath: getAppFilePath(fs.filePath).path) { true } else { false } + let copyAndShareAllowed = !ci.content.text.isEmpty || (ci.content.msgContent?.isImage == true && fileExists) + if copyAndShareAllowed { + menu.append(shareUIAction(ci)) + menu.append(copyUIAction(ci)) + } + if let fileSource = fileSource, fileExists { if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { if image.imageData != nil { menu.append(saveFileAction(fileSource)) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 0572821770..d089c7d6fe 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -104,7 +104,7 @@ struct ComposeState { var sendEnabled: Bool { switch preview { - case .mediaPreviews: return true + case let .mediaPreviews(media): return !media.isEmpty case .voicePreview: return voiceMessageRecordingState == .finished case .filePreview: return true default: return !message.isEmpty || liveMessage != nil @@ -384,10 +384,10 @@ struct ComposeView: View { } } .sheet(isPresented: $showMediaPicker) { - LibraryMediaListPicker(media: $chosenMedia, selectionLimit: 10) { itemsSelected in - showMediaPicker = false - if itemsSelected { - DispatchQueue.main.async { + LibraryMediaListPicker(addMedia: addMediaContent, selectionLimit: 10, finishedPreprocessing: finishedPreprocessingMediaContent) { itemsSelected in + await MainActor.run { + showMediaPicker = false + if itemsSelected { composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: [])) } } @@ -488,6 +488,30 @@ struct ComposeView: View { } } + private func addMediaContent(_ content: UploadContent) async { + if let img = resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { + var newMedia: [(String, UploadContent?)] = [] + if case var .mediaPreviews(media) = composeState.preview { + media.append((img, content)) + newMedia = media + } else { + newMedia = [(img, content)] + } + await MainActor.run { + composeState = composeState.copy(preview: .mediaPreviews(mediaPreviews: newMedia)) + } + } + } + + // When error occurs while converting video, remove media preview + private func finishedPreprocessingMediaContent() { + if case let .mediaPreviews(media) = composeState.preview, media.isEmpty { + DispatchQueue.main.async { + composeState = composeState.copy(preview: .noPreview) + } + } + } + private var maxFileSize: Int64 { getMaxFileSize(.xftp) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 94a018749e..09ead880ad 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -16,7 +16,6 @@ struct GroupChatInfoView: View { @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat @Binding var groupInfo: GroupInfo - @ObservedObject private var alertManager = AlertManager.shared @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: String? @State private var groupLinkMemberRole: GroupMemberRole = .member diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 4a187cecb9..7e336c3328 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -188,17 +188,19 @@ struct GroupMemberInfoView: View { // this condition prevents re-setting picker if !justOpened { return } } - newRole = member.memberRole - do { - let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) - let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) - _ = chatModel.upsertGroupMember(groupInfo, mem) - connectionStats = stats - connectionCode = code - } catch let error { - logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))") - } justOpened = false + DispatchQueue.main.async { + newRole = member.memberRole + do { + let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) + let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) + _ = chatModel.upsertGroupMember(groupInfo, mem) + connectionStats = stats + connectionCode = code + } catch let error { + logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))") + } + } } .onChange(of: newRole) { newRole in if newRole != member.memberRole { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 7e123c389f..18cc3f4d80 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -103,8 +103,10 @@ struct GroupProfileView: View { } } .sheet(isPresented: $showImagePicker) { - LibraryImagePicker(image: $chosenImage) { - didSelectItem in showImagePicker = false + LibraryImagePicker(image: $chosenImage) { _ in + await MainActor.run { + showImagePicker = false + } } } .onChange(of: chosenImage) { image in diff --git a/apps/ios/Shared/Views/Chat/ScanCodeView.swift b/apps/ios/Shared/Views/Chat/ScanCodeView.swift index 09861fa50b..f364b4ed0b 100644 --- a/apps/ios/Shared/Views/Chat/ScanCodeView.swift +++ b/apps/ios/Shared/Views/Chat/ScanCodeView.swift @@ -17,7 +17,7 @@ struct ScanCodeView: View { var body: some View { VStack(alignment: .leading) { - CodeScannerView(codeTypes: [.qr], completion: processQRCode) + CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processQRCode) .aspectRatio(1, contentMode: .fit) .cornerRadius(12) Text("Scan security code from your contact's app.") diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 65ec9ef944..72515a1fac 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -415,7 +415,7 @@ struct DatabaseView: View { do { try initializeChat(start: true) m.chatDbChanged = false - appStateGroupDefault.set(.active) + AppChatState.shared.set(.active) } catch let error { fatalError("Error starting chat \(responseError(error))") } @@ -427,7 +427,7 @@ struct DatabaseView: View { m.chatRunning = true ChatReceiver.shared.start() chatLastStartGroupDefault.set(Date.now) - appStateGroupDefault.set(.active) + AppChatState.shared.set(.active) } catch let error { runChat = false alert = .error(title: "Error starting chat", error: responseError(error)) @@ -477,7 +477,7 @@ func stopChatAsync() async throws { try await apiStopChat() ChatReceiver.shared.stop() await MainActor.run { ChatModel.shared.chatRunning = false } - appStateGroupDefault.set(.stopped) + AppChatState.shared.set(.stopped) } func deleteChatAsync() async throws { diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 1b44c23135..fe8d5bbdd4 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -13,112 +13,130 @@ import SimpleXChat struct LibraryImagePicker: View { @Binding var image: UIImage? - var didFinishPicking: (_ didSelectItems: Bool) -> Void - @State var images: [UploadContent] = [] + var didFinishPicking: (_ didSelectImage: Bool) async -> Void + @State var mediaAdded = false var body: some View { - LibraryMediaListPicker(media: $images, selectionLimit: 1, didFinishPicking: didFinishPicking) - .onChange(of: images) { _ in - if let img = images.first { - image = img.uiImage - } - } + LibraryMediaListPicker(addMedia: addMedia, selectionLimit: 1, didFinishPicking: didFinishPicking) + } + + private func addMedia(_ content: UploadContent) async { + if mediaAdded { return } + await MainActor.run { + mediaAdded = true + image = content.uiImage + } } } struct LibraryMediaListPicker: UIViewControllerRepresentable { typealias UIViewControllerType = PHPickerViewController - @Binding var media: [UploadContent] + var addMedia: (_ content: UploadContent) async -> Void var selectionLimit: Int - var didFinishPicking: (_ didSelectItems: Bool) -> Void + var finishedPreprocessing: () -> Void = {} + var didFinishPicking: (_ didSelectItems: Bool) async -> Void class Coordinator: PHPickerViewControllerDelegate { let parent: LibraryMediaListPicker let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryMediaListPicker") - var media: [UploadContent] = [] - var mediaCount: Int = 0 init(_ parent: LibraryMediaListPicker) { self.parent = parent } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - parent.didFinishPicking(!results.isEmpty) - guard !results.isEmpty else { - return + Task { + await parent.didFinishPicking(!results.isEmpty) + if results.isEmpty { return } + for r in results { + await loadItem(r.itemProvider) + } + parent.finishedPreprocessing() } + } - parent.media = [] - media = [] - mediaCount = results.count - for result in results { - logger.log("LibraryMediaListPicker result") - let p = result.itemProvider - if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { - p.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in - if let url = url { - let tempUrl = URL(fileURLWithPath: getTempFilesDirectory().path + "/" + generateNewFileName("video", url.pathExtension)) - if ((try? FileManager.default.copyItem(at: url, to: tempUrl)) != nil) { - ChatModel.shared.filesToDelete.insert(tempUrl) - self.loadVideo(url: tempUrl, error: error) + private func loadItem(_ p: NSItemProvider) async { + logger.debug("LibraryMediaListPicker result") + if p.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + if let video = await loadVideo(p) { + await self.parent.addMedia(video) + logger.debug("LibraryMediaListPicker: added video") + } + } else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) { + if let img = await loadImageData(p) { + await self.parent.addMedia(img) + logger.debug("LibraryMediaListPicker: added image") + } + } else if p.canLoadObject(ofClass: UIImage.self) { + if let img = await loadImage(p) { + await self.parent.addMedia(.simpleImage(image: img)) + logger.debug("LibraryMediaListPicker: added image") + } + } + } + + private func loadImageData(_ p: NSItemProvider) async -> UploadContent? { + await withCheckedContinuation { cont in + loadFileURL(p, type: UTType.data) { url in + if let url = url { + let img = UploadContent.loadFromURL(url: url) + cont.resume(returning: img) + } else { + cont.resume(returning: nil) + } + } + } + } + + private func loadImage(_ p: NSItemProvider) async -> UIImage? { + await withCheckedContinuation { cont in + p.loadObject(ofClass: UIImage.self) { obj, err in + if let err = err { + logger.error("LibraryMediaListPicker result image error: \(err.localizedDescription)") + cont.resume(returning: nil) + } else { + cont.resume(returning: obj as? UIImage) + } + } + } + } + + private func loadVideo(_ p: NSItemProvider) async -> UploadContent? { + await withCheckedContinuation { cont in + loadFileURL(p, type: UTType.movie) { url in + if let url = url { + let tempUrl = URL(fileURLWithPath: generateNewFileName(getTempFilesDirectory().path + "/" + "rawvideo", url.pathExtension, fullPath: true)) + let convertedVideoUrl = URL(fileURLWithPath: generateNewFileName(getTempFilesDirectory().path + "/" + "video", "mp4", fullPath: true)) + do { +// logger.debug("LibraryMediaListPicker copyItem \(url) to \(tempUrl)") + try FileManager.default.copyItem(at: url, to: tempUrl) + } catch let err { + logger.error("LibraryMediaListPicker copyItem error: \(err.localizedDescription)") + return cont.resume(returning: nil) + } + Task { + let success = await makeVideoQualityLower(tempUrl, outputUrl: convertedVideoUrl) + try? FileManager.default.removeItem(at: tempUrl) + if success { + _ = ChatModel.shared.filesToDelete.insert(convertedVideoUrl) + let video = UploadContent.loadVideoFromURL(url: convertedVideoUrl) + return cont.resume(returning: video) } + try? FileManager.default.removeItem(at: convertedVideoUrl) + cont.resume(returning: nil) } } - } else if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) { - p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in - self.loadImage(object: url, error: error) - } - } else if p.canLoadObject(ofClass: UIImage.self) { - p.loadObject(ofClass: UIImage.self) { image, error in - DispatchQueue.main.async { - self.loadImage(object: image, error: error) - } - } + } + } + } + + private func loadFileURL(_ p: NSItemProvider, type: UTType, completion: @escaping (URL?) -> Void) { + p.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, err in + if let err = err { + logger.error("LibraryMediaListPicker loadFileURL error: \(err.localizedDescription)") + completion(nil) } else { - dispatchQueue.sync { self.mediaCount -= 1} - } - } - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { - self.dispatchQueue.sync { - if self.parent.media.count == 0 { - logger.log("LibraryMediaListPicker: added \(self.media.count) images out of \(results.count)") - self.parent.media = self.media - } - } - } - } - - func loadImage(object: Any?, error: Error? = nil) { - if let error = error { - logger.error("LibraryMediaListPicker: couldn't load image with error: \(error.localizedDescription)") - } else if let image = object as? UIImage { - media.append(.simpleImage(image: image)) - logger.log("LibraryMediaListPicker: added image") - } else if let url = object as? URL, let image = UploadContent.loadFromURL(url: url) { - media.append(image) - } - dispatchQueue.sync { - self.mediaCount -= 1 - if self.mediaCount == 0 && self.parent.media.count == 0 { - logger.log("LibraryMediaListPicker: added all media") - self.parent.media = self.media - self.media = [] - } - } - } - - func loadVideo(url: URL?, error: Error? = nil) { - if let error = error { - logger.error("LibraryMediaListPicker: couldn't load video with error: \(error.localizedDescription)") - } else if let url = url as URL?, let video = UploadContent.loadVideoFromURL(url: url) { - media.append(video) - } - dispatchQueue.sync { - self.mediaCount -= 1 - if self.mediaCount == 0 && self.parent.media.count == 0 { - logger.log("LibraryMediaListPicker: added all media") - self.parent.media = self.media - self.media = [] + completion(url) } } } diff --git a/apps/ios/Shared/Views/Helpers/VideoUtils.swift b/apps/ios/Shared/Views/Helpers/VideoUtils.swift new file mode 100644 index 0000000000..e13893de6e --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/VideoUtils.swift @@ -0,0 +1,26 @@ +// +// VideoUtils.swift +// SimpleX (iOS) +// +// Created by Avently on 25.12.2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import AVFoundation +import Foundation +import SimpleXChat + +func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool { + let asset: AVURLAsset = AVURLAsset(url: input, options: nil) + if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) { + s.outputURL = outputUrl + s.outputFileType = .mp4 + s.metadataItemFilter = AVMetadataItemFilter.forSharing() + await s.export() + if let err = s.error { + logger.error("Failed to export video with error: \(err)") + } + return s.status == .completed + } + return false +} diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index 59b13e45b3..bdb5b03e8c 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -52,7 +52,7 @@ struct LocalAuthView: View { resetChatCtrl() try initializeChat(start: true) m.chatDbChanged = false - appStateGroupDefault.set(.active) + AppChatState.shared.set(.active) if m.currentUser != nil { return } var profile: Profile? = nil if let displayName = displayName, displayName != "" { diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 2d7f31c58e..6c7919669b 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -130,8 +130,10 @@ struct AddGroupView: View { } } .sheet(isPresented: $showImagePicker) { - LibraryImagePicker(image: $chosenImage) { - didSelectItem in showImagePicker = false + LibraryImagePicker(image: $chosenImage) { _ in + await MainActor.run { + showImagePicker = false + } } } .alert(isPresented: $showInvalidNameAlert) { diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index 82c4629c0c..3ddb85079c 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -74,6 +74,7 @@ struct QRCode: View { .onAppear { image = image ?? generateImage(uri, tintColor: tintColor) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index 9a11eee92b..7f3f5e02f8 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -25,7 +25,7 @@ struct ScanToConnectView: View { .fixedSize(horizontal: false, vertical: true) .padding(.vertical) - CodeScannerView(codeTypes: [.qr], completion: processQRCode) + CodeScannerView(codeTypes: [.qr], scanMode: .continuous, completion: processQRCode) .aspectRatio(1, contentMode: .fit) .cornerRadius(12) diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index 935f09cc1b..befb34b318 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -81,11 +81,6 @@ struct CreateSimpleXAddress: View { DispatchQueue.main.async { m.userAddress = UserContactLink(connReqContact: connReqContact) } - if let u = try await apiSetProfileAddress(on: true) { - DispatchQueue.main.async { - m.updateUser(u) - } - } await MainActor.run { progressIndicator = false } } catch let error { logger.error("CreateSimpleXAddress create address: \(responseError(error))") @@ -100,7 +95,7 @@ struct CreateSimpleXAddress: View { } label: { Text("Create SimpleX address").font(.title) } - Text("Your contacts in SimpleX will see it.\nYou can change it in Settings.") + Text("You can make it visible to your SimpleX contacts via Settings.") .multilineTextAlignment(.center) .font(.footnote) .padding(.horizontal, 32) diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index e934bbc89a..6809dc1385 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -332,7 +332,7 @@ struct ConnectDesktopView: View { private func scanDesctopAddressView() -> some View { Section("Scan QR code from desktop") { - CodeScannerView(codeTypes: [.qr], completion: processDesktopQRCode) + CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processDesktopQRCode) .aspectRatio(1, contentMode: .fit) .cornerRadius(12) .listRowBackground(Color.clear) diff --git a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift index 8e8885b518..9da3bac00b 100644 --- a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift @@ -51,9 +51,9 @@ struct AdvancedNetworkSettings: View { } .disabled(currentNetCfg == NetCfg.proxyDefaults) - timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel) - timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel) - timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 60_000, 90_000, 120_000], label: secondsLabel) + timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel) + timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel) + timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 45_000, 60_000, 90_000, 120_000], label: secondsLabel) timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel) intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "") Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive) diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift index 5befe405c6..04c02f0dd2 100644 --- a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift +++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift @@ -14,9 +14,6 @@ struct NotificationsView: View { @State private var notificationMode: NotificationsMode = ChatModel.shared.notificationMode @State private var showAlert: NotificationAlert? @State private var legacyDatabase = dbContainerGroupDefault.get() == .documents -// @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false -// @AppStorage(GROUP_DEFAULT_NTF_ENABLE_LOCAL, store: groupDefaults) private var ntfEnableLocal = false -// @AppStorage(GROUP_DEFAULT_NTF_ENABLE_PERIODIC, store: groupDefaults) private var ntfEnablePeriodic = false var body: some View { List { @@ -88,13 +85,6 @@ struct NotificationsView: View { .padding(.top, 1) } } - -// if developerTools { -// Section(String("Experimental")) { -// Toggle(String("Always enable local"), isOn: $ntfEnableLocal) -// Toggle(String("Always enable periodic"), isOn: $ntfEnablePeriodic) -// } -// } } .disabled(legacyDatabase) } @@ -119,7 +109,7 @@ struct NotificationsView: View { private func ntfModeAlertTitle(_ mode: NotificationsMode) -> LocalizedStringKey { switch mode { - case .off: return "Turn off notifications?" + case .off: return "Use only local notifications?" case .periodic: return "Enable periodic notifications?" case .instant: return "Enable instant notifications?" } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 90b83fa4f3..d8ff2c2f89 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -467,6 +467,7 @@ struct SimplexLockView: View { switch a { case .enableAuth: SetAppPasscodeView { + m.contentViewAccessAuthenticated = true laLockDelay = 30 prefPerformLA = true showChangePassword = true @@ -619,6 +620,7 @@ struct SimplexLockView: View { authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in switch laResult { case .success: + m.contentViewAccessAuthenticated = true prefPerformLA = true laAlert = .laTurnedOnAlert case .failed: diff --git a/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift b/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift index ffdbd1b07e..33825fee62 100644 --- a/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift +++ b/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift @@ -21,7 +21,7 @@ struct ScanProtocolServer: View { .font(.largeTitle) .bold() .padding(.vertical) - CodeScannerView(codeTypes: [.qr], completion: processQRCode) + CodeScannerView(codeTypes: [.qr], scanMode: .oncePerCode, completion: processQRCode) .aspectRatio(1, contentMode: .fit) .cornerRadius(12) .padding(.top) diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index b64ec21de6..e5ec23178d 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -120,8 +120,10 @@ struct UserProfile: View { } } .sheet(isPresented: $showImagePicker) { - LibraryImagePicker(image: $chosenImage) { - didSelectItem in showImagePicker = false + LibraryImagePicker(image: $chosenImage) { _ in + await MainActor.run { + showImagePicker = false + } } } .onChange(of: chosenImage) { image in diff --git a/apps/ios/SimpleX NSE/ConcurrentQueue.swift b/apps/ios/SimpleX NSE/ConcurrentQueue.swift new file mode 100644 index 0000000000..274a683c00 --- /dev/null +++ b/apps/ios/SimpleX NSE/ConcurrentQueue.swift @@ -0,0 +1,64 @@ +// +// ConcurrentQueue.swift +// SimpleX NSE +// +// Created by Evgeny on 08/12/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import Foundation + +struct DequeueElement { + var elementId: UUID? + var task: Task +} + +class ConcurrentQueue { + private var queue: [T] = [] + private var queueLock = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.concurrent-queue.lock.\(UUID())") + private var continuations = [(elementId: UUID, continuation: CheckedContinuation)]() + + func enqueue(_ el: T) { + resumeContinuation(el) { self.queue.append(el) } + } + + func frontEnqueue(_ el: T) { + resumeContinuation(el) { self.queue.insert(el, at: 0) } + } + + private func resumeContinuation(_ el: T, add: @escaping () -> Void) { + queueLock.sync { + if let (_, cont) = continuations.first { + continuations.remove(at: 0) + cont.resume(returning: el) + } else { + add() + } + } + } + + func dequeue() -> DequeueElement { + queueLock.sync { + if queue.isEmpty { + let elementId = UUID() + let task = Task { + await withCheckedContinuation { cont in + continuations.append((elementId, cont)) + } + } + return DequeueElement(elementId: elementId, task: task) + } else { + let el = queue.remove(at: 0) + return DequeueElement(task: Task { el }) + } + } + } + + func cancelDequeue(_ elementId: UUID) { + queueLock.sync { + let cancelled = continuations.filter { $0.elementId == elementId } + continuations.removeAll { $0.elementId == elementId } + cancelled.forEach { $0.continuation.resume(returning: nil) } + } + } +} diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index ea52f4be89..f9b4852e53 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -14,91 +14,231 @@ import SimpleXChat let logger = Logger() -let suspendingDelay: UInt64 = 2_000_000_000 +let appSuspendingDelay: UInt64 = 2_500_000_000 -typealias NtfStream = AsyncStream +let nseSuspendDelay: TimeInterval = 2 +let nseSuspendTimeout: Int = 5 + +typealias NtfStream = ConcurrentQueue + +// Notifications are delivered via concurrent queues, as they are all received from chat controller in a single loop that +// writes to ConcurrentQueue and when notification is processed, the instance of Notification service extension reads from the queue. +// One queue per connection (entity) is used. +// The concurrent queues allow read cancellation, to ensure that notifications are not lost in case the current thread completes +// before expected notification is read (multiple notifications can be expected, because one notification can be delivered for several messages). actor PendingNtfs { static let shared = PendingNtfs() private var ntfStreams: [String: NtfStream] = [:] - private var ntfConts: [String: NtfStream.Continuation] = [:] - func createStream(_ id: String) { - logger.debug("PendingNtfs.createStream: \(id, privacy: .public)") - if ntfStreams.index(forKey: id) == nil { - ntfStreams[id] = AsyncStream { cont in - ntfConts[id] = cont - logger.debug("PendingNtfs.createStream: store continuation") - } + func createStream(_ id: String) async { + logger.debug("NotificationService PendingNtfs.createStream: \(id, privacy: .public)") + if ntfStreams[id] == nil { + ntfStreams[id] = ConcurrentQueue() + logger.debug("NotificationService PendingNtfs.createStream: created ConcurrentQueue") } } - func readStream(_ id: String, for nse: NotificationService, msgCount: Int = 1, showNotifications: Bool) async { - logger.debug("PendingNtfs.readStream: \(id, privacy: .public) \(msgCount, privacy: .public)") + func readStream(_ id: String, for nse: NotificationService, ntfInfo: NtfMessages) async { + logger.debug("NotificationService PendingNtfs.readStream: \(id, privacy: .public) \(ntfInfo.ntfMessages.count, privacy: .public)") + if !ntfInfo.user.showNotifications { + nse.setBestAttemptNtf(.empty) + } if let s = ntfStreams[id] { - logger.debug("PendingNtfs.readStream: has stream") - var rcvCount = max(1, msgCount) - for await ntf in s { - nse.setBestAttemptNtf(showNotifications ? ntf : .empty) - rcvCount -= 1 - if rcvCount == 0 || ntf.categoryIdentifier == ntfCategoryCallInvitation { break } + logger.debug("NotificationService PendingNtfs.readStream: has stream") + var expected = Set(ntfInfo.ntfMessages.map { $0.msgId }) + logger.debug("NotificationService PendingNtfs.readStream: expecting: \(expected, privacy: .public)") + var readCancelled = false + var dequeued: DequeueElement? + nse.cancelRead = { + readCancelled = true + if let elementId = dequeued?.elementId { + s.cancelDequeue(elementId) + } } - logger.debug("PendingNtfs.readStream: exiting") + while !readCancelled { + dequeued = s.dequeue() + if let ntf = await dequeued?.task.value { + if readCancelled { + logger.debug("NotificationService PendingNtfs.readStream: read cancelled, put ntf to queue front") + s.frontEnqueue(ntf) + break + } else if case let .msgInfo(info) = ntf { + let found = expected.remove(info.msgId) + if found != nil { + logger.debug("NotificationService PendingNtfs.readStream: msgInfo, last: \(expected.isEmpty, privacy: .public)") + if expected.isEmpty { break } + } else if let msgTs = ntfInfo.msgTs, info.msgTs > msgTs { + logger.debug("NotificationService PendingNtfs.readStream: unexpected msgInfo") + s.frontEnqueue(ntf) + break + } + } else if ntfInfo.user.showNotifications { + logger.debug("NotificationService PendingNtfs.readStream: setting best attempt") + nse.setBestAttemptNtf(ntf) + if ntf.isCallInvitation { break } + } + } else { + break + } + } + nse.cancelRead = nil + logger.debug("NotificationService PendingNtfs.readStream: exiting") } } - func writeStream(_ id: String, _ ntf: NSENotification) { - logger.debug("PendingNtfs.writeStream: \(id, privacy: .public)") - if let cont = ntfConts[id] { - logger.debug("PendingNtfs.writeStream: writing ntf") - cont.yield(ntf) + func writeStream(_ id: String, _ ntf: NSENotification) async { + logger.debug("NotificationService PendingNtfs.writeStream: \(id, privacy: .public)") + if let s = ntfStreams[id] { + logger.debug("NotificationService PendingNtfs.writeStream: writing ntf") + s.enqueue(ntf) + } + } +} + +// The current implementation assumes concurrent notification delivery and uses semaphores +// to process only one notification per connection (entity) at a time. +class NtfStreamSemaphores { + static let shared = NtfStreamSemaphores() + private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-semaphores.lock") + private var semaphores: [String: DispatchSemaphore] = [:] + + func waitForStream(_ id: String) { + streamSemaphore(id, value: 0)?.wait() + } + + func signalStreamReady(_ id: String) { + streamSemaphore(id, value: 1)?.signal() + } + + // this function returns nil if semaphore is just created, so passed value shoud be coordinated with the desired end value of the semaphore + private func streamSemaphore(_ id: String, value: Int) -> DispatchSemaphore? { + NtfStreamSemaphores.queue.sync { + if let s = semaphores[id] { + return s + } else { + semaphores[id] = DispatchSemaphore(value: value) + return nil + } } } } enum NSENotification { - case nse(notification: UNMutableNotificationContent) - case callkit(invitation: RcvCallInvitation) + case nse(UNMutableNotificationContent) + case callkit(RcvCallInvitation) case empty + case msgInfo(NtfMsgInfo) - var categoryIdentifier: String? { + var isCallInvitation: Bool { switch self { - case let .nse(ntf): return ntf.categoryIdentifier - case .callkit: return ntfCategoryCallInvitation - case .empty: return nil + case let .nse(ntf): ntf.categoryIdentifier == ntfCategoryCallInvitation + case .callkit: true + case .empty: false + case .msgInfo: false } } } +// Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid +// background crashes and contention for database with the application (both UI and background fetch triggered either on schedule +// or when background notification is received. +class NSEThreads { + static let shared = NSEThreads() + private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock") + private var allThreads: Set = [] + private var activeThreads: Set = [] + + func newThread() -> UUID { + NSEThreads.queue.sync { + let (_, t) = allThreads.insert(UUID()) + return t + } + } + + func startThread(_ t: UUID) { + NSEThreads.queue.sync { + if allThreads.contains(t) { + _ = activeThreads.insert(t) + } else { + logger.warning("NotificationService startThread: thread \(t) was removed before it started") + } + } + } + + func endThread(_ t: UUID) -> Bool { + NSEThreads.queue.sync { + let tActive = activeThreads.remove(t) + let t = allThreads.remove(t) + if tActive != nil && activeThreads.isEmpty { + return true + } + if t != nil && allThreads.isEmpty { + NSEChatState.shared.set(.suspended) + } + return false + } + } + + var noThreads: Bool { + allThreads.isEmpty + } +} + +// Notification service extension creates a new instance of the class and calls didReceive for each notification. +// Each didReceive is called in its own thread, but multiple calls can be made in one process, and, empirically, there is never +// more than one process of notification service extension exists at a time. +// Soon after notification service delivers the last notification it is either suspended or terminated. class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptNtf: NSENotification? var badgeCount: Int = 0 + // thread is added to allThreads here - if thread did not start chat, + // chat does not need to be suspended but NSE state still needs to be set to "suspended". + var threadId: UUID? = NSEThreads.shared.newThread() + var receiveEntityId: String? + var cancelRead: (() -> Void)? + var appSubscriber: AppSubscriber? + var returnedSuspension = false override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("DEBUGGING: NotificationService.didReceive") - if let ntf = request.content.mutableCopy() as? UNMutableNotificationContent { - setBestAttemptNtf(ntf) - } + let ntf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() } + setBestAttemptNtf(ntf) self.contentHandler = contentHandler registerGroupDefaults() let appState = appStateGroupDefault.get() + logger.debug("NotificationService: app is \(appState.rawValue, privacy: .public)") switch appState { + case .stopped: + setBadgeCount() + setBestAttemptNtf(createAppStoppedNtf()) + deliverBestAttemptNtf() case .suspended: - logger.debug("NotificationService: app is suspended") setBadgeCount() receiveNtfMessages(request, contentHandler) case .suspending: - logger.debug("NotificationService: app is suspending") setBadgeCount() Task { - var state = appState - for _ in 1...5 { - _ = try await Task.sleep(nanoseconds: suspendingDelay) - state = appStateGroupDefault.get() - if state == .suspended || state != .suspending { break } + let state: AppState = await withCheckedContinuation { cont in + appSubscriber = appStateSubscriber { s in + if s == .suspended { appSuspension(s) } + } + DispatchQueue.global().asyncAfter(deadline: .now() + Double(appSuspendTimeout) + 1) { + logger.debug("NotificationService: appSuspension timeout") + appSuspension(appStateGroupDefault.get()) + } + + @Sendable + func appSuspension(_ s: AppState) { + if !self.returnedSuspension { + self.returnedSuspension = true + self.appSubscriber = nil // this disposes of appStateSubscriber + cont.resume(returning: s) + } + } } - logger.debug("NotificationService: app state is \(state.rawValue, privacy: .public)") + logger.debug("NotificationService: app state is now \(state.rawValue, privacy: .public)") if state.inactive { receiveNtfMessages(request, contentHandler) } else { @@ -106,7 +246,6 @@ class NotificationService: UNNotificationServiceExtension { } } default: - logger.debug("NotificationService: app state is \(appState.rawValue, privacy: .public)") deliverBestAttemptNtf() } } @@ -121,27 +260,35 @@ class NotificationService: UNNotificationServiceExtension { if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], let nonce = ntfData["nonce"] as? String, let encNtfInfo = ntfData["message"] as? String, - let dbStatus = startChat() { + // check it here again + appStateGroupDefault.get().inactive { + // thread is added to activeThreads tracking set here - if thread started chat it needs to be suspended + if let t = threadId { NSEThreads.shared.startThread(t) } + let dbStatus = startChat() if case .ok = dbStatus, - let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { - logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") - if let connEntity = ntfMsgInfo.connEntity { + let ntfInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { + logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfInfo.ntfMessages.count), privacy: .public)") + if let connEntity = ntfInfo.connEntity_ { setBestAttemptNtf( - ntfMsgInfo.ntfsEnabled - ? .nse(notification: createConnectionEventNtf(ntfMsgInfo.user, connEntity)) + ntfInfo.ntfsEnabled + ? .nse(createConnectionEventNtf(ntfInfo.user, connEntity)) : .empty ) if let id = connEntity.id { - Task { - logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") - await PendingNtfs.shared.createStream(id) - await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count, showNotifications: ntfMsgInfo.user.showNotifications) - deliverBestAttemptNtf() + receiveEntityId = id + NtfStreamSemaphores.shared.waitForStream(id) + if receiveEntityId != nil { + Task { + logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") + await PendingNtfs.shared.createStream(id) + await PendingNtfs.shared.readStream(id, for: self, ntfInfo: ntfInfo) + deliverBestAttemptNtf() + } } + return } } - return - } else { + } else if let dbStatus = dbStatus { setBestAttemptNtf(createErrorNtf(dbStatus)) } } @@ -159,14 +306,14 @@ class NotificationService: UNNotificationServiceExtension { } func setBestAttemptNtf(_ ntf: UNMutableNotificationContent) { - setBestAttemptNtf(.nse(notification: ntf)) + setBestAttemptNtf(.nse(ntf)) } func setBestAttemptNtf(_ ntf: NSENotification) { logger.debug("NotificationService.setBestAttemptNtf") if case let .nse(notification) = ntf { notification.badge = badgeCount as NSNumber - bestAttemptNtf = .nse(notification: notification) + bestAttemptNtf = .nse(notification) } else { bestAttemptNtf = ntf } @@ -174,9 +321,41 @@ class NotificationService: UNNotificationServiceExtension { private func deliverBestAttemptNtf() { logger.debug("NotificationService.deliverBestAttemptNtf") + if let cancel = cancelRead { + cancelRead = nil + cancel() + } + if let id = receiveEntityId { + receiveEntityId = nil + NtfStreamSemaphores.shared.signalStreamReady(id) + } + if let t = threadId { + threadId = nil + if NSEThreads.shared.endThread(t) { + logger.debug("NotificationService.deliverBestAttemptNtf: will suspend") + // suspension is delayed to allow chat core finalise any processing + // (e.g., send delivery receipts) + DispatchQueue.global().asyncAfter(deadline: .now() + nseSuspendDelay) { + if NSEThreads.shared.noThreads { + logger.debug("NotificationService.deliverBestAttemptNtf: suspending...") + suspendChat(nseSuspendTimeout) + } + } + } + } if let handler = contentHandler, let ntf = bestAttemptNtf { + contentHandler = nil + bestAttemptNtf = nil + let deliver: (UNMutableNotificationContent?) -> Void = { ntf in + let useNtf = if let ntf = ntf { + appStateGroupDefault.get().running ? UNMutableNotificationContent() : ntf + } else { + UNMutableNotificationContent() + } + handler(useNtf) + } switch ntf { - case let .nse(content): handler(content) + case let .nse(content): deliver(content) case let .callkit(invitation): CXProvider.reportNewIncomingVoIPPushPayload([ "displayName": invitation.contact.displayName, @@ -184,66 +363,201 @@ class NotificationService: UNNotificationServiceExtension { "media": invitation.callType.media.rawValue ]) { error in if error == nil { - handler(UNMutableNotificationContent()) + deliver(nil) } else { - logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)") - handler(createCallInvitationNtf(invitation)) + logger.debug("NotificationService reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)") + deliver(createCallInvitationNtf(invitation)) } } - case .empty: handler(UNMutableNotificationContent()) + case .empty: deliver(nil) // used to mute notifications that did not unsubscribe yet + case .msgInfo: deliver(nil) // unreachable, the best attempt is never set to msgInfo } - bestAttemptNtf = nil } } } -var chatStarted = false -var networkConfig: NetCfg = getNetCfg() -var xftpConfig: XFTPFileConfig? = getXFTPCfg() +// nseStateGroupDefault must not be used in NSE directly, only via this singleton +class NSEChatState { + static let shared = NSEChatState() + private var value_ = NSEState.created + var value: NSEState { + value_ + } + + func set(_ state: NSEState) { + nseStateGroupDefault.set(state) + sendNSEState(state) + value_ = state + } + + init() { + // This is always set to .created state, as in case previous start of NSE crashed in .active state, it is stored correctly. + // Otherwise the app will be activating slower + set(.created) + } +} + +var appSubscriber: AppSubscriber = appStateSubscriber { state in + logger.debug("NotificationService: appSubscriber") + if state.running && NSEChatState.shared.value.canSuspend { + logger.debug("NotificationService: appSubscriber app state \(state.rawValue), suspending") + suspendChat(nseSuspendTimeout) + } +} + +func appStateSubscriber(onState: @escaping (AppState) -> Void) -> AppSubscriber { + appMessageSubscriber { msg in + if case let .state(state) = msg { + logger.debug("NotificationService: appStateSubscriber \(state.rawValue, privacy: .public)") + onState(state) + } + } +} + +var receiverStarted = false +let startLock = DispatchSemaphore(value: 1) +let suspendLock = DispatchSemaphore(value: 1) +var networkConfig: NetCfg = getNetCfg() +let xftpConfig: XFTPFileConfig? = getXFTPCfg() + +// startChat uses semaphore startLock to ensure that only one didReceive thread can start chat controller +// Subsequent calls to didReceive will be waiting on semaphore and won't start chat again, as it will be .active func startChat() -> DBMigrationResult? { + logger.debug("NotificationService: startChat") + if case .active = NSEChatState.shared.value { return .ok } + + startLock.wait() + defer { startLock.signal() } + + return switch NSEChatState.shared.value { + case .created: doStartChat() + case .starting: .ok // it should never get to this branch, as it would be waiting for start on startLock + case .active: .ok + case .suspending: activateChat() + case .suspended: activateChat() + } +} + +func doStartChat() -> DBMigrationResult? { + logger.debug("NotificationService: doStartChat") hs_init(0, nil) - if chatStarted { return .ok } - let (_, dbStatus) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation()) + let (_, dbStatus) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation(), backgroundMode: true) if dbStatus != .ok { resetChatCtrl() + NSEChatState.shared.set(.created) return dbStatus } + let state = NSEChatState.shared.value + NSEChatState.shared.set(.starting) if let user = apiGetActiveUser() { - logger.debug("active user \(String(describing: user))") + logger.debug("NotificationService active user \(String(describing: user))") do { try setNetworkConfig(networkConfig) try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(xftpConfig) try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) - let justStarted = try apiStartChat() - chatStarted = true - if justStarted { - chatLastStartGroupDefault.set(Date.now) - Task { await receiveMessages() } + // prevent suspension while starting chat + suspendLock.wait() + defer { suspendLock.signal() } + if NSEChatState.shared.value == .starting { + updateNetCfg() + let justStarted = try apiStartChat() + NSEChatState.shared.set(.active) + if justStarted { + chatLastStartGroupDefault.set(Date.now) + Task { + if !receiverStarted { + receiverStarted = true + await receiveMessages() + } + } + } + return .ok } - return .ok } catch { logger.error("NotificationService startChat error: \(responseError(error), privacy: .public)") } } else { - logger.debug("no active user") + logger.debug("NotificationService: no active user") } + if NSEChatState.shared.value == .starting { NSEChatState.shared.set(state) } return nil } +func activateChat() -> DBMigrationResult? { + logger.debug("NotificationService: activateChat") + let state = NSEChatState.shared.value + NSEChatState.shared.set(.active) + if apiActivateChat() { + logger.debug("NotificationService: activateChat: after apiActivateChat") + return .ok + } else { + NSEChatState.shared.set(state) + return nil + } +} + +// suspendChat uses semaphore suspendLock to ensure that only one suspension can happen. +func suspendChat(_ timeout: Int) { + logger.debug("NotificationService: suspendChat") + let state = NSEChatState.shared.value + if !state.canSuspend { + logger.error("NotificationService suspendChat called, current state: \(state.rawValue, privacy: .public)") + } else { + suspendLock.wait() + defer { suspendLock.signal() } + + NSEChatState.shared.set(.suspending) + if apiSuspendChat(timeoutMicroseconds: timeout * 1000000) { + logger.debug("NotificationService: suspendChat: after apiSuspendChat") + DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: chatSuspended) + } else { + NSEChatState.shared.set(state) + } + } +} + +func chatSuspended() { + logger.debug("NotificationService chatSuspended") + if case .suspending = NSEChatState.shared.value { + NSEChatState.shared.set(.suspended) + chatCloseStore() + logger.debug("NotificationService chatSuspended: suspended") + } +} + +// A single loop is used per Notification service extension process to receive and process all messages depending on the NSE state +// If the extension is not active yet, or suspended/suspending, or the app is running, the notifications will no be received. func receiveMessages() async { logger.debug("NotificationService receiveMessages") while true { - updateNetCfg() + switch NSEChatState.shared.value { + // it should never get to "created" and "starting" branches, as NSE state is set to .active before the loop start + case .created: await delayWhenInactive() + case .starting: await delayWhenInactive() + case .active: await receiveMsg() + case .suspending: await receiveMsg() + case .suspended: await delayWhenInactive() + } + } + + func receiveMsg() async { if let msg = await chatRecvMsg() { + logger.debug("NotificationService receiveMsg: message") if let (id, ntf) = await receivedMsgNtf(msg) { + logger.debug("NotificationService receiveMsg: notification") await PendingNtfs.shared.createStream(id) await PendingNtfs.shared.writeStream(id, ntf) } } } + + func delayWhenInactive() async { + logger.debug("NotificationService delayWhenInactive") + _ = try? await Task.sleep(nanoseconds: 1000_000000) + } } func chatRecvMsg() async -> ChatResponse? { @@ -257,14 +571,14 @@ private let isInChina = SKStorefront().countryCode == "CHN" private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { - logger.debug("NotificationService processReceivedMsg: \(res.responseType)") + logger.debug("NotificationService receivedMsgNtf: \(res.responseType, privacy: .public)") switch res { case let .contactConnected(user, contact, _): - return (contact.id, .nse(notification: createContactConnectedNtf(user, contact))) + return (contact.id, .nse(createContactConnectedNtf(user, contact))) // case let .contactConnecting(contact): // TODO profile update case let .receivedContactRequest(user, contactRequest): - return (UserContact(contactRequest: contactRequest).id, .nse(notification: createContactRequestNtf(user, contactRequest))) + return (UserContact(contactRequest: contactRequest).id, .nse(createContactRequestNtf(user, contactRequest))) case let .newChatItem(user, aChatItem): let cInfo = aChatItem.chatInfo var cItem = aChatItem.chatItem @@ -274,7 +588,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { if let file = cItem.autoReceiveFile() { cItem = autoReceiveFile(file, encrypted: cItem.encryptLocalFile) ?? cItem } - let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(notification: createMessageReceivedNtf(user, cInfo, cItem)) : .empty + let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(createMessageReceivedNtf(user, cInfo, cItem)) : .empty return cItem.showNotification ? (aChatItem.chatId, ntf) : nil case let .rcvFileSndCancelled(_, aChatItem, _): cleanupFile(aChatItem) @@ -292,10 +606,15 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { // Do not post it without CallKit support, iOS will stop launching the app without showing CallKit return ( invitation.contact.id, - useCallKit() ? .callkit(invitation: invitation) : .nse(notification: createCallInvitationNtf(invitation)) + useCallKit() ? .callkit(invitation) : .nse(createCallInvitationNtf(invitation)) ) + case let .ntfMessage(_, connEntity, ntfMessage): + return if let id = connEntity.id { (id, .msgInfo(ntfMessage)) } else { nil } + case .chatSuspended: + chatSuspended() + return nil default: - logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)") + logger.debug("NotificationService receivedMsgNtf ignored event: \(res.responseType)") return nil } } @@ -334,6 +653,21 @@ func apiStartChat() throws -> Bool { } } +func apiActivateChat() -> Bool { + chatReopenStore() + let r = sendSimpleXCmd(.apiActivateChat(restoreChat: false)) + if case .cmdOk = r { return true } + logger.error("NotificationService apiActivateChat error: \(String(describing: r))") + return false +} + +func apiSuspendChat(timeoutMicroseconds: Int) -> Bool { + let r = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) + if case .cmdOk = r { return true } + logger.error("NotificationService apiSuspendChat error: \(String(describing: r))") + return false +} + func apiSetTempFolder(tempFolder: String) throws { let r = sendSimpleXCmd(.setTempFolder(tempFolder: tempFolder)) if case .cmdOk = r { return } @@ -364,8 +698,8 @@ func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { return nil } let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) - if case let .ntfMessages(user, connEntity, msgTs, ntfMessages) = r, let user = user { - return NtfMessages(user: user, connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages) + if case let .ntfMessages(user, connEntity_, msgTs, ntfMessages) = r, let user = user { + return NtfMessages(user: user, connEntity_: connEntity_, msgTs: msgTs, ntfMessages: ntfMessages) } else if case let .chatCmdError(_, error) = r { logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))") } else { @@ -405,11 +739,11 @@ func setNetworkConfig(_ cfg: NetCfg) throws { struct NtfMessages { var user: User - var connEntity: ConnectionEntity? + var connEntity_: ConnectionEntity? var msgTs: Date? var ntfMessages: [NtfMsgInfo] var ntfsEnabled: Bool { - user.showNotifications && (connEntity?.ntfsEnabled ?? false) + user.showNotifications && (connEntity_?.ntfsEnabled ?? false) } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 1ddff81f99..d29cfac13b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -43,11 +43,6 @@ 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */; }; 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */; }; 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; }; - 5C4BB4B82B1E7D75007981AA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B32B1E7D75007981AA /* libgmp.a */; }; - 5C4BB4B92B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B42B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a */; }; - 5C4BB4BA2B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B52B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a */; }; - 5C4BB4BB2B1E7D75007981AA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B62B1E7D75007981AA /* libffi.a */; }; - 5C4BB4BC2B1E7D75007981AA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C4BB4B72B1E7D75007981AA /* libgmpxx.a */; }; 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 */; }; @@ -119,6 +114,11 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; + 5CC6B3B32B3746E60077D4D0 /* libHSsimplex-chat-5.4.2.0-1dXNnkvLJVS8FSAgswHDGD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC6B3AE2B3746E60077D4D0 /* libHSsimplex-chat-5.4.2.0-1dXNnkvLJVS8FSAgswHDGD.a */; }; + 5CC6B3B42B3746E60077D4D0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC6B3AF2B3746E60077D4D0 /* libgmpxx.a */; }; + 5CC6B3B52B3746E60077D4D0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC6B3B02B3746E60077D4D0 /* libgmp.a */; }; + 5CC6B3B62B3746E60077D4D0 /* libHSsimplex-chat-5.4.2.0-1dXNnkvLJVS8FSAgswHDGD-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC6B3B12B3746E60077D4D0 /* libHSsimplex-chat-5.4.2.0-1dXNnkvLJVS8FSAgswHDGD-ghc9.6.3.a */; }; + 5CC6B3B72B3746E60077D4D0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC6B3B22B3746E60077D4D0 /* libffi.a */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; @@ -150,6 +150,9 @@ 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; }; 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; }; + 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */; }; + 5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */; }; + 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF937212B25034A00E1D781 /* NSESubscriber.swift */; }; 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */; }; 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */; }; 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; @@ -190,6 +193,7 @@ 64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; }; 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 */; }; 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 */; }; @@ -295,11 +299,6 @@ 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettings.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 = ""; }; - 5C4BB4B32B1E7D75007981AA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C4BB4B42B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a"; sourceTree = ""; }; - 5C4BB4B52B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a"; sourceTree = ""; }; - 5C4BB4B62B1E7D75007981AA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C4BB4B72B1E7D75007981AA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; 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 = ""; }; @@ -407,6 +406,11 @@ 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; + 5CC6B3AE2B3746E60077D4D0 /* libHSsimplex-chat-5.4.2.0-1dXNnkvLJVS8FSAgswHDGD.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.2.0-1dXNnkvLJVS8FSAgswHDGD.a"; sourceTree = ""; }; + 5CC6B3AF2B3746E60077D4D0 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CC6B3B02B3746E60077D4D0 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CC6B3B12B3746E60077D4D0 /* libHSsimplex-chat-5.4.2.0-1dXNnkvLJVS8FSAgswHDGD-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.2.0-1dXNnkvLJVS8FSAgswHDGD-ghc9.6.3.a"; sourceTree = ""; }; + 5CC6B3B22B3746E60077D4D0 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; @@ -439,6 +443,9 @@ 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = ""; }; + 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentQueue.swift; sourceTree = ""; }; + 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedFileSubscriber.swift; sourceTree = ""; }; + 5CF937212B25034A00E1D781 /* NSESubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSESubscriber.swift; sourceTree = ""; }; 5CFA59C32860BC6200863A68 /* MigrateToAppGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToAppGroupView.swift; 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; }; @@ -480,6 +487,7 @@ 64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; + 8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -521,13 +529,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5CC6B3B42B3746E60077D4D0 /* libgmpxx.a in Frameworks */, + 5CC6B3B52B3746E60077D4D0 /* libgmp.a in Frameworks */, + 5CC6B3B62B3746E60077D4D0 /* libHSsimplex-chat-5.4.2.0-1dXNnkvLJVS8FSAgswHDGD-ghc9.6.3.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C4BB4BB2B1E7D75007981AA /* libffi.a in Frameworks */, - 5C4BB4BA2B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a in Frameworks */, - 5C4BB4B92B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a in Frameworks */, - 5C4BB4B82B1E7D75007981AA /* libgmp.a in Frameworks */, + 5CC6B3B72B3746E60077D4D0 /* libffi.a in Frameworks */, + 5CC6B3B32B3746E60077D4D0 /* libHSsimplex-chat-5.4.2.0-1dXNnkvLJVS8FSAgswHDGD.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5C4BB4BC2B1E7D75007981AA /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -589,11 +597,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C4BB4B62B1E7D75007981AA /* libffi.a */, - 5C4BB4B32B1E7D75007981AA /* libgmp.a */, - 5C4BB4B72B1E7D75007981AA /* libgmpxx.a */, - 5C4BB4B42B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo-ghc8.10.7.a */, - 5C4BB4B52B1E7D75007981AA /* libHSsimplex-chat-5.4.0.6-DWi9o3X1dc6Jx2FZ4ew1fo.a */, + 5CC6B3B22B3746E60077D4D0 /* libffi.a */, + 5CC6B3B02B3746E60077D4D0 /* libgmp.a */, + 5CC6B3AF2B3746E60077D4D0 /* libgmpxx.a */, + 5CC6B3B12B3746E60077D4D0 /* libHSsimplex-chat-5.4.2.0-1dXNnkvLJVS8FSAgswHDGD-ghc9.6.3.a */, + 5CC6B3AE2B3746E60077D4D0 /* libHSsimplex-chat-5.4.2.0-1dXNnkvLJVS8FSAgswHDGD.a */, ); path = Libraries; sourceTree = ""; @@ -618,6 +626,7 @@ 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */, + 5CF937212B25034A00E1D781 /* NSESubscriber.swift */, 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */, 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */, 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */, @@ -645,6 +654,7 @@ 64466DCB29FFE3E800E3D48D /* MailView.swift */, 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */, 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */, + 8C05382D2B39887E006436DC /* VideoUtils.swift */, ); path = Helpers; sourceTree = ""; @@ -798,6 +808,7 @@ isa = PBXGroup; children = ( 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */, + 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */, 5CDCAD472818589900503DA2 /* NotificationService.swift */, 5CDCAD492818589900503DA2 /* Info.plist */, 5CB0BA862826CB3A00B3292C /* InfoPlist.strings */, @@ -818,6 +829,7 @@ 64DAE1502809D9F5000DA960 /* FileUtils.swift */, 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */, 5C00168028C4FE760094D739 /* KeyChain.swift */, + 5CF9371F2B24DE8C00E1D781 /* SharedFileSubscriber.swift */, 5CE2BA76284530BF00EC33A6 /* SimpleXChat.h */, 5CE2BA8A2845332200EC33A6 /* SimpleX.h */, 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */, @@ -1190,6 +1202,7 @@ 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, + 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */, 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */, 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */, 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */, @@ -1212,6 +1225,7 @@ 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */, 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, + 8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */, 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */, 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */, 5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */, @@ -1269,6 +1283,7 @@ files = ( 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */, + 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1276,6 +1291,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */, 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */, 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */, 5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */, @@ -1512,7 +1528,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1534,7 +1550,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.4; + MARKETING_VERSION = 5.4.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1555,7 +1571,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1577,7 +1593,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.4; + MARKETING_VERSION = 5.4.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1636,7 +1652,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1649,7 +1665,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.4; + MARKETING_VERSION = 5.4.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1668,7 +1684,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1681,7 +1697,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.4; + MARKETING_VERSION = 5.4.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1700,7 +1716,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1724,7 +1740,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.4; + MARKETING_VERSION = 5.4.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1746,7 +1762,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 184; + CURRENT_PROJECT_VERSION = 185; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1770,7 +1786,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.4; + MARKETING_VERSION = 5.4.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index c7e94a2dc0..8d05a066e8 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -17,7 +17,7 @@ public func getChatCtrl(_ useKey: String? = nil) -> chat_ctrl { fatalError("chat controller not initialized") } -public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: MigrationConfirmation? = nil) -> (Bool, DBMigrationResult) { +public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: MigrationConfirmation? = nil, backgroundMode: Bool = false) -> (Bool, DBMigrationResult) { if let res = migrationResult { return res } let dbPath = getAppDatabasePath().path var dbKey = "" @@ -41,7 +41,7 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio var cKey = dbKey.cString(using: .utf8)! var cConfirm = confirm.rawValue.cString(using: .utf8)! // the last parameter of chat_migrate_init is used to return the pointer to chat controller - let cjson = chat_migrate_init(&cPath, &cKey, &cConfirm, &chatController)! + let cjson = chat_migrate_init_key(&cPath, &cKey, 1, &cConfirm, backgroundMode ? 1 : 0, &chatController)! let dbRes = dbMigrationResult(fromCString(cjson)) let encrypted = dbKey != "" let keychainErr = dbRes == .ok && useKeychain && encrypted && !kcDatabasePassword.set(dbKey) @@ -57,6 +57,13 @@ public func chatCloseStore() { } } +public func chatReopenStore() { + let err = fromCString(chat_reopen_store(getChatCtrl())) + if err != "" { + logger.error("chatReopenStore error: \(err)") + } +} + public func resetChatCtrl() { chatController = nil migrationResult = nil diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 3d2c21392e..a199966bab 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -27,7 +27,7 @@ public enum ChatCommand { case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?) case startChat(subscribe: Bool, expire: Bool, xftp: Bool) case apiStopChat - case apiActivateChat + case apiActivateChat(restoreChat: Bool) case apiSuspendChat(timeoutMicroseconds: Int) case setTempFolder(tempFolder: String) case setFilesFolder(filesFolder: String) @@ -156,7 +156,7 @@ public enum ChatCommand { case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))" case let .startChat(subscribe, expire, xftp): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire)) xftp=\(onOff(xftp))" case .apiStopChat: return "/_stop" - case .apiActivateChat: return "/_app activate" + case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))" case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" case let .setTempFolder(tempFolder): return "/_temp_folder \(tempFolder)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" @@ -604,7 +604,8 @@ public enum ChatResponse: Decodable, Error { case callInvitations(callInvitations: [RcvCallInvitation]) case ntfTokenStatus(status: NtfTknStatus) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) - case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) + case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) + case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgInfo) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) // remote desktop responses/events case remoteCtrlList(remoteCtrls: [RemoteCtrlInfo]) @@ -751,6 +752,7 @@ public enum ChatResponse: Decodable, Error { case .ntfTokenStatus: return "ntfTokenStatus" case .ntfToken: return "ntfToken" case .ntfMessages: return "ntfMessages" + case .ntfMessage: return "ntfMessage" case .contactConnectionDeleted: return "contactConnectionDeleted" case .remoteCtrlList: return "remoteCtrlList" case .remoteCtrlFound: return "remoteCtrlFound" @@ -898,6 +900,7 @@ public enum ChatResponse: Decodable, Error { case let .ntfTokenStatus(status): return String(describing: status) case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") + case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))") case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .remoteCtrlList(remoteCtrls): return String(describing: remoteCtrls) case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible): return "remoteCtrl:\n\(String(describing: remoteCtrl))\nctrlAppInfo_:\n\(String(describing: ctrlAppInfo_))\nappVersion: \(appVersion)\ncompatible: \(compatible)" @@ -1204,9 +1207,9 @@ public struct NetCfg: Codable, Equatable { public static let defaults: NetCfg = NetCfg( socksProxy: nil, sessionMode: TransportSessionMode.user, - tcpConnectTimeout: 15_000_000, - tcpTimeout: 10_000_000, - tcpTimeoutPerKb: 30_000, + tcpConnectTimeout: 20_000_000, + tcpTimeout: 15_000_000, + tcpTimeoutPerKb: 45_000, tcpKeepAlive: KeepAliveOpts.defaults, smpPingInterval: 1200_000_000, smpPingCount: 3, @@ -1495,6 +1498,8 @@ public enum PushProvider: String, Decodable { } } +// This notification mode is for app core, UI uses AppNotificationsMode.off to mean completely disable, +// and .local for periodic background checks public enum NotificationsMode: String, Decodable, SelectableItem { case off = "OFF" case periodic = "PERIODIC" @@ -1502,9 +1507,9 @@ public enum NotificationsMode: String, Decodable, SelectableItem { public var label: LocalizedStringKey { switch self { - case .off: return "Off (Local)" - case .periodic: return "Periodically" - case .instant: return "Instantly" + case .off: "Local" + case .periodic: "Periodically" + case .instant: "Instantly" } } diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index cc61fae53f..f79c294e0c 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -9,12 +9,16 @@ import Foundation import SwiftUI +public let appSuspendTimeout: Int = 15 // seconds + let GROUP_DEFAULT_APP_STATE = "appState" +let GROUP_DEFAULT_NSE_STATE = "nseState" let GROUP_DEFAULT_DB_CONTAINER = "dbContainer" public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart" +public let GROUP_DEFAULT_CHAT_LAST_BACKGROUND_RUN = "chatLastBackgroundRun" let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode" -public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" -public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" +public let GROUP_DEFAULT_NTF_ENABLE_LOCAL = "ntfEnableLocal" // no longer used +public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer used 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" @@ -66,13 +70,23 @@ public func registerGroupDefaults() { ]) } -public enum AppState: String { +public enum AppState: String, Codable { case active + case activating case bgRefresh case suspending case suspended case stopped + public var running: Bool { + switch self { + case .active: return true + case .activating: return true + case .bgRefresh: return true + default: return false + } + } + public var inactive: Bool { switch self { case .suspending: return true @@ -84,23 +98,57 @@ public enum AppState: String { public var canSuspend: Bool { switch self { case .active: return true + case .activating: return true case .bgRefresh: return true default: return false } } } +public enum NSEState: String, Codable { + case created + case starting + case active + case suspending + case suspended + + public var inactive: Bool { + switch self { + case .created: true + case .suspended: true + default: false + } + } + + public var canSuspend: Bool { + if case .active = self { true } else { false } + } +} + public enum DBContainer: String { case documents case group } +// appStateGroupDefault must not be used in the app directly, only via AppChatState singleton public let appStateGroupDefault = EnumDefault( defaults: groupDefaults, forKey: GROUP_DEFAULT_APP_STATE, withDefault: .active ) +// nseStateGroupDefault must not be used in NSE directly, only via NSEChatState singleton +public let nseStateGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_NSE_STATE, + withDefault: .suspended // so that NSE that was never launched does not delay the app from resuming +) + +// inactive app states do not include "stopped" state +public func allowBackgroundRefresh() -> Bool { + appStateGroupDefault.get().inactive && nseStateGroupDefault.get().inactive +} + public let dbContainerGroupDefault = EnumDefault( defaults: groupDefaults, forKey: GROUP_DEFAULT_DB_CONTAINER, @@ -109,6 +157,8 @@ public let dbContainerGroupDefault = EnumDefault( public let chatLastStartGroupDefault = DateDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_CHAT_LAST_START) +public let chatLastBackgroundRunGroupDefault = DateDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_CHAT_LAST_BACKGROUND_RUN) + public let ntfPreviewModeGroupDefault = EnumDefault( defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_PREVIEW_MODE, @@ -117,10 +167,6 @@ public let ntfPreviewModeGroupDefault = EnumDefault( public let incognitoGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INCOGNITO) -public let ntfEnableLocalGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_ENABLE_LOCAL) - -public let ntfEnablePeriodicGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_ENABLE_PERIODIC) - public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES) public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index dc4cdda462..a545d3508c 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2016,7 +2016,8 @@ public enum ConnectionEntity: Decodable { } public struct NtfMsgInfo: Decodable { - + public var msgId: String + public var msgTs: Date } public struct AChatItem: Decodable { diff --git a/apps/ios/SimpleXChat/CryptoFile.swift b/apps/ios/SimpleXChat/CryptoFile.swift index dcb2be9ae0..0e539ba97c 100644 --- a/apps/ios/SimpleXChat/CryptoFile.swift +++ b/apps/ios/SimpleXChat/CryptoFile.swift @@ -17,7 +17,7 @@ public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { let ptr: UnsafeMutableRawPointer = malloc(data.count) memcpy(ptr, (data as NSData).bytes, data.count) var cPath = path.cString(using: .utf8)! - let cjson = chat_write_file(&cPath, ptr, Int32(data.count))! + let cjson = chat_write_file(getChatCtrl(), &cPath, ptr, Int32(data.count))! let d = fromCString(cjson).data(using: .utf8)! switch try jsonDecoder.decode(WriteFileResult.self, from: d) { case let .result(cfArgs): return cfArgs @@ -50,7 +50,7 @@ public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> D public func encryptCryptoFile(fromPath: String, toPath: String) throws -> CryptoFileArgs { var cFromPath = fromPath.cString(using: .utf8)! var cToPath = toPath.cString(using: .utf8)! - let cjson = chat_encrypt_file(&cFromPath, &cToPath)! + let cjson = chat_encrypt_file(getChatCtrl(), &cFromPath, &cToPath)! let d = fromCString(cjson).data(using: .utf8)! switch try jsonDecoder.decode(WriteFileResult.self, from: d) { case let .result(cfArgs): return cfArgs diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index d613ff20ae..bc959cb34b 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -146,6 +146,13 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult) -> UNMutableNotificati ) } +public func createAppStoppedNtf() -> UNMutableNotificationContent { + return createNotification( + categoryIdentifier: ntfCategoryConnectionEvent, + title: NSLocalizedString("Encrypted message: app is stopped", comment: "notification") + ) +} + private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember, hideContent: Bool) -> String { hideContent ? NSLocalizedString("Group message:", comment: "notification") diff --git a/apps/ios/SimpleXChat/SharedFileSubscriber.swift b/apps/ios/SimpleXChat/SharedFileSubscriber.swift new file mode 100644 index 0000000000..f496e6999e --- /dev/null +++ b/apps/ios/SimpleXChat/SharedFileSubscriber.swift @@ -0,0 +1,99 @@ +// +// SharedFileSubscriber.swift +// SimpleXChat +// +// Created by Evgeny on 09/12/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import Foundation + +public typealias AppSubscriber = SharedFileSubscriber> + +public typealias NSESubscriber = SharedFileSubscriber> + +public class SharedFileSubscriber: NSObject, NSFilePresenter { + var fileURL: URL + public var presentedItemURL: URL? + public var presentedItemOperationQueue: OperationQueue = .main + var subscriber: (Message) -> Void + + init(fileURL: URL, onMessage: @escaping (Message) -> Void) { + self.fileURL = fileURL + presentedItemURL = fileURL + subscriber = onMessage + super.init() + NSFileCoordinator.addFilePresenter(self) + } + + public func presentedItemDidChange() { + do { + let data = try Data(contentsOf: fileURL) + let msg = try jsonDecoder.decode(Message.self, from: data) + subscriber(msg) + } catch let error { + logger.error("presentedItemDidChange error: \(error)") + } + } + + static func notify(url: URL, message: Message) { + let fc = NSFileCoordinator(filePresenter: nil) + fc.coordinate(writingItemAt: url, options: [], error: nil) { newURL in + do { + let data = try jsonEncoder.encode(message) + try data.write(to: newURL, options: [.atomic]) + } catch { + logger.error("notifyViaSharedFile error: \(error)") + } + } + } + + deinit { + NSFileCoordinator.removeFilePresenter(self) + } +} + +let appMessagesSharedFile = getGroupContainerDirectory().appendingPathComponent("chat.simplex.app.messages", isDirectory: false) + +let nseMessagesSharedFile = getGroupContainerDirectory().appendingPathComponent("chat.simplex.app.SimpleX-NSE.messages", isDirectory: false) + +public struct ProcessMessage: Codable { + var createdAt: Date = Date.now + var message: Message +} + +public enum AppProcessMessage: Codable { + case state(state: AppState) +} + +public enum NSEProcessMessage: Codable { + case state(state: NSEState) +} + +public func sendAppProcessMessage(_ message: AppProcessMessage) { + SharedFileSubscriber.notify(url: appMessagesSharedFile, message: ProcessMessage(message: message)) +} + +public func sendNSEProcessMessage(_ message: NSEProcessMessage) { + SharedFileSubscriber.notify(url: nseMessagesSharedFile, message: ProcessMessage(message: message)) +} + +public func appMessageSubscriber(onMessage: @escaping (AppProcessMessage) -> Void) -> AppSubscriber { + SharedFileSubscriber(fileURL: appMessagesSharedFile) { (msg: ProcessMessage) in + onMessage(msg.message) + } +} + +public func nseMessageSubscriber(onMessage: @escaping (NSEProcessMessage) -> Void) -> NSESubscriber { + SharedFileSubscriber(fileURL: nseMessagesSharedFile) { (msg: ProcessMessage) in + onMessage(msg.message) + } +} + +public func sendAppState(_ state: AppState) { + sendAppProcessMessage(.state(state: state)) +} + +public func sendNSEState(_ state: NSEState) { + sendNSEProcessMessage(.state(state: state)) +} diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 2872922a9b..c49d104514 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -16,20 +16,20 @@ extern void hs_init(int argc, char **argv[]); typedef void* chat_ctrl; // the last parameter is used to return the pointer to chat controller -extern char *chat_migrate_init(char *path, char *key, char *confirm, chat_ctrl *ctrl); +extern char *chat_migrate_init_key(char *path, char *key, int keepKey, char *confirm, int backgroundMode, chat_ctrl *ctrl); extern char *chat_close_store(chat_ctrl ctl); +extern char *chat_reopen_store(chat_ctrl ctl); 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); extern char *chat_parse_server(char *str); extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_valid_name(char *name); -extern char *chat_encrypt_media(char *key, char *frame, int len); +extern char *chat_encrypt_media(chat_ctrl ctl, char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len); // chat_write_file returns null-terminated string with JSON of WriteFileResult -extern char *chat_write_file(char *path, char *data, int len); +extern char *chat_write_file(chat_ctrl ctl, char *path, char *data, int len); // chat_read_file returns a buffer with: // result status (1 byte), then if @@ -38,7 +38,7 @@ extern char *chat_write_file(char *path, char *data, int len); extern char *chat_read_file(char *path, char *key, char *nonce); // chat_encrypt_file returns null-terminated string with JSON of WriteFileResult -extern char *chat_encrypt_file(char *fromPath, char *toPath); +extern char *chat_encrypt_file(chat_ctrl ctl, char *fromPath, char *toPath); // chat_decrypt_file returns null-terminated string with the error message extern char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 09b33316a4..d8350ee222 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ android:exported="true" android:label="${app_name}" android:windowSoftInputMode="adjustResize" + android:configChanges="uiMode" android:theme="@style/Theme.SimpleX"> diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index cbe0ef7b16..8d64ae3c80 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.os.* import android.view.WindowManager import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatDelegate import androidx.fragment.app.FragmentActivity import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager.getUserIdFromIntent @@ -22,6 +23,7 @@ import java.lang.ref.WeakReference class MainActivity: FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + platform.androidSetNightModeIfSupported() applyAppLocale(ChatModel.controller.appPrefs.appLanguage) super.onCreate(savedInstanceState) // testJson() @@ -124,7 +126,9 @@ fun processIntent(intent: Intent?) { when (intent?.action) { "android.intent.action.VIEW" -> { val uri = intent.data - if (uri != null) connectIfOpenedViaUri(chatModel.remoteHostId(), uri.toURI(), ChatModel) + if (uri != null) { + chatModel.appOpenUrl.value = null to uri.toURI() + } } } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index a345e6e48f..ee43da5d44 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -1,7 +1,8 @@ package chat.simplex.app import android.app.Application -import chat.simplex.common.platform.Log +import android.app.UiModeManager +import android.os.* import androidx.lifecycle.* import androidx.work.* import chat.simplex.app.model.NtfManager @@ -10,10 +11,12 @@ import chat.simplex.common.helpers.requiresIgnoringBattery import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.updatingChatsMutex +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.ui.theme.DefaultTheme +import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.common.platform.* -import chat.simplex.common.views.call.RcvCallInvitation import com.jakewharton.processphoenix.ProcessPhoenix import kotlinx.coroutines.* import kotlinx.coroutines.sync.withLock @@ -35,6 +38,21 @@ class SimplexApp: Application(), LifecycleEventObserver { return } else { registerGlobalErrorHandler() + Handler(Looper.getMainLooper()).post { + while (true) { + try { + Looper.loop() + } catch (e: Throwable) { + if (e.message != null && e.message!!.startsWith("Unable to start activity")) { + android.os.Process.killProcess(android.os.Process.myPid()) + break + } else { + // Send it to our exception handled because it will not get the exception otherwise + Thread.getDefaultUncaughtExceptionHandler()?.uncaughtException(Looper.getMainLooper().thread, e) + } + } + } + } } context = this initHaskell() @@ -208,6 +226,23 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidIsBackgroundCallAllowed(): Boolean = !SimplexService.isBackgroundRestricted() + override fun androidSetNightModeIfSupported() { + if (Build.VERSION.SDK_INT < 31) return + + val light = if (CurrentColors.value.name == DefaultTheme.SYSTEM.name) { + null + } else { + CurrentColors.value.colors.isLight + } + val mode = when (light) { + null -> UiModeManager.MODE_NIGHT_AUTO + true -> UiModeManager.MODE_NIGHT_NO + false -> UiModeManager.MODE_NIGHT_YES + } + val uiModeManager = androidAppContext.getSystemService(UI_MODE_SERVICE) as UiModeManager + uiModeManager.setApplicationNightMode(mode) + } + override suspend fun androidAskToAllowBackgroundCalls(): Boolean { if (SimplexService.isBackgroundRestricted()) { val userChoice: CompletableDeferred = CompletableDeferred() diff --git a/apps/multiplatform/android/src/main/res/values-night/themes.xml b/apps/multiplatform/android/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..bb341b71d0 --- /dev/null +++ b/apps/multiplatform/android/src/main/res/values-night/themes.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/apps/multiplatform/android/src/main/res/values/themes.xml b/apps/multiplatform/android/src/main/res/values/themes.xml index f59d099fba..eb6d85bf05 100644 --- a/apps/multiplatform/android/src/main/res/values/themes.xml +++ b/apps/multiplatform/android/src/main/res/values/themes.xml @@ -1,7 +1,7 @@ - diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 4b0e38d8a0..4d2eeca2ee 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -155,6 +155,34 @@ afterEvaluate { val endTagRegex = Regex("]*>.*(<|>).*|[^>]*>.*(<|>).*") val correctHtmlRegex = Regex("[^>]*>.*.*.*|[^>]*>.*.*.*|[^>]*>.*.*.*|[^>]*>.*]*>.*.*") + val possibleFormat = listOf("s", "d", "1\$s", "1\$d", "2s", "f") + + fun String.id(): String = replace(" { + if (!contains("%")) return emptyList() + val value = substringAfter("\">").substringBeforeLast("") + + val formats = ArrayList() + var substring = value.substringAfter("%") + while (true) { + var foundFormat = false + for (format in possibleFormat) { + if (substring.startsWith(format)) { + formats.add(format) + foundFormat = true + break + } + } + if (!foundFormat) { + throw Exception("Unknown formatting in string. Add it to 'possibleFormat' in common/build.gradle.kts if needed: $this \nin $filepath") + } + val was = substring + substring = substring.substringAfter("%") + if (was.length == substring.length) break + } + return formats + } fun String.removeCDATA(): String = if (contains(" + val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree + val baseStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found") + val treeList = ArrayList(tree.toList()) + treeList.remove(baseStringsFile) + treeList.add(0, baseStringsFile) + val baseFormatting = mutableMapOf>() + treeList.forEachIndexed { index, file -> + val isBase = index == 0 val initialLines = ArrayList() val finalLines = ArrayList() + val errors = ArrayList() + file.useLines { lines -> val multiline = ArrayList() lines.forEach { line -> initialLines.add(line) if (stringRegex.matches(line)) { - finalLines.add(line.removeCDATA().addCDATA(file.absolutePath)) + val fixedLine = line.removeCDATA().addCDATA(file.absolutePath) + val lineId = fixedLine.id() + if (isBase) { + baseFormatting[lineId] = fixedLine.formatting(file.absolutePath) + } else if (baseFormatting[lineId] != fixedLine.formatting(file.absolutePath)) { + errors.add("Incorrect formatting in string: $fixedLine \nin ${file.absolutePath}") + } + finalLines.add(fixedLine) } else if (multiline.isEmpty() && startStringRegex.containsMatchIn(line)) { multiline.add(line) } else if (multiline.isNotEmpty() && endStringRegex.containsMatchIn(line)) { multiline.add(line) - finalLines.addAll(multiline.joinToString("\n").removeCDATA().addCDATA(file.absolutePath).split("\n")) + val fixedLines = multiline.joinToString("\n").removeCDATA().addCDATA(file.absolutePath).split("\n") + val fixedLinesJoined = fixedLines.joinToString("") + val lineId = fixedLinesJoined.id() + if (isBase) { + baseFormatting[lineId] = fixedLinesJoined.formatting(file.absolutePath) + } else if (baseFormatting[lineId] != fixedLinesJoined.formatting(file.absolutePath)) { + errors.add("Incorrect formatting in string: $fixedLinesJoined \nin ${file.absolutePath}") + } + finalLines.addAll(fixedLines) multiline.clear() } else if (multiline.isNotEmpty()) { multiline.add(line) @@ -217,10 +269,14 @@ afterEvaluate { } } if (multiline.isNotEmpty()) { - throw Exception("Unclosed string tag: ${multiline.joinToString("\n")} \nin ${file.absolutePath}") + errors.add("Unclosed string tag: ${multiline.joinToString("\n")} \nin ${file.absolutePath}") } } + if (errors.isNotEmpty()) { + throw Exception("Found errors: \n\n${errors.joinToString("\n\n")}") + } + if (!debug && finalLines != initialLines) { file.writer().use { finalLines.forEachIndexed { index, line -> diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 1bc9658496..9e28c4f2bc 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -27,7 +27,6 @@ import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doOnTextChanged -import chat.simplex.common.* import chat.simplex.common.R import chat.simplex.common.helpers.toURI import chat.simplex.common.model.ChatModel @@ -45,6 +44,7 @@ import java.net.URI actual fun PlatformTextField( composeState: MutableState, sendMsgEnabled: Boolean, + sendMsgButtonDisabled: Boolean, textStyle: MutableState, showDeleteTextButton: MutableState, userIsObserver: Boolean, diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index 96bb739113..371c140133 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -4,7 +4,7 @@ import android.app.Activity import android.content.Context import android.content.pm.ActivityInfo import android.graphics.Rect -import android.os.Build +import android.os.* import android.view.* import android.view.inputmethod.InputMethodManager import android.widget.Toast @@ -12,7 +12,6 @@ import androidx.activity.compose.setContent import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalView import chat.simplex.common.AppScreen -import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.helpers.* import androidx.compose.ui.platform.LocalContext as LocalContext1 import chat.simplex.res.MR @@ -79,6 +78,7 @@ actual fun androidIsFinishingMainActivity(): Boolean = (mainActivity.get()?.isFi actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { actual override fun uncaughtException(thread: Thread, e: Throwable) { Log.e(TAG, "App crashed, thread name: " + thread.name + ", exception: " + e.stackTraceToString()) + includeMoreFailedComposables() if (ModalManager.start.hasModalsOpen()) { ModalManager.start.closeModal() } else if (chatModel.chatId.value != null) { @@ -93,19 +93,25 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler { chatModel.callManager.endCall(it) } } - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.app_was_crashed), - text = e.stackTraceToString() - ) - //mainActivity.get()?.recreate() - mainActivity.get()?.apply { - window - ?.decorView - ?.findViewById(android.R.id.content) - ?.removeViewAt(0) - setContent { - AppScreen() + if (thread.name == "main") { + mainActivity.get()?.recreate() + } else { + mainActivity.get()?.apply { + window + ?.decorView + ?.findViewById(android.R.id.content) + ?.removeViewAt(0) + setContent { + AppScreen() + } } } + // Wait until activity recreates to prevent showing two alerts (in case `main` was crashed) + Handler(Looper.getMainLooper()).post { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.app_was_crashed), + text = e.stackTraceToString() + ) + } } } diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index 4fd62524de..676c58fb49 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -65,9 +65,9 @@ extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); -extern char *chat_write_file(const char *path, char *ptr, int length); +extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); -extern char *chat_encrypt_file(const char *from_path, const char *to_path); +extern char *chat_encrypt_file(chat_ctrl ctrl, const char *from_path, const char *to_path); extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path); JNIEXPORT jobjectArray JNICALL @@ -157,11 +157,11 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { +Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jlong controller, jstring path, jobject buffer) { const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); - jstring res = (*env)->NewStringUTF(env, chat_write_file(_path, buff, capacity)); + jstring res = (*env)->NewStringUTF(env, chat_write_file((void*)controller, _path, buff, capacity)); (*env)->ReleaseStringUTFChars(env, path, _path); return res; } @@ -206,10 +206,10 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) { +Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jlong controller, jstring from_path, jstring to_path) { const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE); const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_encrypt_file(_from_path, _to_path)); + jstring res = (*env)->NewStringUTF(env, chat_encrypt_file((void*)controller, _from_path, _to_path)); (*env)->ReleaseStringUTFChars(env, from_path, _from_path); (*env)->ReleaseStringUTFChars(env, to_path, _to_path); return res; diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index fb561dc38d..292715bdc5 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -38,9 +38,9 @@ extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); -extern char *chat_write_file(const char *path, char *ptr, int length); +extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); -extern char *chat_encrypt_file(const char *from_path, const char *to_path); +extern char *chat_encrypt_file(chat_ctrl ctrl, const char *from_path, const char *to_path); extern char *chat_decrypt_file(const char *from_path, const char *key, const char *nonce, const char *to_path); // As a reference: https://stackoverflow.com/a/60002045 @@ -167,11 +167,11 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { +Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jlong controller, jstring path, jobject buffer) { const char *_path = encode_to_utf8_chars(env, path); jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); - jstring res = decode_to_utf8_string(env, chat_write_file(_path, buff, capacity)); + jstring res = decode_to_utf8_string(env, chat_write_file((void*)controller, _path, buff, capacity)); (*env)->ReleaseStringUTFChars(env, path, _path); return res; } @@ -216,10 +216,10 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jstring from_path, jstring to_path) { +Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jlong controller, jstring from_path, jstring to_path) { const char *_from_path = encode_to_utf8_chars(env, from_path); const char *_to_path = encode_to_utf8_chars(env, to_path); - jstring res = decode_to_utf8_string(env, chat_encrypt_file(_from_path, _to_path)); + jstring res = decode_to_utf8_string(env, chat_encrypt_file((void*)controller, _from_path, _to_path)); (*env)->ReleaseStringUTFChars(env, from_path, _from_path); (*env)->ReleaseStringUTFChars(env, to_path, _to_path); return res; diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 4387adf95e..d457eb57a1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -162,11 +162,26 @@ fun MainScreen() { AuthView() } else { SplashView() + ModalManager.fullscreen.showPasscodeInView() + } + } else { + if (chatModel.showCallView.value) { + ActiveCallView() + } else { + // It's needed for privacy settings toggle, so it can be shown even if the app is passcode unlocked + ModalManager.fullscreen.showPasscodeInView() + } + AlertManager.privacySensitive.showInView() + if (onboarding == OnboardingStage.OnboardingComplete) { + LaunchedEffect(chatModel.currentUser.value, chatModel.appOpenUrl.value) { + val (rhId, url) = chatModel.appOpenUrl.value ?: (null to null) + if (url != null) { + chatModel.appOpenUrl.value = null + connectIfOpenedViaUri(rhId, url, chatModel) + } + } } - } else if (chatModel.showCallView.value) { - ActiveCallView() } - ModalManager.fullscreen.showPasscodeInView() val invitation = chatModel.activeCallInvitation.value if (invitation != null) IncomingCallAlertView(invitation, chatModel) AlertManager.shared.showInView() @@ -317,9 +332,11 @@ fun DesktopScreen(settingsState: SettingsViewState) { ) } VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH)) - UserPicker(chatModel, userPickerState) { - scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } - userPickerState.value = AnimatedViewState.GONE + tryOrShowError("UserPicker", error = {}) { + UserPicker(chatModel, userPickerState) { + scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + userPickerState.value = AnimatedViewState.GONE + } } ModalManager.fullscreen.showInView() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 25c87d64ad..708bbb9073 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -58,7 +58,7 @@ object ChatModel { val chatItemStatuses = mutableMapOf() val groupMembers = mutableStateListOf() - val terminalItems = mutableStateListOf() + val terminalItems = mutableStateOf>(listOf()) val userAddress = mutableStateOf(null) // Allows to temporary save servers that are being edited on multiple screens val userSMPServersUnsaved = mutableStateOf<(List)?>(null) @@ -70,8 +70,8 @@ object ChatModel { // Only needed during onboarding when user skipped password setup (left as random password) val desktopOnboardingRandomPassword = mutableStateOf(false) - // set when app is opened via contact or invitation URI - val appOpenUrl = mutableStateOf(null) + // set when app is opened via contact or invitation URI (rhId, uri) + val appOpenUrl = mutableStateOf?>(null) // preferences val notificationPreviewMode by lazy { @@ -620,10 +620,10 @@ object ChatModel { } fun addTerminalItem(item: TerminalItem) { - if (terminalItems.size >= 500) { - terminalItems.removeAt(0) + if (terminalItems.value.size >= 500) { + terminalItems.value = terminalItems.value.subList(1, terminalItems.value.size) } - terminalItems.add(item) + terminalItems.value += item } val connectedToRemote: Boolean @Composable get() = currentRemoteHost.value != null || remoteCtrlSession.value?.active == true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt index 037d27af33..28b46f592d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt @@ -21,10 +21,11 @@ sealed class WriteFileResult { * */ fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { + val ctrl = ChatController.ctrl ?: throw Exception("Controller is not initialized") val buffer = ByteBuffer.allocateDirect(data.size) buffer.put(data) buffer.rewind() - val str = chatWriteFile(path, buffer) + val str = chatWriteFile(ctrl, path, buffer) return when (val d = json.decodeFromString(WriteFileResult.serializer(), str)) { is WriteFileResult.Result -> d.cryptoArgs is WriteFileResult.Error -> throw Exception(d.writeError) @@ -43,7 +44,8 @@ fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray { } fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs { - val str = chatEncryptFile(fromPath, toPath) + val ctrl = ChatController.ctrl ?: throw Exception("Controller is not initialized") + val str = chatEncryptFile(ctrl, fromPath, toPath) val d = json.decodeFromString(WriteFileResult.serializer(), str) return when (d) { is WriteFileResult.Result -> d.cryptoArgs diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 6c01aff5d3..4af3e3f2ed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2023,7 +2023,8 @@ object ChatController { chatModel.chatId.value = null ModalManager.center.closeModals() ModalManager.end.closeModals() - AlertManager.shared.alertViews.clear() + AlertManager.shared.hideAllAlerts() + AlertManager.privacySensitive.hideAllAlerts() chatModel.currentRemoteHost.value = switchRemoteHost(rhId) reloadRemoteHosts() val user = apiGetActiveUser(rhId) @@ -2800,9 +2801,9 @@ data class NetCfg( hostMode = HostMode.OnionViaSocks, requiredHostMode = false, sessionMode = TransportSessionMode.User, - tcpConnectTimeout = 15_000_000, - tcpTimeout = 10_000_000, - tcpTimeoutPerKb = 30_000, + tcpConnectTimeout = 20_000_000, + tcpTimeout = 15_000_000, + tcpTimeoutPerKb = 45_000, tcpKeepAlive = KeepAliveOpts.defaults, smpPingInterval = 1200_000_000, smpPingCount = 3 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index a4c1c333e5..7d097efb7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -22,9 +22,9 @@ external fun chatParseMarkdown(str: String): String external fun chatParseServer(str: String): String external fun chatPasswordHash(pwd: String, salt: String): String external fun chatValidName(name: String): String -external fun chatWriteFile(path: String, buffer: ByteBuffer): String +external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String external fun chatReadFile(path: String, key: String, nonce: String): Array -external fun chatEncryptFile(fromPath: String, toPath: String): String +external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String val chatModel: ChatModel diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 84ffdb6fd7..e55c2c939a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -10,6 +10,7 @@ interface PlatformInterface { fun androidChatStopped() {} fun androidChatInitializedAndStarted() {} fun androidIsBackgroundCallAllowed(): Boolean = true + fun androidSetNightModeIfSupported() {} suspend fun androidAskToAllowBackgroundCalls(): Boolean = true } /** diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt index fa99d0f93c..af47f9c3e0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt @@ -4,13 +4,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.text.TextStyle import chat.simplex.common.views.chat.ComposeState -import java.io.File import java.net.URI @Composable expect fun PlatformTextField( composeState: MutableState, sendMsgEnabled: Boolean, + sendMsgButtonDisabled: Boolean, textStyle: MutableState, showDeleteTextButton: MutableState, userIsObserver: Boolean, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt index 4a7521efb8..49d3203455 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.text.font.FontFamily import chat.simplex.res.MR import chat.simplex.common.model.AppPreferences import chat.simplex.common.model.ChatController +import chat.simplex.common.platform.platform import chat.simplex.common.views.helpers.generalGetString // https://github.com/rsms/inter @@ -96,6 +97,7 @@ object ThemeManager { fun applyTheme(theme: String, darkForSystemTheme: Boolean) { appPrefs.currentTheme.set(theme) CurrentColors.value = currentColors(darkForSystemTheme) + platform.androidSetNightModeIfSupported() } fun changeDarkTheme(theme: String, darkForSystemTheme: Boolean) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 59f62f9c92..4f01d4a39f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -34,7 +34,6 @@ fun TerminalView(chatModel: ChatModel, close: () -> Unit) { close() }) TerminalLayout( - remember { chatModel.terminalItems }, composeState, sendCommand = { sendCommand(chatModel, composeState) }, close @@ -63,7 +62,6 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState, composeState: MutableState, sendCommand: () -> Unit, close: () -> Unit @@ -111,7 +109,7 @@ fun TerminalLayout( .fillMaxWidth(), color = MaterialTheme.colors.background ) { - TerminalLog(terminalItems) + TerminalLog() } } } @@ -120,22 +118,13 @@ fun TerminalLayout( private var lazyListState = 0 to 0 @Composable -fun TerminalLog(terminalItems: List) { +fun TerminalLog() { val listState = rememberLazyListState(lazyListState.first, lazyListState.second) DisposableEffect(Unit) { onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } } val reversedTerminalItems by remember { - derivedStateOf { - // Such logic prevents concurrent modification - val res = ArrayList() - var i = 0 - while (i < terminalItems.size) { - res.add(terminalItems[i]) - i++ - } - res.asReversed() - } + derivedStateOf { chatModel.terminalItems.value.asReversed() } } val clipboard = LocalClipboardManager.current LazyColumn(state = listState, reverseLayout = true) { @@ -152,7 +141,12 @@ fun TerminalLog(terminalItems: List) { .clickable { ModalManager.start.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) { SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) { - Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING)) + val details = item.details + .let { + if (it.length < 100_000) it + else it.substring(0, 100_000) + } + Text(details, modifier = Modifier.heightIn(max = 50_000.dp).padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING)) } } }.padding(horizontal = 8.dp, vertical = 4.dp) @@ -170,7 +164,6 @@ fun TerminalLog(terminalItems: List) { fun PreviewTerminalLayout() { SimpleXTheme { TerminalLayout( - terminalItems = TerminalItem.sampleData, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, sendCommand = {}, close = {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 8eee43035b..0e2a7c1680 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -220,8 +220,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } }, - loadPrevMessages = { cInfo -> - val c = chatModel.getChat(cInfo.id) + loadPrevMessages = { + if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout + val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout) val firstId = chatModel.chatItems.firstOrNull()?.id if (c != null && firstId != null) { withApi { @@ -440,7 +441,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: changeNtfsState = { enabled, currentValue -> toggleNotifications(chat, enabled, chatModel, currentValue) }, onSearchValueChanged = { value -> if (searchText.value == value) return@ChatLayout - val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout + if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout + val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout) ?: return@ChatLayout withApi { apiFindMessages(c, chatModel, value) searchText.value = value @@ -467,7 +469,7 @@ fun ChatLayout( back: () -> Unit, info: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, - loadPrevMessages: (ChatInfo) -> Unit, + loadPrevMessages: () -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long, Boolean) -> Unit, @@ -790,7 +792,7 @@ fun BoxWithConstraintsScope.ChatItemsList( useLinkPreviews: Boolean, linkMode: SimplexLinkMode, showMemberInfo: (GroupInfo, GroupMember) -> Unit, - loadPrevMessages: (ChatInfo) -> Unit, + loadPrevMessages: () -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long, Boolean) -> Unit, @@ -828,9 +830,7 @@ fun BoxWithConstraintsScope.ChatItemsList( } } - PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, chat, chatItems) { c -> - loadPrevMessages(c.chatInfo) - } + PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages) Spacer(Modifier.size(8.dp)) val reversedChatItems by remember { derivedStateOf { chatItems.reversed().toList() } } @@ -900,7 +900,11 @@ fun BoxWithConstraintsScope.ChatItemsList( @Composable fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + tryOrShowError("${cItem.id}ChatItem", error = { + CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) + }) { + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + } } @Composable @@ -1146,24 +1150,32 @@ fun BoxWithConstraintsScope.FloatingButtons( fun PreloadItems( listState: LazyListState, remaining: Int = 10, - chat: Chat, - items: List<*>, - onLoadMore: (chat: Chat) -> Unit, + onLoadMore: () -> Unit, ) { - LaunchedEffect(listState, chat, items) { - snapshotFlow { listState.layoutInfo } - .map { - val totalItemsNumber = it.totalItemsCount - val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 - if (lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT) - totalItemsNumber - else - 0 + // Prevent situation when initial load and load more happens one after another after selecting a chat with long scroll position from previous selection + val allowLoad = remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + snapshotFlow { chatModel.chatId.value } + .filterNotNull() + .collect { + allowLoad.value = listState.layoutInfo.totalItemsCount == listState.layoutInfo.visibleItemsInfo.size + delay(500) + allowLoad.value = true } - .distinctUntilChanged() + } + KeyChangeEffect(allowLoad.value) { + snapshotFlow { + val lInfo = listState.layoutInfo + val totalItemsNumber = lInfo.totalItemsCount + val lastVisibleItemIndex = (lInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + if (allowLoad.value && lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT) + totalItemsNumber + ChatPagination.PRELOAD_COUNT + else + 0 + } .filter { it > 0 } .collect { - onLoadMore(chat) + onLoadMore() } } } @@ -1435,7 +1447,7 @@ fun PreviewChatLayout() { back = {}, info = {}, showMemberInfo = { _, _ -> }, - loadPrevMessages = { _ -> }, + loadPrevMessages = {}, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _, _ -> }, @@ -1508,7 +1520,7 @@ fun PreviewGroupChatLayout() { back = {}, info = {}, showMemberInfo = { _, _ -> }, - loadPrevMessages = { _ -> }, + loadPrevMessages = {}, deleteMessage = { _, _ -> }, deleteMessages = {}, receiveFile = { _, _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index ddcfcf5947..b230d261f1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -201,7 +201,7 @@ suspend fun MutableState.processPickedMedia(uris: List, text: // Image val drawable = getDrawableFromUri(uri) // Do not show alert in case it's already shown from the function above - bitmap = getBitmapFromUri(uri, withAlertOnException = AlertManager.shared.alertViews.isEmpty()) + bitmap = getBitmapFromUri(uri, withAlertOnException = !AlertManager.shared.hasAlertsShown()) if (isAnimImage(uri, drawable)) { // It's a gif or webp val fileSize = getFileSize(uri) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 28882e6b73..e566cf30d3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -29,7 +29,6 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* -import java.io.File import java.net.URI @Composable @@ -82,7 +81,10 @@ fun SendMsgView( val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } - PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) { + val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || + (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || + cs.endLiveDisabled + PlatformTextField(composeState, sendMsgEnabled, sendMsgButtonDisabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) { if (!cs.inProgress) { sendMessage(null) } @@ -155,9 +157,6 @@ fun SendMsgView( else -> { val cs = composeState.value val icon = if (cs.editing || cs.liveMessage != null) painterResource(MR.images.ic_check_filled) else painterResource(MR.images.ic_arrow_upward) - val disabled = !sendMsgEnabled || !cs.sendEnabled() || - (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || - cs.endLiveDisabled val showDropdown = rememberSaveable { mutableStateOf(false) } @Composable @@ -200,12 +199,12 @@ fun SendMsgView( val menuItems = MenuItems() if (menuItems.isNotEmpty()) { - SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !disabled, sendMessage) { showDropdown.value = true } + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) { showDropdown.value = true } DefaultDropdownMenu(showDropdown) { menuItems.forEach { composable -> composable() } } } else { - SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !disabled, sendMessage) + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt new file mode 100644 index 0000000000..d49f8526d5 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt @@ -0,0 +1,18 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import chat.simplex.res.MR + +@Composable +fun CIBrokenComposableView(alignment: Alignment) { + Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp), contentAlignment = alignment) { + Text(stringResource(MR.strings.error_showing_message), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index abb67da50a..daf887e8c3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -195,7 +195,13 @@ fun ChatItemView( } val clipboard = LocalClipboardManager.current val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } - val copyAndShareAllowed = cItem.file == null || !chatModel.connectedToRemote() || getLoadedFilePath(cItem.file) != null || cachedRemoteReqs[cItem.file.fileSource] != false + val copyAndShareAllowed = when { + cItem.content.text.isNotEmpty() -> true + cItem.file != null && chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded -> true + getLoadedFilePath(cItem.file) != null -> true + else -> false + } + if (copyAndShareAllowed) { ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { var fileSource = getLoadedFileSource(cItem.file) @@ -221,7 +227,7 @@ fun ChatItemView( showMenu.value = false }) } - if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file?.fileSource] != false))) { + if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file?.fileSource] != false && cItem.file?.loaded == true))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) } if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 9d662758f8..8d5446aa53 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -61,9 +62,17 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Direct -> { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) }, + chatLinkPreview = { + tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) + } + }, click = { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) }, - dropdownMenuItems = { ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead) }, + dropdownMenuItems = { + tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { + ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead) + } + }, showMenu, stopped, selectedChat @@ -71,25 +80,45 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) }, + chatLinkPreview = { + tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) + } + }, click = { if (!inProgress.value) groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) }, - dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) }, + dropdownMenuItems = { + tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { + GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) + } + }, showMenu, stopped, selectedChat ) is ChatInfo.ContactRequest -> ChatListNavLinkLayout( - chatLinkPreview = { ContactRequestView(chat.chatInfo) }, + chatLinkPreview = { + tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { + ContactRequestView(chat.chatInfo) + } + }, click = { contactRequestAlertDialog(chat.remoteHostId, chat.chatInfo, chatModel) }, - dropdownMenuItems = { ContactRequestMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) }, + dropdownMenuItems = { + tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { + ContactRequestMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) + } + }, showMenu, stopped, selectedChat ) is ChatInfo.ContactConnection -> ChatListNavLinkLayout( - chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) }, + chatLinkPreview = { + tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { + ContactConnectionView(chat.chatInfo.contactConnection) + } + }, click = { ModalManager.center.closeModals() ModalManager.end.closeModals() @@ -97,7 +126,11 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ContactConnectionInfoView(chatModel, chat.remoteHostId, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close) } }, - dropdownMenuItems = { ContactConnectionMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) }, + dropdownMenuItems = { + tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { + ContactConnectionMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) + } + }, showMenu, stopped, selectedChat @@ -105,7 +138,9 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.InvalidJSON -> ChatListNavLinkLayout( chatLinkPreview = { - InvalidDataView() + tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { + InvalidDataView() + } }, click = { ModalManager.end.closeModals() @@ -119,6 +154,13 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } } +@Composable +private fun ErrorChatListItem() { + Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) { + Text(stringResource(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic) + } +} + fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel) { when { contact.activeConn == null && contact.profile.contactLink != null -> askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close = null, openChat = true) @@ -611,12 +653,12 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( close: (() -> Unit)?, openChat: Boolean ) { - AlertManager.shared.showAlertDialogButtonsColumn( + AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = String.format(generalGetString(MR.strings.connect_with_contact_name_question), contact.chatViewName), buttons = { Column { SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { close?.invoke() val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = false) @@ -628,7 +670,7 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { close?.invoke() val ok = connectContactViaAddress(chatModel, rhId, contact.contactId, incognito = true) @@ -640,7 +682,7 @@ fun askCurrentOrIncognitoProfileConnectContactViaAddress( Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() }) { Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -654,7 +696,7 @@ suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactI val contact = chatModel.controller.apiConnectContactViaAddress(rhId, incognito, contactId) if (contact != null) { chatModel.updateContact(rhId, contact) - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), text = generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted), hostDevice = hostDevice(rhId), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index a91e5e7b3c..cf12727d74 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* +import androidx.compose.ui.text.font.FontStyle import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight @@ -49,13 +50,6 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf LaunchedEffect(chatModel.clearOverlays.value) { if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false) } - LaunchedEffect(chatModel.appOpenUrl.value) { - val url = chatModel.appOpenUrl.value - if (url != null) { - chatModel.appOpenUrl.value = null - connectIfOpenedViaUri(chatModel.remoteHostId(), url, chatModel) - } - } if (appPlatform.isDesktop) { KeyChangeEffect(chatModel.chatId.value) { if (chatModel.chatId.value != null) { @@ -71,7 +65,11 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf val (userPickerState, scaffoldState ) = settingsState Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } }, scaffoldState = scaffoldState, - drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) }, + drawerContent = { + tryOrShowError("Settings", error = { ErrorSettingsView() }) { + SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) + } + }, drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f), drawerGesturesEnabled = appPlatform.isAndroid, floatingActionButton = { @@ -118,12 +116,16 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf if (searchInList.isEmpty()) { DesktopActiveCallOverlayLayout(newChatSheetState) // TODO disable this button and sheet for the duration of the switch - NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) + tryOrShowError("NewChatSheet", error = {}) { + NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) + } } if (appPlatform.isAndroid) { - UserPicker(chatModel, userPickerState) { - scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } - userPickerState.value = AnimatedViewState.GONE + tryOrShowError("UserPicker", error = {}) { + UserPicker(chatModel, userPickerState) { + scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + userPickerState.value = AnimatedViewState.GONE + } } } } @@ -302,7 +304,7 @@ expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow, enabled: Boolean, onCli } else null ), modifier = Modifier.focusRequester(focusRequester).onPreviewKeyEvent { - if (onClick != null && it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + if (onClick != null && (it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) { onClick() true } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index ee9a8337a0..177efbfddc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -24,7 +24,7 @@ import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource class AlertManager { - var alertViews = mutableStateListOf<(@Composable () -> Unit)>() + private var alertViews = mutableStateListOf<(@Composable () -> Unit)>() fun showAlert(alert: @Composable () -> Unit) { Log.d(TAG, "AlertManager.showAlert") @@ -35,6 +35,12 @@ class AlertManager { alertViews.removeLastOrNull() } + fun hideAllAlerts() { + alertViews.clear() + } + + fun hasAlertsShown() = alertViews.isNotEmpty() + fun showAlertDialogButtons( title: String, text: String? = null, @@ -220,6 +226,7 @@ class AlertManager { companion object { val shared = AlertManager() + val privacySensitive = AlertManager() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 0a0ef17c4b..9a81b9f9d7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -390,6 +390,28 @@ fun IntSize.Companion.Saver(): Saver = Saver( restore = { IntSize(it.first, it.second) } ) +private var lastExecutedComposables = HashSet() +private val failedComposables = HashSet() + +@Composable +fun tryOrShowError(key: Any = Exception().stackTraceToString().lines()[2], error: @Composable () -> Unit = {}, content: @Composable () -> Unit) { + if (!failedComposables.contains(key)) { + lastExecutedComposables.add(key) + content() + lastExecutedComposables.remove(key) + } else { + error() + } +} + +fun includeMoreFailedComposables() { + lastExecutedComposables.forEach { + failedComposables.add(it) + Log.i(TAG, "Added composable key as failed: $it") + } + lastExecutedComposables.clear() +} + @Composable fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) { DisposableEffect(Unit) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index c64c3dd29a..468dd8580e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -70,7 +70,8 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( m.controller.startChat(createdUser) } ModalManager.fullscreen.closeModals() - AlertManager.shared.hideAlert() + AlertManager.shared.hideAllAlerts() + AlertManager.privacySensitive.hideAllAlerts() completed(LAResult.Success) } catch (e: Exception) { completed(LAResult.Error(generalGetString(MR.strings.incorrect_passcode))) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index 763addae66..7f9fae60a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -67,7 +67,7 @@ fun QRCode( scope.launch { val image = qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) .let { if (withLogo) it.addLogo() else it } - val file = saveTempImageUncompressed(image, false) + val file = saveTempImageUncompressed(image, true) if (file != null) { shareFile("", CryptoFile.plain(file.absolutePath)) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index 2439b16c36..9f28074aef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -20,7 +20,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.IncognitoView import chat.simplex.res.MR import java.net.URI @@ -58,7 +58,7 @@ suspend fun planAndConnect( InvitationLinkPlan.OwnLink -> { Log.d(TAG, "planAndConnect, .InvitationLink, .OwnLink, incognito=$incognito") if (incognito != null) { - AlertManager.shared.showAlertDialog( + AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), @@ -80,13 +80,13 @@ suspend fun planAndConnect( val contact = connectionPlan.invitationLinkPlan.contact_ if (contact != null) { openKnownContact(chatModel, rhId, close, contact) - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName), hostDevice = hostDevice(rhId), ) } else { - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.connect_plan_already_connecting), generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link), hostDevice = hostDevice(rhId), @@ -97,7 +97,7 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .InvitationLink, .Known, incognito=$incognito") val contact = connectionPlan.invitationLinkPlan.contact openKnownContact(chatModel, rhId, close, contact) - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName), hostDevice = hostDevice(rhId), @@ -121,7 +121,7 @@ suspend fun planAndConnect( ContactAddressPlan.OwnLink -> { Log.d(TAG, "planAndConnect, .ContactAddress, .OwnLink, incognito=$incognito") if (incognito != null) { - AlertManager.shared.showAlertDialog( + AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_plan_connect_to_yourself), text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), @@ -141,7 +141,7 @@ suspend fun planAndConnect( ContactAddressPlan.ConnectingConfirmReconnect -> { Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingConfirmReconnect, incognito=$incognito") if (incognito != null) { - AlertManager.shared.showAlertDialog( + AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_plan_repeat_connection_request), text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address), confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), @@ -162,7 +162,7 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingProhibit, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact openKnownContact(chatModel, rhId, close, contact) - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName), hostDevice = hostDevice(rhId), @@ -172,7 +172,7 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .ContactAddress, .Known, incognito=$incognito") val contact = connectionPlan.contactAddressPlan.contact openKnownContact(chatModel, rhId, close, contact) - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.contact_already_exists), String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName), hostDevice = hostDevice(rhId), @@ -193,7 +193,7 @@ suspend fun planAndConnect( GroupLinkPlan.Ok -> { Log.d(TAG, "planAndConnect, .GroupLink, .Ok, incognito=$incognito") if (incognito != null) { - AlertManager.shared.showAlertDialog( + AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_via_group_link), text = generalGetString(MR.strings.you_will_join_group), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), @@ -217,7 +217,7 @@ suspend fun planAndConnect( GroupLinkPlan.ConnectingConfirmReconnect -> { Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingConfirmReconnect, incognito=$incognito") if (incognito != null) { - AlertManager.shared.showAlertDialog( + AlertManager.privacySensitive.showAlertDialog( title = generalGetString(MR.strings.connect_plan_repeat_join_request), text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), @@ -238,12 +238,12 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingProhibit, incognito=$incognito") val groupInfo = connectionPlan.groupLinkPlan.groupInfo_ if (groupInfo != null) { - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.connect_plan_group_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_vName), groupInfo.displayName) ) } else { - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.connect_plan_already_joining_the_group), generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), hostDevice = hostDevice(rhId), @@ -254,7 +254,7 @@ suspend fun planAndConnect( Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito") val groupInfo = connectionPlan.groupLinkPlan.groupInfo openKnownGroup(chatModel, rhId, close, groupInfo) - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( generalGetString(MR.strings.connect_plan_group_already_exists), String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName), hostDevice = hostDevice(rhId), @@ -289,7 +289,7 @@ suspend fun connectViaUri( if (pcc != null) { chatModel.updateContactConnection(rhId, pcc) close?.invoke() - AlertManager.shared.showAlertMsg( + AlertManager.privacySensitive.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), text = when (connLinkType) { @@ -320,14 +320,14 @@ fun askCurrentOrIncognitoProfileAlert( text: AnnotatedString? = null, connectDestructive: Boolean, ) { - AlertManager.shared.showAlertDialogButtonsColumn( + AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = title, text = text, buttons = { Column { val connectColor = if (connectDestructive) MaterialTheme.colors.error else MaterialTheme.colors.primary SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) } @@ -335,7 +335,7 @@ fun askCurrentOrIncognitoProfileAlert( Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) } SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) } @@ -343,7 +343,7 @@ fun askCurrentOrIncognitoProfileAlert( Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) } SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() }) { Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -372,14 +372,14 @@ fun ownGroupLinkConfirmConnect( groupInfo: GroupInfo, close: (() -> Unit)?, ) { - AlertManager.shared.showAlertDialogButtonsColumn( + AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.connect_plan_join_your_group), text = AnnotatedString(String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName)), buttons = { Column { // Open group SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() openKnownGroup(chatModel, rhId, close, groupInfo) }) { Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) @@ -387,7 +387,7 @@ fun ownGroupLinkConfirmConnect( if (incognito != null) { // Join incognito / Join with current profile SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { connectViaUri(chatModel, rhId, uri, incognito, connectionPlan, close) } @@ -400,7 +400,7 @@ fun ownGroupLinkConfirmConnect( } else { // Use current profile SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { connectViaUri(chatModel, rhId, uri, incognito = false, connectionPlan, close) } @@ -409,7 +409,7 @@ fun ownGroupLinkConfirmConnect( } // Use new incognito profile SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() withApi { connectViaUri(chatModel, rhId, uri, incognito = true, connectionPlan, close) } @@ -419,7 +419,7 @@ fun ownGroupLinkConfirmConnect( } // Cancel SectionItemView({ - AlertManager.shared.hideAlert() + AlertManager.privacySensitive.hideAlert() }) { Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt index 092fb8bf63..8534198028 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt @@ -48,14 +48,6 @@ fun CreateSimpleXAddress(m: ChatModel, rhId: Long?) { val connReqContact = m.controller.apiCreateUserAddress(rhId) if (connReqContact != null) { m.userAddress.value = UserContactLinkRec(connReqContact) - try { - val u = m.controller.apiSetProfileAddress(rhId, true) - if (u != null) { - m.updateUser(u) - } - } catch (e: Exception) { - Log.e(TAG, "CreateSimpleXAddress apiSetProfileAddress: ${e.stackTraceToString()}") - } progressIndicator = false } } @@ -100,7 +92,7 @@ private fun CreateSimpleXAddressLayout( ContinueButton(nextStep) } else { CreateAddressButton(createAddress) - TextBelowButton(stringResource(MR.strings.your_contacts_will_see_it)) + TextBelowButton(stringResource(MR.strings.you_can_make_address_visible_via_settings)) Spacer(Modifier.weight(1f)) SkipButton(nextStep) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index c117e89971..a51d9c8a0c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -120,7 +120,7 @@ private fun SetupDatabasePassphraseLayout( .padding(horizontal = DEFAULT_PADDING) .focusRequester(focusRequester) .onPreviewKeyEvent { - if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + if ((it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) { focusManager.moveFocus(FocusDirection.Down) true } else { @@ -150,7 +150,7 @@ private fun SetupDatabasePassphraseLayout( modifier = Modifier .padding(horizontal = DEFAULT_PADDING) .onPreviewKeyEvent { - if (!disabled && it.key == Key.Enter && it.type == KeyEventType.KeyUp) { + if (!disabled && (it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) { onClickUpdate() true } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt index 5849178202..5fb8bfb03e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt @@ -154,20 +154,20 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) { SectionItemView { TimeoutSettingRow( stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeout, - listOf(5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000_000, 45_000_000), secondsLabel + listOf(7_500000, 10_000000, 15_000000, 20_000000, 30_000_000, 45_000_000), secondsLabel ) } SectionItemView { TimeoutSettingRow( stringResource(MR.strings.network_option_protocol_timeout), networkTCPTimeout, - listOf(3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel + listOf(5_000000, 7_000000, 10_000000, 15_000000, 20_000_000, 30_000_000), secondsLabel ) } SectionItemView { // can't be higher than 130ms to avoid overflow on 32bit systems TimeoutSettingRow( stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb, - listOf(15_000, 30_000, 60_000, 90_000, 120_000), secondsLabel + listOf(15_000, 30_000, 45_000, 60_000, 90_000, 120_000), secondsLabel ) } SectionItemView { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 098c748355..d16892096c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -150,7 +150,6 @@ إضافة جهة اتصال جديدة : لإنشاء رمز الاستجابة السريعة الخاص بك لمرة واحدة لجهة اتصالك.]]> امسح رمز الاستجابة السريعة : للاتصال بجهة الاتصال التي تعرض لك رمز الاستجابة السريعة.]]> مكالمتك تحت الإجراء - انتهت المكالمة تغيير عبارة مرور قاعدة البيانات؟ لا يمكن الوصول إلى Keystore لحفظ كلمة مرور قاعدة البيانات إلغاء معاينة الملف @@ -1276,8 +1275,6 @@ فيديو يمكنك مشاركة عنوانك كرابط أو رمز QR - يمكن لأي شخص الاتصال بك. يمكنك إنشاؤه لاحقًا - سوف تراها جهات اتصالك في whatsapp. -\nيمكنك تغييره في الإعدادات. أنت تحاول دعوة جهة اتصال قمت بمشاركة ملف تعريف متخفي معها إلى المجموعة التي تستخدم فيها ملفك الشخصي الرئيسي إلغاء الكتم إلغاء الكتم diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index e0b8f130db..4ad40d7a62 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -45,6 +45,9 @@ moderated invalid chat invalid data + error showing message + error showing content + Decryption error Encryption re-negotiation error @@ -690,7 +693,7 @@ Continue Don\'t create address You can create it later - Your contacts in SimpleX will see it.\nYou can change it in Settings. + You can make it visible to your SimpleX contacts via Settings. Profile name: diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index 3bc721174a..c999d9b952 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -1236,8 +1236,6 @@ Използване на директна интернет връзка\? Използвай .onion хостове Когато са налични - Вашите контакти в SimpleX ще го видят. -\nМожете да го промените в Настройки. Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство. Можете да използвате markdown за форматиране на съобщенията: да получавате съобщенията, вашите контакти – сървърите, които използвате, за да им изпращате съобщения.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index e733201a74..75d09ecfb2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1247,8 +1247,6 @@ Reakce na zprávy může přidávat pouze váš kontakt. Vaše kontakty zůstanou připojeny. Tuto adresu můžete sdílet se svými kontakty, aby se mohli připojit k %s. - Vaše kontakty v SimpleX ji uvidí. -\nMůžete ji změnit v Nastavení. Svou adresu můžete sdílet jako odkaz nebo QR kód - kdokoli se k vám může připojit. Pokud později adresu odstraníte, o kontakty nepřijdete. Přístupový kód aplikace je nahrazen sebedestrukčním přístupovým heslem. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 1f8f75c126..a36eca6663 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1256,8 +1256,6 @@ Stellen Sie sicher, dass die Datei die korrekte YAML-Syntax hat. Exportieren Sie das Design, um ein Beispiel für die Dateistruktur des Designs zu erhalten. Offene Chat-Profile Sie können Ihre Adresse als Link oder QR-Code teilen – jede Person kann sich mit Ihnen verbinden. - Ihre Kontakte in SimpleX werden es sehen. -\nSie können es in den Einstellungen ändern. Werden die App-Daten komplett gelöscht. Es wurde ein leeres Chat-Profil mit dem eingegebenen Namen erstellt und die App öffnet wie gewohnt. Wenn Sie Ihren Selbstzerstörungs-Zugangscode während des Öffnens der App eingeben: diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 714f31732c..7063eb9007 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -41,7 +41,7 @@ Αποδοχή Αποδοχή αιτήματος σύνδεσης; αποδεκτή κλήση - Πρόσβαση στους διακομιστές μέσω SOCKS proxy στην πόρτα 9050; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση. + Πρόσβαση στους διακομιστές μέσω SOCKS proxy στην πόρτα %d; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση. Προσθήκη διακομιστή… Προχωρημένες ρυθμίσεις δικτύου Προσθήκη διακομιστών μέσω σάρωσης QR κωδικών. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 08cc7f9820..246ed36859 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -298,7 +298,7 @@ Cancelar mensaje en directo Confirmar Vaciar - Build de la aplicación + Build de la aplicación: %s ¡La llamada ha terminado! el servidor de envío ha cambiado para tí cancelar vista previa del enlace @@ -1174,8 +1174,6 @@ Compartir con contactos Título Puedes compartir esta dirección con tus contactos para que puedan conectar con %s. - Tus contactos en SimpleX lo verán. -\nPuedes cambiarlo en Configuración. Tus contactos permanecerán conectados. Si más tarde decides eliminar tu dirección los contactos no se perderán. Todos los datos de la aplicación se eliminarán. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index ce8692130f..be4072c4bf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -171,7 +171,6 @@ Arkisto Poista keskusteluarkisto\? Luotu %1$s - %s:n rooli muutettu %s:ksi poistettu ryhmä yhdistää yhdistäminen (hyväksytty) @@ -1141,8 +1140,6 @@ TEEMAN VÄRIT Päivitä kuljetuksen eristystila\? Voit luoda sen myöhemmin - Kontaktisi SimpleX:ssä näkevät sen. -\nVoit muuttaa sitä Asetuksista. Voit paljastaa piilotetun profiilisi kirjoittamalla koko salasanan Keskusteluprofiilit-sivun hakukenttään. Emme tallenna mitään kontaktejasi tai viestejäsi (kun ne on toimitettu) palvelimille. Yksityisyyden suojaamiseksi kaikkien muiden alustojen käyttämien käyttäjätunnusten sijaan SimpleX käyttää viestijonojen tunnisteita, jotka ovat kaikille kontakteille erillisiä. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index efe9b1003f..849daaeefe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1175,8 +1175,6 @@ Guide de l\'utilisateur.]]> Sauvegarder les paramètres d\'acceptation automatique Pour se connecter, votre contact peut scanner le code QR ou utiliser le lien dans l\'application. - Vos contacts dans SimpleX la verront. -\nVous pouvez modifier ce choix dans les Paramètres. Le code d\'accès de l\'application est remplacé par un code d\'autodestruction. Activer l\'autodestruction Un profil de chat vierge portant le nom fourni est créé et l\'application s\'ouvre normalement. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 9ed305da76..79c1897153 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1140,8 +1140,6 @@ Invita amici Salva le impostazioni di accettazione automatica Puoi crearlo più tardi - I tuoi contatti in SimpleX lo vedranno. -\nPuoi modificarlo nelle impostazioni. Condividi indirizzo Inserisci il messaggio di benvenuto… Anteprima diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index 6599a30cc0..58bb6b0a0a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1222,8 +1222,6 @@ \nלא ניתן לבטל פעולה זו – הפרופיל, אנשי הקשר, ההודעות והקבצים שלך ייאבדו באופן בלתי הפיך. הפרופיל הנוכחי שלך אנשי הקשר שלך יישארו מחוברים. - אנשי הקשר שלך ב־SimpleX ייראו זאת. -\nניתן לשנות זאת בהגדרות. שרתי ה־ICE שלך אתם תהיו מחוברים כאשר בקשת החיבור תאושר, אנא חכו או בידקו מאוחר יותר! הפרופיל שלך יישלח לאיש הקשר ממנו קיבלת קישור זה. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index 94a35dbd8b..bebf716e0d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -687,7 +687,6 @@ ファイル送信が中止されました。 送信元が繋がりリクエストを削除したかもしれません。 このサーバで待ち行列を作るには認証が必要です。パスワードをご確認ください。 - アプリが定期的に新しいメッセージを受信します。一日の電池使用量が約3%で、プッシュ通知に頼らずに、あなたの端末のデータをサーバに送ることはありません。 SimpleXロック 通知を受けるには、データベースの暗証フレーズを入力してください。 SimpleX Chat サービス @@ -904,7 +903,6 @@ SIMPLEX CHATを支援 テストサーバ 受信アドレスは別のサーバーに変更されます。アドレス変更は送信者がオンラインになった後に完了します。 - SimpleX バックグラウンド・サービス を使ってます。一日の電池使用量は約3%です。]]> あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。 あなたのチャットプロフィールが他のグループメンバーに送られます。 エンドツーエンド暗号化を確認するには、ご自分の端末と連絡先の端末のコードを比べます (スキャンします)。 @@ -1098,8 +1096,6 @@ \n何らかのバグが原因で、または接続に問題があった場合に発生する可能性があります。 ユーザーに感謝します – Weblate 経由で貢献してください! 接続が要求されたら、それを受け入れるか拒否するかを選択できます。 - SimpleX の連絡先に表示されます。 -\n設定で変更できます。 ビデオが送信されました 管理者は次のことができます。 \n- メンバーのメッセージを削除します。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 6cef9571da..5f45304aac 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1173,8 +1173,6 @@ Gebruikershandleiding.]]> THEMA KLEUREN U kunt uw adres delen als een link of QR-code - iedereen kan verbinding met u maken. - Uw contacten in SimpleX kunnen het zien. -\nU kunt dit wijzigen in Instellingen. Alle app-gegevens worden verwijderd. Er wordt een leeg chatprofiel met de opgegeven naam gemaakt en de app wordt zoals gewoonlijk geopend. Zelfvernietigings wachtwoord inschakelen diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 8f48724788..e811e47ea1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1168,8 +1168,6 @@ Zapisz ustawienia automatycznej akceptacji Udostępnij kontaktom Możesz go utworzyć później - Twoje kontakty w SimpleX będą to widzieć. -\nMożesz to zmienić w Ustawieniach. Adres SimpleX Drugorzędny diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 0cb5b872b0..622ad8b2d6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -1170,8 +1170,6 @@ Abrindo banco de dados… Abrir perfis de bate-papo Compartilhar endereço com os contatos\? - Seus contatos no SimpleX o verão. -\nVocê pode alterá-la nas Configurações. Seus contatos continuarão conectados. Todos os dados do aplicativo serão excluídos. A senha do aplicativo é substituída por uma senha de auto-destruição. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml index f083705545..072eb97ebf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -584,8 +584,6 @@ Muito provavelmente este contato eliminou a conexão consigo. Este texto está disponível nas definições Atualizar definições de servidores .onion\? - Os seus contatos no SimpleX irão vê-lo. -\nVocê pode alterá-lo nas Definições. Pode ser alterado mais tarde através das definições. AJUDA SUPORTE SIMPLEX CHAT diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index f26f77c33b..9e8bd0af8c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1280,8 +1280,7 @@ Продолжить Не создавать адрес Вы можете создать его позже - Ваши контакты в SimpleX получат этот адрес. -\nВы можете изменить это в Настройках. + Вы можете сделать его видимым для ваших контактов в SimpleX через Настройки. Адрес Введите приветственное сообщение… Просмотр diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index b31b2a3a24..91330717c4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -1159,8 +1159,6 @@ สีของธีม ผู้ติดต่อของคุณจะยังคงเชื่อมต่ออยู่ คุณสามารถสร้างได้ในภายหลัง - ผู้ติดต่อของคุณใน SimpleX จะเห็น -\nคุณสามารถเปลี่ยนได้ในการตั้งค่า คุณเป็นผู้ควบคุมการแชทของคุณ! โปรไฟล์นี้แชร์กับผู้ติดต่อของคุณเท่านั้น เราไม่เก็บผู้ติดต่อหรือข้อความของคุณ (เมื่อส่งแล้ว) ไว้บนเซิร์ฟเวอร์ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 9824b0d8f0..ff9febd088 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -337,7 +337,7 @@ Mesaj gönderilirken hata oluştu Adres oluştururken hata oluştu Adres değiştirirken hata oluştu - 1$s sizinle şu yolla bağlantı kurmak istiyor + %1$s sizinle şu yolla bağlantı kurmak istiyor Ayarları değiştirirken hata oluştu Toplu konuşma bağlantısı oluştururken hata oluştu Yetki değiştirirken hata oluştu @@ -747,9 +747,9 @@ Aklınızda bulunsun: kaybederseniz, parolayı kurtaramaz veya değiştiremezsiniz.]]> Sohbet arşivi SOHBET ARŞİVİ - 1$s grubuna davet + %1$s grubuna davet Gruba katıl\? - 1$s davet edildi + %1$s davet edildi grup bağlantınız üzerinden davet edildi davet edildi Gruba davet edin @@ -875,8 +875,6 @@ Biriyle gizli bir profil paylaştığınızda, bu profil sizi davet ettikleri gruplar için kullanılacaktır. İsteğe bağlı karşılama mesajı ile. Kişileriniz bağlı kalacaktır. - SimpleX\'teki kişileriniz bunu görecektir. -\nBunu Ayarlardan değiştirebilirsiniz. Daha sonra oluşturabilirsiniz Mesajları biçimlendirmek için markdown kullanabilirsiniz: Mesaj veri tabanınız diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index dd8d9ac857..d9d49aef7d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -651,8 +651,6 @@ Ідентифікатори бази даних та опція ізоляції транспорту. Сповіщення перестануть працювати, поки ви не перезапустите додаток Ви можете створити його пізніше - Ваші контакти в SimpleX побачать це. -\nВи можете змінити його в Налаштуваннях. Ваш поточний профіль Видалити зображення Зберегти налаштування\? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index ed2b9986c0..c19a3960f7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1211,8 +1211,6 @@ 你好! \n用 SimpleX Chat 与我联系:%s 让我们一起在 SimpleX Chat 里聊天 - 您的 SimpleX 的联系人会看到它。 -\n您可以在设置中更改它。 您可以以后创建它 分享地址 您可以与您的联系人分享该地址,让他们与 %s 联系。 @@ -1344,7 +1342,7 @@ 我们错过的第二个\"√\"!✅ 设定数据库密码 为群组禁用回执吗? - %s、%s 和 %d 已连接 + %s、%s 和 %s 已连接 修复群组成员不支持的问题 已为 %d 组启用送达回执功能 重新协商 @@ -1427,7 +1425,6 @@ 通过链接进行连接吗? 已经加入了该群组! %s、 %s 和 %d 名成员 - %s 审核了 %d 条消息 解封成员 连接到你自己? 轻按连接 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index b1d988e465..7ab98ca323 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -11,7 +11,7 @@ 關於 SimpleX Chat 接受連接請求? 已接受通話 - 要在端口啟用 SOCKS 代理伺服器嗎?在啟用這個選項之前,必須先啟用代理伺服器。 + 要在端口啟用 SOCKS 代理伺服器嗎 %d?在啟用這個選項之前,必須先啟用代理伺服器。 管理員 然後,選按: 新增預設伺服器 @@ -1178,8 +1178,6 @@ 已刪除所有的應用程式數據。 設定密碼 你可以與聯絡人分享此地址,讓他們使用 %s 進行連接。 - 在 SimpleX ,你的聯絡人會看到此。 -\n你可以在設定中修改。 你可以在稍後建立它 為了連接,你的聯絡人可以掃描二維碼或使用此應用程式的連結。 如果你不能面對面接觸此聯絡人,可於視訊通話中出示你的二維碼,或者分享連結。 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 12bead3663..57371e25a7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -45,6 +45,7 @@ fun showApp() { Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString()) window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) closedByError.value = true + includeMoreFailedComposables() // If the left side of screen has open modal, it's probably caused the crash if (ModalManager.start.hasModalsOpen()) { ModalManager.start.closeModal() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index 74df6b8251..8016b18b12 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -45,6 +45,7 @@ import kotlin.text.substring actual fun PlatformTextField( composeState: MutableState, sendMsgEnabled: Boolean, + sendMsgButtonDisabled: Boolean, textStyle: MutableState, showDeleteTextButton: MutableState, userIsObserver: Boolean, @@ -103,7 +104,7 @@ actual fun PlatformTextField( .padding(vertical = 4.dp) .focusRequester(focusRequester) .onPreviewKeyEvent { - if (it.key == Key.Enter && it.type == KeyEventType.KeyDown) { + if ((it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyDown) { if (it.isShiftPressed) { val start = if (minOf(textFieldValue.selection.min) == 0) "" else textFieldValue.text.substring(0 until textFieldValue.selection.min) val newText = start + "\n" + @@ -113,7 +114,7 @@ actual fun PlatformTextField( selection = TextRange(textFieldValue.selection.min + 1) ) onMessageChange(newText) - } else if (cs.message.isNotEmpty()) { + } else if (!sendMsgButtonDisabled) { onDone() } true diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index eb1792474a..19c9fc0fd7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -5,11 +5,11 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density -import chat.simplex.common.model.* +import chat.simplex.common.model.CIFile +import chat.simplex.common.model.readCryptoFile import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState -import java.io.ByteArrayInputStream -import java.io.File +import java.io.* import java.net.URI import javax.imageio.ImageIO import kotlin.io.encoding.Base64 @@ -148,9 +148,8 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) return if (file != null) { try { val ext = if (asPng) "png" else "jpg" - val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext, File(getAppFilePath("")))) - // LALAL FILE IS EMPTY - ImageIO.write(image.toAwtImage(), ext.uppercase(), newFile.outputStream()) + val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext, File(file.absolutePath))) + ImageIO.write(image.toAwtImage(), ext, newFile.outputStream()) newFile } catch (e: Exception) { Log.e(TAG, "Util.kt saveTempImageUncompressed error: ${e.message}") diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 570e982e22..bce98015ce 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.4 -android.version_code=162 +android.version_name=5.4.1 +android.version_code=164 -desktop.version_name=5.4 -desktop.version_code=18 +desktop.version_name=5.4.1 +desktop.version_code=19 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 diff --git a/apps/simplex-chat/README.md b/apps/simplex-chat/README.md index 113f90d185..bbcf40b139 100644 --- a/apps/simplex-chat/README.md +++ b/apps/simplex-chat/README.md @@ -1,3 +1,3 @@ # SimpleX Chat CLI app -See [repo REAMDE](../../README.md#zap-quick-installation-of-a-terminal-app) for installation and usage instructions. +See [repo README](../../README.md#zap-quick-installation-of-a-terminal-app) for installation and usage instructions. diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 89231e4db1..a187ac3e82 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -21,14 +21,18 @@ where import Control.Applicative ((<|>)) import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A +import Data.Functor (($>)) import Data.Text (Text) import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Directory.Store import Simplex.Chat.Controller import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Types +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Util ((<$?>)) import Data.Char (isSpace) import Data.Either (fromRight) @@ -83,6 +87,10 @@ deriving instance Show (SDirectoryRole r) data DirectoryCmdTag (r :: DirectoryRole) where DCHelp_ :: DirectoryCmdTag 'DRUser + DCSearchNext_ :: DirectoryCmdTag 'DRUser + DCAllGroups_ :: DirectoryCmdTag 'DRUser + DCRecentGroups_ :: DirectoryCmdTag 'DRUser + DCSubmitGroup_ :: DirectoryCmdTag 'DRUser DCConfirmDuplicateGroup_ :: DirectoryCmdTag 'DRUser DCListUserGroups_ :: DirectoryCmdTag 'DRUser DCDeleteGroup_ :: DirectoryCmdTag 'DRUser @@ -100,6 +108,10 @@ data ADirectoryCmdTag = forall r. ADCT (SDirectoryRole r) (DirectoryCmdTag r) data DirectoryCmd (r :: DirectoryRole) where DCHelp :: DirectoryCmd 'DRUser DCSearchGroup :: Text -> DirectoryCmd 'DRUser + DCSearchNext :: DirectoryCmd 'DRUser + DCAllGroups :: DirectoryCmd 'DRUser + DCRecentGroups :: DirectoryCmd 'DRUser + DCSubmitGroup :: ConnReqContact -> DirectoryCmd 'DRUser DCConfirmDuplicateGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser DCListUserGroups :: DirectoryCmd 'DRUser DCDeleteGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser @@ -120,7 +132,9 @@ deriving instance Show ADirectoryCmd directoryCmdP :: Parser ADirectoryCmd directoryCmdP = - (A.char '/' *> cmdStrP) <|> (ADC SDRUser . DCSearchGroup <$> A.takeText) + (A.char '/' *> cmdStrP) + <|> (A.char '.' $> ADC SDRUser DCSearchNext) + <|> (ADC SDRUser . DCSearchGroup <$> A.takeText) where cmdStrP = (tagP >>= \(ADCT u t) -> ADC u <$> (cmdP t <|> pure (DCCommandError t))) @@ -128,6 +142,10 @@ directoryCmdP = tagP = A.takeTill (== ' ') >>= \case "help" -> u DCHelp_ "h" -> u DCHelp_ + "next" -> u DCSearchNext_ + "all" -> u DCAllGroups_ + "new" -> u DCRecentGroups_ + "submit" -> u DCSubmitGroup_ "confirm" -> u DCConfirmDuplicateGroup_ "list" -> u DCListUserGroups_ "ls" -> u DCListUserGroups_ @@ -146,6 +164,10 @@ directoryCmdP = cmdP :: DirectoryCmdTag r -> Parser (DirectoryCmd r) cmdP = \case DCHelp_ -> pure DCHelp + DCSearchNext_ -> pure DCSearchNext + DCAllGroups_ -> pure DCAllGroups + DCRecentGroups_ -> pure DCRecentGroups + DCSubmitGroup_ -> fmap DCSubmitGroup . strDecode . encodeUtf8 <$?> (A.takeWhile1 isSpace *> A.takeText) DCConfirmDuplicateGroup_ -> gc DCConfirmDuplicateGroup DCListUserGroups_ -> pure DCListUserGroups DCDeleteGroup_ -> gc DCDeleteGroup diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 0ca8cee789..6d4e1296f2 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -21,6 +21,7 @@ data DirectoryOpts = DirectoryOpts superUsers :: [KnownContact], directoryLog :: Maybe FilePath, serviceName :: String, + searchResults :: Int, testing :: Bool } @@ -54,6 +55,7 @@ directoryOpts appDir defaultDbFileName = do superUsers, directoryLog, serviceName, + searchResults = 10, testing = False } diff --git a/apps/simplex-directory-service/src/Directory/Search.hs b/apps/simplex-directory-service/src/Directory/Search.hs new file mode 100644 index 0000000000..822182b053 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Search.hs @@ -0,0 +1,32 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} + +module Directory.Search where + +import Data.List (sortOn) +import Data.Ord (Down (..)) +import Data.Set (Set) +import qualified Data.Set as S +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Simplex.Chat.Types + +data SearchRequest = SearchRequest + { searchType :: SearchType, + searchTime :: UTCTime, + sentGroups :: Set GroupId + } + +data SearchType = STAll | STRecent | STSearch Text + +takeTop :: Int -> [(GroupInfo, GroupSummary)] -> [(GroupInfo, GroupSummary)] +takeTop n = take n . sortOn (Down . currentMembers . snd) + +takeRecent :: Int -> [(GroupInfo, GroupSummary)] -> [(GroupInfo, GroupSummary)] +takeRecent n = take n . sortOn (Down . (\GroupInfo {createdAt} -> createdAt) . fst) + +groupIds :: [(GroupInfo, GroupSummary)] -> Set GroupId +groupIds = S.fromList . map (\(GroupInfo {groupId}, _) -> groupId) + +filterNotSent :: Set GroupId -> [(GroupInfo, GroupSummary)] -> [(GroupInfo, GroupSummary)] +filterNotSent sentGroups = filter (\(GroupInfo {groupId}, _) -> groupId `S.notMember` sentGroups) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index fb187bbebe..ea79dabb10 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -17,16 +17,16 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Monad import qualified Data.ByteString.Char8 as B -import Data.List (sortOn) import Data.Maybe (fromMaybe, maybeToList) -import Data.Ord (Down(..)) +import Data.Set (Set) import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T -import Data.Time.Clock (getCurrentTime) +import Data.Time.Clock (diffUTCTime, getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) import Directory.Events import Directory.Options +import Directory.Search import Directory.Store import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts @@ -36,8 +36,10 @@ import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Types -import Simplex.Chat.View (serializeChatResponse) +import Simplex.Chat.View (serializeChatResponse, simplexChatContact) import Simplex.Messaging.Encoding.String +import Simplex.Messaging.TMap (TMap) +import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util (safeDecodeUtf8, tshow, ($>>=), (<$$>)) import System.Directory (getAppUserDataDirectory) @@ -55,6 +57,15 @@ data GroupRolesStatus | GRSBadRoles deriving (Eq) +data ServiceState = ServiceState + { searchRequests :: TMap ContactId SearchRequest + } + +newServiceState :: IO ServiceState +newServiceState = do + searchRequests <- atomically TM.empty + pure ServiceState {searchRequests} + welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" @@ -65,8 +76,9 @@ welcomeGetOpts = do pure opts directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> IO () -directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User {userId} cc = do +directoryService st DirectoryOpts {superUsers, serviceName, searchResults, testing} user@User {userId} cc = do initializeBotAddress' (not testing) cc + env <- newServiceState race_ (forever $ void getLine) . forever $ do (_, _, resp) <- atomically . readTBQueue $ outputQ cc forM_ (crDirectoryEvent resp) $ \case @@ -84,7 +96,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { DEItemEditIgnored _ct -> pure () DEItemDeleteIgnored _ct -> pure () DEContactCommand ct ciId aCmd -> case aCmd of - ADC SDRUser cmd -> deUserCommand ct ciId cmd + ADC SDRUser cmd -> deUserCommand env ct ciId cmd ADC SDRSuperUser cmd -> deSuperUserCommand ct ciId cmd where withSuperUsers action = void . forkIO $ forM_ superUsers $ \KnownContact {contactId} -> action contactId @@ -105,8 +117,11 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { T.unpack $ "The group " <> displayName <> " (" <> fullName <> ") is already listed in the directory, please choose another name." getGroups :: Text -> IO (Maybe [(GroupInfo, GroupSummary)]) - getGroups search = - sendChatCmd cc (APIListGroups userId Nothing $ Just $ T.unpack search) >>= \case + getGroups = getGroups_ . Just + + getGroups_ :: Maybe Text -> IO (Maybe [(GroupInfo, GroupSummary)]) + getGroups_ search_ = + sendChatCmd cc (APIListGroups userId Nothing $ T.unpack <$> search_) >>= \case CRGroupsList {groups} -> pure $ Just groups _ -> pure Nothing @@ -140,7 +155,8 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { sendMessage cc ct $ "Welcome to " <> serviceName <> " service!\n\ \Send a search string to find groups or */help* to learn how to add groups to directory.\n\n\ - \For example, send _privacy_ to find groups about privacy.\n\n\ + \For example, send _privacy_ to find groups about privacy.\n\ + \Or send */all* or */new* to list groups.\n\n\ \Content and privacy policy: https://simplex.chat/docs/directory.html" deGroupInvitation :: Contact -> GroupInfo -> GroupMemberRole -> GroupMemberRole -> IO () @@ -201,7 +217,7 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { "Created the public link to join the group via this directory service that is always online.\n\n\ \Please add it to the group welcome message.\n\ \For example, add:" - notifyOwner gr $ "Link to join the group " <> T.unpack displayName <> ": " <> B.unpack (strEncode connReqContact) + notifyOwner gr $ "Link to join the group " <> T.unpack displayName <> ": " <> B.unpack (strEncode $ simplexChatContact connReqContact) CRChatCmdError _ (ChatError e) -> case e of CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." @@ -276,9 +292,10 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { where profileUpdate = \case CRGroupLink {connReqContact} -> - let groupLink = safeDecodeUtf8 $ strEncode connReqContact - hadLinkBefore = groupLink `isInfix` description p - hasLinkNow = groupLink `isInfix` description p' + let groupLink1 = safeDecodeUtf8 $ strEncode connReqContact + groupLink2 = safeDecodeUtf8 $ strEncode $ simplexChatContact connReqContact + hadLinkBefore = groupLink1 `isInfix` description p || groupLink2 `isInfix` description p + hasLinkNow = groupLink1 `isInfix` description p' || groupLink2 `isInfix` description p' in if | hadLinkBefore && hasLinkNow -> GPHasServiceLink | hadLinkBefore -> GPServiceLinkRemoved @@ -379,8 +396,8 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." notifySuperUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)." - deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () - deUserCommand ct ciId = \case + deUserCommand :: ServiceState -> Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () + deUserCommand env@ServiceState {searchRequests} ct ciId = \case DCHelp -> sendMessage cc ct $ "You must be the owner to add the group to the directory:\n\ @@ -389,20 +406,25 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { \3. You will then need to add this link to the group welcome message.\n\ \4. Once the link is added, service admins will approve the group (it can take up to 24 hours), and everybody will be able to find it in directory.\n\n\ \Start from inviting the bot to your group as admin - it will guide you through the process" - DCSearchGroup s -> - getGroups s >>= \case - Just groups -> - atomically (filterListedGroups st groups) >>= \case - [] -> sendReply "No groups found" - gs -> do - sendReply $ "Found " <> show (length gs) <> " group(s)" <> if length gs > 10 then ", sending 10." else "" - void . forkIO $ forM_ (take 10 $ sortOn (Down . currentMembers . snd) gs) $ - \(GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do - let membersStr = "_" <> tshow currentMembers <> " members_" - text = groupInfoText p <> "\n" <> membersStr - msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ - sendComposedMessage cc ct Nothing msg - Nothing -> sendReply "Error: getGroups. Please notify the developers." + DCSearchGroup s -> withFoundListedGroups (Just s) $ sendSearchResults s + DCSearchNext -> + atomically (TM.lookup (contactId' ct) searchRequests) >>= \case + Just search@SearchRequest {searchType, searchTime} -> do + currentTime <- getCurrentTime + if diffUTCTime currentTime searchTime > 300 -- 5 minutes + then do + atomically $ TM.delete (contactId' ct) searchRequests + showAllGroups + else case searchType of + STSearch s -> withFoundListedGroups (Just s) $ sendNextSearchResults takeTop search + STAll -> withFoundListedGroups Nothing $ sendNextSearchResults takeTop search + STRecent -> withFoundListedGroups Nothing $ sendNextSearchResults takeRecent search + Nothing -> showAllGroups + where + showAllGroups = deUserCommand env ct ciId DCAllGroups + DCAllGroups -> withFoundListedGroups Nothing $ sendAllGroups takeTop "top" STAll + DCRecentGroups -> withFoundListedGroups Nothing $ sendAllGroups takeRecent "the most recent" STRecent + DCSubmitGroup _link -> pure () DCConfirmDuplicateGroup ugrId gName -> atomically (getUserGroupReg st (contactId' ct) ugrId) >>= \case Nothing -> sendReply $ "Group ID " <> show ugrId <> " not found" @@ -429,6 +451,54 @@ directoryService st DirectoryOpts {superUsers, serviceName, testing} user@User { DCCommandError tag -> sendReply $ "Command error: " <> show tag where sendReply = sendComposedMessage cc ct (Just ciId) . textMsgContent + withFoundListedGroups s_ action = + getGroups_ s_ >>= \case + Just groups -> atomically (filterListedGroups st groups) >>= action + Nothing -> sendReply "Error: getGroups. Please notify the developers." + sendSearchResults s = \case + [] -> sendReply "No groups found" + gs -> do + let gs' = takeTop searchResults gs + moreGroups = length gs - length gs' + more = if moreGroups > 0 then ", sending top " <> show (length gs') else "" + sendReply $ "Found " <> show (length gs) <> " group(s)" <> more <> "." + updateSearchRequest (STSearch s) $ groupIds gs' + sendFoundGroups gs' moreGroups + sendAllGroups takeFirst sortName searchType = \case + [] -> sendReply "No groups listed" + gs -> do + let gs' = takeFirst searchResults gs + moreGroups = length gs - length gs' + more = if moreGroups > 0 then ", sending " <> sortName <> " " <> show (length gs') else "" + sendReply $ show (length gs) <> " group(s) listed" <> more <> "." + updateSearchRequest searchType $ groupIds gs' + sendFoundGroups gs' moreGroups + sendNextSearchResults takeFirst SearchRequest {searchType, sentGroups} = \case + [] -> do + sendReply "Sorry, no more groups" + atomically $ TM.delete (contactId' ct) searchRequests + gs -> do + let gs' = takeFirst searchResults $ filterNotSent sentGroups gs + sentGroups' = sentGroups <> groupIds gs' + moreGroups = length gs - S.size sentGroups' + sendReply $ "Sending " <> show (length gs') <> " more group(s)." + updateSearchRequest searchType sentGroups' + sendFoundGroups gs' moreGroups + updateSearchRequest :: SearchType -> Set GroupId -> IO () + updateSearchRequest searchType sentGroups = do + searchTime <- getCurrentTime + let search = SearchRequest {searchType, searchTime, sentGroups} + atomically $ TM.insert (contactId' ct) search searchRequests + sendFoundGroups gs moreGroups = + void . forkIO $ do + forM_ gs $ + \(GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do + let membersStr = "_" <> tshow currentMembers <> " members_" + text = groupInfoText p <> "\n" <> membersStr + msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ + sendComposedMessage cc ct Nothing msg + when (moreGroups > 0) $ + sendComposedMessage cc ct Nothing $ MCText $ "Send */next* or just *.* for " <> tshow moreGroups <> " more result(s)." deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO () deSuperUserCommand ct ciId cmd diff --git a/cabal.project b/cabal.project index 0667b83048..768a317263 100644 --- a/cabal.project +++ b/cabal.project @@ -4,25 +4,23 @@ packages: . with-compiler: ghc-8.10.7 -index-state: 2023-10-06T00:00:00Z +index-state: 2023-12-12T00:00:00Z + +package cryptostore + flags: +use_crypton constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: a860936072172e261480fa6bdd95203976e366b2 + tag: d0588bd0ac23a459cbfc9a4789633014e91ffa19 source-repository-package type: git location: https://github.com/simplex-chat/hs-socks.git tag: a30cc7a79a08d8108316094f8f2f82a0c5e1ac51 -source-repository-package - type: git - location: https://github.com/kazu-yamamoto/http2.git - tag: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb - source-repository-package type: git location: https://github.com/simplex-chat/direct-sqlcipher.git diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 55e6ef976f..fa8921c827 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -20,6 +20,7 @@ If you want to view what's stored inside SimpleX data directory you need to have - your device connected via USB or Wi-Fi to the computer. ## The process: + - open SimpleX, go to `Database passphrase & export`, enable `App data backup`. This will make other steps working - _optional_: if you want to view database contents, change database passphrase from random to yours. To do this, stop a chat in `Database passphrase & export` screen, open `Database passphrase`, enter new passphrase and confirm it, then update it. Do not forget it, otherwise you'll lose all your data in case passphrase will be asked again later - open a terminal emulator (Windows CMD/Powershell will not work) and change directory to the one you want to use for storing the backup: @@ -43,4 +44,14 @@ Now the backed-up files will be inside `./apps/chat.simplex.app/`. Please, note, that if you use a modern version of SimpleX, the databases will be encrypted, and you'll not be able to view contents of them without using `sqlcipher` application and without knowing decryption passphrase (you need to change it to yours from randomly generated in the app firstly). -Please, follow [SQL.md](./SQL.md) guide for more info of how to decrypt your databases and to make queries to them. +## Decrypting databases + +In order to view database data you need to decrypt it first. Install `sqlcipher` using your favorite package manager and run the following commands in the directory with databases: +```bash +sqlcipher files_chat.db +pragma key="youDecryptionPassphrase"; +# Ensure it works fine +select * from users; +``` + +If you see `Parse error: no such table: users`, make sure you entered correct passphrase, and you have changed passphrase from random in Android app (if you got this database from Android device, of course). diff --git a/docs/CLI.md b/docs/CLI.md index f781f85749..baf79bb3bc 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -243,37 +243,3 @@ User address is "long-term" in a sense that it is a multiple-use connection link Use `/help address` for other commands. ![simplex-chat](../images/user-addresses.gif) - -### Access chat history - -SimpleX chat stores all your contacts and conversations in a local SQLite database, making it private and portable by design, owned and controlled by user. - -You can view and search your chat history by querying your database. Run the below script to create message views in your database. - -```sh -curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/scripts/message_views.sql | sqlite3 ~/.simplex/simplex_v1_chat.db -``` - -Open SQLite Command Line Shell: - -```sh -sqlite3 ~/.simplex/simplex_v1_chat.db -``` - -See [Message queries](./SQL.md) for examples. - -> **Please note:** SQLite foreign key constraints are disabled by default, and must be **[enabled separately for each database connection](https://sqlite.org/foreignkeys.html#fk_enable)**. The latter can be achieved by running `PRAGMA foreign_keys = ON;` command on an open database connection. By running data altering queries without enabling foreign keys prior to that, you may risk putting your database in an inconsistent state. - -**Convenience queries** - -Get all messages from today (`chat_dt` is in UTC): - -```sql -select * from all_messages_plain where date(chat_dt) > date('now', '-1 day') order by chat_dt; -``` - -Get overnight messages in the morning: - -```sql -select * from all_messages_plain where chat_dt > datetime('now', '-15 hours') order by chat_dt; -``` diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index a43a694097..5362e4f2c3 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -7,7 +7,7 @@ revision: 25.11.2023 | Updated 25.11.2023 | Languages: EN | # Download SimpleX apps -The latest stable version is v5.4.0. +The latest stable version is v5.4.1. You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). @@ -21,24 +21,24 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps. -**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-ubuntu-22_04-x86_64.deb). +**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-desktop-ubuntu-22_04-x86_64.deb). -**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). +**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-desktop-windows-x86_64.msi). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-desktop-windows-x86_64.msi). ## Mobile apps **iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-armv7a.apk). ## Terminal (console) app See [Using terminal app](/docs/CLI.md). -**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-ubuntu-22_04-x86-64). +**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-chat-ubuntu-22_04-x86-64). -**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). +**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0/simplex-chat-windows-x86-64). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.1/simplex-chat-windows-x86-64). diff --git a/docs/SQL.md b/docs/SQL.md deleted file mode 100644 index d5b469050d..0000000000 --- a/docs/SQL.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -title: Accessing messages in the database -revision: 31.01.2023 ---- - -| Updated 31.01.2023 | Languages: EN, [FR](/docs/lang/fr/SQL.md), [CZ](/docs/lang/cs/SQL.md) | - -# Accessing messages in the database - -## Decrypting databases - -In order to view database data you need to decrypt it first. Install `sqlcipher` using your favorite package manager and run the following commands in the directory with databases: -```bash -sqlcipher files_chat.db -pragma key="youDecryptionPassphrase"; -# Ensure it works fine -select * from users; -``` - -If you see `Parse error: no such table: users`, make sure you entered correct passphrase, and you have changed passphrase from random in Android app (if you got this database from Android device, of course). - -## SQL queries - -You can run queries against `direct_messages`, `group_messages` and `all_messages` (or their simpler alternatives `direct_messages_plain`, `group_messages_plain` and `all_messages_plain`), for example: - -```sql --- you can put these or your preferred settings into ~/.sqliterc --- to persist across sqlite3 client sessions -.mode column -.headers on -.nullvalue NULL - --- simple views into direct, group and all_messages --- with user's messages deduplicated for group and all_messages; --- only 'x.msg.new' ("new message") chat events - filters out service events; --- msg_sent is 0 for received, 1 for sent -select * from direct_messages_plain; -select * from group_messages_plain; -select * from all_messages_plain; - --- query other details of your chat history with regular SQL, for example: --- files you offered for sending -select * from direct_messages where msg_sent = 1 and chat_msg_event = 'x.file'; --- everything catherine sent related to cats -select * from direct_messages where msg_sent = 0 and contact = 'catherine' and msg_body like '%cats%'; --- all correspondence with alice in #team -select * from group_messages where group_name = 'team' and contact = 'alice'; - --- aggregate your chat data -select contact_or_group, num_messages from ( - select - contact as contact_or_group, count(1) as num_messages - from direct_messages_plain group by contact - union - select - group_name as contact_or_group, count(1) as num_messages - from group_messages_plain group by group_name -) -order by num_messages desc; -``` diff --git a/docs/lang/cs/ANDROID.md b/docs/lang/cs/ANDROID.md index 38dd105bb5..4edfc3018c 100644 --- a/docs/lang/cs/ANDROID.md +++ b/docs/lang/cs/ANDROID.md @@ -19,6 +19,7 @@ Pokud chcete zobrazit, co je uloženo v datovém adresáři SimpleX, musíte mí - zařízení připojené přes USB nebo Wi-Fi k počítači. ## Postup: + - Otevřete SimpleX, přejděte na `Databáze passphrase & export`, povolte `Zálohování dat aplikace`. Tím se zprovozní další kroky - _Volitelné_: pokud chcete zobrazit obsah databáze, změňte přístupovou frázi databáze z náhodné na svou. Chcete-li to provést, zastavte chat na obrazovce `Database passphrase & export`, otevřete `Database passphrase`, zadejte novou passphrase a potvrďte ji, poté ji aktualizujte. Nezapomeňte ji, jinak přijdete o všechna svá data v případě, že bude passphrase později znovu požadována. - otevřete emulátor terminálu (Windows CMD/Powershell nebude fungovat) a změňte adresář na ten, který chcete použít pro uložení zálohy: @@ -42,4 +43,14 @@ Nyní budou zálohované soubory uvnitř `./apps/chat.simplex.app/`. Upozorňujeme, že pokud používáte moderní verzi SimpleX, budou databáze zašifrované a jejich obsah nebudete moci zobrazit bez použití aplikace `sqlcipher` a bez znalosti dešifrovací fráze (musíte ji nejprve změnit na svou z náhodně vygenerovaných v aplikaci). -Další informace o tom, jak dešifrovat databáze a provádět dotazy do nich, najdete v příručce [SQL.md](./SQL.md). +## Dešifrování databází + +Chcete-li zobrazit data v databázi, musíte je nejprve dešifrovat. Nainstalujte `sqlcipher` pomocí svého oblíbeného správce balíčků a v adresáři s databázemi spusťte následující příkazy: +```bash +sqlcipher files_chat.db +pragma key="youDecryptionPassphrase"; +# Ujistěte se, že vše funguje správně +select * from users; +``` + +Pokud se zobrazí `Parse error: no such table: users`, ujistěte se, že jste zadali správnou přístupovou frázi a že jste ji v aplikaci pro Android změnili z náhodné (pokud jste tuto databázi získali ze zařízení s Androidem, samozřejmě). diff --git a/docs/lang/cs/CLI.md b/docs/lang/cs/CLI.md index 731e236661..aa5a2ba281 100644 --- a/docs/lang/cs/CLI.md +++ b/docs/lang/cs/CLI.md @@ -220,37 +220,3 @@ Uživatelská adresa je "dlouhodobá" v tom smyslu, že se jedná o odkaz pro v Pro ostatní příkazy použijte `/help address`. ![simplex-chat](/images/user-addresses.gif) - -### Přístup k historii chatu - -SimpleX chat ukládá všechny vaše kontakty a konverzace do místní databáze SQLite, takže jsou soukromé a přenosné, vlastněné a kontrolované uživatelem. - -Historii chatu můžete zobrazit a prohledávat dotazem do databáze. Spusťte níže uvedený skript pro vytvoření zobrazení zpráv ve vaší databázi. - -```sh -curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/scripts/message_views.sql | sqlite3 ~/.simplex/simplex_v1_chat.db -``` - -Otevřete SQLite Command Line Shell: - -```sh -sqlite3 ~/.simplex/simplex_v1_chat.db -``` - -Příklady viz [Message queries](./SQL.md). - -> **Upozornění:** Omezení cizích klíčů SQLite jsou ve výchozím nastavení vypnuta a musí být **[povolena pro každé připojení k databázi zvlášť](https://sqlite.org/foreignkeys.html#fk_enable)**. Toho lze dosáhnout spuštěním příkazu `PRAGMA foreign_keys = ON;` na otevřeném databázovém připojení. Spouštěním dotazů měnících data bez předchozího povolení cizích klíčů můžete riskovat, že se databáze dostane do nekonzistentního stavu. - -**Pohodlné dotazy** - -Získat všechny zprávy z dnešního dne (`chat_dt` je v UTC): - -```sql -select * from all_messages_plain where date(chat_dt) > date('now', '-1 day') order by chat_dt; -``` - -Získejte ranní noční zprávy: - -```sql -select * from all_messages_plain where chat_dt > datetime('now', '-15 hours') order by chat_dt; -``` diff --git a/docs/lang/cs/SQL.md b/docs/lang/cs/SQL.md deleted file mode 100644 index 71ef0007c1..0000000000 --- a/docs/lang/cs/SQL.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Přístup ke zprávám v databázi -revision: 31.01.2023 ---- -| Aktualizováno 31.01.2023 | Jazyky: CZ, [EN](/docs/SQL.md), [FR](/docs/lang/fr/SQL.md) | - -# Přístup ke zprávám v databázi - -## Dešifrování databází - -Chcete-li zobrazit data v databázi, musíte je nejprve dešifrovat. Nainstalujte `sqlcipher` pomocí svého oblíbeného správce balíčků a v adresáři s databázemi spusťte následující příkazy: -```bash -sqlcipher files_chat.db -pragma key="youDecryptionPassphrase"; -# Ujistěte se, že vše funguje správně -select * from users; -``` - -Pokud se zobrazí `Parse error: no such table: users`, ujistěte se, že jste zadali správnou přístupovou frázi a že jste ji v aplikaci pro Android změnili z náhodné (pokud jste tuto databázi získali ze zařízení s Androidem, samozřejmě). - -## SQL dotazy - -Můžete spouštět dotazy proti `direct_messages`, `group_messages` a `all_messages` (nebo jejich jednodušším alternativám `direct_messages_plain`, `group_messages_plain` a `all_messages_plain`), např: - -```sql --- tato nebo vámi preferovaná nastavení můžete vložit do souboru ~/.sqliterc --- aby přetrvaly napříč relacemi klienta sqlite3 -.mode column -.headers on -.nullvalue NULL - --- jednoduché pohledy na direct, group a all_messages --- s deduplikací uživatelských zpráv pro group a all_messages; --- pouze události chatu 'x.msg.new' ("nová zpráva") - filtruje události služby; --- msg_sent je 0 pro přijaté, 1 pro odeslané -select * from direct_messages_plain; -select * from group_messages_plain; -select * from all_messages_plain; - --- dotaz na další podrobnosti historie chatu pomocí běžného SQL, například: --- soubory, které jste nabídli k odeslání -select * from direct_messages where msg_sent = 1 and chat_msg_event = 'x.file'; --- vše, co catherine poslala v souvislosti s kočkami -select * from direct_messages where msg_sent = 0 and contact = 'catherine' and msg_body like '%cats%'; --- veškerá korespondence s alice v #teamu -select * from group_messages where group_name = 'team' and contact = 'alice'; - --- shrňte data z chatu -select contact_or_group, num_messages from ( - select - contact as contact_or_group, count(1) as num_messages - from direct_messages_plain group by contact - union - select - group_name as contact_or_group, count(1) as num_messages - from group_messages_plain group by group_name -) -order by num_messages desc; -``` diff --git a/docs/lang/fr/ANDROID.md b/docs/lang/fr/ANDROID.md index 905af50152..0f710542a8 100644 --- a/docs/lang/fr/ANDROID.md +++ b/docs/lang/fr/ANDROID.md @@ -19,6 +19,7 @@ Si vous voulez voir ce qui est stocké dans le répertoire de données de Simple - votre appareil connecté via USB ou Wi-Fi à l'ordinateur. ## La procédure : + - Ouvrez SimpleX, allez dans `Phrase secrète et exportation de la base de données`, activez `Sauvegarde des données de l'app`. Cela permettra aux autres étapes de fonctionner - optionnel_ : si vous voulez voir le contenu de la base de données, changez la phrase secrète de la base de données aléatoire pour la vôtre. Pour ce faire, arrêtez le chat dans le menu "Phrase secrète et exportation de la base de données", ouvrez "Phrase secrète de la base de données", entrez la nouvelle phrase secrète et confirmez-la, puis mettez-la à jour. Ne l'oubliez pas, sinon vous perdrez toutes vos données au cas où la phrase d'authentification vous serait redemandée plus tard. - ouvrez un émulateur de terminal (Windows CMD/Powershell ne fonctionnera pas) et changez de répertoire pour celui que vous voulez utiliser pour stocker la sauvegarde : @@ -42,4 +43,14 @@ Maintenant les fichiers sauvegardés seront dans `./apps/chat.simplex.app/`. Veuillez noter que si vous utilisez une version récente de SimpleX, les bases de données seront chiffrées, et vous ne pourrez pas en voir le contenu sans utiliser l'application `sqlcipher` et sans connaître la phrase secrète de déchiffrement (vous devez d'abord la changer pour la vôtre à partir de celle qui est générée aléatoirement dans l'application). -Veuillez suivre le guide [SQL.md](/SQL.md) pour plus d'informations sur la manière de déchiffrer vos bases de données et d'y effectuer des requêtes. +## Déchiffrer les bases de données + +Afin de visualiser les données de la base de données, vous devez d'abord les déchiffrer. Installez `sqlcipher` en utilisant votre gestionnaire de paquets préféré et exécutez les commandes suivantes dans le répertoire contenant les bases de données : +```bash +sqlcipher files_chat.db +pragma key="youDecryptionPassphrase"; +# S'assurer qu'il fonctionne bien +select * from users; +``` + +Si vous voyez `Parse error : no such table : users`, assurez-vous que vous avez entré la bonne phrase secrète, et que vous avez changé la phrase secrète au hasard dans l'application Android (si vous avez obtenu cette base de données à partir d'un appareil Android, bien sûr). diff --git a/docs/lang/fr/CLI.md b/docs/lang/fr/CLI.md index 2c4a88e2d7..6fe9c86d75 100644 --- a/docs/lang/fr/CLI.md +++ b/docs/lang/fr/CLI.md @@ -222,37 +222,3 @@ L'adresse de l'utilisateur est "à long terme" dans le sens où il s'agit d'un l Utilisez `/help address` pour les autres commandes. ![simplex-chat](/images/user-addresses.gif) - -### Accéder à l'historique des chats - -SimpleX chat stocke tous vos contacts et conversations dans une base de données SQLite locale, ce qui la rend privée et portable par définition, appartenant et contrôlée par l'utilisateur. - -Vous pouvez visualiser et rechercher votre historique de chat en interrogeant votre base de données. Exécutez le script ci-dessous pour créer des aperçus de messages dans votre base de données. - -```sh -curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/scripts/message_views.sql | sqlite3 ~/.simplex/simplex_v1_chat.db -``` - -Ouvrir un terminal de commande SQLite : - -```sh -sqlite3 ~/.simplex/simplex_v1_chat.db -``` - -Voir les [requêtes de message](./SQL.md) à titre d'exemple. - -> **Veuillez noter :** Les contraintes de clé étrangère SQLite sont désactivées par défaut, et doivent être **[activé séparément pour chaque connexion de base de données](https://sqlite.org/foreignkeys.html#fk_enable)**. Cette dernière peut être réalisée en exécutant la commande `PRAGMA foreign_keys = ON;` sur une connexion de base de données ouverte. En exécutant des requêtes modifiant les données sans activer les clés étrangères au préalable, vous risquez de mettre votre base de données dans un état inconsistant. - -**Requêtes de commodité** - -Pour recevoir tous les messages du jour (`chat_dt` est en UTC) : - -```sql -select * from all_messages_plain where date(chat_dt) > date('now', '-1 day') order by chat_dt; -``` - -Pour recevoir les messages de la nuit dans la matinée : - -```sql -select * from all_messages_plain where chat_dt > datetime('now', '-15 hours') order by chat_dt; -``` diff --git a/docs/lang/fr/SQL.md b/docs/lang/fr/SQL.md deleted file mode 100644 index d141153d65..0000000000 --- a/docs/lang/fr/SQL.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Accès aux messages de la base de données -revision: 31.01.2023 ---- -| 31.01.2023 | FR, [EN](/docs/SQL.md), [CZ](/docs/lang/cs/SQL.md) | - -# Accès aux messages de la base de données - -## Déchiffrer les bases de données - -Afin de visualiser les données de la base de données, vous devez d'abord les déchiffrer. Installez `sqlcipher` en utilisant votre gestionnaire de paquets préféré et exécutez les commandes suivantes dans le répertoire contenant les bases de données : -```bash -sqlcipher files_chat.db -pragma key="youDecryptionPassphrase"; -# S'assurer qu'il fonctionne bien -select * from users; -``` - -Si vous voyez `Parse error : no such table : users`, assurez-vous que vous avez entré la bonne phrase secrète, et que vous avez changé la phrase secrète au hasard dans l'application Android (si vous avez obtenu cette base de données à partir d'un appareil Android, bien sûr). - -# Requêtes SQL - -Vous pouvez exécuter des requêtes `direct_messages`, `group_messages` et `all_messages` (ou leurs alternatives plus simples `direct_messages_plain`, `group_messages_plain` et `all_messages_plain`), par exemple : - -```sql --- vous pouvez mettre ces paramètres ou ceux que vous préférez dans ~/.sqliterc --- pour maintenir les sessions du client sqlite3 -.mode column -.headers on -.nullvalue NULL - --- vues simples pour direct, group et all_messages --- avec les messages de l'utilisateur dédupliqués pour group et all_messages ; --- seuls les événements de chat "x.msg.new" ("nouveau message") - filtre les événements de service ; --- msg_sent est 0 pour reçu, 1 pour envoyé -select * from direct_messages_plain; -select * from group_messages_plain; -select * from all_messages_plain; - --- demander d'autres détails de votre historique de chat avec le SQL régulier, par exemple : --- les fichiers que vous avez soumis pour l'envoi -select * from direct_messages where msg_sent = 1 and chat_msg_event = 'x.file'; --- tout ce que Catherine a envoyé lié aux chats -select * from direct_messages where msg_sent = 0 and contact = 'catherine' and msg_body like '%cats%'; --- toute correspondance avec alice dans #team -select * from group_messages where group_name = 'team' and contact = 'alice'; - --- regrouper vos données de chat -select contact_or_group, num_messages from ( - select - contact as contact_or_group, count(1) as num_messages - from direct_messages_plain group by contact - union - select - group_name as contact_or_group, count(1) as num_messages - from group_messages_plain group by group_name -) -order by num_messages desc; -``` diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index 95069c794c..71d5efcef7 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -173,7 +173,7 @@ This message is used to delete previously sent chat items. Receiving clients MUS When content message `x.msg.new` contains file attachment (the invitation to receive the file), this sub-protocol is used to accept this file or to notify the recipient that sending the file was cancelled. -File attachement can optionally include connection address to receive the file - clients MUST include it when sending files to direct connections, and MUST NOT include it when sending file attachment to the group (as different members would need different connections to receive the file). +File attachment can optionally include connection address to receive the file - clients MUST include it when sending files to direct connections, and MUST NOT include it when sending file attachment to the group (as different members would need different connections to receive the file). `x.file.acpt` message is used to accept the file in case when file connection address was included in the message (that is the case when the file invitation was sent in direct message). It is sent as part of file connection handshake via file connection, that is why this message contains no reference to the file - the used connection provides sufficient context for the sender. diff --git a/flake.lock b/flake.lock index 8447f64a90..5f464dbc7e 100644 --- a/flake.lock +++ b/flake.lock @@ -302,11 +302,11 @@ "hackage": { "flake": false, "locked": { - "lastModified": 1696724662, - "narHash": "sha256-jV2ugSjZE0FjMYR2YIx0p2cDBqd+xxhZrRxp5BmieYk=", + "lastModified": 1702340598, + "narHash": "sha256-CC0HI+6iKPtH+8r/ZfcpW5v/OYvL7zMwpr0xfkXV1zU=", "owner": "input-output-hk", "repo": "hackage.nix", - "rev": "df603bff8606d8653a0876ae0c3fd1f9014882f2", + "rev": "24617c569995e38bf3b83b48eec6628a50fdb4fb", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 6c81eaffaa..f737c4ca1e 100644 --- a/flake.nix +++ b/flake.nix @@ -15,7 +15,7 @@ let pkgs = haskellNix.legacyPackages.${system}; in let drv' = { extra-modules, pkgs', ... }: pkgs'.haskell-nix.project { compiler-nix-name = "ghc8107"; - index-state = "2023-10-06T00:00:00Z"; + index-state = "2023-12-12T00:00:00Z"; # We need this, to specify we want the cabal project. # If the stack.yaml was dropped, this would not be necessary. projectFileName = "cabal.project"; diff --git a/package.yaml b/package.yaml index 0f27f6035e..677a21e4d5 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.7 +version: 5.4.2.1 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme @@ -22,7 +22,7 @@ dependencies: - composition == 1.0.* - constraints >= 0.12 && < 0.14 - containers == 0.6.* - - cryptonite == 0.30.* + - crypton == 0.34.* - data-default >= 0.7 && < 0.8 - directory == 1.3.* - direct-sqlcipher == 2.3.* @@ -45,8 +45,8 @@ dependencies: - sqlcipher-simple == 0.4.* - stm == 2.5.* - terminal == 0.2.* - - time == 1.9.* - - tls >= 1.6.0 && < 1.7 + - time == 1.12.* + - tls >= 1.7.0 && < 1.8 - unliftio == 0.2.* - unliftio-core == 0.2.* - zip == 2.0.* diff --git a/scripts/message_views.sql b/scripts/message_views.sql deleted file mode 100644 index e0c90145c0..0000000000 --- a/scripts/message_views.sql +++ /dev/null @@ -1,110 +0,0 @@ -CREATE VIEW direct_messages AS -SELECT - ct.local_display_name AS contact, - m.message_id AS message_id, - m.msg_sent AS msg_sent, - m.chat_msg_event AS chat_msg_event, - m.msg_body AS msg_body, - md.msg_delivery_id AS delivery_id, - datetime(md.chat_ts) AS chat_dt, - md.agent_msg_meta AS msg_meta, - mde.delivery_status AS delivery_status, - datetime(mde.created_at) AS delivery_status_dt -FROM messages m -JOIN msg_deliveries md ON md.message_id = m.message_id -JOIN ( - SELECT msg_delivery_id, MAX(created_at) MaxDate - FROM msg_delivery_events - GROUP BY msg_delivery_id -) MaxDates ON MaxDates.msg_delivery_id = md.msg_delivery_id -JOIN msg_delivery_events mde ON mde.msg_delivery_id = MaxDates.msg_delivery_id - AND mde.created_at = MaxDates.MaxDate -JOIN connections c ON c.connection_id = md.connection_id -JOIN contacts ct ON ct.contact_id = c.contact_id -ORDER BY chat_dt DESC; - -CREATE VIEW direct_messages_plain AS -SELECT - dm.contact AS contact, - dm.msg_sent AS msg_sent, - dm.msg_body AS msg_body, - dm.chat_dt AS chat_dt -FROM direct_messages dm -WHERE dm.chat_msg_event = 'x.msg.new'; - -CREATE VIEW group_messages AS -SELECT - g.local_display_name AS group_name, - gm.local_display_name AS contact, - m.message_id AS message_id, - m.msg_sent AS msg_sent, - m.chat_msg_event AS chat_msg_event, - m.msg_body AS msg_body, - md.msg_delivery_id AS delivery_id, - datetime(md.chat_ts) AS chat_dt, - md.agent_msg_meta AS msg_meta, - mde.delivery_status AS delivery_status, - datetime(mde.created_at) AS delivery_status_dt -FROM messages m -JOIN msg_deliveries md ON md.message_id = m.message_id -JOIN ( - SELECT msg_delivery_id, MAX(created_at) MaxDate - FROM msg_delivery_events - GROUP BY msg_delivery_id -) MaxDates ON MaxDates.msg_delivery_id = md.msg_delivery_id -JOIN msg_delivery_events mde ON mde.msg_delivery_id = MaxDates.msg_delivery_id - AND mde.created_at = MaxDates.MaxDate -JOIN connections c ON c.connection_id = md.connection_id -JOIN group_members gm ON gm.group_member_id = c.group_member_id -JOIN groups g ON g.group_id = gm.group_id -ORDER BY chat_dt DESC; - -CREATE VIEW group_messages_plain AS -SELECT - gm.group_name AS group_name, - (CASE WHEN gm.msg_sent = 0 THEN gm.contact ELSE gm.group_name END) AS contact, - gm.msg_sent AS msg_sent, - gm.msg_body AS msg_body, - gm.chat_dt AS chat_dt -FROM group_messages gm -JOIN ( - SELECT message_id, MIN(delivery_id) MinDeliveryId - FROM group_messages - GROUP BY message_id -) Deduplicated ON Deduplicated.message_id = gm.message_id - AND Deduplicated.MinDeliveryId = gm.delivery_id -WHERE gm.chat_msg_event = 'x.msg.new'; - -CREATE VIEW all_messages ( - group_name, - contact, - message_id, - msg_sent, - chat_msg_event, - msg_body, - delivery_id, - chat_dt, - msg_meta, - delivery_status, - delivery_status_dt -) AS - SELECT * FROM ( - SELECT NULL AS group_name, * FROM direct_messages - UNION - SELECT * FROM group_messages - ) - ORDER BY chat_dt DESC; - -CREATE VIEW all_messages_plain ( - group_name, - contact, - msg_sent, - msg_body, - chat_dt -) AS - SELECT * FROM ( - SELECT NULL AS group_name, * FROM direct_messages_plain - UNION - SELECT * FROM group_messages_plain - ) - ORDER BY chat_dt DESC; diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index fc57b60045..b22cef750e 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,7 +1,6 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a860936072172e261480fa6bdd95203976e366b2" = "16rwnh5zzphmw8d8ypvps6xjvzbmf5ljr6zzy15gz2g0jyh7hd91"; + "https://github.com/simplex-chat/simplexmq.git"."d0588bd0ac23a459cbfc9a4789633014e91ffa19" = "0b17qy74capb0jyli8f3pg1xi4aawhcgpmaz2ykl9g3605png1na"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; - "https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 84ad7cd926..b3c229fd81 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.4.0.7 +version: 5.4.2.1 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -36,6 +36,7 @@ library Simplex.Chat.Help Simplex.Chat.Markdown Simplex.Chat.Messages + Simplex.Chat.Messages.Batch Simplex.Chat.Messages.CIContent Simplex.Chat.Messages.CIContent.Events Simplex.Chat.Migrations.M20220101_initial @@ -125,6 +126,9 @@ library Simplex.Chat.Migrations.M20231113_group_forward Simplex.Chat.Migrations.M20231114_remote_control Simplex.Chat.Migrations.M20231126_remote_ctrl_address + Simplex.Chat.Migrations.M20231207_chat_list_pagination + Simplex.Chat.Migrations.M20231214_item_content_tag + Simplex.Chat.Migrations.M20231215_recreate_msg_deliveries Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -174,7 +178,7 @@ library , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -197,8 +201,8 @@ library , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* - , tls >=1.6.0 && <1.7 + , time ==1.12.* + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -233,7 +237,7 @@ executable simplex-bot , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -257,8 +261,8 @@ executable simplex-bot , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* - , tls >=1.6.0 && <1.7 + , time ==1.12.* + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -293,7 +297,7 @@ executable simplex-bot-advanced , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -317,8 +321,8 @@ executable simplex-bot-advanced , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* - , tls >=1.6.0 && <1.7 + , time ==1.12.* + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -355,7 +359,7 @@ executable simplex-broadcast-bot , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -379,8 +383,8 @@ executable simplex-broadcast-bot , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* - , tls >=1.6.0 && <1.7 + , time ==1.12.* + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -416,7 +420,7 @@ executable simplex-chat , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -440,8 +444,8 @@ executable simplex-chat , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* - , tls >=1.6.0 && <1.7 + , time ==1.12.* + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , websockets ==0.12.* @@ -465,6 +469,7 @@ executable simplex-directory-service other-modules: Directory.Events Directory.Options + Directory.Search Directory.Service Directory.Store Paths_simplex_chat @@ -481,7 +486,7 @@ executable simplex-directory-service , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , direct-sqlcipher ==2.3.* , directory ==1.3.* @@ -505,8 +510,8 @@ executable simplex-directory-service , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* - , tls >=1.6.0 && <1.7 + , time ==1.12.* + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* @@ -532,6 +537,7 @@ test-suite simplex-chat-test Bots.DirectoryTests ChatClient ChatTests + ChatTests.ChatList ChatTests.Direct ChatTests.Files ChatTests.Groups @@ -539,6 +545,7 @@ test-suite simplex-chat-test ChatTests.Utils JSONTests MarkdownTests + MessageBatching MobileTests ProtocolTests RemoteTests @@ -550,6 +557,7 @@ test-suite simplex-chat-test Broadcast.Options Directory.Events Directory.Options + Directory.Search Directory.Service Directory.Store Paths_simplex_chat @@ -569,7 +577,7 @@ test-suite simplex-chat-test , composition ==1.0.* , constraints >=0.12 && <0.14 , containers ==0.6.* - , cryptonite ==0.30.* + , crypton ==0.34.* , data-default ==0.7.* , deepseq ==1.4.* , direct-sqlcipher ==2.3.* @@ -597,8 +605,8 @@ test-suite simplex-chat-test , sqlcipher-simple ==0.4.* , stm ==2.5.* , terminal ==0.2.* - , time ==1.9.* - , tls >=1.6.0 && <1.7 + , time ==1.12.* + , tls >=1.7.0 && <1.8 , unliftio ==0.2.* , unliftio-core ==0.2.* , zip ==2.0.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bcd533a9a1..15e92755fa 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -8,7 +8,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -22,35 +21,36 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader -import Crypto.Random (drgNew) import qualified Data.Aeson as J import Data.Attoparsec.ByteString.Char8 (Parser) import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bifunctor (bimap, first) +import Data.ByteArray (ScrubbedBytes) +import qualified Data.ByteArray as BA import qualified Data.ByteString.Base64 as B64 +import Data.ByteString.Builder (toLazyByteString) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char import Data.Constraint (Dict (..)) -import Data.Either (fromRight, rights) +import Data.Either (fromRight, lefts, partitionEithers, rights) import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl', isSuffixOf, partition, sortBy, sortOn) -import Data.List.NonEmpty (NonEmpty, nonEmpty) +import Data.List (find, foldl', isSuffixOf, partition, sortOn) +import Data.List.NonEmpty (NonEmpty (..), nonEmpty, toList, (<|)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) -import Data.Ord (comparing) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds) -import Data.Time.Clock.System (SystemTime, systemToUTCTime) -import Data.Word (Word16, Word32) +import Data.Time.Clock.System (systemToUTCTime) +import Data.Word (Word32) import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive import Simplex.Chat.Call @@ -58,6 +58,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Files import Simplex.Chat.Markdown import Simplex.Chat.Messages +import Simplex.Chat.Messages.Batch (MsgBatch (..), batchMessages) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Options @@ -76,7 +77,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Util -import Simplex.Chat.Util (encryptFile) +import Simplex.Chat.Util (encryptFile, shuffle) import Simplex.FileTransfer.Client.Main (maxFileSize) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.FileTransfer.Description (ValidFileDescription, gb, kb, mb) @@ -190,107 +191,112 @@ smallGroupsRcptsMemLimit = 20 logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -createChatDatabase :: FilePath -> String -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) -createChatDatabase filePrefix key confirmMigrations = runExceptT $ do - chatStore <- ExceptT $ createChatStore (chatStoreFile filePrefix) key confirmMigrations - agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key confirmMigrations +createChatDatabase :: FilePath -> ScrubbedBytes -> Bool -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) +createChatDatabase filePrefix key keepKey confirmMigrations = runExceptT $ do + chatStore <- ExceptT $ createChatStore (chatStoreFile filePrefix) key keepKey confirmMigrations + agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key keepKey confirmMigrations pure ChatDatabase {chatStore, agentStore} -newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> IO ChatController -newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir, deviceNameForRemote} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, deviceName, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do - let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} - config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable} - firstTime = dbNew chatStore - currentUser <- newTVarIO user - currentRemoteHost <- newTVarIO Nothing - servers <- agentServers config - smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore - agentAsync <- newTVarIO Nothing - idsDrg <- newTVarIO =<< liftIO drgNew - inputQ <- newTBQueueIO tbqSize - outputQ <- newTBQueueIO tbqSize - connNetworkStatuses <- atomically TM.empty - subscriptionMode <- newTVarIO SMSubscribe - chatLock <- newEmptyTMVarIO - sndFiles <- newTVarIO M.empty - rcvFiles <- newTVarIO M.empty - currentCalls <- atomically TM.empty - localDeviceName <- newTVarIO $ fromMaybe deviceNameForRemote deviceName - multicastSubscribers <- newTMVarIO 0 - remoteSessionSeq <- newTVarIO 0 - remoteHostSessions <- atomically TM.empty - remoteHostsFolder <- newTVarIO Nothing - remoteCtrlSession <- newTVarIO Nothing - filesFolder <- newTVarIO optFilesFolder - chatStoreChanged <- newTVarIO False - expireCIThreads <- newTVarIO M.empty - expireCIFlags <- newTVarIO M.empty - cleanupManagerAsync <- newTVarIO Nothing - timedItemThreads <- atomically TM.empty - showLiveItems <- newTVarIO False - encryptLocalFiles <- newTVarIO False - userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg - tempDirectory <- newTVarIO tempDir - contactMergeEnabled <- newTVarIO True - pure - ChatController - { firstTime, - currentUser, - currentRemoteHost, - smpAgent, - agentAsync, - chatStore, - chatStoreChanged, - idsDrg, - inputQ, - outputQ, - connNetworkStatuses, - subscriptionMode, - chatLock, - sndFiles, - rcvFiles, - currentCalls, - localDeviceName, - multicastSubscribers, - remoteSessionSeq, - remoteHostSessions, - remoteHostsFolder, - remoteCtrlSession, - config, - filesFolder, - expireCIThreads, - expireCIFlags, - cleanupManagerAsync, - timedItemThreads, - showLiveItems, - encryptLocalFiles, - userXFTPFileConfig, - tempDirectory, - logFilePath = logFile, - contactMergeEnabled - } - where - configServers :: DefaultAgentServers - configServers = - let smp' = fromMaybe (smp (defaultServers :: DefaultAgentServers)) (nonEmpty smpServers) - xftp' = fromMaybe (xftp (defaultServers :: DefaultAgentServers)) (nonEmpty xftpServers) - in defaultServers {smp = smp', xftp = xftp', netCfg = networkConfig} - agentServers :: ChatConfig -> IO InitialAgentServers - agentServers config@ChatConfig {defaultServers = defServers@DefaultAgentServers {ntf, netCfg}} = do - users <- withTransaction chatStore getUsers - smp' <- getUserServers users SPSMP - xftp' <- getUserServers users SPXFTP - pure InitialAgentServers {smp = smp', xftp = xftp', ntf, netCfg} - where - getUserServers :: forall p. (ProtocolTypeI p, UserProtocol p) => [User] -> SProtocolType p -> IO (Map UserId (NonEmpty (ProtoServerWithAuth p))) - getUserServers users protocol = case users of - [] -> pure $ M.fromList [(1, cfgServers protocol defServers)] - _ -> M.fromList <$> initialServers - where - initialServers :: IO [(UserId, NonEmpty (ProtoServerWithAuth p))] - initialServers = mapM (\u -> (aUserId u,) <$> userServers u) users - userServers :: User -> IO (NonEmpty (ProtoServerWithAuth p)) - userServers user' = activeAgentServers config protocol <$> withTransaction chatStore (`getProtocolServers` user') +newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController +newChatController + ChatDatabase {chatStore, agentStore} + user + cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir, deviceNameForRemote} + ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize, highlyAvailable}, deviceName, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} + backgroundMode = do + let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} + config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize, highlyAvailable} + firstTime = dbNew chatStore + currentUser <- newTVarIO user + currentRemoteHost <- newTVarIO Nothing + servers <- agentServers config + smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore backgroundMode + agentAsync <- newTVarIO Nothing + random <- liftIO C.newRandom + inputQ <- newTBQueueIO tbqSize + outputQ <- newTBQueueIO tbqSize + connNetworkStatuses <- atomically TM.empty + subscriptionMode <- newTVarIO SMSubscribe + chatLock <- newEmptyTMVarIO + sndFiles <- newTVarIO M.empty + rcvFiles <- newTVarIO M.empty + currentCalls <- atomically TM.empty + localDeviceName <- newTVarIO $ fromMaybe deviceNameForRemote deviceName + multicastSubscribers <- newTMVarIO 0 + remoteSessionSeq <- newTVarIO 0 + remoteHostSessions <- atomically TM.empty + remoteHostsFolder <- newTVarIO Nothing + remoteCtrlSession <- newTVarIO Nothing + filesFolder <- newTVarIO optFilesFolder + chatStoreChanged <- newTVarIO False + expireCIThreads <- newTVarIO M.empty + expireCIFlags <- newTVarIO M.empty + cleanupManagerAsync <- newTVarIO Nothing + timedItemThreads <- atomically TM.empty + showLiveItems <- newTVarIO False + encryptLocalFiles <- newTVarIO False + userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg + tempDirectory <- newTVarIO tempDir + contactMergeEnabled <- newTVarIO True + pure + ChatController + { firstTime, + currentUser, + currentRemoteHost, + smpAgent, + agentAsync, + chatStore, + chatStoreChanged, + random, + inputQ, + outputQ, + connNetworkStatuses, + subscriptionMode, + chatLock, + sndFiles, + rcvFiles, + currentCalls, + localDeviceName, + multicastSubscribers, + remoteSessionSeq, + remoteHostSessions, + remoteHostsFolder, + remoteCtrlSession, + config, + filesFolder, + expireCIThreads, + expireCIFlags, + cleanupManagerAsync, + timedItemThreads, + showLiveItems, + encryptLocalFiles, + userXFTPFileConfig, + tempDirectory, + logFilePath = logFile, + contactMergeEnabled + } + where + configServers :: DefaultAgentServers + configServers = + let smp' = fromMaybe (smp (defaultServers :: DefaultAgentServers)) (nonEmpty smpServers) + xftp' = fromMaybe (xftp (defaultServers :: DefaultAgentServers)) (nonEmpty xftpServers) + in defaultServers {smp = smp', xftp = xftp', netCfg = networkConfig} + agentServers :: ChatConfig -> IO InitialAgentServers + agentServers config@ChatConfig {defaultServers = defServers@DefaultAgentServers {ntf, netCfg}} = do + users <- withTransaction chatStore getUsers + smp' <- getUserServers users SPSMP + xftp' <- getUserServers users SPXFTP + pure InitialAgentServers {smp = smp', xftp = xftp', ntf, netCfg} + where + getUserServers :: forall p. (ProtocolTypeI p, UserProtocol p) => [User] -> SProtocolType p -> IO (Map UserId (NonEmpty (ProtoServerWithAuth p))) + getUserServers users protocol = case users of + [] -> pure $ M.fromList [(1, cfgServers protocol defServers)] + _ -> M.fromList <$> initialServers + where + initialServers :: IO [(UserId, NonEmpty (ProtoServerWithAuth p))] + initialServers = mapM (\u -> (aUserId u,) <$> userServers u) users + userServers :: User -> IO (NonEmpty (ProtoServerWithAuth p)) + userServers user' = activeAgentServers config protocol <$> withTransaction chatStore (`getProtocolServers` user') activeAgentServers :: UserProtocol p => ChatConfig -> SProtocolType p -> [ServerCfg p] -> NonEmpty (ProtoServerWithAuth p) activeAgentServers ChatConfig {defaultServers} p = @@ -349,11 +355,12 @@ startChatController subConns enableExpireCIs startXFTPWorkers = do subscribeUsers :: forall m. ChatMonad' m => Bool -> [User] -> m () subscribeUsers onlyNeeded users = do let (us, us') = partition activeUser users - subscribe us - subscribe us' + vr <- chatVersionRange + subscribe vr us + subscribe vr us' where - subscribe :: [User] -> m () - subscribe = mapM_ $ runExceptT . subscribeUserConnections onlyNeeded Agent.subscribeConnections + subscribe :: VersionRange -> [User] -> m () + subscribe vr = mapM_ $ runExceptT . subscribeUserConnections vr onlyNeeded Agent.subscribeConnections startFilesToReceive :: forall m. ChatMonad' m => [User] -> m () startFilesToReceive users = do @@ -430,7 +437,11 @@ parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace -- | Chat API commands interpreted in context of a local zone processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse -processChatCommand = \case +processChatCommand cmd = chatVersionRange >>= (`processChatCommand'` cmd) +{-# INLINE processChatCommand #-} + +processChatCommand' :: forall m. ChatMonad m => VersionRange -> ChatCommand -> m ChatResponse +processChatCommand' vr = \case ShowActiveUser -> withUser' $ pure . CRActiveUser CreateActiveUser NewUser {profile, sameServers, pastTimestamp} -> do forM_ profile $ \Profile {displayName} -> checkValidName displayName @@ -471,12 +482,14 @@ processChatCommand = \case coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day) day = 86400 ListUsers -> CRUsersList <$> withStoreCtx' (Just "ListUsers, getUsersInfo") getUsersInfo - APISetActiveUser userId' viewPwd_ -> withUser $ \user -> do + APISetActiveUser userId' viewPwd_ -> do + unlessM chatStarted $ throwChatError CEChatNotStarted + user_ <- chatReadVar currentUser user' <- privateGetUser userId' - validateUserPassword user user' viewPwd_ + validateUserPassword_ user_ user' viewPwd_ withStoreCtx' (Just "APISetActiveUser, setActiveUser") $ \db -> setActiveUser db userId' let user'' = user' {activeUser = True} - asks currentUser >>= atomically . (`writeTVar` Just user'') + chatWriteVar currentUser $ Just user'' pure $ CRActiveUser user'' SetActiveUser uName viewPwd_ -> do tryChatError (withStore (`getUserIdByName` uName)) >>= \case @@ -537,16 +550,18 @@ processChatCommand = \case APIStopChat -> do ask >>= stopChatController pure CRChatStopped - APIActivateChat -> withUser $ \_ -> do - restoreCalls + APIActivateChat restoreChat -> withUser $ \_ -> do + when restoreChat restoreCalls withAgent foregroundAgent - users <- withStoreCtx' (Just "APIActivateChat, getUsers") getUsers - void . forkIO $ subscribeUsers True users - void . forkIO $ startFilesToReceive users - setAllExpireCIFlags True + when restoreChat $ do + users <- withStoreCtx' (Just "APIActivateChat, getUsers") getUsers + void . forkIO $ subscribeUsers True users + void . forkIO $ startFilesToReceive users + setAllExpireCIFlags True ok_ APISuspendChat t -> do setAllExpireCIFlags False + stopRemoteCtrl withAgent (`suspendAgent` t) ok_ ResubscribeAllConnections -> withStoreCtx' (Just "ResubscribeAllConnections, getUsers") getUsers >>= subscribeUsers False >> ok_ @@ -595,24 +610,26 @@ processChatCommand = \case . sortOn (timeAvg . snd) . M.assocs <$> withConnection st (readTVarIO . DB.slow) - APIGetChats userId withPCC -> withUserId userId $ \user -> - CRApiChats user <$> withStoreCtx' (Just "APIGetChats, getChatPreviews") (\db -> getChatPreviews db user withPCC) + APIGetChats {userId, pendingConnections, pagination, query} -> withUserId' userId $ \user -> do + (errs, previews) <- partitionEithers <$> withStore' (\db -> getChatPreviews db vr user pendingConnections pagination query) + unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs) + pure $ CRApiChats user previews APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do directChat <- withStore (\db -> getDirectChat db user cId pagination search) pure $ CRApiChat user (AChat SCTDirect directChat) CTGroup -> do - groupChat <- withStore (\db -> getGroupChat db user cId pagination search) + groupChat <- withStore (\db -> getGroupChat db vr user cId pagination search) pure $ CRApiChat user (AChat SCTGroup groupChat) CTContactRequest -> pure $ chatCmdError (Just user) "not implemented" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIGetChatItems pagination search -> withUser $ \user -> do - chatItems <- withStore $ \db -> getAllChatItems db user pagination search + chatItems <- withStore $ \db -> getAllChatItems db vr user pagination search pure $ CRChatItems user Nothing chatItems APIGetChatItemInfo chatRef itemId -> withUser $ \user -> do (aci@(AChatItem cType dir _ ci), versions) <- withStore $ \db -> - (,) <$> getAChatItem db user chatRef itemId <*> liftIO (getChatItemVersions db itemId) + (,) <$> getAChatItem db vr user chatRef itemId <*> liftIO (getChatItemVersions db itemId) let itemVersions = if null versions then maybeToList $ mkItemVersion ci else versions memberDeliveryStatuses <- case (cType, dir) of (SCTGroup, SMDSnd) -> do @@ -676,7 +693,7 @@ processChatCommand = \case withStore $ \db -> getDirectChatItem db user chatId quotedItemId (origQmc, qd, sent) <- quoteData qci let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} - qmc = quoteContent origQmc file + qmc = quoteContent mc origQmc file quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) where @@ -686,26 +703,26 @@ processChatCommand = \case quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False) quoteData _ = throwChatError CEInvalidQuote CTGroup -> do - g@(Group gInfo _) <- withStore $ \db -> getGroup db user chatId + g@(Group gInfo _) <- withStore $ \db -> getGroup db vr user chatId assertUserGroupRole gInfo GRAuthor send g where - send g@(Group gInfo@GroupInfo {groupId, membership} ms) + send g@(Group gInfo@GroupInfo {groupId} ms) | isVoice mc && not (groupFeatureAllowed SGFVoice gInfo) = notAllowedError GFVoice | not (isVoice mc) && isJust file_ && not (groupFeatureAllowed SGFFiles gInfo) = notAllowedError GFFiles | otherwise = do - (fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer g (length $ filter memberCurrent ms) - timed_ <- sndGroupCITimed live gInfo itemTTL - (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ membership - (msg@SndMessage {sharedMsgId}, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) - ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live - withStore' $ \db -> - forM_ sentToMembers $ \GroupMember {groupMemberId} -> - createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew - mapM_ (sendGroupFileInline ms sharedMsgId) ft_ - forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) - pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) + (fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer g (length $ filter memberCurrent ms) + timed_ <- sndGroupCITimed live gInfo itemTTL + (msgContainer, quotedItem_) <- prepareGroupMsg user gInfo mc quotedItemId_ fInv_ timed_ live + (msg@SndMessage {sharedMsgId}, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) + ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live + withStore' $ \db -> + forM_ sentToMembers $ \GroupMember {groupMemberId} -> + createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew + mapM_ (sendGroupFileInline ms sharedMsgId) ft_ + forM_ (timed_ >>= timedDeleteAt') $ + startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) + pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) notAllowedError f = pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText f)) setupSndFileTransfer :: Group -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) setupSndFileTransfer g@(Group gInfo _) n = forM file_ $ \file -> do @@ -736,51 +753,9 @@ processChatCommand = \case void . withStore' $ \db -> createSndGroupInlineFT db m conn ft sendMemberFileInline m conn ft sharedMsgId processMember _ = pure () - prepareMsg :: Maybe FileInvitation -> Maybe CITimed -> GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup)) - prepareMsg fInv_ timed_ membership = case quotedItemId_ of - Nothing -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) - Just quotedItemId -> do - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- - withStore $ \db -> getGroupChatItem db user chatId quotedItemId - (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership - let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} - qmc = quoteContent origQmc file - quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} - pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) - where - quoteData :: ChatItem c d -> GroupMember -> m (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) - quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwChatError CEInvalidQuote - quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc} membership' = pure (qmc, CIQGroupSnd, True, membership') - quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) - quoteData _ _ = throwChatError CEInvalidQuote CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" where - quoteContent :: forall d. MsgContent -> Maybe (CIFile d) -> MsgContent - quoteContent qmc ciFile_ - | replaceContent = MCText qTextOrFile - | otherwise = case qmc of - MCImage _ image -> MCImage qTextOrFile image - MCFile _ -> MCFile qTextOrFile - -- consider same for voice messages - -- MCVoice _ voice -> MCVoice qTextOrFile voice - _ -> qmc - where - -- if the message we're quoting with is one of the "large" MsgContents - -- we replace the quote's content with MCText - replaceContent = case mc of - MCText _ -> False - MCFile _ -> False - MCLink {} -> True - MCImage {} -> True - MCVideo {} -> True - MCVoice {} -> False - MCUnknown {} -> True - qText = msgContentText qmc - getFileName :: CIFile d -> String - getFileName CIFile {fileName} = fileName - qFileName = maybe qText (T.pack . getFileName) ciFile_ - qTextOrFile = if T.null qText then qFileName else qText xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> m (FileInvitation, CIFile 'MDSnd, FileTransferMeta) xftpSndFileTransfer user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup = do let fileName = takeFileName filePath @@ -833,7 +808,7 @@ processChatCommand = \case _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> do - Group gInfo@GroupInfo {groupId} ms <- withStore $ \db -> getGroup db user chatId + Group gInfo@GroupInfo {groupId} ms <- withStore $ \db -> getGroup db vr user chatId assertUserGroupRole gInfo GRAuthor cci <- withStore $ \db -> getGroupCIWithReactions db user gInfo itemId case cci of @@ -869,7 +844,7 @@ processChatCommand = \case else markDirectCIDeleted user ct ci msgId True =<< liftIO getCurrentTime (CIDMBroadcast, _, _, _) -> throwChatError CEInvalidChatItemDelete CTGroup -> do - Group gInfo ms <- withStore $ \db -> getGroup db user chatId + Group gInfo ms <- withStore $ \db -> getGroup db vr user chatId CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}} <- withStore $ \db -> getGroupChatItem db user chatId itemId case (mode, msgDir, itemSharedMsgId, editable) of (CIDMInternal, _, _, _) -> deleteGroupCI user gInfo ci True False Nothing =<< liftIO getCurrentTime @@ -881,7 +856,7 @@ processChatCommand = \case CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteMemberChatItem gId mId itemId -> withUser $ \user -> withChatLock "deleteChatItem" $ do - Group gInfo@GroupInfo {membership} ms <- withStore $ \db -> getGroup db user gId + Group gInfo@GroupInfo {membership} ms <- withStore $ \db -> getGroup db vr user gId CChatItem _ ci@ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}} <- withStore $ \db -> getGroupChatItem db user gId itemId case (chatDir, itemSharedMsgId) of (CIGroupRcv GroupMember {groupMemberId, memberRole, memberId}, Just itemSharedMId) -> do @@ -910,7 +885,7 @@ processChatCommand = \case pure $ CRChatItemReaction user add r _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" CTGroup -> - withStore (\db -> (,) <$> getGroup db user chatId <*> getGroupChatItem db user chatId itemId) >>= \case + withStore (\db -> (,) <$> getGroup db vr user chatId <*> getGroupChatItem db user chatId itemId) >>= \case (Group g@GroupInfo {membership} ms, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do unless (groupFeatureAllowed SGFReactions g) $ throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)) @@ -936,6 +911,8 @@ processChatCommand = \case throwChatError (CECommandError $ "reaction already " <> if add then "added" else "removed") when (add && length rs >= maxMsgReactions) $ throwChatError (CECommandError "too many reactions") + APIUserRead userId -> withUserId userId $ \user -> withStore' (`setUserChatsRead` user) >> ok user + UserRead -> withUser $ \User {userId} -> processChatCommand $ APIUserRead userId APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of CTDirect -> do user <- withStore $ \db -> getUserByContactId db chatId @@ -967,7 +944,7 @@ processChatCommand = \case ok user CTGroup -> do withStore $ \db -> do - Group {groupInfo} <- getGroup db user chatId + Group {groupInfo} <- getGroup db vr user chatId liftIO $ updateGroupUnreadChat db user groupInfo unreadChat ok user _ -> pure $ chatCmdError (Just user) "not supported" @@ -992,7 +969,7 @@ processChatCommand = \case withStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn CTGroup -> do - Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user chatId + Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user chatId let isOwner = memberRole (membership :: GroupMember) == GROwner canDelete = isOwner || not (memberCurrent membership) unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner @@ -1035,7 +1012,7 @@ processChatCommand = \case withStore' $ \db -> deleteContactCIs db user ct pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct) CTGroup -> do - gInfo <- withStore $ \db -> getGroupInfo db user chatId + gInfo <- withStore $ \db -> getGroupInfo db vr user chatId filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo deleteFilesAndConns user filesInfo withStore' $ \db -> deleteGroupCIs db user gInfo @@ -1045,10 +1022,12 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIAcceptContact incognito connReqId -> withUser $ \_ -> withChatLock "acceptContact" $ do - (user, cReq) <- withStore $ \db -> getContactRequest' db connReqId + (user@User {userId}, cReq@UserContactRequest {userContactLinkId}) <- withStore $ \db -> getContactRequest' db connReqId + ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId + let contactUsed = (\(_, groupId_, _) -> isNothing groupId_) ucl -- [incognito] generate profile to send, create connection with incognito profile incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - ct <- acceptContactRequest user cReq incognitoProfile + ct <- acceptContactRequest user cReq incognitoProfile contactUsed pure $ CRAcceptingContactRequest user ct APIRejectContact connReqId -> withUser $ \user -> withChatLock "rejectContact" $ do cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- @@ -1065,8 +1044,9 @@ processChatCommand = \case then do calls <- asks currentCalls withChatLock "sendCallInvitation" $ do - callId <- CallId <$> drgRandomBytes 16 - dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing + g <- asks random + callId <- atomically $ CallId <$> C.randomBytes 16 g + dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair} callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} (msg, _) <- sendDirectContactMessage ct (XCallInv callId invitation) @@ -1171,16 +1151,13 @@ processChatCommand = \case APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) >> ok_ APIGetNtfMessage nonce encNtfInfo -> withUser $ \_ -> do (NotificationInfo {ntfConnId, ntfMsgMeta}, msgs) <- withAgent $ \a -> getNotificationMessage a nonce encNtfInfo - let ntfMessages = map (\SMP.SMPMsgMeta {msgTs, msgFlags} -> NtfMsgInfo {msgTs = systemToUTCTime msgTs, msgFlags}) msgs - getMsgTs :: SMP.NMsgMeta -> SystemTime - getMsgTs SMP.NMsgMeta {msgTs} = msgTs - msgTs' = systemToUTCTime . getMsgTs <$> ntfMsgMeta + let msgTs' = systemToUTCTime . (\SMP.NMsgMeta {msgTs} -> msgTs) <$> ntfMsgMeta agentConnId = AgentConnId ntfConnId user_ <- withStore' (`getUserByAConnId` agentConnId) - connEntity <- + connEntity_ <- pure user_ $>>= \user -> - withStore (\db -> Just <$> getConnectionEntity db user agentConnId) `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing) - pure CRNtfMessages {user_, connEntity, msgTs = msgTs', ntfMessages} + withStore (\db -> Just <$> getConnectionEntity db vr user agentConnId) `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing) + pure CRNtfMessages {user_, connEntity_, msgTs = msgTs', ntfMessages = map ntfMsgInfo msgs} APIGetUserProtoServers userId (AProtocolType p) -> withUserId userId $ \user -> withServerProtocol p $ do ChatConfig {defaultServers} <- asks config servers <- withStore' (`getProtocolServers` user) @@ -1204,8 +1181,7 @@ processChatCommand = \case CRServerTestResult user srv <$> withAgent (\a -> testProtocolServer a (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand $ APITestProtoServer userId srv - APISetChatItemTTL userId newTTL_ -> withUser $ \user -> do - checkSameUser userId user + APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do case newTTL_ of @@ -1223,7 +1199,7 @@ processChatCommand = \case ok user SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do processChatCommand $ APISetChatItemTTL userId newTTL_ - APIGetChatItemTTL userId -> withUserId userId $ \user -> do + APIGetChatItemTTL userId -> withUserId' userId $ \user -> do ttl <- withStoreCtx' (Just "APIGetChatItemTTL, getChatItemTTL") (`getChatItemTTL` user) pure $ CRChatItemTTL user ttl GetChatItemTTL -> withUser' $ \User {userId} -> do @@ -1243,7 +1219,7 @@ processChatCommand = \case ok user CTGroup -> do ms <- withStore $ \db -> do - Group _ ms <- getGroup db user chatId + Group _ ms <- getGroup db vr user chatId liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> @@ -1269,10 +1245,10 @@ processChatCommand = \case connectionStats <- mapM (withAgent . flip getConnectionServers) (contactConnId ct) pure $ CRContactInfo user ct connectionStats (fmap fromLocalProfile incognitoProfile) APIGroupInfo gId -> withUser $ \user -> do - (g, s) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> liftIO (getGroupSummary db user gId) + (g, s) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> liftIO (getGroupSummary db user gId) pure $ CRGroupInfo user g s APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) pure $ CRGroupMemberInfo user g m connectionStats APISwitchContact contactId -> withUser $ \user -> do @@ -1283,7 +1259,7 @@ processChatCommand = \case pure $ CRContactSwitchStarted user ct connectionStats Nothing -> throwChatError $ CEContactNotActive ct APISwitchGroupMember gId gMemberId -> withUser $ \user -> do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId case memberConnId m of Just connId -> do connectionStats <- withAgent (\a -> switchConnectionAsync a "" connId) @@ -1297,7 +1273,7 @@ processChatCommand = \case pure $ CRContactSwitchAborted user ct connectionStats Nothing -> throwChatError $ CEContactNotActive ct APIAbortSwitchGroupMember gId gMemberId -> withUser $ \user -> do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId case memberConnId m of Just connId -> do connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId @@ -1312,7 +1288,7 @@ processChatCommand = \case pure $ CRContactRatchetSyncStarted user ct cStats Nothing -> throwChatError $ CEContactNotActive ct APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> withChatLock "syncGroupMemberRatchet" $ do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId case memberConnId m of Just connId -> do cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId force @@ -1334,7 +1310,7 @@ processChatCommand = \case pure $ CRContactCode user ct' code Nothing -> throwChatError $ CEContactNotActive ct APIGetGroupMemberCode gId gMemberId -> withUser $ \user -> do - (g, m@GroupMember {activeConn}) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId + (g, m@GroupMember {activeConn}) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId case activeConn of Just conn@Connection {connId} -> do code <- getConnectionCode $ aConnId conn @@ -1483,9 +1459,9 @@ processChatCommand = \case pure $ CRUserContactLinkDeleted user' DeleteMyAddress -> withUser $ \User {userId} -> processChatCommand $ APIDeleteMyAddress userId - APIShowMyAddress userId -> withUserId userId $ \user -> + APIShowMyAddress userId -> withUserId' userId $ \user -> CRUserContactLink user <$> withStoreCtx (Just "APIShowMyAddress, getUserAddress") (`getUserAddress` user) - ShowMyAddress -> withUser $ \User {userId} -> + ShowMyAddress -> withUser' $ \User {userId} -> processChatCommand $ APIShowMyAddress userId APISetProfileAddress userId False -> withUserId userId $ \user@User {profile = p} -> do let p' = (fromLocalProfile p :: Profile) {contactLink = Nothing} @@ -1516,7 +1492,7 @@ processChatCommand = \case let chatRef = ChatRef CTDirect ctId processChatCommand . APISendMessage chatRef False Nothing $ ComposedMessage Nothing Nothing mc Left _ -> - withStore' (\db -> runExceptT $ getActiveMembersByName db user name) >>= \case + withStore' (\db -> runExceptT $ getActiveMembersByName db vr user name) >>= \case Right [(gInfo, member)] -> do let GroupInfo {localDisplayName = gName} = gInfo GroupMember {localDisplayName = mName} = member @@ -1536,7 +1512,7 @@ processChatCommand = \case let mc = MCText msg case memberContactId m of Nothing -> do - gInfo <- withStore $ \db -> getGroupInfo db user gId + gInfo <- withStore $ \db -> getGroupInfo db vr user gId toView $ CRNoMemberContactCreating user gInfo m processChatCommand (APICreateMemberContact gId mId) >>= \case cr@(CRNewMemberContact _ Contact {contactId} _ _) -> do @@ -1593,16 +1569,16 @@ processChatCommand = \case processChatCommand $ APIChatItemReaction chatRef chatItemId add reaction APINewGroup userId incognito gProfile@GroupProfile {displayName} -> withUserId userId $ \user -> do checkValidName displayName - gVar <- asks idsDrg + gVar <- asks random -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile incognitoProfile + groupInfo <- withStore $ \db -> createNewGroup db vr gVar user gProfile incognitoProfile pure $ CRGroupCreated user groupInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> processChatCommand $ APINewGroup userId incognito gProfile APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do -- TODO for large groups: no need to load all members to determine if contact is a member - (group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db user contactId + (group, contact) <- withStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db user contactId assertDirectAllowed user MDSnd contact XGrpInv_ let Group gInfo members = group Contact {localDisplayName = cName} = contact @@ -1614,7 +1590,7 @@ processChatCommand = \case let sendInvitation = sendGrpInvitation user contact gInfo case contactMember contact members of Nothing -> do - gVar <- asks idsDrg + gVar <- asks random subMode <- chatReadVar subscriptionMode (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode member <- withStore $ \db -> createNewContactMember db gVar user gInfo contact memRole agentConnId cReq subMode @@ -1632,7 +1608,7 @@ processChatCommand = \case APIJoinGroup groupId -> withUser $ \user@User {userId} -> do withChatLock "joinGroup" . procCmd $ do (invitation, ct) <- withStore $ \db -> do - inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db user groupId + inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db vr user groupId (inv,) <$> getContactViaMember db user fromMember let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation Contact {activeConn} = ct @@ -1650,14 +1626,14 @@ processChatCommand = \case Nothing -> throwChatError $ CEContactNotActive ct where updateCIGroupInvitationStatus user = do - AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db user groupId + AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db vr user groupId case (cInfo, content) of (DirectChat ct, CIRcvGroupInvitation ciGroupInv memRole) -> do let aciContent = ACIContent SMDRcv $ CIRcvGroupInvitation ciGroupInv {status = CIGISAccepted} memRole updateDirectChatItemView user ct itemId aciContent False Nothing _ -> pure () -- prohibited APIMemberRole groupId memberId memRole -> withUser $ \user -> do - Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId + Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId if memberId == groupMemberId' membership then changeMemberRole user gInfo members membership $ SGEUserRole memRole else case find ((== memberId) . groupMemberId') members of @@ -1681,7 +1657,7 @@ processChatCommand = \case toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) pure CRMemberRoleUser {user, groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole} APIRemoveMember groupId memberId -> withUser $ \user -> do - Group gInfo members <- withStore $ \db -> getGroup db user groupId + Group gInfo members <- withStore $ \db -> getGroup db vr user groupId case find ((== memberId) . groupMemberId') members of Nothing -> throwChatError CEGroupMemberNotFound Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberProfile} -> do @@ -1700,7 +1676,7 @@ processChatCommand = \case deleteOrUpdateMemberRecord user m pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved} APILeaveGroup groupId -> withUser $ \user@User {userId} -> do - Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId + Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId withChatLock "leaveGroup" . procCmd $ do (msg, _) <- sendGroupMessage user gInfo members XGrpLeave ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft) @@ -1712,7 +1688,7 @@ processChatCommand = \case withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft pure $ CRLeftMemberUser user gInfo {membership = membership {memberStatus = GSMemLeft}} APIListMembers groupId -> withUser $ \user -> - CRGroupMembers user <$> withStore (\db -> getGroup db user groupId) + CRGroupMembers user <$> withStore (\db -> getGroup db vr user groupId) AddMember gName cName memRole -> withUser $ \user -> do (groupId, contactId) <- withStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName processChatCommand $ APIAddMember groupId contactId memRole @@ -1734,23 +1710,23 @@ processChatCommand = \case groupId <- withStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIListMembers groupId APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> - CRGroupsList user <$> withStore' (\db -> getUserGroupsWithSummary db user contactId_ search_) + CRGroupsList user <$> withStore' (\db -> getUserGroupsWithSummary db vr user contactId_ search_) ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do ct_ <- forM cName_ $ \cName -> withStore $ \db -> getContactByName db user cName processChatCommand $ APIListGroups userId (contactId' <$> ct_) search_ APIUpdateGroupProfile groupId p' -> withUser $ \user -> do - g <- withStore $ \db -> getGroup db user groupId + g <- withStore $ \db -> getGroup db vr user groupId runUpdateGroupProfile user g p' UpdateGroupNames gName GroupProfile {displayName, fullName} -> updateGroupProfileByName gName $ \p -> p {displayName, fullName} ShowGroupProfile gName -> withUser $ \user -> - CRGroupProfile user <$> withStore (\db -> getGroupInfoByName db user gName) + CRGroupProfile user <$> withStore (\db -> getGroupInfoByName db vr user gName) UpdateGroupDescription gName description -> updateGroupProfileByName gName $ \p -> p {description} ShowGroupDescription gName -> withUser $ \user -> - CRGroupDescription user <$> withStore (\db -> getGroupInfoByName db user gName) + CRGroupDescription user <$> withStore (\db -> getGroupInfoByName db vr user gName) APICreateGroupLink groupId mRole -> withUser $ \user -> withChatLock "createGroupLink" $ do - gInfo <- withStore $ \db -> getGroupInfo db user groupId + gInfo <- withStore $ \db -> getGroupInfo db vr user groupId assertUserGroupRole gInfo GRAdmin when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole groupLinkId <- GroupLinkId <$> drgRandomBytes 16 @@ -1760,22 +1736,22 @@ processChatCommand = \case withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole subMode pure $ CRGroupLinkCreated user gInfo cReq mRole APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withChatLock "groupLinkMemberRole " $ do - gInfo <- withStore $ \db -> getGroupInfo db user groupId + gInfo <- withStore $ \db -> getGroupInfo db vr user groupId (groupLinkId, groupLink, mRole) <- withStore $ \db -> getGroupLink db user gInfo assertUserGroupRole gInfo GRAdmin when (mRole' > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole' when (mRole' /= mRole) $ withStore' $ \db -> setGroupLinkMemberRole db user groupLinkId mRole' pure $ CRGroupLink user gInfo groupLink mRole' APIDeleteGroupLink groupId -> withUser $ \user -> withChatLock "deleteGroupLink" $ do - gInfo <- withStore $ \db -> getGroupInfo db user groupId + gInfo <- withStore $ \db -> getGroupInfo db vr user groupId deleteGroupLink' user gInfo pure $ CRGroupLinkDeleted user gInfo APIGetGroupLink groupId -> withUser $ \user -> do - gInfo <- withStore $ \db -> getGroupInfo db user groupId + gInfo <- withStore $ \db -> getGroupInfo db vr user groupId (_, groupLink, mRole) <- withStore $ \db -> getGroupLink db user gInfo pure $ CRGroupLink user gInfo groupLink mRole APICreateMemberContact gId gMemberId -> withUser $ \user -> do - (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId + (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db user gId gMemberId assertUserGroupRole g GRAuthor unless (groupFeatureAllowed SGFDirectMessages g) $ throwChatError $ CECommandError "direct messages not allowed" case memberConn m of @@ -1791,7 +1767,7 @@ processChatCommand = \case pure $ CRNewMemberContact user ct g m _ -> throwChatError CEGroupMemberNotActive APISendMemberContactInvitation contactId msgContent_ -> withUser $ \user -> do - (g, m, ct, cReq) <- withStore $ \db -> getMemberContact db user contactId + (g, m, ct, cReq) <- withStore $ \db -> getMemberContact db vr user contactId when (contactGrpInvSent ct) $ throwChatError $ CECommandError "x.grp.direct.inv already sent" case memberConn m of Just mConn -> do @@ -1822,29 +1798,31 @@ processChatCommand = \case let mc = MCText msg processChatCommand . APISendMessage (ChatRef CTGroup groupId) False Nothing $ ComposedMessage Nothing (Just quotedItemId) mc LastChats count_ -> withUser' $ \user -> do - chats <- withStore' $ \db -> getChatPreviews db user False - pure $ CRChats $ maybe id take count_ chats + let count = fromMaybe 5000 count_ + (errs, previews) <- partitionEithers <$> withStore' (\db -> getChatPreviews db vr user False (PTLast count) clqNoFilters) + unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs) + pure $ CRChats previews LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName chatResp <- processChatCommand $ APIGetChat chatRef (CPLast count) search pure $ CRChatItems user (Just chatName) (aChatItems . chat $ chatResp) LastMessages Nothing count search -> withUser $ \user -> do - chatItems <- withStore $ \db -> getAllChatItems db user (CPLast count) search + chatItems <- withStore $ \db -> getAllChatItems db vr user (CPLast count) search pure $ CRChatItems user Nothing chatItems LastChatItemId (Just chatName) index -> withUser $ \user -> do chatRef <- getChatRef user chatName chatResp <- processChatCommand (APIGetChat chatRef (CPLast $ index + 1) Nothing) pure $ CRChatItemId user (fmap aChatItemId . listToMaybe . aChatItems . chat $ chatResp) LastChatItemId Nothing index -> withUser $ \user -> do - chatItems <- withStore $ \db -> getAllChatItems db user (CPLast $ index + 1) Nothing + chatItems <- withStore $ \db -> getAllChatItems db vr user (CPLast $ index + 1) Nothing pure $ CRChatItemId user (fmap aChatItemId . listToMaybe $ chatItems) ShowChatItem (Just itemId) -> withUser $ \user -> do chatItem <- withStore $ \db -> do chatRef <- getChatRefViaItemId db user itemId - getAChatItem db user chatRef itemId + getAChatItem db vr user chatRef itemId pure $ CRChatItems user Nothing ((: []) chatItem) ShowChatItem Nothing -> withUser $ \user -> do - chatItems <- withStore $ \db -> getAllChatItems db user (CPLast 1) Nothing + chatItems <- withStore $ \db -> getAllChatItems db vr user (CPLast 1) Nothing pure $ CRChatItems user Nothing chatItems ShowChatItemInfo chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName @@ -1875,7 +1853,7 @@ processChatCommand = \case SetFileToReceive fileId encrypted_ -> withUser $ \_ -> do withChatLock "setFileToReceive" . procCmd $ do encrypt <- (`fromMaybe` encrypted_) <$> chatReadVar encryptLocalFiles - cfArgs <- if encrypt then Just <$> liftIO CF.randomArgs else pure Nothing + cfArgs <- if encrypt then Just <$> (atomically . CF.randomArgs =<< asks random) else pure Nothing withStore' $ \db -> setRcvFileToReceive db fileId cfArgs ok_ CancelFile fileId -> withUser $ \user@User {userId} -> @@ -1886,19 +1864,19 @@ processChatCommand = \case | not (null fts) && all fileCancelledOrCompleteSMP fts -> throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> do - fileAgentConnIds <- cancelSndFile user ftm fts True - deleteAgentConnectionsAsync user fileAgentConnIds - sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - withStore (\db -> getChatRefByFileId db user fileId) >>= \case - ChatRef CTDirect contactId -> do - contact <- withStore $ \db -> getContact db user contactId - void . sendDirectContactMessage contact $ XFileCancel sharedMsgId - ChatRef CTGroup groupId -> do - Group gInfo ms <- withStore $ \db -> getGroup db user groupId - void . sendGroupMessage user gInfo ms $ XFileCancel sharedMsgId - _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" - ci <- withStore $ \db -> getChatItemByFileId db user fileId - pure $ CRSndFileCancelled user ci ftm fts + fileAgentConnIds <- cancelSndFile user ftm fts True + deleteAgentConnectionsAsync user fileAgentConnIds + sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId + withStore (\db -> getChatRefByFileId db user fileId) >>= \case + ChatRef CTDirect contactId -> do + contact <- withStore $ \db -> getContact db user contactId + void . sendDirectContactMessage contact $ XFileCancel sharedMsgId + ChatRef CTGroup groupId -> do + Group gInfo ms <- withStore $ \db -> getGroup db vr user groupId + void . sendGroupMessage user gInfo ms $ XFileCancel sharedMsgId + _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" + ci <- withStore $ \db -> getChatItemByFileId db vr user fileId + pure $ CRSndFileCancelled user ci ftm fts where fileCancelledOrCompleteSMP SndFileTransfer {fileStatus = s} = s == FSCancelled || (s == FSComplete && isNothing xftpSndFile) @@ -1906,25 +1884,25 @@ processChatCommand = \case | cancelled -> throwChatError $ CEFileCancel fileId "file already cancelled" | rcvFileComplete fileStatus -> throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> case xftpRcvFile of - Nothing -> do - cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user) - ci <- withStore $ \db -> getChatItemByFileId db user fileId - pure $ CRRcvFileCancelled user ci ftr - Just XFTPRcvFile {agentRcvFileId} -> do - forM_ (liveRcvFileTransferPath ftr) $ \filePath -> do - fsFilePath <- toFSFilePath filePath - liftIO $ removeFile fsFilePath `catchAll_` pure () - forM_ agentRcvFileId $ \(AgentRcvFileId aFileId) -> - withAgent (`xftpDeleteRcvFile` aFileId) - ci <- withStore $ \db -> do - liftIO $ do - updateCIFileStatus db user fileId CIFSRcvInvitation - updateRcvFileStatus db fileId FSNew - updateRcvFileAgentId db fileId Nothing - getChatItemByFileId db user fileId - pure $ CRRcvFileCancelled user ci ftr + Nothing -> do + cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user) + ci <- withStore $ \db -> getChatItemByFileId db vr user fileId + pure $ CRRcvFileCancelled user ci ftr + Just XFTPRcvFile {agentRcvFileId} -> do + forM_ (liveRcvFileTransferPath ftr) $ \filePath -> do + fsFilePath <- toFSFilePath filePath + liftIO $ removeFile fsFilePath `catchAll_` pure () + forM_ agentRcvFileId $ \(AgentRcvFileId aFileId) -> + withAgent (`xftpDeleteRcvFile` aFileId) + ci <- withStore $ \db -> do + liftIO $ do + updateCIFileStatus db user fileId CIFSRcvInvitation + updateRcvFileStatus db fileId FSNew + updateRcvFileAgentId db fileId Nothing + getChatItemByFileId db vr user fileId + pure $ CRRcvFileCancelled user ci ftr FileStatus fileId -> withUser $ \user -> do - ci@(AChatItem _ _ _ ChatItem {file}) <- withStore $ \db -> getChatItemByFileId db user fileId + ci@(AChatItem _ _ _ ChatItem {file}) <- withStore $ \db -> getChatItemByFileId db vr user fileId case file of Just CIFile {fileProtocol = FPXFTP} -> pure $ CRFileTransferStatusXFTP user ci @@ -1987,7 +1965,7 @@ processChatCommand = \case DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ QuitChat -> liftIO exitSuccess ShowVersion -> do - let versionInfo = coreVersionInfo $(simplexmqCommitQ) + let versionInfo = coreVersionInfo "" -- $(simplexmqCommitQ) chatMigrations <- map upMigration <$> withStore' (Migrations.getCurrent . DB.conn) agentMigrations <- withAgent getAgentMigrations pure $ CRVersionInfo {versionInfo, chatMigrations, agentMigrations} @@ -2020,7 +1998,7 @@ processChatCommand = \case -- in View.hs `r'` should be defined as `id` in this case -- procCmd :: m ChatResponse -> m ChatResponse -- procCmd action = do - -- ChatController {chatLock = l, smpAgent = a, outputQ = q, idsDrg = gVar} <- ask + -- ChatController {chatLock = l, smpAgent = a, outputQ = q, random = gVar} <- ask -- corrId <- liftIO $ SMP.CorrId <$> randomBytes gVar 8 -- void . forkIO $ -- withAgentLock a . withLock l name $ @@ -2225,7 +2203,7 @@ processChatCommand = \case updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> m ChatResponse updateGroupProfileByName gName update = withUser $ \user -> do g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> - getGroupIdByName db user gName >>= getGroup db user + getGroupIdByName db user gName >>= getGroup db vr user runUpdateGroupProfile user g $ update p withCurrentCall :: ContactId -> (User -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse withCurrentCall ctId action = do @@ -2286,17 +2264,20 @@ processChatCommand = \case then pure Nothing else Just . addUTCTime (realToFrac ttl) <$> liftIO getCurrentTime drgRandomBytes :: Int -> m ByteString - drgRandomBytes n = asks idsDrg >>= liftIO . (`randomBytes` n) + drgRandomBytes n = asks random >>= atomically . C.randomBytes n privateGetUser :: UserId -> m User privateGetUser userId = tryChatError (withStore (`getUser` userId)) >>= \case Left _ -> throwChatError CEUserUnknown Right user -> pure user validateUserPassword :: User -> User -> Maybe UserPwd -> m () - validateUserPassword User {userId} User {userId = userId', viewPwdHash} viewPwd_ = + validateUserPassword = validateUserPassword_ . Just + validateUserPassword_ :: Maybe User -> User -> Maybe UserPwd -> m () + validateUserPassword_ user_ User {userId = userId', viewPwdHash} viewPwd_ = forM_ viewPwdHash $ \pwdHash -> - let pwdOk = case viewPwd_ of - Nothing -> userId == userId' + let userId_ = (\User {userId} -> userId) <$> user_ + pwdOk = case viewPwd_ of + Nothing -> userId_ == Just userId' Just (UserPwd viewPwd) -> validPassword viewPwd pwdHash in unless pwdOk $ throwChatError CEUserUnknown validPassword :: Text -> UserPwdHash -> Bool @@ -2319,16 +2300,16 @@ processChatCommand = \case pure $ CRUserPrivacy {user, updatedUser = user'} checkDeleteChatUser :: User -> m () checkDeleteChatUser user@User {userId} = do - when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId) users <- withStore' getUsers - unless (length users > 1 && (isJust (viewPwdHash user) || length (filter (isNothing . viewPwdHash) users) > 1)) $ - throwChatError (CECantDeleteLastUser userId) + let otherVisible = filter (\User {userId = userId', viewPwdHash} -> userId /= userId' && isNothing viewPwdHash) users + when (activeUser user && length otherVisible > 0) $ throwChatError (CECantDeleteActiveUser userId) deleteChatUser :: User -> Bool -> m ChatResponse deleteChatUser user delSMPQueues = do filesInfo <- withStore' (`getUserFileInfo` user) forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues withStore' (`deleteUserRecord` user) + when (activeUser user) $ chatWriteVar currentUser Nothing ok_ updateChatSettings :: ChatName -> (ChatSettings -> ChatSettings) -> m ChatResponse updateChatSettings (ChatName cType name) updateSettings = withUser $ \user -> do @@ -2337,15 +2318,16 @@ processChatCommand = \case ctId <- getContactIdByName db user name Contact {chatSettings} <- getContact db user ctId pure (ctId, chatSettings) - CTGroup -> withStore $ \db -> do - gId <- getGroupIdByName db user name - GroupInfo {chatSettings} <- getGroupInfo db user gId - pure (gId, chatSettings) + CTGroup -> + withStore $ \db -> do + gId <- getGroupIdByName db user name + GroupInfo {chatSettings} <- getGroupInfo db vr user gId + pure (gId, chatSettings) _ -> throwChatError $ CECommandError "not supported" processChatCommand $ APISetChatSettings (ChatRef cType chatId) $ updateSettings chatSettings connectPlan :: User -> AConnectionRequestUri -> m ConnectionPlan connectPlan user (ACR SCMInvitation cReq) = do - withStore' (\db -> getConnectionEntityByConnReq db user cReqSchemas) >>= \case + withStore' (\db -> getConnectionEntityByConnReq db vr user cReqSchemas) >>= \case Nothing -> pure $ CPInvitationLink ILPOk Just (RcvDirectMsgConnection conn ct_) -> do let Connection {connStatus, contactConnInitiated} = conn @@ -2375,7 +2357,7 @@ processChatCommand = \case withStore' (\db -> getUserContactLinkByConnReq db user cReqSchemas) >>= \case Just _ -> pure $ CPContactAddress CAPOwnLink Nothing -> - withStore' (\db -> getContactConnEntityByConnReqHash db user cReqHashes) >>= \case + withStore' (\db -> getContactConnEntityByConnReqHash db vr user cReqHashes) >>= \case Nothing -> withStore' (\db -> getContactWithoutConnViaAddress db user cReqSchemas) >>= \case Nothing -> pure $ CPContactAddress CAPOk @@ -2388,11 +2370,11 @@ processChatCommand = \case Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" -- group link Just _ -> - withStore' (\db -> getGroupInfoByUserContactLinkConnReq db user cReqSchemas) >>= \case + withStore' (\db -> getGroupInfoByUserContactLinkConnReq db vr user cReqSchemas) >>= \case Just g -> pure $ CPGroupLink (GLPOwnLink g) Nothing -> do - connEnt_ <- withStore' $ \db -> getContactConnEntityByConnReqHash db user cReqHashes - gInfo_ <- withStore' $ \db -> getGroupInfoByGroupLinkHash db user cReqHashes + connEnt_ <- withStore' $ \db -> getContactConnEntityByConnReqHash db vr user cReqHashes + gInfo_ <- withStore' $ \db -> getGroupInfoByGroupLinkHash db vr user cReqHashes case (gInfo_, connEnt_) of (Nothing, Nothing) -> pure $ CPGroupLink GLPOk (Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> pure $ CPGroupLink GLPConnectingConfirmReconnect @@ -2417,6 +2399,50 @@ processChatCommand = \case cReqHashes = bimap hash hash cReqSchemas hash = ConnReqUriHash . C.sha256Hash . strEncode +prepareGroupMsg :: forall m. ChatMonad m => User -> GroupInfo -> MsgContent -> Maybe ChatItemId -> Maybe FileInvitation -> Maybe CITimed -> Bool -> m (MsgContainer, Maybe (CIQuote 'CTGroup)) +prepareGroupMsg user GroupInfo {groupId, membership} mc quotedItemId_ fInv_ timed_ live = case quotedItemId_ of + Nothing -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) + Just quotedItemId -> do + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- + withStore $ \db -> getGroupChatItem db user groupId quotedItemId + (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership + let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} + qmc = quoteContent mc origQmc file + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) + where + quoteData :: ChatItem c d -> GroupMember -> m (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) + quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwChatError CEInvalidQuote + quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc} membership' = pure (qmc, CIQGroupSnd, True, membership') + quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) + quoteData _ _ = throwChatError CEInvalidQuote + +quoteContent :: forall d. MsgContent -> MsgContent -> Maybe (CIFile d) -> MsgContent +quoteContent mc qmc ciFile_ + | replaceContent = MCText qTextOrFile + | otherwise = case qmc of + MCImage _ image -> MCImage qTextOrFile image + MCFile _ -> MCFile qTextOrFile + -- consider same for voice messages + -- MCVoice _ voice -> MCVoice qTextOrFile voice + _ -> qmc + where + -- if the message we're quoting with is one of the "large" MsgContents + -- we replace the quote's content with MCText + replaceContent = case mc of + MCText _ -> False + MCFile _ -> False + MCLink {} -> True + MCImage {} -> True + MCVideo {} -> True + MCVoice {} -> False + MCUnknown {} -> True + qText = msgContentText qmc + getFileName :: CIFile d -> String + getFileName CIFile {fileName} = fileName + qFileName = maybe qText (T.pack . getFileName) ciFile_ + qTextOrFile = if T.null qText then qFileName else qText + assertDirectAllowed :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m () assertDirectAllowed user dir ct event = unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $ @@ -2560,7 +2586,7 @@ toFSFilePath f = setFileToEncrypt :: ChatMonad m => RcvFileTransfer -> m RcvFileTransfer setFileToEncrypt ft@RcvFileTransfer {fileId} = do - cfArgs <- liftIO CF.randomArgs + cfArgs <- atomically . CF.randomArgs =<< asks random withStore' $ \db -> setFileCryptoArgs db fileId cfArgs pure (ft :: RcvFileTransfer) {cryptoArgs = Just cfArgs} @@ -2579,6 +2605,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI unless (fileStatus == RFSNew) $ case fileStatus of RFSCancelled _ -> throwChatError $ CEFileCancelled fName _ -> throwChatError $ CEFileAlreadyReceiving fName + vr <- chatVersionRange case (xftpRcvFile, fileConnReq) of -- direct file protocol (Nothing, Just connReq) -> do @@ -2586,15 +2613,15 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI dm <- directMessage $ XFileAcpt fName connIds <- joinAgentConnectionAsync user True connReq dm subMode filePath <- getRcvFilePath fileId filePath_ fName True - withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnJoined filePath subMode + withStoreCtx (Just "acceptFileReceive, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db vr user fileId connIds ConnJoined filePath subMode -- XFTP (Just XFTPRcvFile {}, _) -> do filePath <- getRcvFilePath fileId filePath_ fName False (ci, rfd) <- withStoreCtx (Just "acceptFileReceive, xftpAcceptRcvFT ...") $ \db -> do -- marking file as accepted and reading description in the same transaction -- to prevent race condition with appending description - ci <- xftpAcceptRcvFT db user fileId filePath - rfd <- getRcvFileDescrByFileId db fileId + ci <- xftpAcceptRcvFT db vr user fileId filePath + rfd <- getRcvFileDescrByRcvFileId db fileId pure (ci, rfd) receiveViaCompleteFD user fileId rfd cryptoArgs pure ci @@ -2617,10 +2644,11 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI acceptFile cmdFunction send = do filePath <- getRcvFilePath fileId filePath_ fName True inline <- receiveInline + vr <- chatVersionRange if | inline -> do -- accepting inline - ci <- withStoreCtx (Just "acceptFile, acceptRcvInlineFT") $ \db -> acceptRcvInlineFT db user fileId filePath + ci <- withStoreCtx (Just "acceptFile, acceptRcvInlineFT") $ \db -> acceptRcvInlineFT db vr user fileId filePath sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId send $ XFileAcptInv sharedMsgId Nothing fName pure ci @@ -2629,7 +2657,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI -- accepting via a new connection subMode <- chatReadVar subscriptionMode connIds <- createAgentConnectionAsync user cmdFunction True SCMInvitation subMode - withStoreCtx (Just "acceptFile, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db user fileId connIds ConnNew filePath subMode + withStoreCtx (Just "acceptFile, acceptRcvFileTransfer") $ \db -> acceptRcvFileTransfer db vr user fileId connIds ConnNew filePath subMode receiveInline :: m Bool receiveInline = do ChatConfig {fileChunkSize, inlineFiles = InlineFilesConfig {receiveChunks, offerChunks}} <- asks config @@ -2650,10 +2678,11 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} startReceivingFile :: ChatMonad m => User -> FileTransferId -> m () startReceivingFile user fileId = do + vr <- chatVersionRange ci <- withStoreCtx (Just "startReceivingFile, updateRcvFileStatus ...") $ \db -> do liftIO $ updateRcvFileStatus db fileId FSConnected liftIO $ updateCIFileStatus db user fileId $ CIFSRcvTransfer 0 1 - getChatItemByFileId db user fileId + getChatItemByFileId db vr user fileId toView $ CRRcvFileStart user ci getRcvFilePath :: forall m. ChatMonad m => FileTransferId -> Maybe FilePath -> String -> Bool -> m FilePath @@ -2690,21 +2719,21 @@ getRcvFilePath fileId fPath_ fn keepHandle = case fPath_ of getTmpHandle :: FilePath -> m Handle getTmpHandle fPath = openFile fPath AppendMode `catchThrow` (ChatError . CEFileInternal . show) -acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact -acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile = do +acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> m Contact +acceptContactRequest user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = cp, userContactLinkId, xContactId} incognitoProfile contactUsed = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile dm <- directMessage $ XInfo profileToSend acId <- withAgent $ \a -> acceptContact a True invId dm subMode - withStore' $ \db -> createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId cp userContactLinkId xContactId incognitoProfile subMode + withStore' $ \db -> createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId cp userContactLinkId xContactId incognitoProfile subMode contactUsed -acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> m Contact -acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile = do +acceptContactRequestAsync :: ChatMonad m => User -> UserContactRequest -> Maybe IncognitoProfile -> Bool -> m Contact +acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvId invId, cReqChatVRange, localDisplayName = cName, profileId, profile = p, userContactLinkId, xContactId} incognitoProfile contactUsed = do subMode <- chatReadVar subscriptionMode let profileToSend = profileToSendOnAccept user incognitoProfile (cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode withStore' $ \db -> do - ct@Contact {activeConn} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode + ct@Contact {activeConn} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode contactUsed forM_ activeConn $ \Connection {connId} -> setCommandConnId db user cmdId connId pure ct @@ -2715,7 +2744,7 @@ acceptGroupJoinRequestAsync ucr@UserContactRequest {agentInvitationId = AgentInvId invId} gLinkMemRole incognitoProfile = do - gVar <- asks idsDrg + gVar <- asks random (groupMemberId, memberId) <- withStore $ \db -> createAcceptedMember db gVar user gInfo ucr gLinkMemRole let Profile {displayName} = profileToSendOnAccept user incognitoProfile GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -2769,14 +2798,14 @@ agentSubscriber = do type AgentBatchSubscribe m = AgentClient -> [ConnId] -> ExceptT AgentErrorType m (Map ConnId (Either AgentErrorType ())) -subscribeUserConnections :: forall m. ChatMonad m => Bool -> AgentBatchSubscribe m -> User -> m () -subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do +subscribeUserConnections :: forall m. ChatMonad m => VersionRange -> Bool -> AgentBatchSubscribe m -> User -> m () +subscribeUserConnections vr onlyNeeded agentBatchSubscribe user@User {userId} = do -- get user connections ce <- asks $ subscriptionEvents . config (conns, cts, ucs, gs, ms, sfts, rfts, pcs) <- if onlyNeeded then do - (conns, entities) <- withStore' getConnectionsToSubscribe + (conns, entities) <- withStore' (`getConnectionsToSubscribe` vr) let (cts, ucs, ms, sfts, rfts, pcs) = foldl' addEntity (M.empty, M.empty, M.empty, M.empty, M.empty, M.empty) entities pure (conns, cts, ucs, [], ms, sfts, rfts, pcs) else do @@ -2826,8 +2855,8 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do getContactConns :: m ([ConnId], Map ConnId Contact) getContactConns = do cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts - let connIds = catMaybes $ map contactConnId (filter contactActive cts) - pure (connIds, M.fromList $ zip connIds cts) + let cts' = mapMaybe (\ct -> (,ct) <$> contactConnId ct) $ filter contactActive cts + pure (map fst cts', M.fromList cts') getUserContactLinkConns :: m ([ConnId], Map ConnId UserContact) getUserContactLinkConns = do (cs, ucs) <- unzip <$> withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContactLinks") getUserContactLinks @@ -2835,7 +2864,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do pure (connIds, M.fromList $ zip connIds ucs) getGroupMemberConns :: m ([Group], [ConnId], Map ConnId GroupMember) getGroupMemberConns = do - gs <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserGroups") getUserGroups + gs <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserGroups") (`getUserGroups` vr) let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs pure (gs, map fst mPairs, M.fromList mPairs) getSndFileTransferConns :: m ([ConnId], Map ConnId SndFileTransfer) @@ -3010,12 +3039,13 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do ts <- liftIO getCurrentTime liftIO $ threadDelay' $ diffToMicroseconds $ diffUTCTime deleteAt ts waitChatStarted + vr <- chatVersionRange case cType of CTDirect -> do (ct, CChatItem _ ci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId deleteDirectCI user ct ci True True >>= toView CTGroup -> do - (gInfo, CChatItem _ ci) <- withStore $ \db -> (,) <$> getGroupInfo db user chatId <*> getGroupChatItem db user chatId itemId + (gInfo, CChatItem _ ci) <- withStore $ \db -> (,) <$> getGroupInfo db vr user chatId <*> getGroupChatItem db user chatId itemId deletedTs <- liftIO getCurrentTime deleteGroupCI user gInfo ci True True Nothing deletedTs >>= toView _ -> toView . CRChatError (Just user) . ChatError $ CEInternalError "bad deleteTimedItem cType" @@ -3030,12 +3060,13 @@ startUpdatedTimedItemThread user chatRef ci ci' = expireChatItems :: forall m. ChatMonad m => User -> Int64 -> Bool -> m () expireChatItems user@User {userId} ttl sync = do currentTs <- liftIO getCurrentTime + vr <- chatVersionRange let expirationDate = addUTCTime (-1 * fromIntegral ttl) currentTs -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs contacts <- withStoreCtx' (Just "expireChatItems, getUserContacts") (`getUserContacts` user) loop contacts $ processContact expirationDate - groups <- withStoreCtx' (Just "expireChatItems, getUserGroupDetails") (\db -> getUserGroupDetails db user Nothing Nothing) + groups <- withStoreCtx' (Just "expireChatItems, getUserGroupDetails") (\db -> getUserGroupDetails db vr user Nothing Nothing) loop groups $ processGroup expirationDate createdAtCutoff where loop :: [a] -> (a -> m ()) -> m () @@ -3069,9 +3100,10 @@ processAgentMessage _ connId (DEL_RCVQ srv qId err_) = toView $ CRAgentRcvQueueDeleted (AgentConnId connId) srv (AgentQueueId qId) err_ processAgentMessage _ connId DEL_CONN = toView $ CRAgentConnDeleted (AgentConnId connId) -processAgentMessage corrId connId msg = +processAgentMessage corrId connId msg = do + vr <- chatVersionRange withStore' (`getUserByAConnId` AgentConnId connId) >>= \case - Just user -> processAgentMessageConn user corrId connId msg `catchChatError` (toView . CRChatError (Just user)) + Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` (toView . CRChatError (Just user)) _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent 'AENone -> m () @@ -3108,17 +3140,18 @@ processAgentMsgSndFile _corrId aFileId msg = (ft@FileTransferMeta {fileId, cancelled}, sfts) <- withStore $ \db -> do fileId <- getXFTPSndFileDBId db user $ AgentSndFileId aFileId getSndFileTransfer db user fileId + vr <- chatVersionRange unless cancelled $ case msg of SFPROG sndProgress sndTotal -> do let status = CIFSSndTransfer {sndProgress, sndTotal} ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId status - getChatItemByFileId db user fileId + getChatItemByFileId db vr user fileId toView $ CRSndFileProgressXFTP user ci ft sndProgress sndTotal SFDONE sndDescr rfds -> do withStore' $ \db -> setSndFTPrivateSndDescr db user fileId (fileDescrText sndDescr) ci@(AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) <- - withStore $ \db -> getChatItemByFileId db user fileId + withStore $ \db -> getChatItemByFileId db vr user fileId case (msgId_, itemDeleted) of (Just sharedMsgId, Nothing) -> do when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send" @@ -3138,7 +3171,7 @@ processAgentMsgSndFile _corrId aFileId msg = forM_ rfdsMemberFTs $ \mt -> sendToMember mt `catchChatError` (toView . CRChatError (Just user)) ci' <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId CIFSSndComplete - getChatItemByFileId db user fileId + getChatItemByFileId db vr user fileId withAgent (`xftpDeleteSndFileInternal` aFileId) toView $ CRSndFileCompleteXFTP user ci' ft where @@ -3160,11 +3193,11 @@ processAgentMsgSndFile _corrId aFileId msg = | temporaryAgentError e -> throwChatError $ CEXFTPSndFile fileId (AgentSndFileId aFileId) e | otherwise -> do - ci <- withStore $ \db -> do - liftIO $ updateFileCancelled db user fileId CIFSSndError - getChatItemByFileId db user fileId - withAgent (`xftpDeleteSndFileInternal` aFileId) - toView $ CRSndFileError user ci + ci <- withStore $ \db -> do + liftIO $ updateFileCancelled db user fileId CIFSSndError + getChatItemByFileId db vr user fileId + withAgent (`xftpDeleteSndFileInternal` aFileId) + toView $ CRSndFileError user ci where fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text fileDescrText = safeDecodeUtf8 . strEncode @@ -3172,17 +3205,29 @@ processAgentMsgSndFile _corrId aFileId msg = sendFileDescription sft rfd msgId sendMsg = do let rfdText = fileDescrText rfd withStore' $ \db -> updateSndFTDescrXFTP db user sft rfdText - partSize <- asks $ xftpDescrPartSize . config - sendParts 1 partSize rfdText + parts <- splitFileDescr rfdText + loopSend parts where - sendParts partNo partSize rfdText = do - let (part, rest) = T.splitAt partSize rfdText - complete = T.null rest - fileDescr = FileDescr {fileDescrText = part, fileDescrPartNo = partNo, fileDescrComplete = complete} + -- returns msgDeliveryId of the last file description message + loopSend :: NonEmpty FileDescr -> m Int64 + loopSend (fileDescr :| fds) = do (_, msgDeliveryId) <- sendMsg $ XMsgFileDescr {msgId, fileDescr} - if complete - then pure msgDeliveryId - else sendParts (partNo + 1) partSize rest + case L.nonEmpty fds of + Just fds' -> loopSend fds' + Nothing -> pure msgDeliveryId + +splitFileDescr :: ChatMonad m => RcvFileDescrText -> m (NonEmpty FileDescr) +splitFileDescr rfdText = do + partSize <- asks $ xftpDescrPartSize . config + pure $ splitParts 1 partSize rfdText + where + splitParts partNo partSize remText = + let (part, rest) = T.splitAt partSize remText + complete = T.null rest + fileDescr = FileDescr {fileDescrText = part, fileDescrPartNo = partNo, fileDescrComplete = complete} + in if complete + then fileDescr :| [] + else fileDescr <| splitParts (partNo + 1) partSize rest processAgentMsgRcvFile :: forall m. ChatMonad m => ACorrId -> RcvFileId -> ACommand 'Agent 'AERcvFile -> m () processAgentMsgRcvFile _corrId aFileId msg = @@ -3197,12 +3242,13 @@ processAgentMsgRcvFile _corrId aFileId msg = ft@RcvFileTransfer {fileId} <- withStore $ \db -> do fileId <- getXFTPRcvFileDBId db $ AgentRcvFileId aFileId getRcvFileTransfer db user fileId + vr <- chatVersionRange unless (rcvFileCompleteOrCancelled ft) $ case msg of RFPROG rcvProgress rcvTotal -> do let status = CIFSRcvTransfer {rcvProgress, rcvTotal} ci <- withStore $ \db -> do liftIO $ updateCIFileStatus db user fileId status - getChatItemByFileId db user fileId + getChatItemByFileId db vr user fileId toView $ CRRcvFileProgressXFTP user ci rcvProgress rcvTotal RFDONE xftpPath -> case liveRcvFileTransferPath ft of @@ -3214,37 +3260,38 @@ processAgentMsgRcvFile _corrId aFileId msg = liftIO $ do updateRcvFileStatus db fileId FSComplete updateCIFileStatus db user fileId CIFSRcvComplete - getChatItemByFileId db user fileId + getChatItemByFileId db vr user fileId agentXFTPDeleteRcvFile aFileId fileId toView $ CRRcvFileComplete user ci RFERR e | temporaryAgentError e -> throwChatError $ CEXFTPRcvFile fileId (AgentRcvFileId aFileId) e | otherwise -> do - ci <- withStore $ \db -> do - liftIO $ updateFileCancelled db user fileId CIFSRcvError - getChatItemByFileId db user fileId - agentXFTPDeleteRcvFile aFileId fileId - toView $ CRRcvFileError user ci e + ci <- withStore $ \db -> do + liftIO $ updateFileCancelled db user fileId CIFSRcvError + getChatItemByFileId db vr user fileId + agentXFTPDeleteRcvFile aFileId fileId + toView $ CRRcvFileError user ci e -processAgentMessageConn :: forall m. ChatMonad m => User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () -processAgentMessageConn user _ agentConnId END = - withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case - RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct - entity -> toView $ CRSubscriptionEnd user entity -processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do - entity <- withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= updateConnStatus - case entity of - RcvDirectMsgConnection conn contact_ -> - processDirectMessage agentMessage entity conn contact_ - RcvGroupMsgConnection conn gInfo m -> - processGroupMessage agentMessage entity conn gInfo m - RcvFileConnection conn ft -> - processRcvFileConn agentMessage entity conn ft - SndFileConnection conn ft -> - processSndFileConn agentMessage entity conn ft - UserContactConnection conn uc -> - processUserContactRequest agentMessage entity conn uc +processAgentMessageConn :: forall m. ChatMonad m => VersionRange -> User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () +processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = do + entity <- withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus + case agentMessage of + END -> case entity of + RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct + _ -> toView $ CRSubscriptionEnd user entity + MSGNTF smpMsgInfo -> toView $ CRNtfMessage user entity $ ntfMsgInfo smpMsgInfo + _ -> case entity of + RcvDirectMsgConnection conn contact_ -> + processDirectMessage agentMessage entity conn contact_ + RcvGroupMsgConnection conn gInfo m -> + processGroupMessage agentMessage entity conn gInfo m + RcvFileConnection conn ft -> + processRcvFileConn agentMessage entity conn ft + SndFileConnection conn ft -> + processSndFileConn agentMessage entity conn ft + UserContactConnection conn uc -> + processUserContactRequest agentMessage entity conn uc where updateConnStatus :: ConnectionEntity -> m ConnectionEntity updateConnStatus acEntity = case agentMsgConnStatus agentMessage of @@ -3276,6 +3323,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do pure () MSG meta _msgFlags msgBody -> do cmdId <- createAckCmd conn + -- TODO only acknowledge without saving message? + -- probably this branch is never executed, so there should be no reason + -- to save message if contact hasn't been created yet - chat item isn't created anyway withAckMessage agentConnId cmdId meta $ do (_conn', _) <- saveDirectRcvMSG conn meta cmdId msgBody pure False @@ -3371,7 +3421,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XOk -> pure () _ -> messageError "INFO for existing contact must have x.grp.mem.info, x.info or x.ok" CON -> - withStore' (\db -> getViaGroupMember db user ct) >>= \case + withStore' (\db -> getViaGroupMember db vr user ct) >>= \case Nothing -> do -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) @@ -3383,20 +3433,20 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do doProbeContacts = isJust groupLinkId probeMatchingContactsAndMembers ct (contactConnIncognito ct) doProbeContacts withStore' $ \db -> resetContactConnInitiated db user conn - forM_ viaUserContactLink $ \userContactLinkId -> - withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case - Just (UserContactLink {autoAccept = Just AutoAccept {autoReply = mc_}}, groupId_, gLinkMemRole) -> do - forM_ mc_ $ \mc -> do - (msg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) - ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) - toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) - forM_ groupId_ $ \groupId -> do - groupInfo <- withStore $ \db -> getGroupInfo db user groupId - subMode <- chatReadVar subscriptionMode - groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode - gVar <- asks idsDrg - withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct gLinkMemRole groupConnIds (fromJVersionRange peerChatVRange) subMode - _ -> pure () + forM_ viaUserContactLink $ \userContactLinkId -> do + ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId + let (UserContactLink {autoAccept}, groupId_, gLinkMemRole) = ucl + forM_ autoAccept $ \(AutoAccept {autoReply = mc_}) -> + forM_ mc_ $ \mc -> do + (msg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) + ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) + toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) + forM_ groupId_ $ \groupId -> do + groupInfo <- withStore $ \db -> getGroupInfo db vr user groupId + subMode <- chatReadVar subscriptionMode + groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode + gVar <- asks random + withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct gLinkMemRole groupConnIds (fromJVersionRange peerChatVRange) subMode Just (gInfo, m@GroupMember {activeConn}) -> when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m $ Just ct @@ -3551,21 +3601,105 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let Connection {viaUserContactLink} = conn when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem members <- withStore' $ \db -> getGroupMembers db user gInfo - intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m - shuffledIntros <- liftIO $ shuffleMembers intros $ \GroupMemberIntro {reMember = GroupMember {memberRole}} -> memberRole - forM_ shuffledIntros $ \intro -> - processIntro intro `catchChatError` (toView . CRChatError (Just user)) + sendIntroductions members + when (groupFeatureAllowed SGFHistory gInfo) sendHistory where sendXGrpLinkMem = do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo profileToSend = profileToSendOnAccept user profileMode void $ sendDirectMessage conn (XGrpLinkMem profileToSend) (GroupId groupId) + sendIntroductions members = do + intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m + shuffledIntros <- liftIO $ shuffleIntros intros + if isCompatibleRange (memberChatVRange' m) batchSendVRange + then do + let events = map (XGrpMemIntro . memberInfo . reMember) shuffledIntros + forM_ (L.nonEmpty events) $ \events' -> + sendGroupMemberMessages user conn events' groupId + else forM_ shuffledIntros $ \intro -> + processIntro intro `catchChatError` (toView . CRChatError (Just user)) + shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro] + shuffleIntros intros = do + let (admins, others) = partition isAdmin intros + (admPics, admNoPics) = partition hasPicture admins + (othPics, othNoPics) = partition hasPicture others + mconcat <$> mapM shuffle [admPics, admNoPics, othPics, othNoPics] + where + isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin + hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image processIntro intro@GroupMemberIntro {introId} = do void $ sendDirectMessage conn (XGrpMemIntro $ memberInfo (reMember intro)) (GroupId groupId) withStore' $ \db -> updateIntroStatus db introId GMIntroSent + sendHistory = + when (isCompatibleRange (memberChatVRange' m) batchSendVRange) $ do + (errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo 100) + (errs', events) <- partitionEithers <$> mapM (tryChatError . itemForwardEvents) items + let errors = map ChatErrorStore errs <> errs' + unless (null errors) $ toView $ CRChatErrors (Just user) errors + forM_ (L.nonEmpty $ concat events) $ \events' -> + sendGroupMemberMessages user conn events' groupId + itemForwardEvents :: CChatItem 'CTGroup -> m [ChatMsgEvent 'Json] + itemForwardEvents cci = case cci of + (CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv sender, content = CIRcvMsgContent mc, file}) -> do + fInvDescr_ <- join <$> forM file getRcvFileInvDescr + processContentItem sender ci mc fInvDescr_ + (CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent mc, file}) -> do + fInvDescr_ <- join <$> forM file getSndFileInvDescr + processContentItem membership ci mc fInvDescr_ + _ -> pure [] + where + getRcvFileInvDescr :: CIFile 'MDRcv -> m (Maybe (FileInvitation, RcvFileDescrText)) + getRcvFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do + expired <- fileExpired + if fileProtocol /= FPXFTP || fileStatus == CIFSRcvCancelled || expired + then pure Nothing + else do + rfd <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId + pure $ invCompleteDescr ciFile rfd + getSndFileInvDescr :: CIFile 'MDSnd -> m (Maybe (FileInvitation, RcvFileDescrText)) + getSndFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus} = do + expired <- fileExpired + if fileProtocol /= FPXFTP || fileStatus == CIFSSndCancelled || expired + then pure Nothing + else do + -- can also lookup in extra_xftp_file_descriptions, though it can be empty; + -- would be best if snd file had a single rcv description for all members saved in files table + rfd <- withStore $ \db -> getRcvFileDescrBySndFileId db fileId + pure $ invCompleteDescr ciFile rfd + fileExpired :: m Bool + fileExpired = do + ttl <- asks $ rcvFilesTTL . agentConfig . config + cutoffTs <- addUTCTime (-ttl) <$> liftIO getCurrentTime + pure $ chatItemTs cci < cutoffTs + invCompleteDescr :: CIFile d -> RcvFileDescr -> Maybe (FileInvitation, RcvFileDescrText) + invCompleteDescr CIFile {fileName, fileSize} RcvFileDescr {fileDescrText, fileDescrComplete} + | fileDescrComplete = + let fInvDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False} + fInv = xftpFileInvitation fileName fileSize fInvDescr + in Just (fInv, fileDescrText) + | otherwise = Nothing + processContentItem :: GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> m [ChatMsgEvent Json] + processContentItem sender ChatItem {meta, quotedItem} mc fInvDescr_ = + if isNothing fInvDescr_ && not (msgContentHasText mc) + then pure [] + else do + let CIMeta {itemTs, itemSharedMsgId, itemTimed} = meta + quotedItemId_ = quoteItemId =<< quotedItem + fInv_ = fst <$> fInvDescr_ + (msgContainer, _) <- prepareGroupMsg user gInfo mc quotedItemId_ fInv_ itemTimed False + let senderVRange = memberChatVRange' sender + xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent = XMsgNew msgContainer} + fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of + (Just fileDescrText, Just msgId) -> do + parts <- splitFileDescr fileDescrText + pure . toList $ L.map (XMsgFileDescr msgId) parts + _ -> pure [] + let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents + GroupMember {memberId} = sender + msgForwardEvents = map (\cm -> XGrpMsgForward memberId cm itemTs) (xMsgNewChatMsg : fileDescrChatMsgs) + pure msgForwardEvents _ -> do - -- TODO notify member who forwarded introduction - question - where it is stored? There is via_contact but probably there should be via_member in group_members table let memCategory = memberCategory m withStore' (\db -> getViaGroupContact db user m) >>= \case Nothing -> do @@ -3593,41 +3727,27 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do void $ sendDirectMessage imConn (XGrpMemCon $ memberId (m :: GroupMember)) (GroupId groupId) _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" MSG msgMeta _msgFlags msgBody -> do + checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta `catchChatError` \_ -> pure () cmdId <- createAckCmd conn - tryChatError (processChatMessage cmdId) >>= \case - Right (ACMsg _ chatMsg, withRcpt) -> do - ackMsg agentConnId cmdId msgMeta $ if withRcpt then Just "" else Nothing - when (memberRole (membership :: GroupMember) >= GRAdmin) $ forwardMsg_ chatMsg - Left e -> ackMsg agentConnId cmdId msgMeta Nothing >> throwError e + let aChatMsgs = parseChatMessages msgBody + withAckMessage agentConnId cmdId msgMeta $ do + forM_ aChatMsgs $ \case + Right (ACMsg _ chatMsg) -> + processEvent cmdId chatMsg `catchChatError` \e -> toView $ CRChatError (Just user) e + Left e -> toView $ CRChatError (Just user) (ChatError . CEException $ "error parsing chat message: " <> e) + checkSendRcpt $ rights aChatMsgs + -- currently only a single message is forwarded + when (memberRole (membership :: GroupMember) >= GRAdmin) $ case aChatMsgs of + [Right (ACMsg _ chatMsg)] -> forwardMsg_ chatMsg + _ -> pure () where - processChatMessage :: Int64 -> m (AChatMessage, Bool) - processChatMessage cmdId = do - msg@(ACMsg _ chatMsg) <- parseAChatMessage conn msgMeta msgBody - checkIntegrity chatMsg `catchChatError` \_ -> pure () - (msg,) <$> processEvent cmdId chatMsg brokerTs = metaBrokerTs msgMeta - checkIntegrity :: ChatMessage e -> m () - checkIntegrity ChatMessage {chatMsgEvent} = do - when checkForEvent $ checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta - where - checkForEvent = case chatMsgEvent of - XMsgNew _ -> True - XFileCancel _ -> True - XFileAcptInv {} -> True - XGrpMemNew _ -> True - XGrpMemRole {} -> True - XGrpMemDel _ -> True - XGrpLeave -> True - XGrpDel -> True - XGrpInfo _ -> True - XGrpDirectInv {} -> True - _ -> False - processEvent :: MsgEncodingI e => CommandId -> ChatMessage e -> m Bool + processEvent :: MsgEncodingI e => CommandId -> ChatMessage e -> m () processEvent cmdId chatMsg = do (m', conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m conn msgMeta cmdId msgBody chatMsg updateChatLock "groupMessage" event case event of - XMsgNew mc -> memberCanSend m' $ newGroupContentMessage gInfo m' mc msg brokerTs + XMsgNew mc -> memberCanSend m' $ newGroupContentMessage gInfo m' mc msg brokerTs False XMsgFileDescr sharedMsgId fileDescr -> memberCanSend m' $ groupMessageFileDescription gInfo m' sharedMsgId fileDescr XMsgUpdate sharedMsgId mContent ttl live -> memberCanSend m' $ groupMessageUpdate gInfo m' sharedMsgId mContent msg brokerTs ttl live XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m' sharedMsgId memberId msg brokerTs @@ -3655,15 +3775,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m') probe BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) - checkSendRcpt event - checkSendRcpt :: ChatMsgEvent e -> m Bool - checkSendRcpt event = do + checkSendRcpt :: [AChatMessage] -> m Bool + checkSendRcpt aChatMsgs = do currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo let GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo pure $ fromMaybe (sendRcptsSmallGroups user) sendRcpts - && hasDeliveryReceipt (toCMEventTag event) + && any aChatMsgHasReceipt aChatMsgs && currentMemCount <= smallGroupsRcptsMemLimit + where + aChatMsgHasReceipt (ACMsg _ ChatMessage {chatMsgEvent}) = + hasDeliveryReceipt (toCMEventTag chatMsgEvent) forwardMsg_ :: MsgEncodingI e => ChatMessage e -> m () forwardMsg_ chatMsg = forM_ (forwardedGroupMsg chatMsg) $ \chatMsg' -> do @@ -3778,7 +3900,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do CON -> do ci <- withStore $ \db -> do liftIO $ updateSndFileStatus db ft FSConnected - updateDirectCIFileStatus db user fileId $ CIFSSndTransfer 0 1 + updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 toView $ CRSndFileStart user ci ft sendFileChunk user ft SENT msgId -> do @@ -3792,7 +3914,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do getChatRefByFileId db user fileId >>= \case ChatRef CTDirect _ -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled _ -> pure () - getChatItemByFileId db user fileId + getChatItemByFileId db vr user fileId toView $ CRSndFileRcvCancelled user ci ft _ -> throwChatError $ CEFileSend fileId err MSG meta _ _ -> withAckMessage' agentConnId conn meta $ pure () @@ -3858,7 +3980,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do FileChunkCancel -> unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) - ci <- withStore $ \db -> getChatItemByFileId db user fileId + ci <- withStore $ \db -> getChatItemByFileId db vr user fileId toView $ CRRcvFileSndCancelled user ci ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of @@ -3881,7 +4003,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do updateRcvFileStatus db fileId FSComplete updateCIFileStatus db user fileId CIFSRcvComplete deleteRcvFileChunks db ft - getChatItemByFileId db user fileId + getChatItemByFileId db vr user fileId toView $ CRRcvFileComplete user ci forM_ conn_ $ \conn -> deleteAgentConnectionAsync user (aConnId conn) RcvChunkDuplicate -> ack $ pure () @@ -3914,28 +4036,27 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId chatVRange p xContactId_) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq -> do - withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case - Just (UserContactLink {autoAccept}, groupId_, gLinkMemRole) -> - case autoAccept of - Just AutoAccept {acceptIncognito} -> case groupId_ of - Nothing -> do - -- [incognito] generate profile to send, create connection with incognito profile - incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - ct <- acceptContactRequestAsync user cReq incognitoProfile - toView $ CRAcceptingContactRequest user ct - Just groupId -> do - gInfo <- withStore $ \db -> getGroupInfo db user groupId - let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - if isCompatibleRange chatVRange groupLinkNoContactVRange - then do - mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode - createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing - toView $ CRAcceptingGroupJoinRequestMember user gInfo mem - else do - ct <- acceptContactRequestAsync user cReq profileMode - toView $ CRAcceptingGroupJoinRequest user gInfo ct - _ -> toView $ CRReceivedContactRequest user cReq - _ -> pure () + ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId + let (UserContactLink {autoAccept}, groupId_, gLinkMemRole) = ucl + case autoAccept of + Just AutoAccept {acceptIncognito} -> case groupId_ of + Nothing -> do + -- [incognito] generate profile to send, create connection with incognito profile + incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + ct <- acceptContactRequestAsync user cReq incognitoProfile True + toView $ CRAcceptingContactRequest user ct + Just groupId -> do + gInfo <- withStore $ \db -> getGroupInfo db vr user groupId + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo + if isCompatibleRange chatVRange groupLinkNoContactVRange + then do + mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode + createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CRAcceptingGroupJoinRequestMember user gInfo mem + else do + ct <- acceptContactRequestAsync user cReq profileMode False + toView $ CRAcceptingGroupJoinRequest user gInfo ct + _ -> toView $ CRReceivedContactRequest user cReq memberCanSend :: GroupMember -> m () -> m () memberCanSend GroupMember {memberRole} a @@ -4001,15 +4122,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ackMsgDeliveryEvent :: Connection -> CommandId -> m () ackMsgDeliveryEvent Connection {connId} ackCmdId = - withStoreCtx' - (Just $ "createRcvMsgDeliveryEvent, connId: " <> show connId <> ", ackCmdId: " <> show ackCmdId <> ", msgDeliveryStatus: MDSRcvAcknowledged") - $ \db -> createRcvMsgDeliveryEvent db connId ackCmdId MDSRcvAcknowledged + withStore' $ \db -> updateRcvMsgDeliveryStatus db connId ackCmdId MDSRcvAcknowledged sentMsgDeliveryEvent :: Connection -> AgentMsgId -> m () sentMsgDeliveryEvent Connection {connId} msgId = - withStoreCtx - (Just $ "createSndMsgDeliveryEvent, connId: " <> show connId <> ", msgId: " <> show msgId <> ", msgDeliveryStatus: MDSSndSent") - $ \db -> createSndMsgDeliveryEvent db connId msgId MDSSndSent + withStore' $ \db -> updateSndMsgDeliveryStatus db connId msgId MDSSndSent agentErrToItemStatus :: AgentErrorType -> CIStatus 'MDSnd agentErrToItemStatus (SMP AUTH) = CISSndErrorAuth @@ -4038,7 +4155,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> m () probeMatchingContactsAndMembers ct connectedIncognito doProbeContacts = do - gVar <- asks idsDrg + gVar <- asks random contactMerge <- readTVarIO =<< asks contactMergeEnabled if contactMerge && not connectedIncognito then do @@ -4062,7 +4179,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do probeMatchingMemberContact :: GroupMember -> IncognitoEnabled -> m () probeMatchingMemberContact GroupMember {activeConn = Nothing} _ = pure () probeMatchingMemberContact m@GroupMember {groupId, activeConn = Just conn} connectedIncognito = do - gVar <- asks idsDrg + gVar <- asks random contactMerge <- readTVarIO =<< asks contactMergeEnabled if contactMerge && not connectedIncognito then do @@ -4271,20 +4388,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ChatErrorStore (SEChatItemSharedMsgIdNotFound sharedMsgId) -> handle sharedMsgId e -> throwError e - newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> m () - newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs + newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> m () + newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded | isVoice content && not (groupFeatureAllowed SGFVoice gInfo) = rejected GFVoice | not (isVoice content) && isJust fInv_ && not (groupFeatureAllowed SGFFiles gInfo) = rejected GFFiles | otherwise = do - -- TODO integrity message check - -- check if message moderation event was received ahead of message - let timed_ = rcvGroupCITimed gInfo itemTTL - live = fromMaybe False live_ - withStore' (\db -> getCIModeration db user gInfo memberId sharedMsgId_) >>= \case - Just ciModeration -> do - applyModeration timed_ live ciModeration - withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_ - Nothing -> createItem timed_ live + let timed_ = + if forwarded + then rcvCITimed_ (Just Nothing) itemTTL + else rcvGroupCITimed gInfo itemTTL + live = fromMaybe False live_ + withStore' (\db -> getCIModeration db user gInfo memberId sharedMsgId_) >>= \case + Just ciModeration -> do + applyModeration timed_ live ciModeration + withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_ + Nothing -> createItem timed_ live where rejected f = void $ newChatItem (CIRcvGroupFeatureRejected f) Nothing Nothing False ExtMsgContent content fInv_ itemTTL live_ = mcExtMsgContent mc @@ -4421,14 +4539,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ft <- withStore (\db -> getRcvFileTransfer db user fileId) unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) - ci <- withStore $ \db -> getChatItemByFileId db user fileId + ci <- withStore $ \db -> getChatItemByFileId db vr user fileId toView $ CRRcvFileSndCancelled user ci ft xFileAcptInv :: Contact -> SharedMsgId -> Maybe ConnReqInvitation -> String -> MsgMeta -> m () xFileAcptInv ct sharedMsgId fileConnReq_ fName msgMeta = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta fileId <- withStore $ \db -> getDirectFileIdBySharedMsgId db user ct sharedMsgId - (AChatItem _ _ _ ci) <- withStore $ \db -> getChatItemByFileId db user fileId + (AChatItem _ _ _ ci) <- withStore $ \db -> getChatItemByFileId db vr user fileId assertSMPAcceptNotProhibited ci ft@FileTransferMeta {fileName, fileSize, fileInline, cancelled} <- withStore (\db -> getFileTransferMeta db user fileId) -- [async agent commands] no continuation needed, but command should be asynchronous for stability @@ -4443,7 +4561,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- receiving inline _ -> do event <- withStore $ \db -> do - ci' <- updateDirectCIFileStatus db user fileId $ CIFSSndTransfer 0 1 + ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 sft <- createSndDirectInlineFT db ct ft pure $ CRSndFileStart user ci' sft toView event @@ -4471,7 +4589,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci@(AChatItem _ _ _ ChatItem {file}) <- withStore $ \db -> do liftIO $ updateSndFileStatus db sft FSComplete liftIO $ deleteSndFileChunks db sft - updateDirectCIFileStatus db user fileId CIFSSndComplete + updateDirectCIFileStatus db vr user fileId CIFSSndComplete case file of Just CIFile {fileProtocol = FPXFTP} -> do ft <- withStore $ \db -> getFileTransferMeta db user fileId @@ -4516,7 +4634,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ft <- withStore (\db -> getRcvFileTransfer db user fileId) unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) - ci <- withStore $ \db -> getChatItemByFileId db user fileId + ci <- withStore $ \db -> getChatItemByFileId db vr user fileId toView $ CRRcvFileSndCancelled user ci ft else messageError "x.file.cancel: group member attempted to cancel file of another member" -- shouldn't happen now that query includes group member id (SMDSnd, _) -> messageError "x.file.cancel: group member attempted invalid file cancel" @@ -4524,7 +4642,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe ConnReqInvitation -> String -> m () xFileAcptInvGroup GroupInfo {groupId} m@GroupMember {activeConn} sharedMsgId fileConnReq_ fName = do fileId <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId - (AChatItem _ _ _ ci) <- withStore $ \db -> getChatItemByFileId db user fileId + (AChatItem _ _ _ ci) <- withStore $ \db -> getChatItemByFileId db vr user fileId assertSMPAcceptNotProhibited ci -- TODO check that it's not already accepted ft@FileTransferMeta {fileName, fileSize, fileInline, cancelled} <- withStore (\db -> getFileTransferMeta db user fileId) @@ -4540,7 +4658,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (_, Just conn) -> do -- receiving inline event <- withStore $ \db -> do - ci' <- updateDirectCIFileStatus db user fileId $ CIFSSndTransfer 0 1 + ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 sft <- liftIO $ createSndGroupInlineFT db m conn ft pure $ CRSndFileStart user ci' sft toView event @@ -4564,7 +4682,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId -- [incognito] if direct connection with host is incognito, create membership using the same incognito profile - (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = membership@GroupMember {groupMemberId, memberId}}, hostId) <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId + (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = membership@GroupMember {groupMemberId, memberId}}, hostId) <- + withStore $ \db -> createGroupInvitation db vr user ct inv customUserProfileId if sameGroupLinkId groupLinkId groupLinkId' then do subMode <- chatReadVar subscriptionMode @@ -4680,7 +4799,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createGroupFeatureItems :: GroupInfo -> GroupMember -> m () createGroupFeatureItems g@GroupInfo {fullGroupPreferences} m = - forM_ allGroupFeatures $ \(AGF f) -> do + forM_ allGroupFeatureItems $ \(AGF f) -> do let p = getGroupPreference f fullGroupPreferences (_, param) = groupFeatureState p createInternalChatItem user (CDGroupRcv g m) (CIRcvGroupFeature (toGroupFeature f) (toGroupPreference p) param) Nothing @@ -4763,7 +4882,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta if featureAllowed SCFCalls forContact ct then do - dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing + g <- asks random + dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing ci <- saveCallItem CISCallPending let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} @@ -4914,14 +5034,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do associateMemberWithContact :: Contact -> GroupMember -> m Contact associateMemberWithContact c1 m2@GroupMember {groupId} = do withStore' $ \db -> associateMemberWithContactRecord db user c1 m2 - g <- withStore $ \db -> getGroupInfo db user groupId + g <- withStore $ \db -> getGroupInfo db vr user groupId toView $ CRContactAndMemberAssociated user c1 g m2 c1 pure c1 associateContactWithMember :: GroupMember -> Contact -> m Contact associateContactWithMember m1@GroupMember {groupId} c2 = do c2' <- withStore $ \db -> associateContactWithMemberRecord db user m1 c2 - g <- withStore $ \db -> getGroupInfo db user groupId + g <- withStore $ \db -> getGroupInfo db vr user groupId toView $ CRContactAndMemberAssociated user c2 g m1 c2' pure c2' @@ -4931,11 +5051,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do conn' <- updatePeerChatVRange activeConn chatVRange case chatMsgEvent of XInfo p -> do - ct <- withStore $ \db -> createDirectContact db user conn' p + let contactUsed = connDirect activeConn + ct <- withStore $ \db -> createDirectContact db user conn' p contactUsed toView $ CRContactConnecting user ct pure conn' XGrpLinkInv glInv -> do - (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db user conn' glInv + (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db vr user conn' glInv toView $ CRGroupLinkConnecting user gInfo host pure conn' -- TODO show/log error, other events in SMP confirmation @@ -4990,7 +5111,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> do GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv - void . sendGroupMessage' user [reMember] (XGrpMemFwd (memberInfo m) introInv) groupId (Just introId) $ + sendGroupMemberMessage user reMember (XGrpMemFwd (memberInfo m) introInv) groupId (Just introId) $ withStore' $ \db -> updateIntroStatus db introId GMIntroInvForwarded _ -> messageError "x.grp.mem.inv can be only sent by invitee member" @@ -5203,7 +5324,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let body = LB.toStrict $ J.encode msg rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} <- saveGroupFwdRcvMsg user groupId m author body chatMsg case event of - XMsgNew mc -> memberCanSend author $ newGroupContentMessage gInfo author mc rcvMsg msgTs + XMsgNew mc -> memberCanSend author $ newGroupContentMessage gInfo author mc rcvMsg msgTs True XMsgFileDescr sharedMsgId fileDescr -> memberCanSend author $ groupMessageFileDescription gInfo author sharedMsgId fileDescr XMsgUpdate sharedMsgId mContent ttl live -> memberCanSend author $ groupMessageUpdate gInfo author sharedMsgId mContent rcvMsg msgTs ttl live XMsgDel sharedMsgId memId -> groupMessageDelete gInfo author sharedMsgId memId rcvMsg msgTs @@ -5222,14 +5343,19 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do - withStore $ \db -> createSndMsgDeliveryEvent db connId agentMsgId $ MDSSndRcvd msgRcptStatus + withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus updateDirectItemStatus ct conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete + -- TODO [batch send] update status of all messages in batch + -- - this is for when we implement identifying inactive connections + -- - regular messages sent in batch would all be marked as delivered by a single receipt + -- - repeat for directMsgReceived if same logic is applied to direct messages + -- - getChatItemIdByAgentMsgId to return [ChatItemId] groupMsgReceived :: GroupInfo -> GroupMember -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m () groupMsgReceived gInfo m conn@Connection {connId} msgMeta msgRcpts = do checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do - withStore $ \db -> createSndMsgDeliveryEvent db connId agentMsgId $ MDSSndRcvd msgRcptStatus + withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus updateGroupItemStatus gInfo m conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete updateDirectItemStatus :: Contact -> Connection -> AgentMsgId -> CIStatus 'MDSnd -> m () @@ -5320,28 +5446,25 @@ sendFileInline_ FileTransferMeta {filePath, chunkSize} sharedMsgId sendMsg = chSize = fromIntegral chunkSize parseChatMessage :: ChatMonad m => Connection -> ByteString -> m (ChatMessage 'Json) -parseChatMessage conn = parseChatMessage_ conn Nothing -{-# INLINE parseChatMessage #-} - -parseAChatMessage :: ChatMonad m => Connection -> MsgMeta -> ByteString -> m AChatMessage -parseAChatMessage conn msgMeta = parseChatMessage_ conn (Just msgMeta) -{-# INLINE parseAChatMessage #-} - -parseChatMessage_ :: (ChatMonad m, StrEncoding s) => Connection -> Maybe MsgMeta -> ByteString -> m s -parseChatMessage_ conn msgMeta s = liftEither . first (ChatError . errType) $ strDecode s +parseChatMessage conn s = do + case parseChatMessages s of + [msg] -> liftEither . first (ChatError . errType) $ (\(ACMsg _ m) -> checkEncoding m) =<< msg + _ -> throwChatError $ CEException "parseChatMessage: single message is expected" where - errType = CEInvalidChatMessage conn (msgMetaToJson <$> msgMeta) (safeDecodeUtf8 s) + errType = CEInvalidChatMessage conn Nothing (safeDecodeUtf8 s) +{-# INLINE parseChatMessage #-} sendFileChunk :: ChatMonad m => User -> SndFileTransfer -> m () sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = - unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ + unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ do + vr <- chatVersionRange withStore' (`createSndFileChunk` ft) >>= \case Just chunkNo -> sendFileChunkNo ft chunkNo Nothing -> do ci <- withStore $ \db -> do liftIO $ updateSndFileStatus db ft FSComplete liftIO $ deleteSndFileChunks db ft - updateDirectCIFileStatus db user fileId CIFSSndComplete + updateDirectCIFileStatus db vr user fileId CIFSSndComplete toView $ CRSndFileComplete user ci ft closeFileHandle fileId sndFiles deleteAgentConnectionAsync user acId @@ -5505,58 +5628,117 @@ sendDirectMessage conn chatMsgEvent connOrGroupId = do createSndMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> ConnOrGroupId -> m SndMessage createSndMessage chatMsgEvent connOrGroupId = do - gVar <- asks idsDrg - ChatConfig {chatVRange} <- asks config - withStore $ \db -> createNewSndMessage db gVar connOrGroupId $ \sharedMsgId -> - let msgBody = strEncode ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent} - in NewMessage {chatMsgEvent, msgBody} + gVar <- asks random + vr <- chatVersionRange + withStore $ \db -> createNewSndMessage db gVar connOrGroupId chatMsgEvent (encodeMessage vr) + where + encodeMessage chatVRange sharedMsgId = + encodeChatMessage ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent} + +sendGroupMemberMessages :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> Connection -> NonEmpty (ChatMsgEvent e) -> GroupId -> m () +sendGroupMemberMessages user conn@Connection {connId} events groupId = do + when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn) + (errs, msgs) <- partitionEithers <$> createSndMessages + unless (null errs) $ toView $ CRChatErrors (Just user) errs + unless (null msgs) $ do + let (errs', msgBatches) = partitionEithers $ batchMessages maxChatMsgSize msgs + -- shouldn't happen, as large messages would have caused createNewSndMessage to throw SELargeMsg + unless (null errs') $ toView $ CRChatErrors (Just user) errs' + forM_ msgBatches $ \batch -> + processBatch batch `catchChatError` (toView . CRChatError (Just user)) + where + processBatch :: MsgBatch -> m () + processBatch (MsgBatch builder sndMsgs) = do + let batchBody = LB.toStrict $ toLazyByteString builder + agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) MsgFlags {notification = True} batchBody + let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} + void . withStoreBatch' $ \db -> map (\SndMessage {msgId} -> createSndMsgDelivery db sndMsgDelivery msgId) sndMsgs + createSndMessages :: m [Either ChatError SndMessage] + createSndMessages = do + gVar <- asks random + vr <- chatVersionRange + withStoreBatch $ \db -> map (createMsg db gVar vr) (toList events) + createMsg db gVar chatVRange evnt = do + r <- runExceptT $ createNewSndMessage db gVar (GroupId groupId) evnt (encodeMessage chatVRange evnt) + pure $ first ChatErrorStore r + encodeMessage chatVRange evnt sharedMsgId = + encodeChatMessage ChatMessage {chatVRange, msgId = Just sharedMsgId, chatMsgEvent = evnt} directMessage :: (MsgEncodingI e, ChatMonad m) => ChatMsgEvent e -> m ByteString directMessage chatMsgEvent = do - ChatConfig {chatVRange} <- asks config - pure $ strEncode ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} + chatVRange <- chatVersionRange + let r = encodeChatMessage ChatMessage {chatVRange, msgId = Nothing, chatMsgEvent} + case r of + ECMEncoded encodedBody -> pure . LB.toStrict $ encodedBody + ECMLarge -> throwChatError $ CEException "large message" -deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> MsgBody -> MessageId -> m Int64 -deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do +deliverMessage :: ChatMonad m => Connection -> CMEventTag e -> LazyMsgBody -> MessageId -> m Int64 +deliverMessage conn cmEventTag msgBody msgId = do let msgFlags = MsgFlags {notification = hasNotification cmEventTag} - agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) msgFlags msgBody - let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} - withStore' $ \db -> createSndMsgDelivery db sndMsgDelivery msgId + deliverMessage' conn msgFlags msgBody msgId + +deliverMessage' :: ChatMonad m => Connection -> MsgFlags -> LazyMsgBody -> MessageId -> m Int64 +deliverMessage' conn msgFlags msgBody msgId = + deliverMessages [(conn, msgFlags, msgBody, msgId)] >>= \case + [r] -> liftEither r + rs -> throwChatError $ CEInternalError $ "deliverMessage: expected 1 result, got " <> show (length rs) + +deliverMessages :: ChatMonad' m => [(Connection, MsgFlags, LazyMsgBody, MessageId)] -> m [Either ChatError Int64] +deliverMessages msgReqs = do + sent <- zipWith prepareBatch msgReqs <$> withAgent' (`sendMessages` aReqs) + withStoreBatch $ \db -> map (bindRight $ createDelivery db) sent + where + aReqs = map (\(conn, msgFlags, msgBody, _msgId) -> (aConnId conn, msgFlags, LB.toStrict msgBody)) msgReqs + prepareBatch req = bimap (`ChatErrorAgent` Nothing) (req,) + createDelivery :: DB.Connection -> ((Connection, MsgFlags, LazyMsgBody, MessageId), AgentMsgId) -> IO (Either ChatError Int64) + createDelivery db ((Connection {connId}, _, _, msgId), agentMsgId) = + Right <$> createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId}) msgId sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) -sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = - sendGroupMessage' user members chatMsgEvent groupId Nothing $ pure () - -sendGroupMessage' :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> [GroupMember] -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m (SndMessage, [GroupMember]) -sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do - msg <- createSndMessage chatMsgEvent (GroupId groupId) - -- TODO collect failed deliveries into a single error - recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) $ \GroupMember {memberRole} -> memberRole - rs <- forM recipientMembers $ \m -> - messageMember m msg `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing) - let sentToMembers = catMaybes rs +sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = do + msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent (GroupId groupId) + recipientMembers <- liftIO $ shuffleMembers (filter memberCurrent members) + let msgFlags = MsgFlags {notification = hasNotification $ toCMEventTag chatMsgEvent} + (toSend, pending) = foldr addMember ([], []) recipientMembers + msgReqs = map (\(_, conn) -> (conn, msgFlags, msgBody, msgId)) toSend + delivered <- deliverMessages msgReqs + let errors = lefts delivered + unless (null errors) $ toView $ CRChatErrors (Just user) errors + stored <- withStoreBatch' $ \db -> map (\m -> createPendingGroupMessage db (groupMemberId' m) msgId Nothing) pending + let sentToMembers = filterSent delivered toSend fst <> filterSent stored pending id pure (msg, sentToMembers) where - messageMember :: GroupMember -> SndMessage -> m (Maybe GroupMember) - messageMember m@GroupMember {groupMemberId} SndMessage {msgId, msgBody} = case memberConn m of - Nothing -> pendingOrForwarded - Just conn@Connection {connStatus} - | connDisabled conn || connStatus == ConnDeleted -> pure Nothing - | connStatus == ConnSndReady || connStatus == ConnReady -> do - let tag = toCMEventTag chatMsgEvent - deliverMessage conn tag msgBody msgId >> postDeliver - pure $ Just m - | otherwise -> pendingOrForwarded + shuffleMembers :: [GroupMember] -> IO [GroupMember] + shuffleMembers ms = do + let (adminMs, otherMs) = partition isAdmin ms + liftM2 (<>) (shuffle adminMs) (shuffle otherMs) where - pendingOrForwarded - | forwardSupported && isForwardedGroupMsg chatMsgEvent = pure Nothing - | isXGrpMsgForward chatMsgEvent = pure Nothing - | otherwise = do - withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ - pure $ Just m - forwardSupported = do + isAdmin GroupMember {memberRole} = memberRole >= GRAdmin + addMember m (toSend, pending) = case memberSendAction chatMsgEvent members m of + Just (MSASend conn) -> ((m, conn) : toSend, pending) + Just MSAPending -> (toSend, m : pending) + Nothing -> (toSend, pending) + filterSent :: [Either ChatError a] -> [mem] -> (mem -> GroupMember) -> [GroupMember] + filterSent rs ms mem = [mem m | (Right _, m) <- zip rs ms] + +data MemberSendAction = MSASend Connection | MSAPending + +memberSendAction :: ChatMsgEvent e -> [GroupMember] -> GroupMember -> Maybe MemberSendAction +memberSendAction chatMsgEvent members m = case memberConn m of + Nothing -> pendingOrForwarded + Just conn@Connection {connStatus} + | connDisabled conn || connStatus == ConnDeleted -> Nothing + | connStatus == ConnSndReady || connStatus == ConnReady -> Just (MSASend conn) + | otherwise -> pendingOrForwarded + where + pendingOrForwarded + | forwardSupported && isForwardedGroupMsg chatMsgEvent = Nothing + | isXGrpMsgForward chatMsgEvent = Nothing + | otherwise = Just MSAPending + where + forwardSupported = let mcvr = memberChatVRange' m - isCompatibleRange mcvr groupForwardVRange && invitingMemberSupportsForward + in isCompatibleRange mcvr groupForwardVRange && invitingMemberSupportsForward invitingMemberSupportsForward = case invitedByGroupMemberId m of Just invMemberId -> -- can be optimized for large groups by replacing [GroupMember] with Map GroupMemberId GroupMember @@ -5570,14 +5752,15 @@ sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do XGrpMsgForward {} -> True _ -> False -shuffleMembers :: [a] -> (a -> GroupMemberRole) -> IO [a] -shuffleMembers ms role = do - let (adminMs, otherMs) = partition ((GRAdmin <=) . role) ms - liftM2 (<>) (shuffle adminMs) (shuffle otherMs) +sendGroupMemberMessage :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> GroupMember -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m () +sendGroupMemberMessage user m@GroupMember {groupMemberId} chatMsgEvent groupId introId_ postDeliver = do + msg <- createSndMessage chatMsgEvent (GroupId groupId) + messageMember msg `catchChatError` (\e -> toView (CRChatError (Just user) e)) where - random :: IO Word16 - random = randomRIO (0, 65535) - shuffle xs = map snd . sortBy (comparing fst) <$> mapM (\x -> (,x) <$> random) xs + messageMember :: SndMessage -> m () + messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction chatMsgEvent [m] m) $ \case + MSASend conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver + MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ sendPendingGroupMessages :: ChatMonad m => User -> GroupMember -> Connection -> m () sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn = do @@ -5595,21 +5778,25 @@ sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn _ -> throwChatError $ CEGroupMemberIntroNotFound localDisplayName _ -> pure () +-- TODO [batch send] refactor direct message processing same as groups (e.g. checkIntegrity before processing) saveDirectRcvMSG :: ChatMonad m => Connection -> MsgMeta -> CommandId -> MsgBody -> m (Connection, RcvMessage) -saveDirectRcvMSG conn@Connection {connId} agentMsgMeta agentAckCmdId msgBody = do - ACMsg _ ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} <- parseAChatMessage conn agentMsgMeta msgBody - conn' <- updatePeerChatVRange conn chatVRange - let agentMsgId = fst $ recipient agentMsgMeta - newMsg = NewMessage {chatMsgEvent, msgBody} - rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} - msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing - pure (conn', msg) +saveDirectRcvMSG conn@Connection {connId} agentMsgMeta agentAckCmdId msgBody = + case parseChatMessages msgBody of + [Right (ACMsg _ ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent})] -> do + conn' <- updatePeerChatVRange conn chatVRange + let agentMsgId = fst $ recipient agentMsgMeta + newMsg = NewRcvMessage {chatMsgEvent, msgBody} + rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} + msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing + pure (conn', msg) + [Left e] -> error $ "saveDirectRcvMSG: error parsing chat message: " <> e + _ -> error "saveDirectRcvMSG: batching not supported" saveGroupRcvMsg :: (MsgEncodingI e, ChatMonad m) => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> CommandId -> MsgBody -> ChatMessage e -> m (GroupMember, Connection, RcvMessage) saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta agentAckCmdId msgBody ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = do (am', conn') <- updateMemberChatVRange authorMember conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta - newMsg = NewMessage {chatMsgEvent, msgBody} + newMsg = NewRcvMessage {chatMsgEvent, msgBody} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta, agentAckCmdId} amId = Just $ groupMemberId' am' msg <- @@ -5625,7 +5812,7 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta saveGroupFwdRcvMsg :: (MsgEncodingI e, ChatMonad m) => User -> GroupId -> GroupMember -> GroupMember -> MsgBody -> ChatMessage e -> m RcvMessage saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} = do - let newMsg = NewMessage {chatMsgEvent, msgBody} + let newMsg = NewRcvMessage {chatMsgEvent, msgBody} fwdMemberId = Just $ groupMemberId' forwardingMember refAuthorId = Just $ groupMemberId' refAuthorMember withStore (\db -> createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) @@ -5816,7 +6003,7 @@ createFeatureItems user Contact {mergedPreferences = cups} ct'@Contact {mergedPr createGroupFeatureChangedItems :: (MsgDirectionI d, ChatMonad m) => User -> ChatDirection 'CTGroup d -> (GroupFeature -> GroupPreference -> Maybe Int -> CIContent d) -> GroupInfo -> GroupInfo -> m () createGroupFeatureChangedItems user cd ciContent GroupInfo {fullGroupPreferences = gps} GroupInfo {fullGroupPreferences = gps'} = - forM_ allGroupFeatures $ \(AGF f) -> do + forM_ allGroupFeatureItems $ \(AGF f) -> do let state = groupFeatureState $ getGroupPreference f gps pref' = getGroupPreference f gps' state'@(_, int') = groupFeatureState pref' @@ -5911,6 +6098,11 @@ withUser action = withUser' $ \user -> withUser_ :: ChatMonad m => m ChatResponse -> m ChatResponse withUser_ = withUser . const +withUserId' :: ChatMonad m => UserId -> (User -> m ChatResponse) -> m ChatResponse +withUserId' userId action = withUser' $ \user -> do + checkSameUser userId user + action user + withUserId :: ChatMonad m => UserId -> (User -> m ChatResponse) -> m ChatResponse withUserId userId action = withUser $ \user -> do checkSameUser userId user @@ -5927,6 +6119,11 @@ waitChatStarted = do agentStarted <- asks agentAsync atomically $ readTVar agentStarted >>= \a -> unless (isJust a) retry +chatVersionRange :: ChatMonad' m => m VersionRange +chatVersionRange = do + ChatConfig {chatVRange} <- asks config + pure chatVRange + chatCommandP :: Parser ChatCommand chatCommandP = choice @@ -5960,7 +6157,8 @@ chatCommandP = "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP <* " xftp=" <*> onOffP), "/_start" $> StartChat True True True, "/_stop" $> APIStopChat, - "/_app activate" $> APIActivateChat, + "/_app activate restore=" *> (APIActivateChat <$> onOffP), + "/_app activate" $> APIActivateChat True, "/_app suspend " *> (APISuspendChat <$> A.decimal), "/_resubscribe all" $> ResubscribeAllConnections, "/_temp_folder " *> (SetTempFolder <$> filePath), @@ -5975,13 +6173,19 @@ chatCommandP = "/_db import " *> (APIImportArchive <$> jsonP), "/_db delete" $> APIDeleteStorage, "/_db encryption " *> (APIStorageEncryption <$> jsonP), - "/db encrypt " *> (APIStorageEncryption . DBEncryptionConfig "" <$> dbKeyP), - "/db key " *> (APIStorageEncryption <$> (DBEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)), - "/db decrypt " *> (APIStorageEncryption . (`DBEncryptionConfig` "") <$> dbKeyP), + "/db encrypt " *> (APIStorageEncryption . dbEncryptionConfig "" <$> dbKeyP), + "/db key " *> (APIStorageEncryption <$> (dbEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)), + "/db decrypt " *> (APIStorageEncryption . (`dbEncryptionConfig` "") <$> dbKeyP), "/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP), "/sql slow" $> SlowSQLQueries, - "/_get chats " *> (APIGetChats <$> A.decimal <*> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)), + "/_get chats " + *> ( APIGetChats + <$> A.decimal + <*> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False) + <*> (A.space *> paginationByTimeP <|> pure (PTLast 5000)) + <*> (A.space *> jsonP <|> pure clqNoFilters) + ), "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), @@ -5990,6 +6194,8 @@ chatCommandP = "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <* A.space <*> A.decimal <* A.space <*> A.decimal), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), + "/_read user " *> (APIUserRead <$> A.decimal), + "/read user" $> UserRead, "/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))), "/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP), "/_delete " *> (APIDeleteChat <$> chatRefP <*> (A.space *> "notify=" *> onOffP <|> pure True)), @@ -6175,6 +6381,7 @@ chatCommandP = "/set voice @" *> (SetContactFeature (ACF SCFVoice) <$> displayName <*> optional (A.space *> strP)), "/set voice " *> (SetUserFeature (ACF SCFVoice) <$> strP), "/set files #" *> (SetGroupFeature (AGF SGFFiles) <$> displayName <*> (A.space *> strP)), + "/set history #" *> (SetGroupFeature (AGF SGFHistory) <$> displayName <*> (A.space *> strP)), "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayName <*> optional (A.space *> strP)), "/set calls " *> (SetUserFeature (ACF SCFCalls) <$> strP), "/set delete #" *> (SetGroupFeature (AGF SGFFullDelete) <$> displayName <*> (A.space *> strP)), @@ -6219,6 +6426,10 @@ chatCommandP = (CPLast <$ "count=" <*> A.decimal) <|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) <|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal) + paginationByTimeP = + (PTLast <$ "count=" <*> A.decimal) + <|> (PTAfter <$ "after=" <*> strP <* A.space <* "count=" <*> A.decimal) + <|> (PTBefore <$ "before=" <*> strP <* A.space <* "count=" <*> A.decimal) mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal @@ -6258,7 +6469,12 @@ chatCommandP = jsonP = J.eitherDecodeStrict' <$?> A.takeByteString groupProfile = do (gName, fullName) <- profileNames - let groupPreferences = Just (emptyGroupPrefs :: GroupPreferences) {directMessages = Just DirectMessagesGroupPreference {enable = FEOn}} + let groupPreferences = + Just + (emptyGroupPrefs :: GroupPreferences) + { directMessages = Just DirectMessagesGroupPreference {enable = FEOn}, + history = Just HistoryGroupPreference {enable = FEOn} + } pure GroupProfile {displayName = gName, fullName, description = Nothing, image = Nothing, groupPreferences} fullNameP = A.space *> textP <|> pure "" textP = safeDecodeUtf8 <$> A.takeByteString @@ -6296,6 +6512,7 @@ chatCommandP = <|> ("day" $> 86400) <|> ("week" $> (7 * 86400)) <|> ("month" $> (30 * 86400)) + <|> A.decimal timedTTLOnOffP = optional ("on" *> A.space) *> (Just <$> timedTTLP) <|> ("off" $> Nothing) @@ -6318,7 +6535,8 @@ chatCommandP = A.decimal ] dbKeyP = nonEmptyKey <$?> strP - nonEmptyKey k@(DBEncryptionKey s) = if null s then Left "empty key" else Right k + nonEmptyKey k@(DBEncryptionKey s) = if BA.null s then Left "empty key" else Right k + dbEncryptionConfig currentKey newKey = DBEncryptionConfig {currentKey, newKey, keepKey = Just False} autoAcceptP = ifM onOffP diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 22e5f1ee2f..d386b48d40 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -17,12 +17,14 @@ import qualified Codec.Archive.Zip as Z import Control.Monad import Control.Monad.Except import Control.Monad.Reader +import qualified Data.ByteArray as BA import Data.Functor (($>)) +import Data.Maybe (fromMaybe) import qualified Data.Text as T import qualified Database.SQLite3 as SQL import Simplex.Chat.Controller import Simplex.Messaging.Agent.Client (agentClientStore) -import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), closeSQLiteStore, sqlString) +import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), closeSQLiteStore, keyString, sqlString, storeKey) import Simplex.Messaging.Util import System.FilePath import UnliftIO.Directory @@ -118,7 +120,7 @@ storageFiles = do pure StorageFiles {chatStore, agentStore, filesPath} sqlCipherExport :: forall m. ChatMonad m => DBEncryptionConfig -> m () -sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key'} = +sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key', keepKey} = when (key /= key') $ do fs <- storageFiles checkFile `withDBs` fs @@ -134,15 +136,15 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D backup f = copyFile f (f <> ".bak") restore f = copyFile (f <> ".bak") f checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f - checkEncryption SQLiteStore {dbEncrypted} = do - enc <- readTVarIO dbEncrypted - when (enc && null key) $ throwDBError DBErrorEncrypted - when (not enc && not (null key)) $ throwDBError DBErrorPlaintext + checkEncryption SQLiteStore {dbKey} = do + enc <- maybe True (not . BA.null) <$> readTVarIO dbKey + when (enc && BA.null key) $ throwDBError DBErrorEncrypted + when (not enc && not (BA.null key)) $ throwDBError DBErrorPlaintext exported = (<> ".exported") removeExported f = whenM (doesFileExist $ exported f) $ removeFile (exported f) - moveExported SQLiteStore {dbFilePath = f, dbEncrypted} = do + moveExported SQLiteStore {dbFilePath = f, dbKey} = do renameFile (exported f) f - atomically $ writeTVar dbEncrypted $ not (null key') + atomically $ writeTVar dbKey $ storeKey key' (fromMaybe False keepKey) export f = do withDB f (`SQL.exec` exportSQL) DBErrorExport withDB (exported f) (`SQL.exec` testSQL) DBErrorOpen @@ -161,7 +163,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D exportSQL = T.unlines $ keySQL key - <> [ "ATTACH DATABASE " <> sqlString (f <> ".exported") <> " AS exported KEY " <> sqlString key' <> ";", + <> [ "ATTACH DATABASE " <> sqlString (T.pack f <> ".exported") <> " AS exported KEY " <> keyString key' <> ";", "SELECT sqlcipher_export('exported');", "DETACH DATABASE exported;" ] @@ -172,7 +174,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D "PRAGMA secure_delete = ON;", "SELECT count(*) FROM sqlite_master;" ] - keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)] + keySQL k = ["PRAGMA key = " <> keyString k <> ";" | not (BA.null k)] withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index fb2ff89a28..b198cccbf7 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -29,6 +29,8 @@ import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bifunctor (first) +import Data.ByteArray (ScrubbedBytes) +import qualified Data.ByteArray as BA import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (ord) @@ -39,7 +41,9 @@ import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.String import Data.Text (Text) +import Data.Text.Encoding (decodeLatin1) import Data.Time (NominalDiffTime, UTCTime) +import Data.Time.Clock.System (systemToUTCTime) import Data.Version (showVersion) import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) @@ -69,7 +73,7 @@ import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, MsgFlags, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), CorrId, NtfServer, ProtoServerWithAuth, ProtocolTypeI, QueueId, SMPMsgMeta (..), SProtocolType, SubscriptionMode (..), UserProtocol, XFTPServerWithAuth, userProtocol) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) @@ -80,6 +84,7 @@ import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitatio import Simplex.RemoteControl.Types import System.IO (Handle) import System.Mem.Weak (Weak) +import qualified UnliftIO.Exception as E import UnliftIO.STM versionNumber :: String @@ -175,7 +180,7 @@ data ChatController = ChatController agentAsync :: TVar (Maybe (Async (), Maybe (Async ()))), chatStore :: SQLiteStore, chatStoreChanged :: TVar Bool, -- if True, chat should be fully restarted - idsDrg :: TVar ChaChaDRG, + random :: TVar ChaChaDRG, inputQ :: TBQueue String, outputQ :: TBQueue (Maybe CorrId, Maybe RemoteHostId, ChatResponse), connNetworkStatuses :: TMap AgentConnId NetworkStatus, @@ -230,7 +235,7 @@ data ChatCommand | DeleteUser UserName Bool (Maybe UserPwd) | StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool, startXFTPWorkers :: Bool} | APIStopChat - | APIActivateChat + | APIActivateChat {restoreChat :: Bool} | APISuspendChat {suspendTimeout :: Int} | ResubscribeAllConnections | SetTempFolder FilePath @@ -247,7 +252,7 @@ data ChatCommand | ExecChatStoreSQL Text | ExecAgentStoreSQL Text | SlowSQLQueries - | APIGetChats {userId :: UserId, pendingConnections :: Bool} + | APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery} | APIGetChat ChatRef ChatPagination (Maybe String) | APIGetChatItems ChatPagination (Maybe String) | APIGetChatItemInfo ChatRef ChatItemId @@ -256,6 +261,8 @@ data ChatCommand | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode | APIDeleteMemberChatItem GroupId GroupMemberId ChatItemId | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} + | APIUserRead UserId + | UserRead | APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId)) | APIChatUnread ChatRef Bool | APIDeleteChat ChatRef Bool -- `notify` flag is only applied to direct chats @@ -453,7 +460,7 @@ allowRemoteCommand :: ChatCommand -> Bool -- XXX: consider using Relay/Block/For allowRemoteCommand = \case StartChat {} -> False APIStopChat -> False - APIActivateChat -> False + APIActivateChat _ -> False APISuspendChat _ -> False QuitChat -> False SetTempFolder _ -> False @@ -654,7 +661,8 @@ data ChatResponse | CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode} - | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} + | CRNtfMessages {user_ :: Maybe User, connEntity_ :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} + | CRNtfMessage {user :: User, connEntity :: ConnectionEntity, ntfMessage :: NtfMsgInfo} | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRRemoteHostList {remoteHosts :: [RemoteHostInfo]} | CRCurrentRemoteHost {remoteHost_ :: Maybe RemoteHostInfo} @@ -683,6 +691,7 @@ data ChatResponse | CRMessageError {user :: User, severity :: Text, errorMessage :: Text} | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} | CRChatError {user_ :: Maybe User, chatError :: ChatError} + | CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]} | CRArchiveImported {archiveErrors :: [ArchiveError]} | CRTimedAction {action :: String, durationMilliseconds :: Int64} deriving (Show) @@ -731,6 +740,26 @@ logResponseToFile = \case CRMessageError {} -> True _ -> False +data ChatPagination + = CPLast Int + | CPAfter ChatItemId Int + | CPBefore ChatItemId Int + deriving (Show) + +data PaginationByTime + = PTLast Int + | PTAfter UTCTime Int + | PTBefore UTCTime Int + deriving (Show) + +data ChatListQuery + = CLQFilters {favorite :: Bool, unread :: Bool} + | CLQSearch {search :: String} + deriving (Show) + +clqNoFilters :: ChatListQuery +clqNoFilters = CLQFilters {favorite = False, unread = False} + data ConnectionPlan = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} | CPContactAddress {contactAddressPlan :: ContactAddressPlan} @@ -825,17 +854,17 @@ deriving instance Show AUserProtoServers data ArchiveConfig = ArchiveConfig {archivePath :: FilePath, disableCompression :: Maybe Bool, parentTempDirectory :: Maybe FilePath} deriving (Show) -data DBEncryptionConfig = DBEncryptionConfig {currentKey :: DBEncryptionKey, newKey :: DBEncryptionKey} +data DBEncryptionConfig = DBEncryptionConfig {currentKey :: DBEncryptionKey, newKey :: DBEncryptionKey, keepKey :: Maybe Bool} deriving (Show) -newtype DBEncryptionKey = DBEncryptionKey String +newtype DBEncryptionKey = DBEncryptionKey ScrubbedBytes deriving (Show) instance IsString DBEncryptionKey where fromString = parseString $ parseAll strP instance StrEncoding DBEncryptionKey where - strEncode (DBEncryptionKey s) = B.pack s - strP = DBEncryptionKey . B.unpack <$> A.takeWhile (\c -> c /= ' ' && ord c >= 0x21 && ord c <= 0x7E) + strEncode (DBEncryptionKey s) = BA.convert s + strP = DBEncryptionKey . BA.convert <$> A.takeWhile (\c -> c /= ' ' && ord c >= 0x21 && ord c <= 0x7E) instance FromJSON DBEncryptionKey where parseJSON = strParseJSON "DBEncryptionKey" @@ -900,9 +929,12 @@ data XFTPFileConfig = XFTPFileConfig defaultXFTPFileConfig :: XFTPFileConfig defaultXFTPFileConfig = XFTPFileConfig {minFileSize = 0} -data NtfMsgInfo = NtfMsgInfo {msgTs :: UTCTime, msgFlags :: MsgFlags} +data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime} deriving (Show) +ntfMsgInfo :: SMPMsgMeta -> NtfMsgInfo +ntfMsgInfo SMPMsgMeta {msgId, msgTs} = NtfMsgInfo {msgId = decodeLatin1 $ strEncode msgId, msgTs = systemToUTCTime msgTs} + crNtfToken :: (DeviceToken, NtfTknStatus, NotificationsMode) -> ChatResponse crNtfToken (token, status, ntfMode) = CRNtfToken {token, status, ntfMode} @@ -1256,14 +1288,30 @@ withStoreCtx ctx_ action = do handleInternal :: String -> SomeException -> IO (Either StoreError a) handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr +withStoreBatch :: (ChatMonad' m, Traversable t) => (DB.Connection -> t (IO (Either ChatError a))) -> m (t (Either ChatError a)) +withStoreBatch actions = do + ChatController {chatStore} <- ask + liftIO $ withTransaction chatStore $ mapM (`E.catch` handleInternal) . actions + where + handleInternal :: E.SomeException -> IO (Either ChatError a) + handleInternal = pure . Left . ChatError . CEInternalError . show + +withStoreBatch' :: (ChatMonad' m, Traversable t) => (DB.Connection -> t (IO a)) -> m (t (Either ChatError a)) +withStoreBatch' actions = withStoreBatch $ fmap (fmap Right) . actions + withAgent :: ChatMonad m => (AgentClient -> ExceptT AgentErrorType m a) -> m a withAgent action = asks smpAgent >>= runExceptT . action >>= liftEither . first (`ChatErrorAgent` Nothing) +withAgent' :: ChatMonad' m => (AgentClient -> m a) -> m a +withAgent' action = asks smpAgent >>= action + $(JQ.deriveJSON (enumJSON $ dropPrefix "HS") ''HelpSection) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CLQ") ''ChatListQuery) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "ILP") ''InvitationLinkPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CAP") ''ContactAddressPlan) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 0706dda084..1d870bf381 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -22,13 +22,13 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {core withGlobalLogging logCfg initRun _ -> initRun where - initRun = createChatDatabase dbFilePrefix dbKey confirmMigrations >>= either exit run + initRun = createChatDatabase dbFilePrefix dbKey False confirmMigrations >>= either exit run exit e = do putStrLn $ "Error opening database: " <> show e exitFailure run db@ChatDatabase {chatStore} = do u <- getCreateActiveUser chatStore testView - cc <- newChatController db (Just u) cfg opts + cc <- newChatController db (Just u) cfg opts False runSimplexChat opts u cc chat runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO () diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 5d0548ca3f..ac93e05533 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -155,7 +155,8 @@ groupsHelpInfo = "", green "Group chat preferences:", indent <> highlight "/set voice # on/off " <> " - enable/disable voice messages", - -- indent <> highlight "/set files # on/off " <> " - enable/disable files and media (other than voice)", + indent <> highlight "/set files # on/off " <> " - enable/disable files and media (other than voice)", + indent <> highlight "/set history # on/off " <> " - enable/disable sending recent history to new members", indent <> highlight "/set delete # on/off " <> " - enable/disable full message deletion", indent <> highlight "/set direct # on/off " <> " - enable/disable direct messages to other members", indent <> highlight "/set disappear # on