mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
ea32e407a9
Updated the HomeVC, SettingsVC and GlobalSearch UI to use theming Removed the "fade view" gradients from the various screens Added a simple log to the PagedDatabaseObserver to make debugging easier Updated the FullConversationCell to also show the "read" state for messages Updated the read receipt icons to use SFSymbols directly Updated the PlaceholderIcon to use the PrimaryColour's as it's colour options
561 lines
20 KiB
Swift
561 lines
20 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import GRDB
|
|
import PromiseKit
|
|
import SessionMessagingKit
|
|
import SignalUtilitiesKit
|
|
|
|
/// There are two primary components in our system notification integration:
|
|
///
|
|
/// 1. The `NotificationPresenter` shows system notifications to the user.
|
|
/// 2. The `NotificationActionHandler` handles the users interactions with these
|
|
/// notifications.
|
|
///
|
|
/// The NotificationPresenter is driven by the adapter pattern to provide a unified interface to
|
|
/// presenting notifications on iOS9, which uses UINotifications vs iOS10+ which supports
|
|
/// UNUserNotifications.
|
|
///
|
|
/// The `NotificationActionHandler`s also need slightly different integrations for UINotifications
|
|
/// vs. UNUserNotifications, but because they are integrated at separate system defined callbacks,
|
|
/// there is no need for an Adapter, and instead the appropriate NotificationActionHandler is
|
|
/// wired directly into the appropriate callback point.
|
|
|
|
enum AppNotificationCategory: CaseIterable {
|
|
case incomingMessage
|
|
case incomingMessageFromNoLongerVerifiedIdentity
|
|
case errorMessage
|
|
case threadlessErrorMessage
|
|
}
|
|
|
|
enum AppNotificationAction: CaseIterable {
|
|
case markAsRead
|
|
case reply
|
|
case showThread
|
|
}
|
|
|
|
struct AppNotificationUserInfoKey {
|
|
static let threadId = "Signal.AppNotificationsUserInfoKey.threadId"
|
|
static let callBackNumber = "Signal.AppNotificationsUserInfoKey.callBackNumber"
|
|
static let localCallId = "Signal.AppNotificationsUserInfoKey.localCallId"
|
|
static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter"
|
|
}
|
|
|
|
extension AppNotificationCategory {
|
|
var identifier: String {
|
|
switch self {
|
|
case .incomingMessage:
|
|
return "Signal.AppNotificationCategory.incomingMessage"
|
|
case .incomingMessageFromNoLongerVerifiedIdentity:
|
|
return "Signal.AppNotificationCategory.incomingMessageFromNoLongerVerifiedIdentity"
|
|
case .errorMessage:
|
|
return "Signal.AppNotificationCategory.errorMessage"
|
|
case .threadlessErrorMessage:
|
|
return "Signal.AppNotificationCategory.threadlessErrorMessage"
|
|
}
|
|
}
|
|
|
|
var actions: [AppNotificationAction] {
|
|
switch self {
|
|
case .incomingMessage:
|
|
return [.markAsRead, .reply]
|
|
case .incomingMessageFromNoLongerVerifiedIdentity:
|
|
return [.markAsRead, .showThread]
|
|
case .errorMessage:
|
|
return [.showThread]
|
|
case .threadlessErrorMessage:
|
|
return []
|
|
}
|
|
}
|
|
}
|
|
|
|
extension AppNotificationAction {
|
|
var identifier: String {
|
|
switch self {
|
|
case .markAsRead:
|
|
return "Signal.AppNotifications.Action.markAsRead"
|
|
case .reply:
|
|
return "Signal.AppNotifications.Action.reply"
|
|
case .showThread:
|
|
return "Signal.AppNotifications.Action.showThread"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delay notification of incoming messages when it's a background polling to
|
|
// avoid too many notifications fired at the same time
|
|
let kNotificationDelayForBackgroumdPoll: TimeInterval = 5
|
|
|
|
let kAudioNotificationsThrottleCount = 2
|
|
let kAudioNotificationsThrottleInterval: TimeInterval = 5
|
|
|
|
protocol NotificationPresenterAdaptee: AnyObject {
|
|
|
|
func registerNotificationSettings() -> Promise<Void>
|
|
|
|
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?)
|
|
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?)
|
|
|
|
func cancelNotifications(threadId: String)
|
|
func cancelNotifications(identifiers: [String])
|
|
func clearAllNotifications()
|
|
}
|
|
|
|
@objc(OWSNotificationPresenter)
|
|
public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|
|
|
private let adaptee: NotificationPresenterAdaptee
|
|
|
|
@objc
|
|
public override init() {
|
|
self.adaptee = UserNotificationPresenterAdaptee()
|
|
|
|
super.init()
|
|
|
|
AppReadiness.runNowOrWhenAppDidBecomeReady {
|
|
NotificationCenter.default.addObserver(self, selector: #selector(self.handleMessageRead), name: .incomingMessageMarkedAsRead, object: nil)
|
|
}
|
|
SwiftSingletons.register(self)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
func handleMessageRead(notification: Notification) {
|
|
AssertIsOnMainThread()
|
|
|
|
switch notification.object {
|
|
case let interaction as Interaction:
|
|
guard interaction.variant == .standardIncoming else { return }
|
|
|
|
Logger.debug("canceled notification for message: \(interaction)")
|
|
cancelNotifications(identifiers: interaction.notificationIdentifiers)
|
|
|
|
default: break
|
|
}
|
|
}
|
|
|
|
// MARK: - Presenting Notifications
|
|
|
|
func registerNotificationSettings() -> Promise<Void> {
|
|
return adaptee.registerNotificationSettings()
|
|
}
|
|
|
|
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) {
|
|
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
|
|
|
// Ensure we should be showing a notification for the thread
|
|
guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest) else {
|
|
return
|
|
}
|
|
|
|
let identifier: String = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll)
|
|
|
|
// While batch processing, some of the necessary changes have not been commited.
|
|
let rawMessageText = interaction.previewText(db)
|
|
|
|
// iOS strips anything that looks like a printf formatting character from
|
|
// the notification body, so if we want to dispay a literal "%" in a notification
|
|
// it must be escaped.
|
|
// see https://developer.apple.com/documentation/uikit/uilocalnotification/1616646-alertbody
|
|
// for more details.
|
|
let messageText: String? = String.filterNotificationText(rawMessageText)
|
|
let notificationTitle: String?
|
|
var notificationBody: String?
|
|
|
|
let senderName = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant)
|
|
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
|
|
.defaulting(to: .nameAndPreview)
|
|
|
|
switch previewType {
|
|
case .noNameNoPreview:
|
|
notificationTitle = "Session"
|
|
|
|
case .nameNoPreview, .nameAndPreview:
|
|
switch thread.variant {
|
|
case .contact:
|
|
notificationTitle = (isMessageRequest ? "Session" : senderName)
|
|
|
|
case .closedGroup, .openGroup:
|
|
let groupName: String = SessionThread
|
|
.displayName(
|
|
threadId: thread.id,
|
|
variant: thread.variant,
|
|
closedGroupName: try? thread.closedGroup
|
|
.select(.name)
|
|
.asRequest(of: String.self)
|
|
.fetchOne(db),
|
|
openGroupName: try? thread.openGroup
|
|
.select(.name)
|
|
.asRequest(of: String.self)
|
|
.fetchOne(db)
|
|
)
|
|
|
|
notificationTitle = (isBackgroundPoll ? groupName :
|
|
String(
|
|
format: NotificationStrings.incomingGroupMessageTitleFormat,
|
|
senderName,
|
|
groupName
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
switch previewType {
|
|
case .noNameNoPreview, .nameNoPreview: notificationBody = NotificationStrings.incomingMessageBody
|
|
case .nameAndPreview: notificationBody = messageText
|
|
}
|
|
|
|
// If it's a message request then overwrite the body to be something generic (only show a notification
|
|
// when receiving a new message request if there aren't any others or the user had hidden them)
|
|
if isMessageRequest {
|
|
notificationBody = "MESSAGE_REQUESTS_NOTIFICATION".localized()
|
|
}
|
|
|
|
guard notificationBody != nil || notificationTitle != nil else {
|
|
SNLog("AppNotifications error: No notification content")
|
|
return
|
|
}
|
|
|
|
// Don't reply from lockscreen if anyone in this conversation is
|
|
// "no longer verified".
|
|
let category = AppNotificationCategory.incomingMessage
|
|
|
|
let userInfo = [
|
|
AppNotificationUserInfoKey.threadId: thread.id
|
|
]
|
|
|
|
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
|
let userBlindedKey: String? = SessionThread.getUserHexEncodedBlindedKey(
|
|
threadId: thread.id,
|
|
threadVariant: thread.variant
|
|
)
|
|
|
|
DispatchQueue.main.async {
|
|
notificationBody = MentionUtilities.highlightMentionsNoAttributes(
|
|
in: (notificationBody ?? ""),
|
|
threadVariant: thread.variant,
|
|
currentUserPublicKey: userPublicKey,
|
|
currentUserBlindedPublicKey: userBlindedKey
|
|
)
|
|
let sound: Preferences.Sound? = self.requestSound(thread: thread)
|
|
|
|
self.adaptee.notify(
|
|
category: category,
|
|
title: notificationTitle,
|
|
body: notificationBody ?? "",
|
|
userInfo: userInfo,
|
|
sound: sound,
|
|
replacingIdentifier: identifier
|
|
)
|
|
}
|
|
}
|
|
|
|
public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) {
|
|
// No call notifications for muted or group threads
|
|
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
|
|
guard thread.variant != .closedGroup && thread.variant != .openGroup else { return }
|
|
guard
|
|
interaction.variant == .infoCall,
|
|
let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8),
|
|
let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
|
|
CallMessage.MessageInfo.self,
|
|
from: infoMessageData
|
|
)
|
|
else { return }
|
|
|
|
// Only notify missed calls
|
|
guard messageInfo.state == .missed || messageInfo.state == .permissionDenied else { return }
|
|
|
|
let category = AppNotificationCategory.errorMessage
|
|
|
|
let userInfo = [
|
|
AppNotificationUserInfoKey.threadId: thread.id
|
|
]
|
|
|
|
let notificationTitle = interaction.previewText(db)
|
|
var notificationBody: String?
|
|
|
|
if messageInfo.state == .permissionDenied {
|
|
notificationBody = String(
|
|
format: "modal_call_missed_tips_explanation".localized(),
|
|
SessionThread.displayName(
|
|
threadId: thread.id,
|
|
variant: thread.variant,
|
|
closedGroupName: nil, // Not supported
|
|
openGroupName: nil // Not supported
|
|
)
|
|
)
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
let sound = self.requestSound(thread: thread)
|
|
|
|
self.adaptee.notify(
|
|
category: category,
|
|
title: notificationTitle,
|
|
body: notificationBody ?? "",
|
|
userInfo: userInfo,
|
|
sound: sound,
|
|
replacingIdentifier: UUID().uuidString
|
|
)
|
|
}
|
|
}
|
|
|
|
public func notifyForFailedSend(_ db: Database, in thread: SessionThread) {
|
|
let notificationTitle: String?
|
|
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
|
|
.defaulting(to: .nameAndPreview)
|
|
|
|
switch previewType {
|
|
case .noNameNoPreview: notificationTitle = nil
|
|
case .nameNoPreview, .nameAndPreview:
|
|
notificationTitle = SessionThread.displayName(
|
|
threadId: thread.id,
|
|
variant: thread.variant,
|
|
closedGroupName: try? thread.closedGroup
|
|
.select(.name)
|
|
.asRequest(of: String.self)
|
|
.fetchOne(db),
|
|
openGroupName: try? thread.openGroup
|
|
.select(.name)
|
|
.asRequest(of: String.self)
|
|
.fetchOne(db),
|
|
isNoteToSelf: (thread.isNoteToSelf(db) == true),
|
|
profile: try? Profile.fetchOne(db, id: thread.id)
|
|
)
|
|
}
|
|
|
|
let notificationBody = NotificationStrings.failedToSendBody
|
|
|
|
let userInfo = [
|
|
AppNotificationUserInfoKey.threadId: thread.id
|
|
]
|
|
|
|
DispatchQueue.main.async {
|
|
let sound: Preferences.Sound? = self.requestSound(thread: thread)
|
|
|
|
self.adaptee.notify(
|
|
category: .errorMessage,
|
|
title: notificationTitle,
|
|
body: notificationBody,
|
|
userInfo: userInfo,
|
|
sound: sound
|
|
)
|
|
}
|
|
}
|
|
|
|
@objc
|
|
public func cancelNotifications(identifiers: [String]) {
|
|
DispatchQueue.main.async {
|
|
self.adaptee.cancelNotifications(identifiers: identifiers)
|
|
}
|
|
}
|
|
|
|
@objc
|
|
public func cancelNotifications(threadId: String) {
|
|
self.adaptee.cancelNotifications(threadId: threadId)
|
|
}
|
|
|
|
@objc
|
|
public func clearAllNotifications() {
|
|
adaptee.clearAllNotifications()
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
var mostRecentNotifications = TruncatedList<UInt64>(maxLength: kAudioNotificationsThrottleCount)
|
|
|
|
private func requestSound(thread: SessionThread) -> Preferences.Sound? {
|
|
guard checkIfShouldPlaySound() else {
|
|
return nil
|
|
}
|
|
|
|
return thread.notificationSound
|
|
}
|
|
|
|
private func checkIfShouldPlaySound() -> Bool {
|
|
AssertIsOnMainThread()
|
|
|
|
guard UIApplication.shared.applicationState == .active else { return true }
|
|
guard Storage.shared[.playNotificationSoundInForeground] else { return false }
|
|
|
|
let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000))
|
|
let recentThreshold = nowMs - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs))
|
|
|
|
let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold }
|
|
|
|
guard recentNotifications.count < kAudioNotificationsThrottleCount else {
|
|
return false
|
|
}
|
|
|
|
mostRecentNotifications.append(nowMs)
|
|
return true
|
|
}
|
|
}
|
|
|
|
class NotificationActionHandler {
|
|
|
|
static let shared: NotificationActionHandler = NotificationActionHandler()
|
|
|
|
// MARK: - Dependencies
|
|
|
|
var notificationPresenter: NotificationPresenter {
|
|
return AppEnvironment.shared.notificationPresenter
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func markAsRead(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
|
|
guard let threadId: String = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
|
throw NotificationError.failDebug("threadId was unexpectedly nil")
|
|
}
|
|
|
|
guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else {
|
|
throw NotificationError.failDebug("unable to find thread with id: \(threadId)")
|
|
}
|
|
|
|
return markAsRead(thread: thread)
|
|
}
|
|
|
|
func reply(userInfo: [AnyHashable: Any], replyText: String) throws -> Promise<Void> {
|
|
guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
|
throw NotificationError.failDebug("threadId was unexpectedly nil")
|
|
}
|
|
|
|
guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else {
|
|
throw NotificationError.failDebug("unable to find thread with id: \(threadId)")
|
|
}
|
|
|
|
let (promise, seal) = Promise<Void>.pending()
|
|
|
|
Storage.shared.writeAsync { db in
|
|
let interaction: Interaction = try Interaction(
|
|
threadId: thread.id,
|
|
authorId: getUserHexEncodedPublicKey(db),
|
|
variant: .standardOutgoing,
|
|
body: replyText,
|
|
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)),
|
|
hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: replyText)
|
|
).inserted(db)
|
|
|
|
try Interaction.markAsRead(
|
|
db,
|
|
interactionId: interaction.id,
|
|
threadId: thread.id,
|
|
includingOlder: true,
|
|
trySendReadReceipt: true
|
|
)
|
|
|
|
return try MessageSender.sendNonDurably(
|
|
db,
|
|
interaction: interaction,
|
|
in: thread
|
|
)
|
|
}
|
|
.done { seal.fulfill(()) }
|
|
.catch { error in
|
|
Storage.shared.read { [weak self] db in
|
|
self?.notificationPresenter.notifyForFailedSend(db, in: thread)
|
|
}
|
|
|
|
seal.reject(error)
|
|
}
|
|
.retainUntilComplete()
|
|
|
|
return promise
|
|
}
|
|
|
|
func showThread(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {
|
|
guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else {
|
|
return showHomeVC()
|
|
}
|
|
|
|
// If this happens when the the app is not, visible we skip the animation so the thread
|
|
// can be visible to the user immediately upon opening the app, rather than having to watch
|
|
// it animate in from the homescreen.
|
|
let shouldAnimate: Bool = (UIApplication.shared.applicationState == .active)
|
|
SessionApp.presentConversation(for: threadId, animated: shouldAnimate)
|
|
return Promise.value(())
|
|
}
|
|
|
|
func showHomeVC() -> Promise<Void> {
|
|
SessionApp.showHomeView()
|
|
return Promise.value(())
|
|
}
|
|
|
|
private func markAsRead(thread: SessionThread) -> Promise<Void> {
|
|
let (promise, seal) = Promise<Void>.pending()
|
|
|
|
Storage.shared.writeAsync(
|
|
updates: { db in
|
|
try Interaction.markAsRead(
|
|
db,
|
|
interactionId: try thread.interactions
|
|
.select(.id)
|
|
.order(Interaction.Columns.timestampMs.desc)
|
|
.asRequest(of: Int64?.self)
|
|
.fetchOne(db),
|
|
threadId: thread.id,
|
|
includingOlder: true,
|
|
trySendReadReceipt: true
|
|
)
|
|
},
|
|
completion: { _, result in
|
|
switch result {
|
|
case .success: seal.fulfill(())
|
|
case .failure(let error): seal.reject(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
return promise
|
|
}
|
|
}
|
|
|
|
enum NotificationError: Error {
|
|
case assertionError(description: String)
|
|
}
|
|
|
|
extension NotificationError {
|
|
static func failDebug(_ description: String) -> NotificationError {
|
|
owsFailDebug(description)
|
|
return NotificationError.assertionError(description: description)
|
|
}
|
|
}
|
|
|
|
struct TruncatedList<Element> {
|
|
let maxLength: Int
|
|
private var contents: [Element] = []
|
|
|
|
init(maxLength: Int) {
|
|
self.maxLength = maxLength
|
|
}
|
|
|
|
mutating func append(_ newElement: Element) {
|
|
var newElements = self.contents
|
|
newElements.append(newElement)
|
|
self.contents = Array(newElements.suffix(maxLength))
|
|
}
|
|
}
|
|
|
|
extension TruncatedList: Collection {
|
|
typealias Index = Int
|
|
|
|
var startIndex: Index {
|
|
return contents.startIndex
|
|
}
|
|
|
|
var endIndex: Index {
|
|
return contents.endIndex
|
|
}
|
|
|
|
subscript (position: Index) -> Element {
|
|
return contents[position]
|
|
}
|
|
|
|
func index(after i: Index) -> Index {
|
|
return contents.index(after: i)
|
|
}
|
|
}
|