diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e29a92a46d..7fca87c52a 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -47,7 +47,7 @@ enum TerminalItem: Identifiable { } } -private func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { +func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { var id: UIBackgroundTaskIdentifier! var running = true let endTask = { @@ -143,6 +143,20 @@ func apiStartChat() throws -> Bool { } } +func apiStopChat() throws { + let r = chatSendCmdSync(.apiStopChat) + switch r { + case .chatStopped: return + default: throw r + } +} + +func apiSetAppPhase(appPhase: AgentPhase) { + let r = chatSendCmdSync(.apiSetAppPhase(appPhase: appPhase)) + if case .cmdOk = r { return } + logger.error("apiSetAppPhase error: \(String(describing: r))") +} + func apiSetFilesFolder(filesFolder: String) throws { let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder)) if case .cmdOk = r { return } @@ -512,7 +526,7 @@ class ChatReceiver { func receiveMsgLoop() async { let msg = await chatRecvMsg() self._lastMsgTime = .now - processReceivedMsg(msg) + await processReceivedMsg(msg) if self.receiveMessages { do { try await Task.sleep(nanoseconds: 7_500_000) } catch { logger.error("receiveMsgLoop: Task.sleep error: \(error.localizedDescription)") } @@ -528,9 +542,9 @@ class ChatReceiver { } } -func processReceivedMsg(_ res: ChatResponse) { +func processReceivedMsg(_ res: ChatResponse) async { let m = ChatModel.shared - DispatchQueue.main.async { + await MainActor.run { m.terminalItems.append(.resp(.now, res)) logger.debug("processReceivedMsg: \(res.responseType)") switch res { @@ -682,6 +696,8 @@ func processReceivedMsg(_ res: ChatResponse) { m.callCommand = .end // CallController.shared.reportCallRemoteEnded(call: call) } + case let .appPhase(appPhase): + setAppState(AppState(appPhase: appPhase)) default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 8ce48b9da9..4c46ecde5c 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -11,13 +11,6 @@ import SimpleXChat let logger = Logger() -let machMessenger = MachMessenger(APP_MACH_PORT, callback: receivedNSEMachMessage) - -func receivedNSEMachMessage(msgId: Int32, msg: String) -> String? { - logger.debug("MachMessenger: receivedNSEMachMessage \"\(msg)\" from NSE, replying") - return "reply from App to: \(msg)" -} - @main struct SimpleXApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @@ -34,7 +27,6 @@ struct SimpleXApp: App { UserDefaults.standard.register(defaults: appDefaults) BGManager.shared.register() NtfManager.shared.registerCategories() - machMessenger.start() } var body: some Scene { @@ -50,20 +42,18 @@ struct SimpleXApp: App { } .onChange(of: scenePhase) { phase in logger.debug("scenePhase \(String(describing: scenePhase))") - let res = machMessenger.sendMessageWithReply(NSE_MACH_PORT, msg: "App scenePhase changed to \(String(describing: scenePhase))") - logger.debug("MachMessenger \(String(describing: res), privacy: .public)") - setAppState(phase) switch (phase) { case .background: + pauseApp() BGManager.shared.schedule() if userAuthorized == true { enteredBackground = ProcessInfo.processInfo.systemUptime } doAuthenticate = false - machMessenger.stop() case .active: + setAppState(.active) + apiSetAppPhase(appPhase: .active) doAuthenticate = authenticationExpired() - machMessenger.start() default: break } @@ -71,6 +61,18 @@ struct SimpleXApp: App { } } + private func pauseApp() { + setAppState(.pausing) + apiSetAppPhase(appPhase: .paused) + let endTask = beginBGTask { + if getAppState() != .active { + setAppState(.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/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 41668e9543..7cac78b46f 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -12,29 +12,28 @@ import SimpleXChat let logger = Logger() -let machMessenger = MachMessenger(NSE_MACH_PORT, callback: receivedAppMachMessage) - class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("NotificationService.didReceive") - machMessenger.start() - let res = machMessenger.sendMessageWithReply(APP_MACH_PORT, msg: "starting NSE didReceive") - logger.debug("MachMessenger \(String(describing: res), privacy: .public)") - if getAppState() != .background { + let appState = getAppState() + if appState.running { contentHandler(request.content) - machMessenger.stop() return } logger.debug("NotificationService: app is in the background") self.contentHandler = contentHandler bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - if let _ = startChat() { + 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) let content = receiveMessages() contentHandler (content) - machMessenger.stop() return } @@ -44,7 +43,6 @@ class NotificationService: UNNotificationServiceExtension { contentHandler(bestAttemptContent) } - machMessenger.stop() } override func serviceExtensionTimeWillExpire() { @@ -145,3 +143,12 @@ func apiSetFilesFolder(filesFolder: String) throws { throw r } +func apiGetNtfMessage(nonce: String, encNtfInfo: String) { + let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) + if case let .ntfMessages(connEntity, msgTs, ntfMessages) = r { + print(connEntity) + print(msgTs) + print(ntfMessages) + return + } +} diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d45ceef19e..4424cf2c73 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -15,6 +15,8 @@ public enum ChatCommand { case showActiveUser case createActiveUser(profile: Profile) case startChat + case apiStopChat + case apiSetAppPhase(appPhase: AgentPhase) case setFilesFolder(filesFolder: String) case apiGetChats case apiGetChat(type: ChatType, id: Int64) @@ -25,6 +27,7 @@ public enum ChatCommand { case apiVerifyToken(token: String, code: String, nonce: String) case apiIntervalNofication(token: String, interval: Int) case apiDeleteToken(token: String) + case apiGetNtfMessage(nonce: String, encNtfInfo: String) case getUserSMPServers case setUserSMPServers(smpServers: [String]) case addContact @@ -56,6 +59,8 @@ public enum ChatCommand { case .showActiveUser: return "/u" case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" case .startChat: return "/_start" + case .apiStopChat: return "/_stop" + case let .apiSetAppPhase(appPhase): return "/_app phase \(appPhase)" case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" case .apiGetChats: return "/_get chats pcc=on" case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" @@ -68,6 +73,7 @@ public enum ChatCommand { 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)" + case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)" case .getUserSMPServers: return "/smp_servers" case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" case .addContact: return "/connect" @@ -101,6 +107,8 @@ public enum ChatCommand { case .showActiveUser: return "showActiveUser" case .createActiveUser: return "createActiveUser" case .startChat: return "startChat" + case .apiStopChat: return "apiStopChat" + case .apiSetAppPhase: return "apiSetAppPhase" case .setFilesFolder: return "setFilesFolder" case .apiGetChats: return "apiGetChats" case .apiGetChat: return "apiGetChat" @@ -111,6 +119,7 @@ public enum ChatCommand { case .apiVerifyToken: return "apiVerifyToken" case .apiIntervalNofication: return "apiIntervalNofication" case .apiDeleteToken: return "apiDeleteToken" + case .apiGetNtfMessage: return "apiGetNtfMessage" case .getUserSMPServers: return "getUserSMPServers" case .setUserSMPServers: return "setUserSMPServers" case .addContact: return "addContact" @@ -156,6 +165,8 @@ public enum ChatResponse: Decodable, Error { case activeUser(user: User) case chatStarted case chatRunning + case chatStopped + case appPhase(appPhase: AgentPhase) case apiChats(chats: [ChatData]) case apiChat(chat: ChatData) case userSMPServers(smpServers: [String]) @@ -205,6 +216,7 @@ public enum ChatResponse: Decodable, Error { case callExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) case callEnded(contact: Contact) case ntfTokenStatus(status: NtfTknStatus) + case ntfMessages(connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) case newContactConnection(connection: PendingContactConnection) case contactConnectionDeleted(connection: PendingContactConnection) case cmdOk @@ -218,6 +230,8 @@ public enum ChatResponse: Decodable, Error { case .activeUser: return "activeUser" case .chatStarted: return "chatStarted" case .chatRunning: return "chatRunning" + case .chatStopped: return "chatStopped" + case .appPhase: return "appPhase" case .apiChats: return "apiChats" case .apiChat: return "apiChat" case .userSMPServers: return "userSMPServers" @@ -265,6 +279,7 @@ public enum ChatResponse: Decodable, Error { case .callExtraInfo: return "callExtraInfo" case .callEnded: return "callEnded" case .ntfTokenStatus: return "ntfTokenStatus" + case .ntfMessages: return "ntfMessages" case .newContactConnection: return "newContactConnection" case .contactConnectionDeleted: return "contactConnectionDeleted" case .cmdOk: return "cmdOk" @@ -281,6 +296,8 @@ public enum ChatResponse: Decodable, Error { case let .activeUser(user): return String(describing: user) case .chatStarted: return noDetails case .chatRunning: return noDetails + case .chatStopped: return noDetails + case let .appPhase(appPhase): return appPhase.rawValue case let .apiChats(chats): return String(describing: chats) case let .apiChat(chat): return String(describing: chat) case let .userSMPServers(smpServers): return String(describing: smpServers) @@ -328,6 +345,7 @@ public enum ChatResponse: Decodable, Error { case let .callExtraInfo(contact, extraInfo): return "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))" case let .callEnded(contact): return "contact: \(contact.id)" case let .ntfTokenStatus(status): return String(describing: status) + case let .ntfMessages(connEntity, msgTs, ntfMessages): return "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))" case let .newContactConnection(connection): return String(describing: connection) case let .contactConnectionDeleted(connection): return String(describing: connection) case .cmdOk: return noDetails @@ -346,6 +364,12 @@ struct ComposedMessage: Encodable { var msgContent: MsgContent } +public enum AgentPhase: String, Codable { + case active = "ACTIVE" + case paused = "PAUSED" + case suspended = "SUSPENDED" +} + 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 92573e0354..abb996ecab 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -9,169 +9,51 @@ import Foundation import SwiftUI -let GROUP_DEFAULT_APP_IN_BACKGROUND = "appInBackground" +let GROUP_DEFAULT_APP_STATE = "appState" let APP_GROUP_NAME = "group.chat.simplex.app" -public let NSE_MACH_PORT = "\(APP_GROUP_NAME).nse" as CFString - -public let APP_MACH_PORT = "\(APP_GROUP_NAME).app" as CFString - func getGroupDefaults() -> UserDefaults? { UserDefaults(suiteName: APP_GROUP_NAME) } -public func setAppState(_ phase: ScenePhase) { +public enum AppState: String { + case active + case pausing + case paused + 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 func setAppState(_ state: AppState) { if let defaults = getGroupDefaults() { - defaults.set(phase == .background, forKey: GROUP_DEFAULT_APP_IN_BACKGROUND) + defaults.set(state.rawValue, forKey: GROUP_DEFAULT_APP_STATE) defaults.synchronize() } } -public func getAppState() -> ScenePhase { - if let defaults = getGroupDefaults() { - if defaults.bool(forKey: GROUP_DEFAULT_APP_IN_BACKGROUND) { - return .background - } +public func getAppState() -> AppState { + if let defaults = getGroupDefaults(), + let rawValue = defaults.string(forKey: GROUP_DEFAULT_APP_STATE), + let state = AppState(rawValue: rawValue) { + return state } return .active } - -let MACH_SEND_TIMEOUT: CFTimeInterval = 1.0 -let MACH_REPLY_TIMEOUT: CFTimeInterval = 1.0 - -public class MachMessenger { - public init(_ localPortName: CFString, callback: @escaping Callback) { - self.localPortName = localPortName - self.callback = callback - self.localPort = nil - } - - var localPortName: CFString - var callback: Callback - var localPort: CFMessagePort? - - public enum SendError: Error { - case sndTimeout - case rcvTimeout - case portInvalid - case sendError(Int32) - case msgError - } - - public typealias Callback = (_ msgId: Int32, _ msg: String) -> String? - - class CallbackInfo { - internal init(callback: @escaping MachMessenger.Callback) { - self.callback = callback - } - - var callback: Callback - } - - public static func sendError(_ code: Int32) -> SendError? { - switch code { - case kCFMessagePortSuccess: return nil - case kCFMessagePortSendTimeout: return .sndTimeout - case kCFMessagePortReceiveTimeout: return .rcvTimeout - case kCFMessagePortIsInvalid: return .portInvalid - case kCFMessagePortBecameInvalidError: return .portInvalid - default: return .sendError(code) - } - } - - public func start() { - logger.debug("MachMessenger.start") - localPort = createLocalPort(localPortName, callback: callback) - } - - public func stop() { - if let port = localPort { - logger.debug("MachMessenger.stop") - CFMessagePortInvalidate(port) - localPort = nil - } - } - - public func sendMessage(_ remotePortName: CFString, msgId: Int32 = 0, msg: String) -> SendError? { - logger.debug("MachMessenger.sendMessage") - if let port = createRemotePort(remotePortName) { - logger.debug("MachMessenger.sendMessage: sending...") - return sendMessage(port, msgId: msgId, msg: msg) - } else { - logger.debug("MachMessenger.sendMessage: no remote port") - return .portInvalid - } - } - - public func sendMessageWithReply(_ remotePortName: CFString, msgId: Int32 = 0, msg: String) -> Result { - logger.debug("MachMessenger.sendMessageWithReply") - if let port = createRemotePort(remotePortName) { - logger.debug("MachMessenger.sendMessageWithReply: sending...") - return sendMessageWithReply(port, msgId: msgId, msg: msg) - } else { - logger.debug("MachMessenger.sendMessageWithReply: no remote port") - return .failure(.portInvalid) - } - } - - private func createLocalPort(_ portName: CFString, callback: @escaping Callback) -> CFMessagePort? { - logger.debug("MachMessenger.createLocalPort") - if let port = localPort { return port } - logger.debug("MachMessenger.createLocalPort: creating...") - var context = CFMessagePortContext() - context.version = 0 - context.info = Unmanaged.passRetained(CallbackInfo(callback: callback)).toOpaque() - let callout: CFMessagePortCallBack = { port, msgId, msgData, info in - logger.debug("MachMessenger CFMessagePortCallBack called") - if let data = msgData, - let msg = String(data: data as Data, encoding: .utf8), - let info = info, - let resp = Unmanaged.fromOpaque(info).takeUnretainedValue().callback(msgId, msg), - let respData = resp.data(using: .utf8) { - return Unmanaged.passRetained(respData as CFData) - } - return nil - } - return withUnsafeMutablePointer(to: &context) { cxt in - let port = CFMessagePortCreateLocal(kCFAllocatorDefault, portName, callout, cxt, nil) - CFMessagePortSetDispatchQueue(port, DispatchQueue.main); - localPort = port - logger.debug("MachMessenger.createLocalPort created: \(portName)") - return port - } - } - - private func createRemotePort(_ portName: CFString) -> CFMessagePort? { - CFMessagePortCreateRemote(kCFAllocatorDefault, portName) - } - - private func sendMessage(_ remotePort: CFMessagePort, msgId: Int32 = 0, msg: String) -> SendError? { - if let data = msg.data(using: .utf8) { - logger.debug("MachMessenger sendMessage") - let msgData = data as CFData - let code = CFMessagePortSendRequest(remotePort, msgId, msgData, MACH_SEND_TIMEOUT, 0, nil, nil) - logger.debug("MachMessenger sendMessage \(code)") - return MachMessenger.sendError(code) - } - return .msgError - } - - private func sendMessageWithReply(_ remotePort: CFMessagePort, msgId: Int32 = 0, msg: String) -> Result { - if let data = msg.data(using: .utf8) { - let msgData = data as CFData - var respData: Unmanaged? = nil - let code = CFMessagePortSendRequest(remotePort, msgId, msgData, MACH_SEND_TIMEOUT, MACH_REPLY_TIMEOUT, CFRunLoopMode.defaultMode.rawValue, &respData) - if let err = MachMessenger.sendError(code) { - return .failure(err) - } else if let data = respData?.takeUnretainedValue(), - let resp = String(data: data as Data, encoding: .utf8) { - return .success(resp) - } else { - return .success(nil) - } - - } - return .failure(.msgError) - } -} diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index fd631a0cc4..1b97a14a0c 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -264,6 +264,9 @@ public struct Connection: Decodable { ) } +public struct UserContact: Decodable { +} + public struct UserContactRequest: Decodable, NamedChat { var contactRequestId: Int64 var localDisplayName: ContactName @@ -437,6 +440,18 @@ public struct MemberSubError: Decodable { var memberError: ChatError } +public enum ConnectionEntity: Decodable { + case rcvDirectMsgConnection(entityConnection: Connection, contact: Contact?) + case rcvGroupMsgConnection(entityConnection: Connection, groupInfo: GroupInfo, groupMember: GroupMember) + case sndFileConnection(entityConnection: Connection, sndFileTransfer: SndFileTransfer) + case rcvFileConnection(entityConnection: Connection, rcvFileTransfer: RcvFileTransfer) + case userContactConnection(entityConnection: Connection, userContact: UserContact) +} + +public struct NtfMsgInfo: Decodable { + +} + public struct AChatItem: Decodable { public var chatInfo: ChatInfo public var chatItem: ChatItem @@ -901,6 +916,10 @@ public struct SndFileTransfer: Decodable { } +public struct RcvFileTransfer: Decodable { + +} + public struct FileTransferMeta: Decodable { } diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 0aac23fecc..3da2d37979 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1096,6 +1096,7 @@ processAgentMessage Nothing _ _ = throwChatError CENoActiveUser processAgentMessage (Just User {userId}) "" agentMessage = case agentMessage of DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected" + PHASE phase -> toView $ CRAppPhase phase _ -> pure () where serverEvent srv@SMP.ProtocolServer {host, port} conns event str = do diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index df6dc640ba..43194387a5 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -33,7 +33,7 @@ import Simplex.Chat.Call import Simplex.Chat.Types import Simplex.Chat.Util (safeDecodeUtf8) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (fromTextField_, sumTypeJSON) +import Simplex.Messaging.Parsers (fromTextField_, fstToLower, sumTypeJSON) import Simplex.Messaging.Util (eitherToMaybe, (<$?>)) data ConnectionEntity @@ -45,8 +45,8 @@ data ConnectionEntity deriving (Eq, Show, Generic) instance ToJSON ConnectionEntity where - toJSON = J.genericToJSON $ sumTypeJSON id - toEncoding = J.genericToEncoding $ sumTypeJSON id + toJSON = J.genericToJSON $ sumTypeJSON fstToLower + toEncoding = J.genericToEncoding $ sumTypeJSON fstToLower updateEntityConnStatus :: ConnectionEntity -> ConnStatus -> ConnectionEntity updateEntityConnStatus connEntity connStatus = case connEntity of