diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 4e21969b70..3e5fde0aa0 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -110,7 +110,7 @@ final class ChatModel: ObservableObject { if case .rcvNew = cItem.meta.itemStatus { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { if self.chatId == cInfo.id { - Task { await SimpleX.markChatItemRead(cInfo, cItem) } + Task { await apiMarkChatItemRead(cInfo, cItem) } } } } @@ -216,178 +216,6 @@ final class ChatModel: ObservableObject { } } -struct User: Decodable, NamedChat { - var userId: Int64 - var userContactId: Int64 - var localDisplayName: ContactName - var profile: Profile - var activeUser: Bool - - var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } - var image: String? { get { profile.image } } - - static let sampleData = User( - userId: 1, - userContactId: 1, - localDisplayName: "alice", - profile: Profile.sampleData, - activeUser: true - ) -} - -typealias ContactName = String - -typealias GroupName = String - -struct Profile: Codable, NamedChat { - var displayName: String - var fullName: String - var image: String? - - static let sampleData = Profile( - displayName: "alice", - fullName: "Alice" - ) -} - -enum ChatType: String { - case direct = "@" - case group = "#" - case contactRequest = "<@" - case contactConnection = ":" -} - -protocol NamedChat { - var displayName: String { get } - var fullName: String { get } - var image: String? { get } -} - -extension NamedChat { - var chatViewName: String { - get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") } - } -} - -typealias ChatId = String - -enum ChatInfo: Identifiable, Decodable, NamedChat { - case direct(contact: Contact) - case group(groupInfo: GroupInfo) - case contactRequest(contactRequest: UserContactRequest) - case contactConnection(contactConnection: PendingContactConnection) - - var localDisplayName: String { - get { - switch self { - case let .direct(contact): return contact.localDisplayName - case let .group(groupInfo): return groupInfo.localDisplayName - case let .contactRequest(contactRequest): return contactRequest.localDisplayName - case let .contactConnection(contactConnection): return contactConnection.localDisplayName - } - } - } - - var displayName: String { - get { - switch self { - case let .direct(contact): return contact.displayName - case let .group(groupInfo): return groupInfo.displayName - case let .contactRequest(contactRequest): return contactRequest.displayName - case let .contactConnection(contactConnection): return contactConnection.displayName - } - } - } - - var fullName: String { - get { - switch self { - case let .direct(contact): return contact.fullName - case let .group(groupInfo): return groupInfo.fullName - case let .contactRequest(contactRequest): return contactRequest.fullName - case let .contactConnection(contactConnection): return contactConnection.fullName - } - } - } - - var image: String? { - get { - switch self { - case let .direct(contact): return contact.image - case let .group(groupInfo): return groupInfo.image - case let .contactRequest(contactRequest): return contactRequest.image - case let .contactConnection(contactConnection): return contactConnection.image - } - } - } - - var id: ChatId { - get { - switch self { - case let .direct(contact): return contact.id - case let .group(groupInfo): return groupInfo.id - case let .contactRequest(contactRequest): return contactRequest.id - case let .contactConnection(contactConnection): return contactConnection.id - } - } - } - - var chatType: ChatType { - get { - switch self { - case .direct: return .direct - case .group: return .group - case .contactRequest: return .contactRequest - case .contactConnection: return .contactConnection - } - } - } - - var apiId: Int64 { - get { - switch self { - case let .direct(contact): return contact.apiId - case let .group(groupInfo): return groupInfo.apiId - case let .contactRequest(contactRequest): return contactRequest.apiId - case let .contactConnection(contactConnection): return contactConnection.apiId - } - } - } - - var ready: Bool { - get { - switch self { - case let .direct(contact): return contact.ready - case let .group(groupInfo): return groupInfo.ready - case let .contactRequest(contactRequest): return contactRequest.ready - case let .contactConnection(contactConnection): return contactConnection.ready - } - } - } - - var createdAt: Date { - switch self { - case let .direct(contact): return contact.createdAt - case let .group(groupInfo): return groupInfo.createdAt - case let .contactRequest(contactRequest): return contactRequest.createdAt - case let .contactConnection(contactConnection): return contactConnection.createdAt - } - } - - struct SampleData { - var direct: ChatInfo - var group: ChatInfo - var contactRequest: ChatInfo - } - - static var sampleData: ChatInfo.SampleData = SampleData( - direct: ChatInfo.direct(contact: Contact.sampleData), - group: ChatInfo.group(groupInfo: GroupInfo.sampleData), - contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData) - ) -} - final class Chat: ObservableObject, Identifiable { @Published var chatInfo: ChatInfo @Published var chatItems: [ChatItem] @@ -450,606 +278,3 @@ final class Chat: ObservableObject, Identifiable { var id: ChatId { get { chatInfo.id } } } - -struct ChatData: Decodable, Identifiable { - var chatInfo: ChatInfo - var chatItems: [ChatItem] - var chatStats: ChatStats - - var id: ChatId { get { chatInfo.id } } -} - -struct ChatStats: Decodable { - var unreadCount: Int = 0 - var minUnreadItemId: Int64 = 0 -} - -struct Contact: Identifiable, Decodable, NamedChat { - var contactId: Int64 - var localDisplayName: ContactName - var profile: Profile - var activeConn: Connection - var viaGroup: Int64? - var createdAt: Date - - var id: ChatId { get { "@\(contactId)" } } - var apiId: Int64 { get { contactId } } - var ready: Bool { get { activeConn.connStatus == .ready } } - var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } - var image: String? { get { profile.image } } - - static let sampleData = Contact( - contactId: 1, - localDisplayName: "alice", - profile: Profile.sampleData, - activeConn: Connection.sampleData, - createdAt: .now - ) -} - -struct ContactRef: Decodable { - var contactId: Int64 - var localDisplayName: ContactName - - var id: ChatId { get { "@\(contactId)" } } -} - -struct ContactSubStatus: Decodable { - var contact: Contact - var contactError: ChatError? -} - -struct Connection: Decodable { - var connId: Int64 - var connStatus: ConnStatus - - var id: ChatId { get { ":\(connId)" } } - - static let sampleData = Connection( - connId: 1, - connStatus: .ready - ) -} - -struct UserContactRequest: Decodable, NamedChat { - var contactRequestId: Int64 - var localDisplayName: ContactName - var profile: Profile - var createdAt: Date - var updatedAt: Date - - var id: ChatId { get { "<@\(contactRequestId)" } } - var apiId: Int64 { get { contactRequestId } } - var ready: Bool { get { true } } - var displayName: String { get { profile.displayName } } - var fullName: String { get { profile.fullName } } - var image: String? { get { profile.image } } - - static let sampleData = UserContactRequest( - contactRequestId: 1, - localDisplayName: "alice", - profile: Profile.sampleData, - createdAt: .now, - updatedAt: .now - ) -} - -struct PendingContactConnection: Decodable, NamedChat { - var pccConnId: Int64 - var pccAgentConnId: String - var pccConnStatus: ConnStatus - var viaContactUri: Bool - var createdAt: Date - var updatedAt: Date - - var id: ChatId { get { ":\(pccConnId)" } } - var apiId: Int64 { get { pccConnId } } - var ready: Bool { get { false } } - var localDisplayName: String { - get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) } - } - var displayName: String { - get { - if let initiated = pccConnStatus.initiated { - return initiated && !viaContactUri - ? NSLocalizedString("invited to connect", comment: "chat list item title") - : NSLocalizedString("connecting…", comment: "chat list item title") - } else { - // this should not be in the list - return NSLocalizedString("connection established", comment: "chat list item title (it should not be shown") - } - } - } - var fullName: String { get { "" } } - var image: String? { get { nil } } - var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } } - - var description: String { - get { - if let initiated = pccConnStatus.initiated { - return initiated && !viaContactUri - ? NSLocalizedString("you shared one-time link", comment: "chat list item description") - : viaContactUri - ? NSLocalizedString("via contact address link", comment: "chat list item description") - : NSLocalizedString("via one-time link", comment: "chat list item description") - } else { - return "" - } - } - } - - static func getSampleData(_ status: ConnStatus = .new, viaContactUri: Bool = false) -> PendingContactConnection { - PendingContactConnection( - pccConnId: 1, - pccAgentConnId: "abcd", - pccConnStatus: status, - viaContactUri: viaContactUri, - createdAt: .now, - updatedAt: .now - ) - } -} - -enum ConnStatus: String, Decodable { - case new = "new" - case joined = "joined" - case requested = "requested" - case accepted = "accepted" - case sndReady = "snd-ready" - case ready = "ready" - case deleted = "deleted" - - var initiated: Bool? { - get { - switch self { - case .new: return true - case .joined: return false - case .requested: return true - case .accepted: return true - case .sndReady: return false - case .ready: return nil - case .deleted: return nil - } - } - } -} - -struct GroupInfo: Identifiable, Decodable, NamedChat { - var groupId: Int64 - var localDisplayName: GroupName - var groupProfile: GroupProfile - var createdAt: Date - - var id: ChatId { get { "#\(groupId)" } } - var apiId: Int64 { get { groupId } } - var ready: Bool { get { true } } - var displayName: String { get { groupProfile.displayName } } - var fullName: String { get { groupProfile.fullName } } - var image: String? { get { groupProfile.image } } - - static let sampleData = GroupInfo( - groupId: 1, - localDisplayName: "team", - groupProfile: GroupProfile.sampleData, - createdAt: .now - ) -} - -struct GroupProfile: Codable, NamedChat { - var displayName: String - var fullName: String - var image: String? - - static let sampleData = GroupProfile( - displayName: "team", - fullName: "My Team" - ) -} - -struct GroupMember: Decodable { - var groupMemberId: Int64 - var memberId: String -// var memberRole: GroupMemberRole -// var memberCategory: GroupMemberCategory -// var memberStatus: GroupMemberStatus -// var invitedBy: InvitedBy - var localDisplayName: ContactName - var memberProfile: Profile - var memberContactId: Int64? -// var activeConn: Connection? - - var directChatId: ChatId? { - get { - if let chatId = memberContactId { - return "@\(chatId)" - } else { - return nil - } - } - } - - static let sampleData = GroupMember( - groupMemberId: 1, - memberId: "abcd", - localDisplayName: "alice", - memberProfile: Profile.sampleData, - memberContactId: 1 - ) -} - -struct MemberSubError: Decodable { - var member: GroupMember - var memberError: ChatError -} - -struct AChatItem: Decodable { - var chatInfo: ChatInfo - var chatItem: ChatItem -} - -struct ChatItem: Identifiable, Decodable { - var chatDir: CIDirection - var meta: CIMeta - var content: CIContent - var formattedText: [FormattedText]? - var quotedItem: CIQuote? - var file: CIFile? - - var id: Int64 { get { meta.itemId } } - - var timestampText: Text { get { meta.timestampText } } - - var text: String { - get { - switch (content.text, file) { - case let ("", .some(file)): return file.fileName - default: return content.text - } - } - } - - func isRcvNew() -> Bool { - if case .rcvNew = meta.itemStatus { return true } - return false - } - - func isMsgContent() -> Bool { - switch content { - case .sndMsgContent: return true - case .rcvMsgContent: return true - default: return false - } - } - - func isDeletedContent() -> Bool { - switch content { - case .sndDeleted: return true - case .rcvDeleted: return true - default: return false - } - } - - var memberDisplayName: String? { - get { - if case let .groupRcv(groupMember) = chatDir { - return groupMember.memberProfile.displayName - } else { - return nil - } - } - } - - static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { - ChatItem( - chatDir: dir, - meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), - content: .sndMsgContent(msgContent: .text(text)), - quotedItem: quotedItem, - file: file - ) - } - - static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem { - ChatItem( - chatDir: dir, - meta: CIMeta.getSample(id, ts, text, status, false, false, false), - content: .rcvDeleted(deleteMode: .cidmBroadcast), - quotedItem: nil, - file: nil - ) - } -} - -enum CIDirection: Decodable { - case directSnd - case directRcv - case groupSnd - case groupRcv(groupMember: GroupMember) - - var sent: Bool { - get { - switch self { - case .directSnd: return true - case .directRcv: return false - case .groupSnd: return true - case .groupRcv: return false - } - } - } -} - -struct CIMeta: Decodable { - var itemId: Int64 - var itemTs: Date - var itemText: String - var itemStatus: CIStatus - var createdAt: Date - var itemDeleted: Bool - var itemEdited: Bool - var editable: Bool - - var timestampText: Text { get { SimpleX.timestampText(itemTs) } } - - static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta { - CIMeta( - itemId: id, - itemTs: ts, - itemText: text, - itemStatus: status, - createdAt: ts, - itemDeleted: itemDeleted, - itemEdited: itemEdited, - editable: editable - ) - } -} - -let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute() -let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) - -func timestampText(_ date: Date) -> Text { - let now = Calendar.current.dateComponents([.day, .hour], from: .now) - let dc = Calendar.current.dateComponents([.day, .hour], from: date) - let recent = now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12) - return Text(date, format: recent ? msgTimeFormat : msgDateFormat) -} - -enum CIStatus: Decodable { - case sndNew - case sndSent - case sndErrorAuth - case sndError(agentError: AgentErrorType) - case rcvNew - case rcvRead -} - -enum CIDeleteMode: String, Decodable { - case cidmBroadcast = "broadcast" - case cidmInternal = "internal" -} - -protocol ItemContent { - var text: String { get } -} - -enum CIContent: Decodable, ItemContent { - case sndMsgContent(msgContent: MsgContent) - case rcvMsgContent(msgContent: MsgContent) - case sndDeleted(deleteMode: CIDeleteMode) - case rcvDeleted(deleteMode: CIDeleteMode) - - var text: String { - get { - switch self { - case let .sndMsgContent(mc): return mc.text - case let .rcvMsgContent(mc): return mc.text - case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - } - } - } - - var msgContent: MsgContent? { - get { - switch self { - case let .sndMsgContent(mc): return mc - case let .rcvMsgContent(mc): return mc - default: return nil - } - } - } -} - -struct CIQuote: Decodable, ItemContent { - var chatDir: CIDirection? - var itemId: Int64? - var sharedMsgId: String? = nil - var sentAt: Date - var content: MsgContent - var formattedText: [FormattedText]? - - var text: String { get { content.text } } - - var sender: String? { - get { - switch (chatDir) { - case .directSnd: return "you" - case .directRcv: return nil - case .groupSnd: return ChatModel.shared.currentUser?.displayName - case let .groupRcv(member): return member.memberProfile.displayName - case nil: return nil - } - } - } - - static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?, image: String? = nil) -> CIQuote { - let mc: MsgContent - if let image = image { - mc = .image(text: text, image: image) - } else { - mc = .text(text) - } - return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) - } -} - -struct CIFile: Decodable { - var fileId: Int64 - var fileName: String - var fileSize: Int64 - var filePath: String? - var fileStatus: CIFileStatus - - static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile { - CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus) - } - - var stored: Bool { - get { - switch self.fileStatus { - case .sndStored: return true - case .sndCancelled: return true - case .rcvComplete: return true - default: return false - } - } - } -} - -enum CIFileStatus: String, Decodable { - case sndStored = "snd_stored" - case sndCancelled = "snd_cancelled" - case rcvInvitation = "rcv_invitation" - case rcvTransfer = "rcv_transfer" - case rcvComplete = "rcv_complete" - case rcvCancelled = "rcv_cancelled" -} - -enum MsgContent { - case text(String) - case link(text: String, preview: LinkPreview) - case image(text: String, image: String) - // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift - case unknown(type: String, text: String) - - var text: String { - get { - switch self { - case let .text(text): return text - case let .link(text, _): return text - case let .image(text, _): return text - case let .unknown(_, text): return text - } - } - } - - var cmdString: String { - get { - switch self { - case let .text(text): return "text \(text)" - case let .link(text: text, preview: preview): - return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}" - case let .image(text: text, image: image): - return "json {\"type\":\"image\",\"text\":\(encodeJSON(text)),\"image\":\(encodeJSON(image))}" - default: return "" - } - } - } - - enum CodingKeys: String, CodingKey { - case type - case text - case preview - case image - } -} - -// TODO define Encodable -extension MsgContent: Decodable { - init(from decoder: Decoder) throws { - do { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: CodingKeys.type) - switch type { - case "text": - let text = try container.decode(String.self, forKey: CodingKeys.text) - self = .text(text) - case "link": - let text = try container.decode(String.self, forKey: CodingKeys.text) - let preview = try container.decode(LinkPreview.self, forKey: CodingKeys.preview) - self = .link(text: text, preview: preview) - case "image": - let text = try container.decode(String.self, forKey: CodingKeys.text) - let image = try container.decode(String.self, forKey: CodingKeys.image) - self = .image(text: text, image: image) - default: - let text = try? container.decode(String.self, forKey: CodingKeys.text) - self = .unknown(type: type, text: text ?? "unknown message format") - } - } catch { - self = .unknown(type: "unknown", text: "invalid message format") - } - } -} - -struct FormattedText: Decodable { - var text: String - var format: Format? -} - -enum Format: Decodable, Equatable { - case bold - case italic - case strikeThrough - case snippet - case secret - case colored(color: FormatColor) - case uri - case email - case phone -} - -enum FormatColor: String, Decodable { - case red = "red" - case green = "green" - case blue = "blue" - case yellow = "yellow" - case cyan = "cyan" - case magenta = "magenta" - case black = "black" - case white = "white" - - var uiColor: Color { - get { - switch (self) { - case .red: return .red - case .green: return .green - case .blue: return .blue - case .yellow: return .yellow - case .cyan: return .cyan - case .magenta: return .purple - case .black: return .primary - case .white: return .primary - } - } - } -} - -// Struct to use with simplex API -struct LinkPreview: Codable { - var uri: URL - var title: String - // TODO remove once optional in haskell - var description: String = "" - var image: String -} - -enum NtfTknStatus: String, Decodable { - case new = "NEW" - case registered = "REGISTERED" - case invalid = "INVALID" - case confirmed = "CONFIRMED" - case active = "ACTIVE" - case expired = "EXPIRED" -} diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 176ed9bf18..f6ba0fda00 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -12,14 +12,6 @@ import UIKit let ntfActionAccept = "NTF_ACT_ACCEPT" -let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST" -let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED" -let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED" -// TODO remove -let ntfCategoryCheckingMessages = "NTF_CAT_CHECKING_MESSAGES" - -let appNotificationId = "chat.simplex.app.notification" - private let ntfTimeInterval: TimeInterval = 1 class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { @@ -69,10 +61,13 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { // in another chat return recentInTheSameChat(content) ? [.banner, .list] : [.sound, .banner, .list] } + // this notification is deliverd from the notifications server + // when the app is in foreground it does not need to be shown + case ntfCategoryCheckMessage: return [] default: return [.sound, .banner, .list] } } else { - return [.sound, .banner, .list] + return content.categoryIdentifier == ntfCategoryCheckMessage ? [] : [.sound, .banner, .list] } } @@ -146,76 +141,31 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { func notifyContactRequest(_ contactRequest: UserContactRequest) { logger.debug("NtfManager.notifyContactRequest") - addNotification( - categoryIdentifier: ntfCategoryContactRequest, - title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName), - body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName), - targetContentIdentifier: nil, - userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] - ) + addNotification(createContactRequestNtf(contactRequest)) } func notifyContactConnected(_ contact: Contact) { logger.debug("NtfManager.notifyContactConnected") - addNotification( - categoryIdentifier: ntfCategoryContactConnected, - title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName), - body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName), - targetContentIdentifier: contact.id -// userInfo: ["chatId": contact.id, "contactId": contact.apiId] - ) + addNotification(createContactConnectedNtf(contact)) } func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") - addNotification( - categoryIdentifier: ntfCategoryMessageReceived, - title: "\(cInfo.chatViewName):", - body: hideSecrets(cItem), - targetContentIdentifier: cInfo.id -// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] - ) + addNotification(createMessageReceivedNtf(cInfo, cItem)) } // TODO remove func notifyCheckingMessages() { logger.debug("NtfManager.notifyCheckingMessages") - addNotification( + let content = createNotification( categoryIdentifier: ntfCategoryCheckingMessages, title: NSLocalizedString("Checking new messages...", comment: "notification") ) + addNotification(content) } - func hideSecrets(_ cItem: ChatItem) -> String { - if let md = cItem.formattedText { - var res = "" - for ft in md { - if case .secret = ft.format { - res = res + "..." - } else { - res = res + ft.text - } - } - return res - } else { - return cItem.content.text - } - } - - private func addNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil, - targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) { + private func addNotification(_ content: UNMutableNotificationContent) { if !granted { return } - let content = UNMutableNotificationContent() - content.categoryIdentifier = categoryIdentifier - content.title = title - if let s = subtitle { content.subtitle = s } - if let s = body { content.body = s } - content.targetContentIdentifier = targetContentIdentifier - content.userInfo = userInfo - // TODO move logic of adding sound here, so it applies to background notifications too - content.sound = .default -// content.interruptionLevel = .active -// content.relevanceScore = 0.5 // 0-1 let trigger = UNTimeIntervalNotificationTrigger(timeInterval: ntfTimeInterval, repeats: false) let request = UNNotificationRequest(identifier: appNotificationId, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) { error in diff --git a/apps/ios/Shared/Model/Shared/API.swift b/apps/ios/Shared/Model/Shared/API.swift new file mode 100644 index 0000000000..7208c45d99 --- /dev/null +++ b/apps/ios/Shared/Model/Shared/API.swift @@ -0,0 +1,66 @@ +// +// API.swift +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +private var chatController: chat_ctrl? + +func getChatCtrl() -> chat_ctrl { + if let controller = chatController { return controller } + let dataDir = getDocumentsDirectory().path + "/mobile_v1" + logger.debug("documents directory \(dataDir)") + var cstr = dataDir.cString(using: .utf8)! + chatController = chat_init(&cstr) + logger.debug("getChatCtrl: chat_init") + return chatController! +} + +func sendSimpleXCmd(_ cmd: ChatCommand) -> ChatResponse { + var c = cmd.cmdString.cString(using: .utf8)! + return chatResponse(chat_send_cmd(getChatCtrl(), &c)) +} + +func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse { + let s = String.init(cString: cjson) + let d = s.data(using: .utf8)! +// TODO is there a way to do it without copying the data? e.g: +// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) +// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + do { + let r = try jsonDecoder.decode(APIResponse.self, from: d) + return r.resp + } catch { + logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") + } + + var type: String? + var json: String? + if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { + if let j1 = j["resp"] as? NSDictionary, j1.count == 1 { + type = j1.allKeys[0] as? String + } + json = prettyJSON(j) + } + free(cjson) + return ChatResponse.response(type: type ?? "invalid", json: json ?? s) +} + +func prettyJSON(_ obj: NSDictionary) -> String? { + if let d = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) { + return String(decoding: d, as: UTF8.self) + } + return nil +} + +func responseError(_ err: Error) -> String { + if let r = err as? ChatResponse { + return String(describing: r) + } else { + return err.localizedDescription + } +} diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift new file mode 100644 index 0000000000..c813ee23ff --- /dev/null +++ b/apps/ios/Shared/Model/Shared/APITypes.swift @@ -0,0 +1,451 @@ +// +// SimpleXAPI.swift +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation + +let jsonDecoder = getJSONDecoder() +let jsonEncoder = getJSONEncoder() + +enum ChatCommand { + case showActiveUser + case createActiveUser(profile: Profile) + case startChat + case setFilesFolder(filesFolder: String) + case apiGetChats + case apiGetChat(type: ChatType, id: Int64) + 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 apiVerifyToken(token: String, code: String, nonce: String) + case apiIntervalNofication(token: String, interval: Int) + case apiDeleteToken(token: String) + case getUserSMPServers + case setUserSMPServers(smpServers: [String]) + case addContact + case connect(connReq: String) + case apiDeleteChat(type: ChatType, id: Int64) + case apiUpdateProfile(profile: Profile) + case apiParseMarkdown(text: String) + case createMyAddress + case deleteMyAddress + case showMyAddress + case apiAcceptContact(contactReqId: Int64) + case apiRejectContact(contactReqId: Int64) + case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) + case receiveFile(fileId: Int64) + case string(String) + + var cmdString: String { + get { + switch self { + case .showActiveUser: return "/u" + case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" + case .startChat: return "/_start" + 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" + case let .apiSendMessage(type, id, file, quotedItemId, mc): + switch (file, quotedItemId) { + case (nil, nil): return "/_send \(ref(type, id)) \(mc.cmdString)" + case let (.some(file), nil): return "/_send \(ref(type, id)) file \(file) \(mc.cmdString)" + case let (nil, .some(quotedItemId)): return "/_send \(ref(type, id)) quoted \(quotedItemId) \(mc.cmdString)" + case let (.some(file), .some(quotedItemId)): return "/_send \(ref(type, id)) file \(file) quoted \(quotedItemId) \(mc.cmdString)" + } + 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 .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 .getUserSMPServers: return "/smp_servers" + case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" + case .addContact: return "/connect" + case let .connect(connReq): return "/connect \(connReq)" + case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" + case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" + case let .apiParseMarkdown(text): return "/_parse \(text)" + case .createMyAddress: return "/address" + case .deleteMyAddress: return "/delete_address" + case .showMyAddress: return "/show_address" + case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" + case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" + case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" + case let .receiveFile(fileId): return "/freceive \(fileId)" + case let .string(str): return str + } + } + } + + var cmdType: String { + get { + switch self { + case .showActiveUser: return "showActiveUser" + case .createActiveUser: return "createActiveUser" + case .startChat: return "startChat" + case .setFilesFolder: return "setFilesFolder" + case .apiGetChats: return "apiGetChats" + case .apiGetChat: return "apiGetChat" + case .apiSendMessage: return "apiSendMessage" + case .apiUpdateChatItem: return "apiUpdateChatItem" + case .apiDeleteChatItem: return "apiDeleteChatItem" + case .apiRegisterToken: return "apiRegisterToken" + case .apiVerifyToken: return "apiVerifyToken" + case .apiIntervalNofication: return "apiIntervalNofication" + case .apiDeleteToken: return "apiDeleteToken" + case .getUserSMPServers: return "getUserSMPServers" + case .setUserSMPServers: return "setUserSMPServers" + case .addContact: return "addContact" + case .connect: return "connect" + case .apiDeleteChat: return "apiDeleteChat" + case .apiUpdateProfile: return "apiUpdateProfile" + case .apiParseMarkdown: return "apiParseMarkdown" + case .createMyAddress: return "createMyAddress" + case .deleteMyAddress: return "deleteMyAddress" + case .showMyAddress: return "showMyAddress" + case .apiAcceptContact: return "apiAcceptContact" + case .apiRejectContact: return "apiRejectContact" + case .apiChatRead: return "apiChatRead" + case .receiveFile: return "receiveFile" + case .string: return "console command" + } + } + } + + func ref(_ type: ChatType, _ id: Int64) -> String { + "\(type.rawValue)\(id)" + } + + func smpServersStr(smpServers: [String]) -> String { + smpServers.isEmpty ? "default" : smpServers.joined(separator: ",") + } +} + +struct APIResponse: Decodable { + var resp: ChatResponse +} + +enum ChatResponse: Decodable, Error { + case response(type: String, json: String) + case activeUser(user: User) + case chatStarted + case chatRunning + case apiChats(chats: [ChatData]) + case apiChat(chat: ChatData) + case userSMPServers(smpServers: [String]) + case invitation(connReqInvitation: String) + case sentConfirmation + case sentInvitation + case contactAlreadyExists(contact: Contact) + case contactDeleted(contact: Contact) + case userProfileNoChange + case userProfileUpdated(fromProfile: Profile, toProfile: Profile) + case apiParsedMarkdown(formattedText: [FormattedText]?) + case userContactLink(connReqContact: String) + case userContactLinkCreated(connReqContact: String) + case userContactLinkDeleted + case contactConnected(contact: Contact) + case contactConnecting(contact: Contact) + case receivedContactRequest(contactRequest: UserContactRequest) + case acceptingContactRequest(contact: Contact) + case contactRequestRejected + case contactUpdated(toContact: Contact) + case contactsSubscribed(server: String, contactRefs: [ContactRef]) + case contactsDisconnected(server: String, contactRefs: [ContactRef]) + case contactSubError(contact: Contact, chatError: ChatError) + case contactSubSummary(contactSubscriptions: [ContactSubStatus]) + case groupSubscribed(groupInfo: GroupInfo) + case memberSubErrors(memberSubErrors: [MemberSubError]) + case groupEmpty(groupInfo: GroupInfo) + case userContactLinkSubscribed + case newChatItem(chatItem: AChatItem) + case chatItemStatusUpdated(chatItem: AChatItem) + case chatItemUpdated(chatItem: AChatItem) + case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem) + case rcvFileAccepted + case rcvFileComplete(chatItem: AChatItem) + case ntfTokenStatus(status: NtfTknStatus) + case newContactConnection(connection: PendingContactConnection) + case contactConnectionDeleted(connection: PendingContactConnection) + case cmdOk + case chatCmdError(chatError: ChatError) + case chatError(chatError: ChatError) + + var responseType: String { + get { + switch self { + case let .response(type, _): return "* \(type)" + case .activeUser: return "activeUser" + case .chatStarted: return "chatStarted" + case .chatRunning: return "chatRunning" + case .apiChats: return "apiChats" + case .apiChat: return "apiChat" + case .userSMPServers: return "userSMPServers" + case .invitation: return "invitation" + case .sentConfirmation: return "sentConfirmation" + case .sentInvitation: return "sentInvitation" + case .contactAlreadyExists: return "contactAlreadyExists" + case .contactDeleted: return "contactDeleted" + case .userProfileNoChange: return "userProfileNoChange" + case .userProfileUpdated: return "userProfileUpdated" + case .apiParsedMarkdown: return "apiParsedMarkdown" + case .userContactLink: return "userContactLink" + case .userContactLinkCreated: return "userContactLinkCreated" + case .userContactLinkDeleted: return "userContactLinkDeleted" + case .contactConnected: return "contactConnected" + case .contactConnecting: return "contactConnecting" + case .receivedContactRequest: return "receivedContactRequest" + case .acceptingContactRequest: return "acceptingContactRequest" + case .contactRequestRejected: return "contactRequestRejected" + case .contactUpdated: return "contactUpdated" + case .contactsSubscribed: return "contactsSubscribed" + case .contactsDisconnected: return "contactsDisconnected" + case .contactSubError: return "contactSubError" + case .contactSubSummary: return "contactSubSummary" + case .groupSubscribed: return "groupSubscribed" + case .memberSubErrors: return "memberSubErrors" + case .groupEmpty: return "groupEmpty" + case .userContactLinkSubscribed: return "userContactLinkSubscribed" + case .newChatItem: return "newChatItem" + case .chatItemStatusUpdated: return "chatItemStatusUpdated" + case .chatItemUpdated: return "chatItemUpdated" + case .chatItemDeleted: return "chatItemDeleted" + case .rcvFileAccepted: return "rcvFileAccepted" + case .rcvFileComplete: return "rcvFileComplete" + case .ntfTokenStatus: return "ntfTokenStatus" + case .newContactConnection: return "newContactConnection" + case .contactConnectionDeleted: return "contactConnectionDeleted" + case .cmdOk: return "cmdOk" + case .chatCmdError: return "chatCmdError" + case .chatError: return "chatError" + } + } + } + + var details: String { + get { + switch self { + case let .response(_, json): return json + case let .activeUser(user): return String(describing: user) + case .chatStarted: return noDetails + case .chatRunning: 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) + case let .invitation(connReqInvitation): return connReqInvitation + case .sentConfirmation: return noDetails + case .sentInvitation: return noDetails + case let .contactAlreadyExists(contact): return String(describing: contact) + case let .contactDeleted(contact): return String(describing: contact) + case .userProfileNoChange: return noDetails + case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) + case let .apiParsedMarkdown(formattedText): return String(describing: formattedText) + case let .userContactLink(connReq): return connReq + case let .userContactLinkCreated(connReq): return connReq + case .userContactLinkDeleted: return noDetails + case let .contactConnected(contact): return String(describing: contact) + case let .contactConnecting(contact): return String(describing: contact) + case let .receivedContactRequest(contactRequest): return String(describing: contactRequest) + case let .acceptingContactRequest(contact): return String(describing: contact) + case .contactRequestRejected: return noDetails + case let .contactUpdated(toContact): return String(describing: toContact) + case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" + case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" + case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))" + case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions) + case let .groupSubscribed(groupInfo): return String(describing: groupInfo) + case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors) + case let .groupEmpty(groupInfo): return String(describing: groupInfo) + case .userContactLinkSubscribed: return noDetails + case let .newChatItem(chatItem): return String(describing: chatItem) + case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem) + case let .chatItemUpdated(chatItem): return String(describing: chatItem) + case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))" + case .rcvFileAccepted: return noDetails + case let .rcvFileComplete(chatItem): return String(describing: chatItem) + case let .ntfTokenStatus(status): return String(describing: status) + case let .newContactConnection(connection): return String(describing: connection) + case let .contactConnectionDeleted(connection): return String(describing: connection) + case .cmdOk: return noDetails + case let .chatCmdError(chatError): return String(describing: chatError) + case let .chatError(chatError): return String(describing: chatError) + } + } + } + + private var noDetails: String { get { "\(responseType): no details" } } +} + +private func decodeCJSON(_ cjson: UnsafePointer) -> T? { + let s = String.init(cString: cjson) + let d = s.data(using: .utf8)! +// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) +// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) + return try? jsonDecoder.decode(T.self, from: d) +} + +private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { + let s = String.init(cString: cjson) + let d = s.data(using: .utf8)! + return try? JSONSerialization.jsonObject(with: d) as? NSDictionary +} + +func encodeJSON(_ value: T) -> String { + let data = try! jsonEncoder.encode(value) + return String(decoding: data, as: UTF8.self) +} + +private func encodeCJSON(_ value: T) -> [CChar] { + encodeJSON(value).cString(using: .utf8)! +} + +enum ChatError: Decodable { + case error(errorType: ChatErrorType) + case errorAgent(agentError: AgentErrorType) + case errorStore(storeError: StoreError) +} + +enum ChatErrorType: Decodable { + case noActiveUser + case activeUserExists + case chatNotStarted + case invalidConnReq + case invalidChatMessage(message: String) + case contactNotReady(contact: Contact) + case contactGroups(contact: Contact, groupNames: [GroupName]) + case groupUserRole + case groupContactRole(contactName: ContactName) + case groupDuplicateMember(contactName: ContactName) + case groupDuplicateMemberId + case groupNotJoined(groupInfo: GroupInfo) + case groupMemberNotActive + case groupMemberUserRemoved + case groupMemberNotFound(contactName: ContactName) + case groupMemberIntroNotFound(contactName: ContactName) + case groupCantResendInvitation(groupInfo: GroupInfo, contactName: ContactName) + case groupInternal(message: String) + case fileNotFound(message: String) + case fileAlreadyReceiving(message: String) + case fileAlreadyExists(filePath: String) + case fileRead(filePath: String, message: String) + case fileWrite(filePath: String, message: String) + case fileSend(fileId: Int64, agentError: String) + case fileRcvChunk(message: String) + case fileInternal(message: String) + case invalidQuote + case invalidChatItemUpdate + case invalidChatItemDelete + case agentVersion + case commandError(message: String) +} + +enum StoreError: Decodable { + case duplicateName + case contactNotFound(contactId: Int64) + case contactNotFoundByName(contactName: ContactName) + case contactNotReady(contactName: ContactName) + case duplicateContactLink + case userContactLinkNotFound + case contactRequestNotFound(contactRequestId: Int64) + case contactRequestNotFoundByName(contactName: ContactName) + case groupNotFound(groupId: Int64) + case groupNotFoundByName(groupName: GroupName) + case groupWithoutUser + case duplicateGroupMember + case groupAlreadyJoined + case groupInvitationNotFound + case sndFileNotFound(fileId: Int64) + case sndFileInvalid(fileId: Int64) + case rcvFileNotFound(fileId: Int64) + case fileNotFound(fileId: Int64) + case rcvFileInvalid(fileId: Int64) + case connectionNotFound(agentConnId: String) + case pendingConnectionNotFound(connId: Int64) + case introNotFound + case uniqueID + case internalError(message: String) + case noMsgDelivery(connId: Int64, agentMsgId: String) + case badChatItem(itemId: Int64) + case chatItemNotFound(itemId: Int64) + case quotedChatItemNotFound + case chatItemSharedMsgIdNotFound(sharedMsgId: String) + case chatItemNotFoundByFileId(fileId: Int64) +} + +enum AgentErrorType: Decodable { + case CMD(cmdErr: CommandErrorType) + case CONN(connErr: ConnectionErrorType) + case SMP(smpErr: ProtocolErrorType) + case NTF(ntfErr: ProtocolErrorType) + case BROKER(brokerErr: BrokerErrorType) + case AGENT(agentErr: SMPAgentError) + case INTERNAL(internalErr: String) +} + +enum CommandErrorType: Decodable { + case PROHIBITED + case SYNTAX + case NO_CONN + case SIZE + case LARGE +} + +enum ConnectionErrorType: Decodable { + case NOT_FOUND + case DUPLICATE + case SIMPLEX + case NOT_ACCEPTED + case NOT_AVAILABLE +} + +enum BrokerErrorType: Decodable { + case RESPONSE(smpErr: ProtocolErrorType) + case UNEXPECTED + case NETWORK + case TRANSPORT(transportErr: ProtocolTransportError) + case TIMEOUT +} + +enum ProtocolErrorType: Decodable { + case BLOCK + case SESSION + case CMD(cmdErr: ProtocolCommandError) + case AUTH + case QUOTA + case NO_MSG + case LARGE_MSG + case INTERNAL +} + +enum ProtocolCommandError: Decodable { + case UNKNOWN + case SYNTAX + case NO_AUTH + case HAS_AUTH + case NO_ENTITY +} + +enum ProtocolTransportError: Decodable { + case badBlock + case largeMsg + case badSession + case handshake(handshakeErr: SMPHandshakeError) +} + +enum SMPHandshakeError: Decodable { + case PARSE + case VERSION + case IDENTITY +} + +enum SMPAgentError: Decodable { + case A_MESSAGE + case A_PROHIBITED + case A_VERSION + case A_ENCRYPTION +} diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift new file mode 100644 index 0000000000..0b3e90a6ec --- /dev/null +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -0,0 +1,783 @@ +// +// ChatModel.swift +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI + +struct User: Decodable, NamedChat { + var userId: Int64 + var userContactId: Int64 + var localDisplayName: ContactName + var profile: Profile + var activeUser: Bool + + var displayName: String { get { profile.displayName } } + var fullName: String { get { profile.fullName } } + var image: String? { get { profile.image } } + + static let sampleData = User( + userId: 1, + userContactId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + activeUser: true + ) +} + +typealias ContactName = String + +typealias GroupName = String + +struct Profile: Codable, NamedChat { + var displayName: String + var fullName: String + var image: String? + + static let sampleData = Profile( + displayName: "alice", + fullName: "Alice" + ) +} + +enum ChatType: String { + case direct = "@" + case group = "#" + case contactRequest = "<@" + case contactConnection = ":" +} + +protocol NamedChat { + var displayName: String { get } + var fullName: String { get } + var image: String? { get } +} + +extension NamedChat { + var chatViewName: String { + get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") } + } +} + +typealias ChatId = String + +enum ChatInfo: Identifiable, Decodable, NamedChat { + case direct(contact: Contact) + case group(groupInfo: GroupInfo) + case contactRequest(contactRequest: UserContactRequest) + case contactConnection(contactConnection: PendingContactConnection) + + var localDisplayName: String { + get { + switch self { + case let .direct(contact): return contact.localDisplayName + case let .group(groupInfo): return groupInfo.localDisplayName + case let .contactRequest(contactRequest): return contactRequest.localDisplayName + case let .contactConnection(contactConnection): return contactConnection.localDisplayName + } + } + } + + var displayName: String { + get { + switch self { + case let .direct(contact): return contact.displayName + case let .group(groupInfo): return groupInfo.displayName + case let .contactRequest(contactRequest): return contactRequest.displayName + case let .contactConnection(contactConnection): return contactConnection.displayName + } + } + } + + var fullName: String { + get { + switch self { + case let .direct(contact): return contact.fullName + case let .group(groupInfo): return groupInfo.fullName + case let .contactRequest(contactRequest): return contactRequest.fullName + case let .contactConnection(contactConnection): return contactConnection.fullName + } + } + } + + var image: String? { + get { + switch self { + case let .direct(contact): return contact.image + case let .group(groupInfo): return groupInfo.image + case let .contactRequest(contactRequest): return contactRequest.image + case let .contactConnection(contactConnection): return contactConnection.image + } + } + } + + var id: ChatId { + get { + switch self { + case let .direct(contact): return contact.id + case let .group(groupInfo): return groupInfo.id + case let .contactRequest(contactRequest): return contactRequest.id + case let .contactConnection(contactConnection): return contactConnection.id + } + } + } + + var chatType: ChatType { + get { + switch self { + case .direct: return .direct + case .group: return .group + case .contactRequest: return .contactRequest + case .contactConnection: return .contactConnection + } + } + } + + var apiId: Int64 { + get { + switch self { + case let .direct(contact): return contact.apiId + case let .group(groupInfo): return groupInfo.apiId + case let .contactRequest(contactRequest): return contactRequest.apiId + case let .contactConnection(contactConnection): return contactConnection.apiId + } + } + } + + var ready: Bool { + get { + switch self { + case let .direct(contact): return contact.ready + case let .group(groupInfo): return groupInfo.ready + case let .contactRequest(contactRequest): return contactRequest.ready + case let .contactConnection(contactConnection): return contactConnection.ready + } + } + } + + var createdAt: Date { + switch self { + case let .direct(contact): return contact.createdAt + case let .group(groupInfo): return groupInfo.createdAt + case let .contactRequest(contactRequest): return contactRequest.createdAt + case let .contactConnection(contactConnection): return contactConnection.createdAt + } + } + + struct SampleData { + var direct: ChatInfo + var group: ChatInfo + var contactRequest: ChatInfo + } + + static var sampleData: ChatInfo.SampleData = SampleData( + direct: ChatInfo.direct(contact: Contact.sampleData), + group: ChatInfo.group(groupInfo: GroupInfo.sampleData), + contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData) + ) +} + +struct ChatData: Decodable, Identifiable { + var chatInfo: ChatInfo + var chatItems: [ChatItem] + var chatStats: ChatStats + + var id: ChatId { get { chatInfo.id } } +} + +struct ChatStats: Decodable { + var unreadCount: Int = 0 + var minUnreadItemId: Int64 = 0 +} + +struct Contact: Identifiable, Decodable, NamedChat { + var contactId: Int64 + var localDisplayName: ContactName + var profile: Profile + var activeConn: Connection + var viaGroup: Int64? + var createdAt: Date + + var id: ChatId { get { "@\(contactId)" } } + var apiId: Int64 { get { contactId } } + var ready: Bool { get { activeConn.connStatus == .ready } } + var displayName: String { get { profile.displayName } } + var fullName: String { get { profile.fullName } } + var image: String? { get { profile.image } } + + static let sampleData = Contact( + contactId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + activeConn: Connection.sampleData, + createdAt: .now + ) +} + +struct ContactRef: Decodable { + var contactId: Int64 + var localDisplayName: ContactName + + var id: ChatId { get { "@\(contactId)" } } +} + +struct ContactSubStatus: Decodable { + var contact: Contact + var contactError: ChatError? +} + +struct Connection: Decodable { + var connId: Int64 + var connStatus: ConnStatus + + var id: ChatId { get { ":\(connId)" } } + + static let sampleData = Connection( + connId: 1, + connStatus: .ready + ) +} + +struct UserContactRequest: Decodable, NamedChat { + var contactRequestId: Int64 + var localDisplayName: ContactName + var profile: Profile + var createdAt: Date + var updatedAt: Date + + var id: ChatId { get { "<@\(contactRequestId)" } } + var apiId: Int64 { get { contactRequestId } } + var ready: Bool { get { true } } + var displayName: String { get { profile.displayName } } + var fullName: String { get { profile.fullName } } + var image: String? { get { profile.image } } + + static let sampleData = UserContactRequest( + contactRequestId: 1, + localDisplayName: "alice", + profile: Profile.sampleData, + createdAt: .now, + updatedAt: .now + ) +} + +struct PendingContactConnection: Decodable, NamedChat { + var pccConnId: Int64 + var pccAgentConnId: String + var pccConnStatus: ConnStatus + var viaContactUri: Bool + var createdAt: Date + var updatedAt: Date + + var id: ChatId { get { ":\(pccConnId)" } } + var apiId: Int64 { get { pccConnId } } + var ready: Bool { get { false } } + var localDisplayName: String { + get { String.localizedStringWithFormat(NSLocalizedString("connection:%@", comment: "connection information"), pccConnId) } + } + var displayName: String { + get { + if let initiated = pccConnStatus.initiated { + return initiated && !viaContactUri + ? NSLocalizedString("invited to connect", comment: "chat list item title") + : NSLocalizedString("connecting…", comment: "chat list item title") + } else { + // this should not be in the list + return NSLocalizedString("connection established", comment: "chat list item title (it should not be shown") + } + } + } + var fullName: String { get { "" } } + var image: String? { get { nil } } + var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } } + + var description: String { + get { + if let initiated = pccConnStatus.initiated { + return initiated && !viaContactUri + ? NSLocalizedString("you shared one-time link", comment: "chat list item description") + : viaContactUri + ? NSLocalizedString("via contact address link", comment: "chat list item description") + : NSLocalizedString("via one-time link", comment: "chat list item description") + } else { + return "" + } + } + } + + static func getSampleData(_ status: ConnStatus = .new, viaContactUri: Bool = false) -> PendingContactConnection { + PendingContactConnection( + pccConnId: 1, + pccAgentConnId: "abcd", + pccConnStatus: status, + viaContactUri: viaContactUri, + createdAt: .now, + updatedAt: .now + ) + } +} + +enum ConnStatus: String, Decodable { + case new = "new" + case joined = "joined" + case requested = "requested" + case accepted = "accepted" + case sndReady = "snd-ready" + case ready = "ready" + case deleted = "deleted" + + var initiated: Bool? { + get { + switch self { + case .new: return true + case .joined: return false + case .requested: return true + case .accepted: return true + case .sndReady: return false + case .ready: return nil + case .deleted: return nil + } + } + } +} + +struct GroupInfo: Identifiable, Decodable, NamedChat { + var groupId: Int64 + var localDisplayName: GroupName + var groupProfile: GroupProfile + var createdAt: Date + + var id: ChatId { get { "#\(groupId)" } } + var apiId: Int64 { get { groupId } } + var ready: Bool { get { true } } + var displayName: String { get { groupProfile.displayName } } + var fullName: String { get { groupProfile.fullName } } + var image: String? { get { groupProfile.image } } + + static let sampleData = GroupInfo( + groupId: 1, + localDisplayName: "team", + groupProfile: GroupProfile.sampleData, + createdAt: .now + ) +} + +struct GroupProfile: Codable, NamedChat { + var displayName: String + var fullName: String + var image: String? + + static let sampleData = GroupProfile( + displayName: "team", + fullName: "My Team" + ) +} + +struct GroupMember: Decodable { + var groupMemberId: Int64 + var memberId: String +// var memberRole: GroupMemberRole +// var memberCategory: GroupMemberCategory +// var memberStatus: GroupMemberStatus +// var invitedBy: InvitedBy + var localDisplayName: ContactName + var memberProfile: Profile + var memberContactId: Int64? +// var activeConn: Connection? + + var directChatId: ChatId? { + get { + if let chatId = memberContactId { + return "@\(chatId)" + } else { + return nil + } + } + } + + static let sampleData = GroupMember( + groupMemberId: 1, + memberId: "abcd", + localDisplayName: "alice", + memberProfile: Profile.sampleData, + memberContactId: 1 + ) +} + +struct MemberSubError: Decodable { + var member: GroupMember + var memberError: ChatError +} + +struct AChatItem: Decodable { + var chatInfo: ChatInfo + var chatItem: ChatItem +} + +struct ChatItem: Identifiable, Decodable { + var chatDir: CIDirection + var meta: CIMeta + var content: CIContent + var formattedText: [FormattedText]? + var quotedItem: CIQuote? + var file: CIFile? + + var id: Int64 { get { meta.itemId } } + + var timestampText: Text { get { meta.timestampText } } + + var text: String { + get { + switch (content.text, file) { + case let ("", .some(file)): return file.fileName + default: return content.text + } + } + } + + func isRcvNew() -> Bool { + if case .rcvNew = meta.itemStatus { return true } + return false + } + + func isMsgContent() -> Bool { + switch content { + case .sndMsgContent: return true + case .rcvMsgContent: return true + default: return false + } + } + + func isDeletedContent() -> Bool { + switch content { + case .sndDeleted: return true + case .rcvDeleted: return true + default: return false + } + } + + var memberDisplayName: String? { + get { + if case let .groupRcv(groupMember) = chatDir { + return groupMember.memberProfile.displayName + } else { + return nil + } + } + } + + static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { + ChatItem( + chatDir: dir, + meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), + content: .sndMsgContent(msgContent: .text(text)), + quotedItem: quotedItem, + file: file + ) + } + + static func getDeletedContentSample (_ id: Int64 = 1, dir: CIDirection = .directRcv, _ ts: Date = .now, _ text: String = "this item is deleted", _ status: CIStatus = .rcvRead) -> ChatItem { + ChatItem( + chatDir: dir, + meta: CIMeta.getSample(id, ts, text, status, false, false, false), + content: .rcvDeleted(deleteMode: .cidmBroadcast), + quotedItem: nil, + file: nil + ) + } +} + +enum CIDirection: Decodable { + case directSnd + case directRcv + case groupSnd + case groupRcv(groupMember: GroupMember) + + var sent: Bool { + get { + switch self { + case .directSnd: return true + case .directRcv: return false + case .groupSnd: return true + case .groupRcv: return false + } + } + } +} + +struct CIMeta: Decodable { + var itemId: Int64 + var itemTs: Date + var itemText: String + var itemStatus: CIStatus + var createdAt: Date + var itemDeleted: Bool + var itemEdited: Bool + var editable: Bool + + var timestampText: Text { get { formatTimestampText(itemTs) } } + + static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> CIMeta { + CIMeta( + itemId: id, + itemTs: ts, + itemText: text, + itemStatus: status, + createdAt: ts, + itemDeleted: itemDeleted, + itemEdited: itemEdited, + editable: editable + ) + } +} + +let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute() +let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) + +func formatTimestampText(_ date: Date) -> Text { + let now = Calendar.current.dateComponents([.day, .hour], from: .now) + let dc = Calendar.current.dateComponents([.day, .hour], from: date) + let recent = now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12) + return Text(date, format: recent ? msgTimeFormat : msgDateFormat) +} + +enum CIStatus: Decodable { + case sndNew + case sndSent + case sndErrorAuth + case sndError(agentError: AgentErrorType) + case rcvNew + case rcvRead +} + +enum CIDeleteMode: String, Decodable { + case cidmBroadcast = "broadcast" + case cidmInternal = "internal" +} + +protocol ItemContent { + var text: String { get } +} + +enum CIContent: Decodable, ItemContent { + case sndMsgContent(msgContent: MsgContent) + case rcvMsgContent(msgContent: MsgContent) + case sndDeleted(deleteMode: CIDeleteMode) + case rcvDeleted(deleteMode: CIDeleteMode) + + var text: String { + get { + switch self { + case let .sndMsgContent(mc): return mc.text + case let .rcvMsgContent(mc): return mc.text + case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + } + } + } + + var msgContent: MsgContent? { + get { + switch self { + case let .sndMsgContent(mc): return mc + case let .rcvMsgContent(mc): return mc + default: return nil + } + } + } +} + +struct CIQuote: Decodable, ItemContent { + var chatDir: CIDirection? + var itemId: Int64? + var sharedMsgId: String? = nil + var sentAt: Date + var content: MsgContent + var formattedText: [FormattedText]? + + var text: String { get { content.text } } + + func getSender(_ currentUser: User?) -> String? { + switch (chatDir) { + case .directSnd: return "you" + case .directRcv: return nil + case .groupSnd: return currentUser?.displayName + case let .groupRcv(member): return member.memberProfile.displayName + case nil: return nil + } + } + + static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?, image: String? = nil) -> CIQuote { + let mc: MsgContent + if let image = image { + mc = .image(text: text, image: image) + } else { + mc = .text(text) + } + return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) + } +} + +struct CIFile: Decodable { + var fileId: Int64 + var fileName: String + var fileSize: Int64 + var filePath: String? + var fileStatus: CIFileStatus + + static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile { + CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus) + } + + var stored: Bool { + get { + switch self.fileStatus { + case .sndStored: return true + case .sndCancelled: return true + case .rcvComplete: return true + default: return false + } + } + } +} + +enum CIFileStatus: String, Decodable { + case sndStored = "snd_stored" + case sndCancelled = "snd_cancelled" + case rcvInvitation = "rcv_invitation" + case rcvTransfer = "rcv_transfer" + case rcvComplete = "rcv_complete" + case rcvCancelled = "rcv_cancelled" +} + +enum MsgContent { + case text(String) + case link(text: String, preview: LinkPreview) + case image(text: String, image: String) + // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift + case unknown(type: String, text: String) + + var text: String { + get { + switch self { + case let .text(text): return text + case let .link(text, _): return text + case let .image(text, _): return text + case let .unknown(_, text): return text + } + } + } + + var cmdString: String { + get { + switch self { + case let .text(text): return "text \(text)" + case let .link(text: text, preview: preview): + return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}" + case let .image(text: text, image: image): + return "json {\"type\":\"image\",\"text\":\(encodeJSON(text)),\"image\":\(encodeJSON(image))}" + default: return "" + } + } + } + + enum CodingKeys: String, CodingKey { + case type + case text + case preview + case image + } +} + +// TODO define Encodable +extension MsgContent: Decodable { + init(from decoder: Decoder) throws { + do { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: CodingKeys.type) + switch type { + case "text": + let text = try container.decode(String.self, forKey: CodingKeys.text) + self = .text(text) + case "link": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let preview = try container.decode(LinkPreview.self, forKey: CodingKeys.preview) + self = .link(text: text, preview: preview) + case "image": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let image = try container.decode(String.self, forKey: CodingKeys.image) + self = .image(text: text, image: image) + default: + let text = try? container.decode(String.self, forKey: CodingKeys.text) + self = .unknown(type: type, text: text ?? "unknown message format") + } + } catch { + self = .unknown(type: "unknown", text: "invalid message format") + } + } +} + +struct FormattedText: Decodable { + var text: String + var format: Format? +} + +enum Format: Decodable, Equatable { + case bold + case italic + case strikeThrough + case snippet + case secret + case colored(color: FormatColor) + case uri + case email + case phone +} + +enum FormatColor: String, Decodable { + case red = "red" + case green = "green" + case blue = "blue" + case yellow = "yellow" + case cyan = "cyan" + case magenta = "magenta" + case black = "black" + case white = "white" + + var uiColor: Color { + get { + switch (self) { + case .red: return .red + case .green: return .green + case .blue: return .blue + case .yellow: return .yellow + case .cyan: return .cyan + case .magenta: return .purple + case .black: return .primary + case .white: return .primary + } + } + } +} + +// Struct to use with simplex API +struct LinkPreview: Codable { + var uri: URL + var title: String + // TODO remove once optional in haskell + var description: String = "" + var image: String +} + +enum NtfTknStatus: String, Decodable { + case new = "NEW" + case registered = "REGISTERED" + case invalid = "INVALID" + case confirmed = "CONFIRMED" + case active = "ACTIVE" + case expired = "EXPIRED" +} diff --git a/apps/ios/Shared/Model/Shared/GroupDefaults.swift b/apps/ios/Shared/Model/Shared/GroupDefaults.swift new file mode 100644 index 0000000000..ca5aaef410 --- /dev/null +++ b/apps/ios/Shared/Model/Shared/GroupDefaults.swift @@ -0,0 +1,30 @@ +// +// GroupDefaults.swift +// SimpleX (iOS) +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI + +func getGroupDefaults() -> UserDefaults? { + UserDefaults(suiteName: "5NN7GUYB6T.group.chat.simplex.app") +} + +func setAppState(_ phase: ScenePhase) { + if let defaults = getGroupDefaults() { + defaults.set(phase == .background, forKey: "appInBackground") + defaults.synchronize() + } +} + +func getAppState() -> ScenePhase { + if let defaults = getGroupDefaults() { + if defaults.bool(forKey: "appInBackground") { + return .background + } + } + return .active +} diff --git a/apps/ios/Shared/Model/JSON.swift b/apps/ios/Shared/Model/Shared/JSON.swift similarity index 100% rename from apps/ios/Shared/Model/JSON.swift rename to apps/ios/Shared/Model/Shared/JSON.swift diff --git a/apps/ios/Shared/Model/Shared/Notifications.swift b/apps/ios/Shared/Model/Shared/Notifications.swift new file mode 100644 index 0000000000..5da0fe08ad --- /dev/null +++ b/apps/ios/Shared/Model/Shared/Notifications.swift @@ -0,0 +1,81 @@ +// +// Notifications.swift +// SimpleX +// +// Created by Evgeny on 28/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import UserNotifications + +let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST" +let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED" +let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED" +let ntfCategoryCheckMessage = "NTF_CAT_CHECK_MESSAGE" +// TODO remove +let ntfCategoryCheckingMessages = "NTF_CAT_CHECKING_MESSAGES" + +let appNotificationId = "chat.simplex.app.notification" + +func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNMutableNotificationContent { + createNotification( + categoryIdentifier: ntfCategoryContactRequest, + title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName), + body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName), + targetContentIdentifier: nil, + userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] + ) +} + +func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificationContent { + createNotification( + categoryIdentifier: ntfCategoryContactConnected, + title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName), + body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName), + targetContentIdentifier: contact.id +// userInfo: ["chatId": contact.id, "contactId": contact.apiId] + ) +} + +func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent { + createNotification( + categoryIdentifier: ntfCategoryMessageReceived, + title: "\(cInfo.chatViewName):", + body: hideSecrets(cItem), + targetContentIdentifier: cInfo.id +// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] + ) +} + +func createNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil, + targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) -> UNMutableNotificationContent { + let content = UNMutableNotificationContent() + content.categoryIdentifier = categoryIdentifier + content.title = title + if let s = subtitle { content.subtitle = s } + if let s = body { content.body = s } + content.targetContentIdentifier = targetContentIdentifier + content.userInfo = userInfo + // TODO move logic of adding sound here, so it applies to background notifications too + content.sound = .default +// content.interruptionLevel = .active +// content.relevanceScore = 0.5 // 0-1 + return content +} + +func hideSecrets(_ cItem: ChatItem) -> String { + if let md = cItem.formattedText { + var res = "" + for ft in md { + if case .secret = ft.format { + res = res + "..." + } else { + res = res + ft.text + } + } + return res + } else { + return cItem.content.text + } +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e719956a66..cd46e4c28b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -12,278 +12,6 @@ import Dispatch import BackgroundTasks private var chatController: chat_ctrl? -private let jsonDecoder = getJSONDecoder() -let jsonEncoder = getJSONEncoder() - -enum ChatCommand { - case showActiveUser - case createActiveUser(profile: Profile) - case startChat - case setFilesFolder(filesFolder: String) - case apiGetChats - case apiGetChat(type: ChatType, id: Int64) - 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 apiVerifyToken(token: String, code: String, nonce: String) - case apiIntervalNofication(token: String, interval: Int) - case apiDeleteToken(token: String) - case getUserSMPServers - case setUserSMPServers(smpServers: [String]) - case addContact - case connect(connReq: String) - case apiDeleteChat(type: ChatType, id: Int64) - case apiUpdateProfile(profile: Profile) - case apiParseMarkdown(text: String) - case createMyAddress - case deleteMyAddress - case showMyAddress - case apiAcceptContact(contactReqId: Int64) - case apiRejectContact(contactReqId: Int64) - case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) - case receiveFile(fileId: Int64) - case string(String) - - var cmdString: String { - get { - switch self { - case .showActiveUser: return "/u" - case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" - case .startChat: return "/_start" - 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" - case let .apiSendMessage(type, id, file, quotedItemId, mc): - switch (file, quotedItemId) { - case (nil, nil): return "/_send \(ref(type, id)) \(mc.cmdString)" - case let (.some(file), nil): return "/_send \(ref(type, id)) file \(file) \(mc.cmdString)" - case let (nil, .some(quotedItemId)): return "/_send \(ref(type, id)) quoted \(quotedItemId) \(mc.cmdString)" - case let (.some(file), .some(quotedItemId)): return "/_send \(ref(type, id)) file \(file) quoted \(quotedItemId) \(mc.cmdString)" - } - 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 .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 .getUserSMPServers: return "/smp_servers" - case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))" - case .addContact: return "/connect" - case let .connect(connReq): return "/connect \(connReq)" - case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" - case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" - case let .apiParseMarkdown(text): return "/_parse \(text)" - case .createMyAddress: return "/address" - case .deleteMyAddress: return "/delete_address" - case .showMyAddress: return "/show_address" - case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" - case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" - case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" - case let .receiveFile(fileId): return "/freceive \(fileId)" - case let .string(str): return str - } - } - } - - var cmdType: String { - get { - switch self { - case .showActiveUser: return "showActiveUser" - case .createActiveUser: return "createActiveUser" - case .startChat: return "startChat" - case .setFilesFolder: return "setFilesFolder" - case .apiGetChats: return "apiGetChats" - case .apiGetChat: return "apiGetChat" - case .apiSendMessage: return "apiSendMessage" - case .apiUpdateChatItem: return "apiUpdateChatItem" - case .apiDeleteChatItem: return "apiDeleteChatItem" - case .apiRegisterToken: return "apiRegisterToken" - case .apiVerifyToken: return "apiVerifyToken" - case .apiIntervalNofication: return "apiIntervalNofication" - case .apiDeleteToken: return "apiDeleteToken" - case .getUserSMPServers: return "getUserSMPServers" - case .setUserSMPServers: return "setUserSMPServers" - case .addContact: return "addContact" - case .connect: return "connect" - case .apiDeleteChat: return "apiDeleteChat" - case .apiUpdateProfile: return "apiUpdateProfile" - case .apiParseMarkdown: return "apiParseMarkdown" - case .createMyAddress: return "createMyAddress" - case .deleteMyAddress: return "deleteMyAddress" - case .showMyAddress: return "showMyAddress" - case .apiAcceptContact: return "apiAcceptContact" - case .apiRejectContact: return "apiRejectContact" - case .apiChatRead: return "apiChatRead" - case .receiveFile: return "receiveFile" - case .string: return "console command" - } - } - } - - func ref(_ type: ChatType, _ id: Int64) -> String { - "\(type.rawValue)\(id)" - } - - func smpServersStr(smpServers: [String]) -> String { - smpServers.isEmpty ? "default" : smpServers.joined(separator: ",") - } -} - -struct APIResponse: Decodable { - var resp: ChatResponse -} - -enum ChatResponse: Decodable, Error { - case response(type: String, json: String) - case activeUser(user: User) - case chatStarted - case chatRunning - case apiChats(chats: [ChatData]) - case apiChat(chat: ChatData) - case userSMPServers(smpServers: [String]) - case invitation(connReqInvitation: String) - case sentConfirmation - case sentInvitation - case contactAlreadyExists(contact: Contact) - case contactDeleted(contact: Contact) - case userProfileNoChange - case userProfileUpdated(fromProfile: Profile, toProfile: Profile) - case apiParsedMarkdown(formattedText: [FormattedText]?) - case userContactLink(connReqContact: String) - case userContactLinkCreated(connReqContact: String) - case userContactLinkDeleted - case contactConnected(contact: Contact) - case contactConnecting(contact: Contact) - case receivedContactRequest(contactRequest: UserContactRequest) - case acceptingContactRequest(contact: Contact) - case contactRequestRejected - case contactUpdated(toContact: Contact) - case contactsSubscribed(server: String, contactRefs: [ContactRef]) - case contactsDisconnected(server: String, contactRefs: [ContactRef]) - case contactSubError(contact: Contact, chatError: ChatError) - case contactSubSummary(contactSubscriptions: [ContactSubStatus]) - case groupSubscribed(groupInfo: GroupInfo) - case memberSubErrors(memberSubErrors: [MemberSubError]) - case groupEmpty(groupInfo: GroupInfo) - case userContactLinkSubscribed - case newChatItem(chatItem: AChatItem) - case chatItemStatusUpdated(chatItem: AChatItem) - case chatItemUpdated(chatItem: AChatItem) - case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem) - case rcvFileAccepted - case rcvFileComplete(chatItem: AChatItem) - case ntfTokenStatus(status: NtfTknStatus) - case newContactConnection(connection: PendingContactConnection) - case contactConnectionDeleted(connection: PendingContactConnection) - case cmdOk - case chatCmdError(chatError: ChatError) - case chatError(chatError: ChatError) - - var responseType: String { - get { - switch self { - case let .response(type, _): return "* \(type)" - case .activeUser: return "activeUser" - case .chatStarted: return "chatStarted" - case .chatRunning: return "chatRunning" - case .apiChats: return "apiChats" - case .apiChat: return "apiChat" - case .userSMPServers: return "userSMPServers" - case .invitation: return "invitation" - case .sentConfirmation: return "sentConfirmation" - case .sentInvitation: return "sentInvitation" - case .contactAlreadyExists: return "contactAlreadyExists" - case .contactDeleted: return "contactDeleted" - case .userProfileNoChange: return "userProfileNoChange" - case .userProfileUpdated: return "userProfileUpdated" - case .apiParsedMarkdown: return "apiParsedMarkdown" - case .userContactLink: return "userContactLink" - case .userContactLinkCreated: return "userContactLinkCreated" - case .userContactLinkDeleted: return "userContactLinkDeleted" - case .contactConnected: return "contactConnected" - case .contactConnecting: return "contactConnecting" - case .receivedContactRequest: return "receivedContactRequest" - case .acceptingContactRequest: return "acceptingContactRequest" - case .contactRequestRejected: return "contactRequestRejected" - case .contactUpdated: return "contactUpdated" - case .contactsSubscribed: return "contactsSubscribed" - case .contactsDisconnected: return "contactsDisconnected" - case .contactSubError: return "contactSubError" - case .contactSubSummary: return "contactSubSummary" - case .groupSubscribed: return "groupSubscribed" - case .memberSubErrors: return "memberSubErrors" - case .groupEmpty: return "groupEmpty" - case .userContactLinkSubscribed: return "userContactLinkSubscribed" - case .newChatItem: return "newChatItem" - case .chatItemStatusUpdated: return "chatItemStatusUpdated" - case .chatItemUpdated: return "chatItemUpdated" - case .chatItemDeleted: return "chatItemDeleted" - case .rcvFileAccepted: return "rcvFileAccepted" - case .rcvFileComplete: return "rcvFileComplete" - case .ntfTokenStatus: return "ntfTokenStatus" - case .newContactConnection: return "newContactConnection" - case .contactConnectionDeleted: return "contactConnectionDeleted" - case .cmdOk: return "cmdOk" - case .chatCmdError: return "chatCmdError" - case .chatError: return "chatError" - } - } - } - - var details: String { - get { - switch self { - case let .response(_, json): return json - case let .activeUser(user): return String(describing: user) - case .chatStarted: return noDetails - case .chatRunning: 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) - case let .invitation(connReqInvitation): return connReqInvitation - case .sentConfirmation: return noDetails - case .sentInvitation: return noDetails - case let .contactAlreadyExists(contact): return String(describing: contact) - case let .contactDeleted(contact): return String(describing: contact) - case .userProfileNoChange: return noDetails - case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) - case let .apiParsedMarkdown(formattedText): return String(describing: formattedText) - case let .userContactLink(connReq): return connReq - case let .userContactLinkCreated(connReq): return connReq - case .userContactLinkDeleted: return noDetails - case let .contactConnected(contact): return String(describing: contact) - case let .contactConnecting(contact): return String(describing: contact) - case let .receivedContactRequest(contactRequest): return String(describing: contactRequest) - case let .acceptingContactRequest(contact): return String(describing: contact) - case .contactRequestRejected: return noDetails - case let .contactUpdated(toContact): return String(describing: toContact) - case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" - case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" - case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))" - case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions) - case let .groupSubscribed(groupInfo): return String(describing: groupInfo) - case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors) - case let .groupEmpty(groupInfo): return String(describing: groupInfo) - case .userContactLinkSubscribed: return noDetails - case let .newChatItem(chatItem): return String(describing: chatItem) - case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem) - case let .chatItemUpdated(chatItem): return String(describing: chatItem) - case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))" - case .rcvFileAccepted: return noDetails - case let .rcvFileComplete(chatItem): return String(describing: chatItem) - case let .ntfTokenStatus(status): return String(describing: status) - case let .newContactConnection(connection): return String(describing: connection) - case let .contactConnectionDeleted(connection): return String(describing: connection) - case .cmdOk: return noDetails - case let .chatCmdError(chatError): return String(describing: chatError) - case let .chatError(chatError): return String(describing: chatError) - } - } - } - - private var noDetails: String { get { "\(responseType): no details" } } -} enum TerminalItem: Identifiable { case cmd(Date, ChatCommand) @@ -317,11 +45,6 @@ enum TerminalItem: Identifiable { } } -private func _sendCmd(_ cmd: ChatCommand) -> ChatResponse { - var c = cmd.cmdString.cString(using: .utf8)! - return chatResponse(chat_send_cmd(getChatCtrl(), &c)) -} - private func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { var id: UIBackgroundTaskIdentifier! var running = true @@ -362,8 +85,8 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> ChatResponse) func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) -> ChatResponse { logger.debug("chatSendCmd \(cmd.cmdType)") let resp = bgTask - ? withBGTask(bgDelay: bgDelay) { _sendCmd(cmd) } - : _sendCmd(cmd) + ? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd) } + : sendSimpleXCmd(cmd) logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)") if case let .response(_, json) = resp { logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") @@ -640,7 +363,7 @@ func markChatRead(_ chat: Chat) async { } } -func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { +func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { do { try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) } @@ -655,14 +378,6 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { throw r } -func responseError(_ err: Error) -> String { - if let r = err as? ChatResponse { - return String(describing: r) - } else { - return err.localizedDescription - } -} - func initializeChat() { do { ChatModel.shared.currentUser = try apiGetActiveUser() @@ -833,218 +548,3 @@ private struct UserResponse: Decodable { var user: User? var error: String? } - -private func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse { - let s = String.init(cString: cjson) - let d = s.data(using: .utf8)! -// TODO is there a way to do it without copying the data? e.g: -// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) -// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - -// TODO some mechanism to update model without passing it - maybe Publisher / Subscriber? - - do { - let r = try jsonDecoder.decode(APIResponse.self, from: d) - return r.resp - } catch { - logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") - } - - var type: String? - var json: String? - if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let j1 = j["resp"] as? NSDictionary, j1.count == 1 { - type = j1.allKeys[0] as? String - } - json = prettyJSON(j) - } - free(cjson) - return ChatResponse.response(type: type ?? "invalid", json: json ?? s) -} - -func prettyJSON(_ obj: NSDictionary) -> String? { - if let d = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) { - return String(decoding: d, as: UTF8.self) - } - return nil -} - -private func getChatCtrl() -> chat_ctrl { - if let controller = chatController { return controller } - let dataDir = getDocumentsDirectory().path + "/mobile_v1" - var cstr = dataDir.cString(using: .utf8)! - logger.debug("getChatCtrl: chat_init") - ChatModel.shared.terminalItems.append(.cmd(.now, .string("chat_init"))) - chatController = chat_init(&cstr) - ChatModel.shared.terminalItems.append(.resp(.now, .response(type: "chat_controller", json: "chat_controller: no details"))) - return chatController! -} - -private func decodeCJSON(_ cjson: UnsafePointer) -> T? { - let s = String.init(cString: cjson) - let d = s.data(using: .utf8)! -// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson)) -// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free) - return try? jsonDecoder.decode(T.self, from: d) -} - -private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { - let s = String.init(cString: cjson) - let d = s.data(using: .utf8)! - return try? JSONSerialization.jsonObject(with: d) as? NSDictionary -} - -func encodeJSON(_ value: T) -> String { - let data = try! jsonEncoder.encode(value) - return String(decoding: data, as: UTF8.self) -} - -private func encodeCJSON(_ value: T) -> [CChar] { - encodeJSON(value).cString(using: .utf8)! -} - -enum ChatError: Decodable { - case error(errorType: ChatErrorType) - case errorAgent(agentError: AgentErrorType) - case errorStore(storeError: StoreError) -} - -enum ChatErrorType: Decodable { - case noActiveUser - case activeUserExists - case chatNotStarted - case invalidConnReq - case invalidChatMessage(message: String) - case contactNotReady(contact: Contact) - case contactGroups(contact: Contact, groupNames: [GroupName]) - case groupUserRole - case groupContactRole(contactName: ContactName) - case groupDuplicateMember(contactName: ContactName) - case groupDuplicateMemberId - case groupNotJoined(groupInfo: GroupInfo) - case groupMemberNotActive - case groupMemberUserRemoved - case groupMemberNotFound(contactName: ContactName) - case groupMemberIntroNotFound(contactName: ContactName) - case groupCantResendInvitation(groupInfo: GroupInfo, contactName: ContactName) - case groupInternal(message: String) - case fileNotFound(message: String) - case fileAlreadyReceiving(message: String) - case fileAlreadyExists(filePath: String) - case fileRead(filePath: String, message: String) - case fileWrite(filePath: String, message: String) - case fileSend(fileId: Int64, agentError: String) - case fileRcvChunk(message: String) - case fileInternal(message: String) - case invalidQuote - case invalidChatItemUpdate - case invalidChatItemDelete - case agentVersion - case commandError(message: String) -} - -enum StoreError: Decodable { - case duplicateName - case contactNotFound(contactId: Int64) - case contactNotFoundByName(contactName: ContactName) - case contactNotReady(contactName: ContactName) - case duplicateContactLink - case userContactLinkNotFound - case contactRequestNotFound(contactRequestId: Int64) - case contactRequestNotFoundByName(contactName: ContactName) - case groupNotFound(groupId: Int64) - case groupNotFoundByName(groupName: GroupName) - case groupWithoutUser - case duplicateGroupMember - case groupAlreadyJoined - case groupInvitationNotFound - case sndFileNotFound(fileId: Int64) - case sndFileInvalid(fileId: Int64) - case rcvFileNotFound(fileId: Int64) - case fileNotFound(fileId: Int64) - case rcvFileInvalid(fileId: Int64) - case connectionNotFound(agentConnId: String) - case pendingConnectionNotFound(connId: Int64) - case introNotFound - case uniqueID - case internalError(message: String) - case noMsgDelivery(connId: Int64, agentMsgId: String) - case badChatItem(itemId: Int64) - case chatItemNotFound(itemId: Int64) - case quotedChatItemNotFound - case chatItemSharedMsgIdNotFound(sharedMsgId: String) - case chatItemNotFoundByFileId(fileId: Int64) -} - -enum AgentErrorType: Decodable { - case CMD(cmdErr: CommandErrorType) - case CONN(connErr: ConnectionErrorType) - case SMP(smpErr: ProtocolErrorType) - case NTF(ntfErr: ProtocolErrorType) - case BROKER(brokerErr: BrokerErrorType) - case AGENT(agentErr: SMPAgentError) - case INTERNAL(internalErr: String) -} - -enum CommandErrorType: Decodable { - case PROHIBITED - case SYNTAX - case NO_CONN - case SIZE - case LARGE -} - -enum ConnectionErrorType: Decodable { - case NOT_FOUND - case DUPLICATE - case SIMPLEX - case NOT_ACCEPTED - case NOT_AVAILABLE -} - -enum BrokerErrorType: Decodable { - case RESPONSE(smpErr: ProtocolErrorType) - case UNEXPECTED - case NETWORK - case TRANSPORT(transportErr: ProtocolTransportError) - case TIMEOUT -} - -enum ProtocolErrorType: Decodable { - case BLOCK - case SESSION - case CMD(cmdErr: ProtocolCommandError) - case AUTH - case QUOTA - case NO_MSG - case LARGE_MSG - case INTERNAL -} - -enum ProtocolCommandError: Decodable { - case UNKNOWN - case SYNTAX - case NO_AUTH - case HAS_AUTH - case NO_ENTITY -} - -enum ProtocolTransportError: Decodable { - case badBlock - case largeMsg - case badSession - case handshake(handshakeErr: SMPHandshakeError) -} - -enum SMPHandshakeError: Decodable { - case PARSE - case VERSION - case IDENTITY -} - -enum SMPAgentError: Decodable { - case A_MESSAGE - case A_PROHIBITED - case A_VERSION - case A_ENCRYPTION -} diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index d99a0d197c..8dac273ab8 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -34,6 +34,8 @@ struct SimpleXApp: App { initializeChat() } .onChange(of: scenePhase) { phase in + logger.debug("scenePhase \(String(describing: scenePhase))") + setAppState(phase) if phase == .background { BGManager.shared.schedule() } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 80ba354785..21d7f7cf9e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -153,7 +153,7 @@ private struct MetaColorPreferenceKey: PreferenceKey { private func ciQuotedMsgView(_ qi: CIQuote) -> some View { MsgContentView( content: qi, - sender: qi.sender + sender: qi.getSender(ChatModel.shared.currentUser) ) .lineLimit(3) .font(.subheadline) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 5b94a4b7d2..d039f6eeb3 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -35,7 +35,7 @@ struct ChatPreviewView: View { .foregroundColor(chat.chatInfo.ready ? .primary : .secondary) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - (cItem?.timestampText ?? timestampText(chat.chatInfo.createdAt)) + (cItem?.timestampText ?? formatTimestampText(chat.chatInfo.createdAt)) .font(.subheadline) .frame(minWidth: 60, alignment: .trailing) .foregroundColor(.secondary) diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift index 90c9b70bd8..5243188c6b 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift @@ -30,7 +30,7 @@ struct ContactConnectionView: View { .padding(.top, 4) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - timestampText(contactConnection.updatedAt) + formatTimestampText(contactConnection.updatedAt) .font(.subheadline) .padding(.trailing, 8) .padding(.top, 4) diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index d139550664..ac873fc8f9 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -26,7 +26,7 @@ struct ContactRequestView: View { .padding(.top, 4) .frame(maxHeight: .infinity, alignment: .topLeading) Spacer() - timestampText(contactRequest.updatedAt) + formatTimestampText(contactRequest.updatedAt) .font(.subheadline) .padding(.trailing, 8) .padding(.top, 4) diff --git a/apps/ios/SimpleX (iOS).entitlements b/apps/ios/SimpleX (iOS).entitlements index 7e024ae355..6dda31ceba 100644 --- a/apps/ios/SimpleX (iOS).entitlements +++ b/apps/ios/SimpleX (iOS).entitlements @@ -10,5 +10,9 @@ applinks:www.simplex.chat applinks:simplex.chat?mode=developer + com.apple.security.application-groups + + group.chat.simplex.app + diff --git a/apps/ios/SimpleX NSE/Info.plist b/apps/ios/SimpleX NSE/Info.plist new file mode 100644 index 0000000000..57421ebf9b --- /dev/null +++ b/apps/ios/SimpleX NSE/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift new file mode 100644 index 0000000000..a615ac5b71 --- /dev/null +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -0,0 +1,132 @@ +// +// NotificationService.swift +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import UserNotifications +import OSLog + +let logger = Logger() + +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + logger.debug("NotificationService.didReceive") + if getAppState() != .background { + contentHandler(request.content) + return + } + logger.debug("NotificationService: app is in the background") + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + if let _ = startChat() { + let content = receiveMessages() + contentHandler (content) + return + } + + 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 { + contentHandler(bestAttemptContent) + } + } + +} + +func startChat() -> User? { + hs_init(0, nil) + if let user = apiGetActiveUser() { + logger.debug("active user \(String(describing: user))") + do { + try apiStartChat() + try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) + return user + } catch { + logger.error("NotificationService startChat error: \(responseError(error))") + } + } else { + logger.debug("no active user") + } + return nil +} + +func receiveMessages() -> UNNotificationContent { + logger.debug("NotificationService receiveMessages started") + while true { + let res = chatResponse(chat_recv_msg(getChatCtrl())!) + logger.debug("NotificationService receiveMessages: \(res.responseType)") + switch res { +// case let .newContactConnection(connection): +// case let .contactConnectionDeleted(connection): + case let .contactConnected(contact): + return createContactConnectedNtf(contact) +// case let .contactConnecting(contact): +// TODO profile update + case let .receivedContactRequest(contactRequest): + return createContactRequestNtf(contactRequest) +// case let .contactUpdated(toContact): +// TODO profile updated + case let .newChatItem(aChatItem): + let cInfo = aChatItem.chatInfo + let cItem = aChatItem.chatItem + return createMessageReceivedNtf(cInfo, cItem) +// case let .chatItemUpdated(aChatItem): +// TODO message updated +// let cInfo = aChatItem.chatInfo +// let cItem = aChatItem.chatItem +// NtfManager.shared.notifyMessageReceived(cInfo, cItem) +// case let .chatItemDeleted(_, toChatItem): +// TODO message updated +// case let .rcvFileComplete(aChatItem): +// TODO file received? +// let cInfo = aChatItem.chatInfo +// let cItem = aChatItem.chatItem +// NtfManager.shared.notifyMessageReceived(cInfo, cItem) + default: + logger.debug("NotificationService ignored event: \(res.responseType)") + } + } +} + +func apiGetActiveUser() -> User? { + let _ = getChatCtrl() + let r = sendSimpleXCmd(.showActiveUser) + logger.debug("apiGetActiveUser sendSimpleXCmd responce: \(String(describing: r))") + switch r { + case let .activeUser(user): return user + case .chatCmdError(.error(.noActiveUser)): return nil + default: + logger.error("NotificationService apiGetActiveUser unexpected response: \(String(describing: r))") + return nil + } +} + +func apiStartChat() throws { + let r = sendSimpleXCmd(.startChat) + if case .chatStarted = r { return } + throw r +} + +func apiSetFilesFolder(filesFolder: String) throws { + let r = sendSimpleXCmd(.setFilesFolder(filesFolder: filesFolder)) + if case .cmdOk = r { return } + throw r +} + diff --git a/apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h b/apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h new file mode 100644 index 0000000000..bc28b42d38 --- /dev/null +++ b/apps/ios/SimpleX NSE/SimpleX NSE-Bridging-Header.h @@ -0,0 +1,11 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + +extern void hs_init(int argc, char **argv[]); + +typedef void* chat_ctrl; + +extern chat_ctrl chat_init(char *path); +extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); +extern char *chat_recv_msg(chat_ctrl ctl); diff --git a/apps/ios/SimpleX NSE/SimpleX NSE.entitlements b/apps/ios/SimpleX NSE/SimpleX NSE.entitlements new file mode 100644 index 0000000000..82cf32be67 --- /dev/null +++ b/apps/ios/SimpleX NSE/SimpleX NSE.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.chat.simplex.app + + + diff --git a/apps/ios/SimpleX NSE/dummy.m b/apps/ios/SimpleX NSE/dummy.m new file mode 100644 index 0000000000..adef540363 --- /dev/null +++ b/apps/ios/SimpleX NSE/dummy.m @@ -0,0 +1,24 @@ +// +// dummy.m +// SimpleX NSE +// +// Created by Evgeny on 26/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +#import + +#if defined(__x86_64__) && TARGET_IPHONE_SIMULATOR + +#import + +int readdir_r$INODE64(DIR *restrict dirp, struct dirent *restrict entry, + struct dirent **restrict result) { + return readdir_r(dirp, entry, result); +} + +DIR *opendir$INODE64(const char *name) { + return opendir(name); +} + +#endif diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index dff3f05f88..e5b00c54bc 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -63,6 +63,28 @@ 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; + 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; + 5CDCAD4C2818589900503DA2 /* SimpleX NSE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */; }; + 5CDCAD5428186F9700503DA2 /* GroupDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */; }; + 5CDCAD5828187C7500503DA2 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD5728187C7500503DA2 /* dummy.m */; }; + 5CDCAD5928187CF200503DA2 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C882812A833007A6B96 /* libgmp.a */; }; + 5CDCAD5A28187CF200503DA2 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C8A2812A833007A6B96 /* libffi.a */; }; + 5CDCAD5B28187CF200503DA2 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C8B2812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a */; }; + 5CDCAD5C28187CF200503DA2 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C872812A833007A6B96 /* libgmpxx.a */; }; + 5CDCAD5D28187CF200503DA2 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C892812A833007A6B96 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a */; }; + 5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */; }; + 5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CDCAD6028187D7900503DA2 /* libz.tbd */; }; + 5CDCAD682818876500503DA2 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; + 5CDCAD7628188D3600503DA2 /* APITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7428188D2900503DA2 /* APITypes.swift */; }; + 5CDCAD7728188D3800503DA2 /* ChatTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */; }; + 5CDCAD7828188FD300503DA2 /* ChatTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */; }; + 5CDCAD7928188FD600503DA2 /* APITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7428188D2900503DA2 /* APITypes.swift */; }; + 5CDCAD7C2818924D00503DA2 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64DAE1502809D9F5000DA960 /* FileUtils.swift */; }; + 5CDCAD7E2818941F00503DA2 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7D2818941F00503DA2 /* API.swift */; }; + 5CDCAD7F281894FB00503DA2 /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD7D2818941F00503DA2 /* API.swift */; }; + 5CDCAD81281A7E2700503DA2 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD80281A7E2700503DA2 /* Notifications.swift */; }; + 5CDCAD82281A7E2700503DA2 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD80281A7E2700503DA2 /* Notifications.swift */; }; 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; @@ -83,8 +105,29 @@ remoteGlobalIDString = 5CA059C9279559F40002BEB4; remoteInfo = "SimpleX (iOS)"; }; + 5CDCAD4A2818589900503DA2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5CDCAD442818589900503DA2; + remoteInfo = "SimpleX NSE"; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 5CDCAD4D2818589900503DA2 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 5CDCAD4C2818589900503DA2 /* SimpleX NSE.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; @@ -146,6 +189,19 @@ 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = ""; }; + 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX NSE.entitlements"; sourceTree = ""; }; + 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupDefaults.swift; sourceTree = ""; }; + 5CDCAD5628187C7500503DA2 /* SimpleX NSE-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX NSE-Bridging-Header.h"; sourceTree = ""; }; + 5CDCAD5728187C7500503DA2 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; + 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/usr/lib/libiconv.tbd; sourceTree = DEVELOPER_DIR; }; + 5CDCAD6028187D7900503DA2 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; }; + 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTypes.swift; sourceTree = ""; }; + 5CDCAD7428188D2900503DA2 /* APITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APITypes.swift; sourceTree = ""; }; + 5CDCAD7D2818941F00503DA2 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; + 5CDCAD80281A7E2700503DA2 /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; @@ -182,6 +238,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5CDCAD422818589900503DA2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CDCAD5F28187D6900503DA2 /* libiconv.tbd in Frameworks */, + 5CDCAD5C28187CF200503DA2 /* libgmpxx.a in Frameworks */, + 5CDCAD6128187D8000503DA2 /* libz.tbd in Frameworks */, + 5CDCAD5B28187CF200503DA2 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK-ghc8.10.7.a in Frameworks */, + 5CDCAD5928187CF200503DA2 /* libgmp.a in Frameworks */, + 5CDCAD5A28187CF200503DA2 /* libffi.a in Frameworks */, + 5CDCAD5D28187CF200503DA2 /* libHSsimplex-chat-1.6.0-IWA13KO27d7KsfAsFQIqFK.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -228,6 +298,8 @@ 5C764E7A279C71D4000C6508 /* Frameworks */ = { isa = PBXGroup; children = ( + 5CDCAD6028187D7900503DA2 /* libz.tbd */, + 5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */, 5C764E7C279C71DB000C6508 /* libz.tbd */, 5C764E7B279C71D4000C6508 /* libiconv.tbd */, ); @@ -237,9 +309,9 @@ 5C764E87279CBC8E000C6508 /* Model */ = { isa = PBXGroup; children = ( + 5CDCAD7128188CEB00503DA2 /* Shared */, 5C764E88279CBCB3000C6508 /* ChatModel.swift */, 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */, - 5C9FD96A27A56D4D0075386C /* JSON.swift */, 5C35CFC727B2782E00FB6C6D /* BGManager.swift */, 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */, ); @@ -271,6 +343,7 @@ 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, 5C764E5C279C70B7000C6508 /* Libraries */, 5CA059C2279559F40002BEB4 /* Shared */, + 5CDCAD462818589900503DA2 /* SimpleX NSE */, 5CA059DA279559F40002BEB4 /* Tests iOS */, 5CA059CB279559F40002BEB4 /* Products */, 5C764E7A279C71D4000C6508 /* Frameworks */, @@ -299,6 +372,7 @@ children = ( 5CA059CA279559F40002BEB4 /* SimpleX.app */, 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */, + 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */, ); name = Products; sourceTree = ""; @@ -351,6 +425,31 @@ path = ChatList; sourceTree = ""; }; + 5CDCAD462818589900503DA2 /* SimpleX NSE */ = { + isa = PBXGroup; + children = ( + 5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */, + 5CDCAD472818589900503DA2 /* NotificationService.swift */, + 5CDCAD492818589900503DA2 /* Info.plist */, + 5CDCAD5728187C7500503DA2 /* dummy.m */, + 5CDCAD5628187C7500503DA2 /* SimpleX NSE-Bridging-Header.h */, + ); + path = "SimpleX NSE"; + sourceTree = ""; + }; + 5CDCAD7128188CEB00503DA2 /* Shared */ = { + isa = PBXGroup; + children = ( + 5CDCAD5228186F9500503DA2 /* GroupDefaults.swift */, + 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */, + 5CDCAD7428188D2900503DA2 /* APITypes.swift */, + 5C9FD96A27A56D4D0075386C /* JSON.swift */, + 5CDCAD7D2818941F00503DA2 /* API.swift */, + 5CDCAD80281A7E2700503DA2 /* Notifications.swift */, + ); + path = Shared; + sourceTree = ""; + }; 5CE4407427ADB657007B033A /* ChatItem */ = { isa = PBXGroup; children = ( @@ -383,10 +482,12 @@ 5CA059C6279559F40002BEB4 /* Sources */, 5CA059C7279559F40002BEB4 /* Frameworks */, 5CA059C8279559F40002BEB4 /* Resources */, + 5CDCAD4D2818589900503DA2 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + 5CDCAD4B2818589900503DA2 /* PBXTargetDependency */, ); name = "SimpleX (iOS)"; packageProductDependencies = ( @@ -414,6 +515,23 @@ productReference = 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 5CDCAD442818589900503DA2 /* SimpleX NSE */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */; + buildPhases = ( + 5CDCAD412818589900503DA2 /* Sources */, + 5CDCAD422818589900503DA2 /* Frameworks */, + 5CDCAD432818589900503DA2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "SimpleX NSE"; + productName = "SimpleX NSE"; + productReference = 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -421,7 +539,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1320; + LastSwiftUpdateCheck = 1330; LastUpgradeCheck = 1330; ORGANIZATIONNAME = "SimpleX Chat"; TargetAttributes = { @@ -433,6 +551,10 @@ CreatedOnToolsVersion = 13.2.1; TestTargetID = 5CA059C9279559F40002BEB4; }; + 5CDCAD442818589900503DA2 = { + CreatedOnToolsVersion = 13.3; + LastSwiftMigration = 1330; + }; }; }; buildConfigurationList = 5CA059C1279559F40002BEB4 /* Build configuration list for PBXProject "SimpleX" */; @@ -454,6 +576,7 @@ targets = ( 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */, 5CA059D6279559F40002BEB4 /* Tests iOS */, + 5CDCAD442818589900503DA2 /* SimpleX NSE */, ); }; /* End PBXProject section */ @@ -476,6 +599,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5CDCAD432818589900503DA2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -484,10 +614,14 @@ buildActionMask = 2147483647; files = ( 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */, + 5CDCAD7F281894FB00503DA2 /* API.swift in Sources */, + 5CDCAD81281A7E2700503DA2 /* Notifications.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, + 5CDCAD7728188D3800503DA2 /* ChatTypes.swift in Sources */, 5C36027327F47AD5009F19D9 /* AppDelegate.swift in Sources */, 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, + 5CDCAD5328186F9500503DA2 /* GroupDefaults.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, @@ -502,6 +636,7 @@ 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, + 5CDCAD7628188D3600503DA2 /* APITypes.swift in Sources */, 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */, 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */, @@ -548,6 +683,22 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5CDCAD412818589900503DA2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5CDCAD7928188FD600503DA2 /* APITypes.swift in Sources */, + 5CDCAD5828187C7500503DA2 /* dummy.m in Sources */, + 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */, + 5CDCAD82281A7E2700503DA2 /* Notifications.swift in Sources */, + 5CDCAD7C2818924D00503DA2 /* FileUtils.swift in Sources */, + 5CDCAD682818876500503DA2 /* JSON.swift in Sources */, + 5CDCAD7828188FD300503DA2 /* ChatTypes.swift in Sources */, + 5CDCAD7E2818941F00503DA2 /* API.swift in Sources */, + 5CDCAD5428186F9700503DA2 /* GroupDefaults.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -556,6 +707,11 @@ target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; targetProxy = 5CA059D8279559F40002BEB4 /* PBXContainerItemProxy */; }; + 5CDCAD4B2818589900503DA2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5CDCAD442818589900503DA2 /* SimpleX NSE */; + targetProxy = 5CDCAD4A2818589900503DA2 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -695,6 +851,7 @@ 5CA059F4279559F40002BEB4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -718,10 +875,6 @@ "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; MARKETING_VERSION = 1.7; @@ -739,6 +892,7 @@ 5CA059F5279559F40002BEB4 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -762,10 +916,6 @@ "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios"; "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim"; MARKETING_VERSION = 1.7; @@ -821,6 +971,86 @@ }; name = Release; }; + 5CDCAD4E2818589900503DA2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 39; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_BITCODE = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SimpleX NSE/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "SimpleX NSE"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 SimpleX Chat. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/ios", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/sim", + ); + MARKETING_VERSION = 1.6; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "SimpleX NSE/SimpleX NSE-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 5CDCAD4F2818589900503DA2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 39; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_BITCODE = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SimpleX NSE/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "SimpleX NSE"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 SimpleX Chat. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/ios", + ); + "LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries/sim", + ); + MARKETING_VERSION = 1.6; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "SimpleX NSE/SimpleX NSE-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -851,6 +1081,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5CDCAD4E2818589900503DA2 /* Debug */, + 5CDCAD4F2818589900503DA2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */