diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 7b0a2bea8d..ec8a19cde6 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -26,7 +26,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { if useNotifications { Task { do { - m.tokenStatus = try await apiRegisterToken(token: token) + m.tokenStatus = try await apiRegisterToken(token: token, notificationMode: .instant) } catch { logger.error("apiRegisterToken error: \(responseError(error))") } diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index e1d9e819b1..6f35732d15 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -17,11 +17,14 @@ private let waitForMessages: TimeInterval = 6 private let bgRefreshInterval: TimeInterval = 450 +private let maxTimerCount = 9 + class BGManager { static let shared = BGManager() var chatReceiver: ChatReceiver? var bgTimer: Timer? var completed = true + var timerCount = 0 func register() { logger.debug("BGManager.register") @@ -60,6 +63,8 @@ class BGManager { self.chatReceiver = nil self.bgTimer?.invalidate() self.bgTimer = nil + self.timerCount = 0 + suspendBgRefresh() complete() } } @@ -82,14 +87,18 @@ class BGManager { return } logger.debug("BGManager.receiveMessages: starting chat") + activateChat(appState: .bgRefresh) let cr = ChatReceiver() self.chatReceiver = cr cr.start() RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in logger.debug("BGManager.receiveMessages: timer") self.bgTimer = timer + self.timerCount += 1 if cr.lastMsgTime.distance(to: Date.now) >= waitForMessages { completeReceiving("timer (no messages after \(waitForMessages) seconds)") + } else if self.timerCount >= maxTimerCount { + completeReceiving("timer (called \(maxTimerCount) times") } }, forMode: .default) } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e60f51d534..78fc66bf1c 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -30,6 +30,8 @@ final class ChatModel: ObservableObject { @Published var appOpenUrl: URL? @Published var deviceToken: String? @Published var tokenStatus = NtfTknStatus.new + @Published var notificationMode = NotificationMode.off + @Published var notificationPreview: NotificationPreviewMode? = .message // current WebRTC call @Published var callInvitations: Dictionary = [:] @Published var activeCall: Call? diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index bcab112b64..e353ebf1ed 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -149,10 +149,16 @@ func apiStopChat() async throws { } } -func apiSetAppPhase(appPhase: AgentPhase) { - let r = chatSendCmdSync(.apiSetAppPhase(appPhase: appPhase)) +func apiActivateChat() { + let r = chatSendCmdSync(.apiActivateChat) if case .cmdOk = r { return } - logger.error("apiSetAppPhase error: \(String(describing: r))") + logger.error("apiActivateChat error: \(String(describing: r))") +} + +func apiSuspendChat(timeoutMicroseconds: Int) { + let r = chatSendCmdSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) + if case .cmdOk = r { return } + logger.error("apiSuspendChat error: \(String(describing: r))") } func apiSetFilesFolder(filesFolder: String) throws { @@ -220,8 +226,8 @@ func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteM throw r } -func apiRegisterToken(token: String) async throws -> NtfTknStatus { - let r = await chatSendCmd(.apiRegisterToken(token: token)) +func apiRegisterToken(token: String, notificationMode: NotificationMode) async throws -> NtfTknStatus { + let r = await chatSendCmd(.apiRegisterToken(token: token, notificationMode: notificationMode)) if case let .ntfTokenStatus(status) = r { return status } throw r } @@ -702,8 +708,8 @@ func processReceivedMsg(_ res: ChatResponse) async { m.callCommand = .end // CallController.shared.reportCallRemoteEnded(call: call) } - case let .appPhase(appPhase): - appStateGroupDefault.set(AppState(appPhase: appPhase)) + case .chatSuspended: + chatSuspended() default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift new file mode 100644 index 0000000000..ab552ad9f7 --- /dev/null +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -0,0 +1,52 @@ +// +// SuspendChat.swift +// SimpleX (iOS) +// +// Created by Evgeny on 26/06/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SimpleXChat + +private let suspendLockQueue = DispatchQueue(label: "chat.simplex.app.suspend.lock") + +let appSuspendTimeout: Int = 15 // seconds + +let bgSuspendTimeout: Int = 5 // seconds + +private func _suspendChat(timeout: Int) { + appStateGroupDefault.set(.suspending) + apiSuspendChat(timeoutMicroseconds: timeout * 1000000) + let endTask = beginBGTask(chatSuspended) + DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout) + 1, execute: endTask) +} + +func suspendChat() { + suspendLockQueue.sync { + _suspendChat(timeout: appSuspendTimeout) + } +} + +func suspendBgRefresh() { + suspendLockQueue.sync { + if case .bgRefresh = appStateGroupDefault.get() { + _suspendChat(timeout: bgSuspendTimeout) + } + } +} + +func chatSuspended() { + suspendLockQueue.sync { + if case .suspending = appStateGroupDefault.get() { + appStateGroupDefault.set(.suspended) + } + } +} + +func activateChat(appState: AppState = .active) { + suspendLockQueue.sync { + appStateGroupDefault.set(appState) + apiActivateChat() + } +} diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 557ef1f532..5d983832a7 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -49,15 +49,20 @@ struct SimpleXApp: App { logger.debug("scenePhase \(String(describing: scenePhase))") switch (phase) { case .background: - pauseApp() + suspendChat() + if chatModel.chatRunning == true { + ChatReceiver.shared.stop() + } BGManager.shared.schedule() if userAuthorized == true { enteredBackground = ProcessInfo.processInfo.systemUptime } doAuthenticate = false case .active: - appStateGroupDefault.set(.active) - apiSetAppPhase(appPhase: .active) + activateChat() + if chatModel.chatRunning == true { + ChatReceiver.shared.start() + } doAuthenticate = authenticationExpired() default: break @@ -88,18 +93,6 @@ struct SimpleXApp: App { } } - private func pauseApp() { - appStateGroupDefault.set(.pausing) - apiSetAppPhase(appPhase: .paused) - let endTask = beginBGTask { - if appStateGroupDefault.get() != .active { - appStateGroupDefault.set(.suspending) - apiSetAppPhase(appPhase: .suspended) - } - } - DispatchQueue.global().asyncAfter(deadline: .now() + maxTaskDuration, execute: endTask) - } - private func authenticationExpired() -> Bool { if let enteredBackground = enteredBackground { return ProcessInfo.processInfo.systemUptime - enteredBackground >= 30 diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 6ff5a10348..9611089914 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -221,6 +221,7 @@ struct DatabaseView: View { Task { do { try await apiStopChat() + ChatReceiver.shared.stop() await MainActor.run { m.chatRunning = false } } catch let error { await MainActor.run { @@ -315,6 +316,7 @@ struct DatabaseView: View { _ = try apiStartChat() runChat = true m.chatRunning = true + ChatReceiver.shared.start() chatLastStartGroupDefault.set(Date.now) } catch let error { runChat = false diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift new file mode 100644 index 0000000000..ce1eefcbfe --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift @@ -0,0 +1,163 @@ +// +// NotificationsView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 26/06/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct NotificationsView: View { + @EnvironmentObject var m: ChatModel + @State private var notificationMode: NotificationMode? + @State private var alert: NotificationMode? + + var body: some View { + List { + Section { + NavigationLink { + List { + Section { + SelectionListView(list: NotificationMode.values, selection: $notificationMode) { mode in + alert = mode + } + } footer: { + VStack(alignment: .leading) { + if let mode = notificationMode { + Text(ntfModeDescription(mode)) + } + } + .font(.callout) + .padding(.top, 1) + } + } + .navigationTitle("Send notifications") + .navigationBarTitleDisplayMode(.inline) + .alert(item: $alert) { notificationAlert($0) } + .onAppear { notificationMode = m.notificationMode } + } label: { + HStack { + Text("Send notifications") + Spacer() + Text(m.notificationMode.label) + } + } + + NavigationLink { + List { + Section { + SelectionListView(list: NotificationPreviewMode.values, selection: $m.notificationPreview) + } footer: { + + } + } + .navigationTitle("Show preview") + .navigationBarTitleDisplayMode(.inline) + } label: { + HStack { + Text("Show preview") + Spacer() + Text(m.notificationPreview?.label ?? "") + } + } + } header: { + Text("Message notifications") + } + } + } + + private func notificationAlert(_ mode: NotificationMode) -> Alert { + switch mode { + case .off: + return Alert( + title: Text("Turn off notifications?"), + message: Text(ntfModeDescription(mode)), + primaryButton: .default(Text("Turn off")) { + notificationMode = mode + m.notificationMode = mode + }, + secondaryButton: .cancel() { + notificationMode = m.notificationMode + } + ) + case .periodic: + return Alert( + title: Text("Enable periodic notifcations?"), + message: Text(ntfModeDescription(mode)), + primaryButton: .default(Text("Enable")) { + notificationMode = mode + m.notificationMode = mode + }, + secondaryButton: .cancel() { + notificationMode = m.notificationMode + } + ) + case .instant: + return Alert( + title: Text("Enable instant notifications?"), + message: Text(ntfModeDescription(mode)), + primaryButton: .default(Text("Enable")) { + notificationMode = mode + m.notificationMode = mode + }, + secondaryButton: .cancel() { + notificationMode = m.notificationMode + } + ) + } + } +} + +func ntfModeDescription(_ mode: NotificationMode) -> LocalizedStringKey { + switch mode { + case .off: return "**Maximum privacy**: push notifications are off.\nNo meta-data is shared with SimpleX Chat notification server." + case .periodic: return "**High privacy**: new messages are checked every 20 minutes.\nYour device token is shared with SimpleX Chat notification server, but it cannot see how many connections you have or how many messages you receive." + case .instant: return "**Medium privacy** (recommended): notifications are sent instantly.\nYour device token and notifications are sent to SimpleX Chat notification server, but it cannot access the message content, size or who is it from." + } +} + +struct SelectionListView: View { + var list: [Item] + @Binding var selection: Item? + var onSelection: ((Item) -> Void)? + @State private var tapped: Item? = nil + + var body: some View { + ForEach(list) { item in + HStack { + Text(item.label) + Spacer() + if selection == item { + Image(systemName: "checkmark") + .resizable().scaledToFit().frame(width: 16) + .foregroundColor(.accentColor) + } + } + .contentShape(Rectangle()) + .listRowBackground(Color(uiColor: tapped == item ? .secondarySystemFill : .systemBackground)) + .onTapGesture { + if let f = onSelection { + f(item) + } else { + selection = item + } + } + ._onButtonGesture { down in + if down { + tapped = item + } else { + tapped = nil + } + } perform: {} + } + .environment(\.editMode, .constant(.active)) + } +} + +struct NotificationsView_Previews: PreviewProvider { + static var previews: some View { + NotificationsView() + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 5226e11b1e..47dff51fe1 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -95,14 +95,21 @@ struct SettingsView: View { } Section("Settings") { + NavigationLink { + NotificationsView() + .navigationTitle("Notifications") + } label: { + HStack { + notificationsIcon() + Text("Notifications") + } + } if enableCalls { NavigationLink { CallSettings() .navigationTitle("Your calls") } label: { - settingsRow("video") { - Text("Audio & video calls") - } + settingsRow("video") { Text("Audio & video calls") } } } NavigationLink { @@ -269,7 +276,7 @@ struct SettingsView: View { primaryButton: .destructive(Text("Confirm")) { Task { do { - chatModel.tokenStatus = try await apiRegisterToken(token: token) + chatModel.tokenStatus = try await apiRegisterToken(token: token, notificationMode: .instant) } catch { DispatchQueue.main.async { useNotifications = false diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 769c0754b3..56419b281a 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -18,50 +18,60 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("NotificationService.didReceive") + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) let appState = appStateGroupDefault.get() - if appState.running { + switch appState { + case .suspended: + logger.debug("NotificationService: app is suspended") + self.contentHandler = contentHandler + receiveNtfMessages(request, contentHandler) + case .suspending: + self.contentHandler = contentHandler + receiveNtfMessages(request, contentHandler) + default: print("userInfo", request.content.userInfo) contentHandler(request.content) - return } - logger.debug("NotificationService: app is in the background") - self.contentHandler = contentHandler - bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + } + + func receiveNtfMessages(_ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { let userInfo = request.content.userInfo if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], let nonce = ntfData["nonce"] as? String, let encNtfInfo = ntfData["message"] as? String, let _ = startChat() { - apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) - if let content = receiveMessages() { - contentHandler(content) - return + if let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { + if let content = receiveMessageForNotification() { + contentHandler(content) + } else if let connEntity = ntfMsgInfo.connEntity { + switch connEntity { + case let .rcvDirectMsgConnection(_, contact): + () + case let .rcvGroupMsgConnection(_, groupInfo, groupMember): + () + case let .sndFileConnection(_, sndFileTransfer): + () + case let .rcvFileConnection(_, rcvFileTransfer): + () + case let .userContactConnection(_, userContact): + () + } + contentHandler(request.content) + } } } - - if let bestAttemptContent = bestAttemptContent { - // Modify the notification content here... - bestAttemptContent.title = "\(bestAttemptContent.title) [modified]" - - contentHandler(bestAttemptContent) - } } - + override func serviceExtensionTimeWillExpire() { logger.debug("NotificationService.serviceExtensionTimeWillExpire") // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. - if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + if let contentHandler = self.contentHandler, let bestAttemptContent = self.bestAttemptContent { contentHandler(bestAttemptContent) } } } -func receivedAppMachMessage(msgId: Int32, msg: String) -> String? { - logger.debug("MachMessenger: receivedAppMachMessage \"\(msg)\" from App, replying") - return "reply from NSE to: \(msg)" -} - func startChat() -> User? { hs_init(0, nil) if let user = apiGetActiveUser() { @@ -80,7 +90,7 @@ func startChat() -> User? { return nil } -func receiveMessages() -> UNNotificationContent? { +func receiveMessageForNotification() -> UNNotificationContent? { logger.debug("NotificationService receiveMessages started") while true { if let res = recvSimpleXMsg() { @@ -149,12 +159,16 @@ func apiSetFilesFolder(filesFolder: String) throws { throw r } -func apiGetNtfMessage(nonce: String, encNtfInfo: String) { +func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) if case let .ntfMessages(connEntity, msgTs, ntfMessages) = r { - if let connEntity = connEntity { print("connEntity", connEntity) } - if let msgTs = msgTs { print("msgTs", msgTs) } - print("ntfMessages", ntfMessages) - return + return NtfMessages(connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages) } + return nil +} + +struct NtfMessages { + var connEntity: ConnectionEntity? + var msgTs: Date? + var ntfMessages: [NtfMsgInfo] } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index ed5e204143..c408623643 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -63,6 +63,8 @@ 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA91282713FD00B3292C /* CreateProfile.swift */; }; 5CB0BA962827143500B3292C /* MakeConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA952827143500B3292C /* MakeConnection.swift */; }; 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0BA992827FD8800B3292C /* HowItWorks.swift */; }; + 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; }; + 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; }; 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; @@ -236,6 +238,8 @@ 5CB0BA91282713FD00B3292C /* CreateProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProfile.swift; sourceTree = ""; }; 5CB0BA952827143500B3292C /* MakeConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeConnection.swift; sourceTree = ""; }; 5CB0BA992827FD8800B3292C /* HowItWorks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = ""; }; + 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = ""; }; + 5CB346E62868D76D001FD2EF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; 5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; @@ -405,6 +409,7 @@ 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */, 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, + 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */, ); path = Model; sourceTree = ""; @@ -506,6 +511,7 @@ children = ( 5CB924D327A853F100ACCCDD /* SettingsButton.swift */, 5CB924D627A8563F00ACCCDD /* SettingsView.swift */, + 5CB346E62868D76D001FD2EF /* NotificationsView.swift */, 5C05DF522840AA1D00C683F9 /* CallSettings.swift */, 5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */, 5CB924E327A8683A00ACCCDD /* UserAddress.swift */, @@ -832,6 +838,7 @@ 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, 5CB0BA962827143500B3292C /* MakeConnection.swift in Sources */, 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, + 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */, 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */, 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */, 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */, @@ -847,6 +854,7 @@ 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */, 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, + 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */, 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */, 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 42a012b4bd..f5474623f3 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -7,6 +7,7 @@ // import Foundation +import SwiftUI let jsonDecoder = getJSONDecoder() let jsonEncoder = getJSONEncoder() @@ -16,7 +17,8 @@ public enum ChatCommand { case createActiveUser(profile: Profile) case startChat(subscribe: Bool) case apiStopChat - case apiSetAppPhase(appPhase: AgentPhase) + case apiActivateChat + case apiSuspendChat(timeoutMicroseconds: Int) case setFilesFolder(filesFolder: String) case apiExportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig) @@ -26,7 +28,7 @@ public enum ChatCommand { case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) - case apiRegisterToken(token: String) + case apiRegisterToken(token: String, notificationMode: NotificationMode) case apiVerifyToken(token: String, code: String, nonce: String) case apiIntervalNofication(token: String, interval: Int) case apiDeleteToken(token: String) @@ -62,7 +64,8 @@ public enum ChatCommand { case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" case let .startChat(subscribe): return "/_start subscribe=\(subscribe ? "on" : "off")" case .apiStopChat: return "/_stop" - case let .apiSetAppPhase(appPhase): return "/_app phase \(appPhase.rawValue)" + case .apiActivateChat: return "/_app activate" + case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" @@ -74,7 +77,7 @@ public enum ChatCommand { return "/_send \(ref(type, id)) json \(msg)" case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" - case let .apiRegisterToken(token): return "/_ntf register apns \(token)" + case let .apiRegisterToken(token, notificationMode): return "/_ntf register apns \(token) \(notificationMode.rawValue)" case let .apiVerifyToken(token, code, nonce): return "/_ntf verify apns \(token) \(code) \(nonce)" case let .apiIntervalNofication(token, interval): return "/_ntf interval apns \(token) \(interval)" case let .apiDeleteToken(token): return "/_ntf delete apns \(token)" @@ -112,7 +115,8 @@ public enum ChatCommand { case .createActiveUser: return "createActiveUser" case .startChat: return "startChat" case .apiStopChat: return "apiStopChat" - case .apiSetAppPhase: return "apiSetAppPhase" + case .apiActivateChat: return "apiActivateChat" + case .apiSuspendChat: return "apiSuspendChat" case .setFilesFolder: return "setFilesFolder" case .apiExportArchive: return "apiExportArchive" case .apiImportArchive: return "apiImportArchive" @@ -172,7 +176,7 @@ public enum ChatResponse: Decodable, Error { case chatStarted case chatRunning case chatStopped - case appPhase(appPhase: AgentPhase) + case chatSuspended case apiChats(chats: [ChatData]) case apiChat(chat: ChatData) case userSMPServers(smpServers: [String]) @@ -236,7 +240,7 @@ public enum ChatResponse: Decodable, Error { case .chatStarted: return "chatStarted" case .chatRunning: return "chatRunning" case .chatStopped: return "chatStopped" - case .appPhase: return "appPhase" + case .chatSuspended: return "chatSuspended" case .apiChats: return "apiChats" case .apiChat: return "apiChat" case .userSMPServers: return "userSMPServers" @@ -301,7 +305,7 @@ public enum ChatResponse: Decodable, Error { case .chatStarted: return noDetails case .chatRunning: return noDetails case .chatStopped: return noDetails - case let .appPhase(appPhase): return appPhase.rawValue + case .chatSuspended: return noDetails case let .apiChats(chats): return String(describing: chats) case let .apiChat(chat): return String(describing: chat) case let .userSMPServers(smpServers): return String(describing: smpServers) @@ -367,12 +371,6 @@ struct ComposedMessage: Encodable { var msgContent: MsgContent } -public enum AgentPhase: String, Decodable { - case active = "ACTIVE" - case paused = "PAUSED" - case suspended = "SUSPENDED" -} - public struct ArchiveConfig: Encodable { var archivePath: String var disableCompression: Bool? @@ -383,6 +381,47 @@ public struct ArchiveConfig: Encodable { } } +public protocol SelectableItem: Hashable, Identifiable { + var label: LocalizedStringKey { get } + static var values: [Self] { get } +} + +public enum NotificationMode: String, SelectableItem { + case off = "OFF" + case periodic = "PERIODIC" + case instant = "INSTANT" + + public var label: LocalizedStringKey { + switch self { + case .off: return "Off" + case .periodic: return "Periodically" + case .instant: return "Instantly" + } + } + + public var id: String { self.rawValue } + + public static var values: [NotificationMode] = [.instant, .periodic, .off] +} + +public enum NotificationPreviewMode: String, SelectableItem { + case hidden + case contact + case message + + public var label: LocalizedStringKey { + switch self { + case .hidden: return "Hidden" + case .contact: return "Contact" + case .message: return "Message" + } + } + + public var id: String { self.rawValue } + + public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden] +} + public func decodeJSON(_ json: String) -> T? { if let data = json.data(using: .utf8) { return try? jsonDecoder.decode(T.self, from: data) diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 15c26d896f..b55c559bcd 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -19,27 +19,9 @@ public let groupDefaults = UserDefaults(suiteName: APP_GROUP_NAME)! public enum AppState: String { case active - case pausing - case paused + case bgRefresh case suspending case suspended - - public init(appPhase: AgentPhase) { - switch appPhase { - case .active: self = .active - case .paused: self = .paused - case .suspended: self = .suspended - } - } - - public var running: Bool { - switch self { - case .paused: return false - case .suspending: return false - case .suspended: return false - default: return true - } - } } public enum DBContainer: String {