For certain kinds of notifications, we attach "intents" to let the system know who's contacting us (for use in, say, share sheet suggestions; controlled by Settings > Chats > Share Contacts with iOS). Previously we only provided "rich" intents on iOS 15 and a minimal implementation for iOS 13, but even if iOS 13 won't do as much with the information we can be more consistent.
342 lines
14 KiB
Swift
342 lines
14 KiB
Swift
//
|
|
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
// MARK: - Enqueue messages
|
|
|
|
@objc
|
|
public extension ThreadUtil {
|
|
|
|
typealias PersistenceCompletion = () -> Void
|
|
|
|
// A serial queue that ensures that messages are sent in the
|
|
// same order in which they are enqueued.
|
|
static var enqueueSendQueue: DispatchQueue { .sharedUserInitiated }
|
|
|
|
static func enqueueSendAsyncWrite(_ block: @escaping (SDSAnyWriteTransaction) -> Void) {
|
|
enqueueSendQueue.async {
|
|
Self.databaseStorage.write { transaction in
|
|
block(transaction)
|
|
}
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
class func enqueueMessage(withContactShare contactShare: OWSContact,
|
|
thread: TSThread) -> TSOutgoingMessage {
|
|
AssertIsOnMainThread()
|
|
assert(contactShare.ows_isValid())
|
|
|
|
let builder = TSOutgoingMessageBuilder(thread: thread)
|
|
builder.contactShare = contactShare
|
|
|
|
return enqueueMessage(outgoingMessageBuilder: builder, thread: thread)
|
|
}
|
|
|
|
@discardableResult
|
|
class func enqueueMessage(outgoingMessageBuilder builder: TSOutgoingMessageBuilder,
|
|
thread: TSThread) -> TSOutgoingMessage {
|
|
|
|
let message: TSOutgoingMessage = databaseStorage.read { transaction in
|
|
builder.expiresInSeconds = thread.disappearingMessagesDuration(with: transaction)
|
|
return builder.build(transaction: transaction)
|
|
}
|
|
|
|
Self.enqueueSendAsyncWrite { transaction in
|
|
message.anyInsert(transaction: transaction)
|
|
self.messageSenderJobQueue.add(message: message.asPreparer, transaction: transaction)
|
|
if message.hasRenderableContent() { thread.donateSendMessageIntent(for: message, transaction: transaction) }
|
|
}
|
|
|
|
return message
|
|
}
|
|
|
|
@discardableResult
|
|
class func enqueueMessage(outgoingMessageBuilder builder: TSOutgoingMessageBuilder,
|
|
thread: TSThread,
|
|
transaction: SDSAnyWriteTransaction) -> TSOutgoingMessage {
|
|
|
|
builder.expiresInSeconds = thread.disappearingMessagesDuration(with: transaction)
|
|
|
|
let message = builder.build(transaction: transaction)
|
|
message.anyInsert(transaction: transaction)
|
|
self.messageSenderJobQueue.add(message: message.asPreparer, transaction: transaction)
|
|
|
|
if message.hasRenderableContent() { thread.donateSendMessageIntent(for: message, transaction: transaction) }
|
|
|
|
return message
|
|
}
|
|
|
|
@nonobjc
|
|
class func enqueueMessagePromise(
|
|
message: TSOutgoingMessage,
|
|
limitToCurrentProcessLifetime: Bool = false,
|
|
isHighPriority: Bool = false,
|
|
transaction: SDSAnyWriteTransaction
|
|
) -> Promise<Void> {
|
|
let promise = messageSenderJobQueue.add(
|
|
.promise,
|
|
message: message.asPreparer,
|
|
limitToCurrentProcessLifetime: limitToCurrentProcessLifetime,
|
|
isHighPriority: isHighPriority,
|
|
transaction: transaction
|
|
)
|
|
if message.hasRenderableContent() {
|
|
message
|
|
.thread(transaction: transaction)
|
|
.donateSendMessageIntent(for: message, transaction: transaction)
|
|
}
|
|
return promise
|
|
}
|
|
}
|
|
|
|
// MARK: - Delete all content
|
|
|
|
extension ThreadUtil {
|
|
public static func deleteAllContentWithSneakyTransaction() {
|
|
Logger.info("")
|
|
|
|
databaseStorage.write { transaction in
|
|
TSThread.anyEnumerate(transaction: transaction, batched: true) { thread, _ in
|
|
thread.softDelete(with: transaction)
|
|
}
|
|
|
|
TSInteraction.anyRemoveAllWithInstantation(transaction: transaction)
|
|
StoryMessage.anyRemoveAllWithInstantiation(transaction: transaction)
|
|
TSAttachment.anyRemoveAllWithInstantation(transaction: transaction)
|
|
|
|
// Deleting attachments above should be enough to remove any gallery items, but
|
|
// we redunantly clean up *all* gallery items to be safe.
|
|
MediaGalleryManager.didRemoveAllContent(transaction: transaction)
|
|
}
|
|
|
|
TSAttachmentStream.deleteAttachmentsFromDisk()
|
|
}
|
|
}
|
|
|
|
// MARK: - Sharing Suggestions
|
|
|
|
import Intents
|
|
import SignalServiceKit
|
|
|
|
extension TSThread {
|
|
|
|
/// This function should be called every time the user
|
|
/// initiates message sending via the UI. It should *not*
|
|
/// be called for messages we send automatically, like
|
|
/// receipts.
|
|
@objc(donateSendMessageIntentForOutgoingMessage:transaction:)
|
|
public func donateSendMessageIntent(for outgoingMessage: TSOutgoingMessage, transaction: SDSAnyReadTransaction) {
|
|
// We never need to do this pre-iOS 13, because sharing
|
|
// suggestions aren't support in previous iOS versions.
|
|
guard #available(iOS 13, *) else { return }
|
|
|
|
// Never donate for story sends, we don't want them as share suggestions
|
|
guard !(outgoingMessage is OutgoingStoryMessage) else { return }
|
|
|
|
guard SSKPreferences.areIntentDonationsEnabled(transaction: transaction) else { return }
|
|
guard let sendMessageIntent = generateSendMessageIntent(context: .outgoingMessage(outgoingMessage), transaction: transaction) else { return }
|
|
|
|
let interaction = INInteraction(intent: sendMessageIntent, response: nil)
|
|
interaction.groupIdentifier = uniqueId
|
|
interaction.direction = .outgoing
|
|
interaction.donate(completion: { error in
|
|
guard let error = error else { return }
|
|
owsFailDebug("Failed to donate message intent for \(self.uniqueId) \(error)")
|
|
})
|
|
}
|
|
|
|
public enum IntentContext {
|
|
case senderAddress(SignalServiceAddress)
|
|
case incomingMessage(TSIncomingMessage)
|
|
case outgoingMessage(TSOutgoingMessage)
|
|
}
|
|
|
|
public func generateSendMessageIntent(context: IntentContext, transaction: SDSAnyReadTransaction) -> INSendMessageIntent? {
|
|
// We never need to do this pre-iOS 13, because sharing
|
|
// suggestions aren't support in previous iOS versions.
|
|
guard #available(iOS 13, *) else { return nil }
|
|
|
|
guard SSKPreferences.areIntentDonationsEnabled(transaction: transaction) else { return nil }
|
|
|
|
guard let localAddress = tsAccountManager.localAddress else {
|
|
owsFailDebug("Missing local address")
|
|
return nil
|
|
}
|
|
|
|
let senderAddress: SignalServiceAddress
|
|
let message: TSMessage?
|
|
let recipients: [INPerson]?
|
|
switch context {
|
|
case .outgoingMessage(let outgoingMessage):
|
|
senderAddress = localAddress
|
|
message = outgoingMessage
|
|
|
|
// For 1:1 outgoing messages, we must populate the recipient of the message,
|
|
// otherwise sharing suggestions won't be populated correctly.
|
|
if !isGroupThread {
|
|
recipients = outgoingMessage.recipientAddresses().map { inPersonForRecipient($0, transaction: transaction) }
|
|
} else {
|
|
recipients = nil
|
|
}
|
|
case .incomingMessage(let incomingMessage):
|
|
senderAddress = incomingMessage.authorAddress
|
|
message = incomingMessage
|
|
recipients = nil
|
|
case .senderAddress(let address):
|
|
senderAddress = address
|
|
message = nil
|
|
recipients = nil
|
|
}
|
|
|
|
var conversationIdentifier = uniqueId
|
|
var threadName = contactsManager.displayName(for: self, transaction: transaction)
|
|
if isGroupThread && message?.isGroupStoryReply == true {
|
|
threadName = String(
|
|
format: OWSLocalizedString(
|
|
"QUOTED_REPLY_STORY_AUTHOR_INDICATOR_FORMAT",
|
|
comment: "Message header when you are quoting a story. Embeds {{ story author name }}"
|
|
),
|
|
threadName
|
|
)
|
|
|
|
// Uniquely namespace the notifications for group stories.
|
|
conversationIdentifier += "_groupStory"
|
|
}
|
|
let inSender = inPersonForRecipient(senderAddress, transaction: transaction)
|
|
|
|
let sendMessageIntent: INSendMessageIntent
|
|
if #available(iOS 14, *) {
|
|
sendMessageIntent = INSendMessageIntent(
|
|
recipients: recipients,
|
|
outgoingMessageType: .outgoingMessageText,
|
|
content: nil,
|
|
speakableGroupName: isGroupThread ? INSpeakableString(spokenPhrase: threadName) : nil,
|
|
conversationIdentifier: conversationIdentifier,
|
|
serviceName: nil,
|
|
sender: inSender
|
|
)
|
|
} else {
|
|
sendMessageIntent = INSendMessageIntent(
|
|
recipients: recipients,
|
|
content: nil,
|
|
speakableGroupName: isGroupThread ? INSpeakableString(spokenPhrase: threadName) : nil,
|
|
conversationIdentifier: conversationIdentifier,
|
|
serviceName: nil,
|
|
sender: inSender
|
|
)
|
|
}
|
|
|
|
if isGroupThread {
|
|
if #available(iOS 15, *) {
|
|
let donationMetadata = INSendMessageIntentDonationMetadata()
|
|
donationMetadata.recipientCount = recipientAddresses(with: transaction).count
|
|
|
|
if let message = message {
|
|
let mentionedAddresses = MentionFinder.mentionedAddresses(for: message, transaction: transaction.unwrapGrdbRead)
|
|
donationMetadata.mentionsCurrentUser = mentionedAddresses.contains(localAddress)
|
|
donationMetadata.isReplyToCurrentUser = message.quotedMessage?.authorAddress.isEqualToAddress(localAddress) ?? false
|
|
}
|
|
|
|
sendMessageIntent.donationMetadata = donationMetadata
|
|
}
|
|
|
|
if let image = intentThreadAvatarImage(transaction: transaction) {
|
|
sendMessageIntent.setImage(image, forParameterNamed: \.speakableGroupName)
|
|
}
|
|
}
|
|
|
|
return sendMessageIntent
|
|
}
|
|
|
|
@available(iOS 13, *)
|
|
public func generateStartCallIntent(callerAddress: SignalServiceAddress) -> INStartCallIntent? {
|
|
databaseStorage.read { transaction in
|
|
guard SSKPreferences.areIntentDonationsEnabled(transaction: transaction) else { return nil }
|
|
|
|
let caller = inPersonForRecipient(callerAddress, transaction: transaction)
|
|
|
|
let startCallIntent = INStartCallIntent(audioRoute: .unknown,
|
|
destinationType: .normal,
|
|
contacts: [caller],
|
|
recordTypeForRedialing: .unknown,
|
|
callCapability: .unknown)
|
|
|
|
if #available(iOS 14, *), self.isGroupThread {
|
|
if let image = intentThreadAvatarImage(transaction: transaction) {
|
|
startCallIntent.setImage(image, forParameterNamed: \.callRecordToCallBack)
|
|
}
|
|
}
|
|
|
|
return startCallIntent
|
|
}
|
|
}
|
|
|
|
private func inPersonForRecipient(_ recipient: SignalServiceAddress,
|
|
transaction: SDSAnyReadTransaction) -> INPerson {
|
|
|
|
// Generate recipient name
|
|
let contactName = contactsManager.displayName(for: recipient, transaction: transaction)
|
|
let nameComponents = contactsManager.nameComponents(for: recipient, transaction: transaction)
|
|
|
|
// Generate contact handle
|
|
let handle: INPersonHandle
|
|
let suggestionType: INPersonSuggestionType
|
|
if let phoneNumber = recipient.phoneNumber {
|
|
handle = INPersonHandle(value: phoneNumber, type: .phoneNumber, label: nil)
|
|
suggestionType = .none
|
|
} else {
|
|
handle = INPersonHandle(value: recipient.uuidString, type: .unknown, label: nil)
|
|
suggestionType = .instantMessageAddress
|
|
}
|
|
|
|
// Generate avatar
|
|
let image = intentRecipientAvatarImage(recipient: recipient, transaction: transaction)
|
|
if #available(iOS 15, *) {
|
|
return INPerson(personHandle: handle, nameComponents: nameComponents, displayName: contactName, image: image, contactIdentifier: nil, customIdentifier: nil, isMe: false, suggestionType: suggestionType)
|
|
} else {
|
|
return INPerson(personHandle: handle, nameComponents: nameComponents, displayName: contactName, image: image, contactIdentifier: nil, customIdentifier: nil, isMe: false)
|
|
}
|
|
}
|
|
|
|
// Use the same point size as chat list avatars, so it's likely cached and ready for the NSE.
|
|
// The NSE cannot read the device scale, so we rely on a cached scale to correctly calculate
|
|
// the appropriate pixel size for our avatars.
|
|
private static let intentAvatarDiameterPixels: CGFloat = 56 * Environment.preferences.cachedDeviceScale
|
|
|
|
private func intentRecipientAvatarImage(recipient: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> INImage? {
|
|
// Generate avatar
|
|
let image: INImage
|
|
if let contactAvatar = avatarBuilder.avatarImage(
|
|
forAddress: recipient,
|
|
diameterPixels: Self.intentAvatarDiameterPixels,
|
|
localUserDisplayMode: .asUser,
|
|
transaction: transaction
|
|
),
|
|
let contactAvatarPNG = contactAvatar.pngData() {
|
|
image = INImage(imageData: contactAvatarPNG)
|
|
} else {
|
|
image = INImage(named: "profile-placeholder-56")
|
|
}
|
|
return image
|
|
}
|
|
|
|
private func intentThreadAvatarImage(transaction: SDSAnyReadTransaction) -> INImage? {
|
|
let image: INImage
|
|
if let threadAvatar = avatarBuilder.avatarImage(
|
|
forThread: self,
|
|
diameterPixels: Self.intentAvatarDiameterPixels,
|
|
localUserDisplayMode: .noteToSelf,
|
|
transaction: transaction
|
|
),
|
|
let threadAvatarPng = threadAvatar.pngData() {
|
|
image = INImage(imageData: threadAvatarPng)
|
|
} else {
|
|
image = INImage(named: isGroupThread ? "group-placeholder-56" : "profile-placeholder-56")
|
|
}
|
|
return image
|
|
}
|
|
}
|