* experiments * audio recording in swiftui * recording encapsulated * permission + playback * stopAudioPlayback on cancel * method names * check permission in recording start * run timer on main thread * remove obsolete view * don't call playback timer callback unless player is playing * compose + send view + preview + send * animation + improve state + quality * fix recording not stopping in time * animate to end * remove recorder delegate, fix cancelling during recording * replace print with log * recording start error constructor * CIVoiceView file * chat item wip * chat item wip * refactor settings * layout * send correct duration * item previews * more background, animation * more layout * more layout, send button conditions * context, preview, quote, notification texts * chat item actions * use isEmpty * remove comment * uncomment file.loaded * more layout, hold to record * more layout * preview player stop on disappear * more layout * comment * only one player or recording * remove voice message on chat close * fix state bug * remove commented code * length 30 Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
303 lines
11 KiB
Swift
303 lines
11 KiB
Swift
//
|
|
// NotificationService.swift
|
|
// SimpleX NSE
|
|
//
|
|
// Created by Evgeny on 26/04/2022.
|
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
|
//
|
|
|
|
import UserNotifications
|
|
import OSLog
|
|
import SimpleXChat
|
|
|
|
let logger = Logger()
|
|
|
|
let suspendingDelay: UInt64 = 2_000_000_000
|
|
|
|
typealias NtfStream = AsyncStream<UNMutableNotificationContent>
|
|
|
|
actor PendingNtfs {
|
|
static let shared = PendingNtfs()
|
|
private var ntfStreams: [String: NtfStream] = [:]
|
|
private var ntfConts: [String: NtfStream.Continuation] = [:]
|
|
|
|
func createStream(_ id: String) {
|
|
logger.debug("PendingNtfs.createStream: \(id, privacy: .public)")
|
|
if ntfStreams.index(forKey: id) == nil {
|
|
ntfStreams[id] = AsyncStream { cont in
|
|
ntfConts[id] = cont
|
|
logger.debug("PendingNtfs.createStream: store continuation")
|
|
}
|
|
}
|
|
}
|
|
|
|
func readStream(_ id: String, for nse: NotificationService, msgCount: Int = 1) async {
|
|
logger.debug("PendingNtfs.readStream: \(id, privacy: .public) \(msgCount, privacy: .public)")
|
|
if let s = ntfStreams[id] {
|
|
logger.debug("PendingNtfs.readStream: has stream")
|
|
var rcvCount = max(1, msgCount)
|
|
for await ntf in s {
|
|
nse.setBestAttemptNtf(ntf)
|
|
rcvCount -= 1
|
|
if rcvCount == 0 || ntf.categoryIdentifier == ntfCategoryCallInvitation { break }
|
|
}
|
|
logger.debug("PendingNtfs.readStream: exiting")
|
|
}
|
|
}
|
|
|
|
func writeStream(_ id: String, _ ntf: UNMutableNotificationContent) {
|
|
logger.debug("PendingNtfs.writeStream: \(id, privacy: .public)")
|
|
if let cont = ntfConts[id] {
|
|
logger.debug("PendingNtfs.writeStream: writing ntf")
|
|
cont.yield(ntf)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
class NotificationService: UNNotificationServiceExtension {
|
|
var contentHandler: ((UNNotificationContent) -> Void)?
|
|
var bestAttemptNtf: UNMutableNotificationContent?
|
|
var badgeCount: Int = 0
|
|
|
|
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
|
logger.debug("NotificationService.didReceive")
|
|
setBestAttemptNtf(request.content.mutableCopy() as? UNMutableNotificationContent)
|
|
self.contentHandler = contentHandler
|
|
registerGroupDefaults()
|
|
let appState = appStateGroupDefault.get()
|
|
switch appState {
|
|
case .suspended:
|
|
logger.debug("NotificationService: app is suspended")
|
|
setBadgeCount()
|
|
receiveNtfMessages(request, contentHandler)
|
|
case .suspending:
|
|
logger.debug("NotificationService: app is suspending")
|
|
setBadgeCount()
|
|
Task {
|
|
var state = appState
|
|
for _ in 1...5 {
|
|
_ = try await Task.sleep(nanoseconds: suspendingDelay)
|
|
state = appStateGroupDefault.get()
|
|
if state == .suspended || state != .suspending { break }
|
|
}
|
|
logger.debug("NotificationService: app state is \(state.rawValue, privacy: .public)")
|
|
if state.inactive {
|
|
receiveNtfMessages(request, contentHandler)
|
|
} else {
|
|
deliverBestAttemptNtf()
|
|
}
|
|
}
|
|
default:
|
|
logger.debug("NotificationService: app state is \(appState.rawValue, privacy: .public)")
|
|
deliverBestAttemptNtf()
|
|
}
|
|
}
|
|
|
|
func receiveNtfMessages(_ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) {
|
|
logger.debug("NotificationService: receiveNtfMessages")
|
|
if case .documents = dbContainerGroupDefault.get() {
|
|
deliverBestAttemptNtf()
|
|
return
|
|
}
|
|
let userInfo = request.content.userInfo
|
|
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
|
|
let nonce = ntfData["nonce"] as? String,
|
|
let encNtfInfo = ntfData["message"] as? String,
|
|
let dbStatus = startChat() {
|
|
if case .ok = dbStatus,
|
|
let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) {
|
|
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)")
|
|
if let connEntity = ntfMsgInfo.connEntity {
|
|
setBestAttemptNtf(createConnectionEventNtf(connEntity))
|
|
if let id = connEntity.id {
|
|
Task {
|
|
logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)")
|
|
await PendingNtfs.shared.createStream(id)
|
|
await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count)
|
|
deliverBestAttemptNtf()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
} else {
|
|
setBestAttemptNtf(createErrorNtf(dbStatus))
|
|
}
|
|
}
|
|
deliverBestAttemptNtf()
|
|
}
|
|
|
|
override func serviceExtensionTimeWillExpire() {
|
|
logger.debug("NotificationService.serviceExtensionTimeWillExpire")
|
|
deliverBestAttemptNtf()
|
|
}
|
|
|
|
func setBadgeCount() {
|
|
badgeCount = ntfBadgeCountGroupDefault.get() + 1
|
|
ntfBadgeCountGroupDefault.set(badgeCount)
|
|
}
|
|
|
|
func setBestAttemptNtf(_ ntf: UNMutableNotificationContent?) {
|
|
logger.debug("NotificationService.setBestAttemptNtf")
|
|
bestAttemptNtf = ntf
|
|
bestAttemptNtf?.badge = badgeCount as NSNumber
|
|
}
|
|
|
|
private func deliverBestAttemptNtf() {
|
|
logger.debug("NotificationService.deliverBestAttemptNtf")
|
|
if let handler = contentHandler, let content = bestAttemptNtf {
|
|
handler(content)
|
|
bestAttemptNtf = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
var chatStarted = false
|
|
|
|
func startChat() -> DBMigrationResult? {
|
|
hs_init(0, nil)
|
|
if chatStarted { return .ok }
|
|
let (_, dbStatus) = chatMigrateInit()
|
|
if dbStatus != .ok {
|
|
resetChatCtrl()
|
|
return dbStatus
|
|
}
|
|
if let user = apiGetActiveUser() {
|
|
logger.debug("active user \(String(describing: user))")
|
|
do {
|
|
try setNetworkConfig(getNetCfg())
|
|
let justStarted = try apiStartChat()
|
|
chatStarted = true
|
|
if justStarted {
|
|
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
|
|
try apiSetIncognito(incognito: incognitoGroupDefault.get())
|
|
chatLastStartGroupDefault.set(Date.now)
|
|
Task { await receiveMessages() }
|
|
}
|
|
return .ok
|
|
} catch {
|
|
logger.error("NotificationService startChat error: \(responseError(error), privacy: .public)")
|
|
}
|
|
} else {
|
|
logger.debug("no active user")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func receiveMessages() async {
|
|
logger.debug("NotificationService receiveMessages")
|
|
while true {
|
|
if let msg = await chatRecvMsg() {
|
|
if let (id, ntf) = await receivedMsgNtf(msg) {
|
|
await PendingNtfs.shared.createStream(id)
|
|
await PendingNtfs.shared.writeStream(id, ntf)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func chatRecvMsg() async -> ChatResponse? {
|
|
await withCheckedContinuation { cont in
|
|
let resp = recvSimpleXMsg()
|
|
cont.resume(returning: resp)
|
|
}
|
|
}
|
|
|
|
func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotificationContent)? {
|
|
logger.debug("NotificationService processReceivedMsg: \(res.responseType)")
|
|
switch res {
|
|
case let .contactConnected(contact, _):
|
|
return (contact.id, createContactConnectedNtf(contact))
|
|
// case let .contactConnecting(contact):
|
|
// TODO profile update
|
|
case let .receivedContactRequest(contactRequest):
|
|
return (UserContact(contactRequest: contactRequest).id, createContactRequestNtf(contactRequest))
|
|
case let .newChatItem(aChatItem):
|
|
let cInfo = aChatItem.chatInfo
|
|
var cItem = aChatItem.chatItem
|
|
if case .image = cItem.content.msgContent {
|
|
if let file = cItem.file,
|
|
file.fileSize <= maxImageSize,
|
|
privacyAcceptImagesGroupDefault.get() {
|
|
let inline = privacyTransferImagesInlineGroupDefault.get()
|
|
cItem = apiReceiveFile(fileId: file.fileId, inline: inline)?.chatItem ?? cItem
|
|
}
|
|
} else if case .voice = cItem.content.msgContent { // TODO check inlineFileMode != IFMSent
|
|
if let file = cItem.file,
|
|
file.fileSize <= maxImageSize,
|
|
privacyAcceptImagesGroupDefault.get() {
|
|
let inline = privacyTransferImagesInlineGroupDefault.get()
|
|
cItem = apiReceiveFile(fileId: file.fileId, inline: inline)?.chatItem ?? cItem
|
|
}
|
|
}
|
|
return cItem.isCall ? nil : (aChatItem.chatId, createMessageReceivedNtf(cInfo, cItem))
|
|
case let .callInvitation(invitation):
|
|
return (invitation.contact.id, createCallInvitationNtf(invitation))
|
|
default:
|
|
logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func apiGetActiveUser() -> User? {
|
|
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 -> Bool {
|
|
let r = sendSimpleXCmd(.startChat(subscribe: false, expire: false))
|
|
switch r {
|
|
case .chatStarted: return true
|
|
case .chatRunning: return false
|
|
default: throw r
|
|
}
|
|
}
|
|
|
|
func apiSetFilesFolder(filesFolder: String) throws {
|
|
let r = sendSimpleXCmd(.setFilesFolder(filesFolder: filesFolder))
|
|
if case .cmdOk = r { return }
|
|
throw r
|
|
}
|
|
|
|
func apiSetIncognito(incognito: Bool) throws {
|
|
let r = sendSimpleXCmd(.setIncognito(incognito: incognito))
|
|
if case .cmdOk = r { return }
|
|
throw r
|
|
}
|
|
|
|
func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
|
|
let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo))
|
|
if case let .ntfMessages(connEntity, msgTs, ntfMessages) = r {
|
|
return NtfMessages(connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages)
|
|
}
|
|
logger.debug("apiGetNtfMessage ignored response: \(String.init(describing: r), privacy: .public)")
|
|
return nil
|
|
}
|
|
|
|
func apiReceiveFile(fileId: Int64, inline: Bool) -> AChatItem? {
|
|
let r = sendSimpleXCmd(.receiveFile(fileId: fileId, inline: inline))
|
|
if case let .rcvFileAccepted(chatItem) = r { return chatItem }
|
|
logger.error("receiveFile error: \(responseError(r))")
|
|
return nil
|
|
}
|
|
|
|
func setNetworkConfig(_ cfg: NetCfg) throws {
|
|
let r = sendSimpleXCmd(.apiSetNetworkConfig(networkConfig: cfg))
|
|
if case .cmdOk = r { return }
|
|
throw r
|
|
}
|
|
|
|
struct NtfMessages {
|
|
var connEntity: ConnectionEntity?
|
|
var msgTs: Date?
|
|
var ntfMessages: [NtfMsgInfo]
|
|
}
|