This adds a table of "cancelled group rings", to handle out-of-order delivery between a cancellation and a ring. (This can happen when the cancellation comes from a linked device rather than the original ringer, though it's very unlikely.) Thirty-minute-old cancellations are cleared lazily. This also adds a new IncomingCallControls to replace the usual CallControls in a GroupCallViewController. And finally, there's a lot of code that was 1:1-call-specific that now handles group calls as well, either by being more general or by checking which kind of call we have.
349 lines
14 KiB
Swift
349 lines
14 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
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 or replies, we don't want them as share suggestions
|
|
guard
|
|
!(outgoingMessage is OutgoingStoryMessage),
|
|
!outgoingMessage.isGroupStoryReply
|
|
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 generateIncomingCallIntent(callerAddress: SignalServiceAddress) -> INIntent? {
|
|
databaseStorage.read { transaction in
|
|
guard !self.isGroupThread else {
|
|
// Fall back to a "send message" intent for group calls,
|
|
// because the "start call" intent makes the notification look too much like a 1:1 call.
|
|
return self.generateSendMessageIntent(context: .senderAddress(callerAddress),
|
|
transaction: transaction)
|
|
}
|
|
|
|
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)
|
|
|
|
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
|
|
}
|
|
}
|