diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index b083361a0..bb1de9435 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) } diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index aae1e15fa..a39155efe 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -15,6 +15,7 @@ private let receiveTaskId = "chat.simplex.app.receive" // TCP timeout + 2 sec private let waitForMessages: TimeInterval = 6 +// This is the smallest interval between refreshes, and also target interval in "off" mode private let bgRefreshInterval: TimeInterval = 600 private let maxTimerCount = 9 diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a7f4bcdbe..e7932f2d9 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -104,14 +104,10 @@ final class ChatModel: ObservableObject { static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } - var ntfEnableLocal: Bool { - true -// notificationMode == .off || ntfEnableLocalGroupDefault.get() - } + let ntfEnableLocal = true var ntfEnablePeriodic: Bool { notificationMode != .off -// notificationMode == .periodic || ntfEnablePeriodicGroupDefault.get() } var activeRemoteCtrl: Bool { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e2161cbf9..e67dab18f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1235,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() } } @@ -1251,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 7ced1351a..9b03f38f3 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -9,6 +9,7 @@ import Foundation import UIKit import SimpleXChat +import SwiftUI private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock") @@ -103,11 +104,32 @@ func activateChat(appState: AppState = .active) { func initChatAndMigrate(refreshInvitations: Bool = true) { 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))" + ) } } } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index d75738d04..057188c37 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -54,7 +54,7 @@ struct SimpleXApp: App { } .onAppear() { showInitializationView = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { initChatAndMigrate() } } @@ -77,15 +77,17 @@ struct SimpleXApp: App { case .active: CallController.shared.shouldSuspendChat = false let appState = AppChatState.shared.value - startChatAndActivate { - if appState.inactive && chatModel.chatRunning == true { - updateChats() - if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { - updateCallInvitations() + if appState != .stopped { + startChatAndActivate { + if appState.inactive && chatModel.chatRunning == true { + updateChats() + if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { + updateCallInvitations() + } } + doAuthenticate = authenticationExpired() + canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() } - doAuthenticate = authenticationExpired() - canConnectCall = !(doAuthenticate && prefPerformLA) || unlockedRecently() } default: break diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift index 5befe405c..04c02f0dd 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/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index c937c9ec9..eaa1131eb 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -23,8 +23,8 @@ 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 for read cancellation, to ensure that notifications are not lost in case the next the current thread completes -// before expected notification is read (multiple notifications can be expected, because one notification can be delivered for several messages. +// 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] = [:] @@ -181,7 +181,7 @@ class NSEThreads { // 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 for notification service extension. +// 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)? @@ -189,7 +189,7 @@ class NotificationService: UNNotificationServiceExtension { 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? + var threadId: UUID? = NSEThreads.shared.newThread() var receiveEntityId: String? var cancelRead: (() -> Void)? var appSubscriber: AppSubscriber? @@ -197,20 +197,21 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("DEBUGGING: NotificationService.didReceive") - let newThreadId = NSEThreads.shared.newThread() - threadId = newThreadId 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 .suspended: - logger.debug("NotificationService: app is suspended") + case .stopped: setBadgeCount() - receiveNtfMessages(newThreadId, request, contentHandler) + setBestAttemptNtf(createAppStoppedNtf()) + deliverBestAttemptNtf() + case .suspended: + setBadgeCount() + receiveNtfMessages(request, contentHandler) case .suspending: - logger.debug("NotificationService: app is suspending") setBadgeCount() Task { let state: AppState = await withCheckedContinuation { cont in @@ -231,20 +232,19 @@ class NotificationService: UNNotificationServiceExtension { } } } - 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(newThreadId, request, contentHandler) + receiveNtfMessages(request, contentHandler) } else { deliverBestAttemptNtf() } } default: - logger.debug("NotificationService: app state is \(appState.rawValue, privacy: .public)") deliverBestAttemptNtf() } } - func receiveNtfMessages(_ newThreadId: UUID, _ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { + func receiveNtfMessages(_ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("NotificationService: receiveNtfMessages") if case .documents = dbContainerGroupDefault.get() { deliverBestAttemptNtf() @@ -257,7 +257,7 @@ class NotificationService: UNNotificationServiceExtension { // check it here again appStateGroupDefault.get().inactive { // thread is added to activeThreads tracking set here - if thread started chat it needs to be suspended - NSEThreads.shared.startThread(newThreadId) + if let t = threadId { NSEThreads.shared.startThread(t) } let dbStatus = startChat() if case .ok = dbStatus, let ntfInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index c03951e60..4d1446965 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1498,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" @@ -1505,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 0804741c9..10625e2ed 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -16,8 +16,8 @@ let GROUP_DEFAULT_NSE_STATE = "nseState" let GROUP_DEFAULT_DB_CONTAINER = "dbContainer" public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart" 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" @@ -143,6 +143,7 @@ public let nseStateGroupDefault = EnumDefault( 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 } @@ -163,10 +164,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/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index d613ff20a..bc959cb34 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/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index eb322bcd9..91ca3857a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -599,7 +599,7 @@ processChatCommand = \case . sortOn (timeAvg . snd) . M.assocs <$> withConnection st (readTVarIO . DB.slow) - APIGetChats userId withPCC -> withUserId userId $ \user -> + APIGetChats userId withPCC -> withUserId' userId $ \user -> CRApiChats user <$> withStoreCtx' (Just "APIGetChats, getChatPreviews") (\db -> getChatPreviews db user withPCC) APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled @@ -1205,8 +1205,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 @@ -1224,7 +1223,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 @@ -1483,9 +1482,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} @@ -5911,6 +5910,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