// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // public import Intents import LibSignalClient // MARK: - Enqueue messages public final class ThreadUtil { public typealias PersistenceCompletion = () -> Void // A serial queue that ensures that messages are sent in the // same order in which they are enqueued. public static var enqueueSendQueue = SerialTaskQueue() public static func enqueueSendAsyncWrite(_ block: @escaping (DBWriteTransaction) -> Void) { enqueueSendQueue.enqueue { await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in block(transaction) } } } private static func applyDisappearingMessagesConfiguration(to builder: TSOutgoingMessageBuilder, tx: DBReadTransaction) { let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore let dmConfig = dmConfigurationStore.fetchOrBuildDefault(for: .thread(builder.thread), tx: tx) builder.expiresInSeconds = dmConfig.durationSeconds builder.expireTimerVersion = NSNumber(value: dmConfig.timerVersion) } public class func enqueueMessagePromise( message: PreparedOutgoingMessage, limitToCurrentProcessLifetime: Bool = false, isHighPriority: Bool = false, transaction: DBWriteTransaction ) -> Promise { let promise = SSKEnvironment.shared.messageSenderJobQueueRef.add( .promise, message: message, limitToCurrentProcessLifetime: limitToCurrentProcessLifetime, isHighPriority: isHighPriority, transaction: transaction ) if let messageForIntent = message.messageForIntentDonation(tx: transaction) { messageForIntent.thread(tx: transaction)?.donateSendMessageIntent(for: messageForIntent, transaction: transaction) } return promise } } // MARK: - Contact Shares public extension ThreadUtil { @discardableResult class func enqueueMessage( withContactShare contactShareDraft: ContactShareDraft, thread: TSThread ) -> TSOutgoingMessage { AssertIsOnMainThread() assert(contactShareDraft.ows_isValid) let builder: TSOutgoingMessageBuilder = .withDefaultValues(thread: thread) let message: TSOutgoingMessage = SSKEnvironment.shared.databaseStorageRef.read { tx in applyDisappearingMessagesConfiguration(to: builder, tx: tx) return builder.build(transaction: tx) } Self.enqueueSendQueue.enqueue { guard let sendableContactShareDraft = try? await DependenciesBridge.shared.contactShareManager .validateAndPrepare(draft: contactShareDraft) else { owsFailDebug("Failed to build contact share") return } // stickers don't have bodies owsPrecondition(message.body == nil) let unpreparedMessage = UnpreparedOutgoingMessage.forMessage( message, body: nil, contactShareDraft: sendableContactShareDraft ) await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in guard let preparedMessage = try? unpreparedMessage.prepare(tx: transaction) else { owsFailDebug("Unable to build message for sending!") return } SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction) if let messageForIntent = preparedMessage.messageForIntentDonation(tx: transaction), let thread = messageForIntent.thread(tx: transaction) { thread.donateSendMessageIntent(for: messageForIntent, transaction: transaction) } } } return message } } // MARK: - Stickers public extension ThreadUtil { @discardableResult class func enqueueMessage(withInstalledSticker stickerInfo: StickerInfo, thread: TSThread) -> TSOutgoingMessage { AssertIsOnMainThread() let builder = TSOutgoingMessageBuilder.outgoingMessageBuilder(thread: thread) let message = SSKEnvironment.shared.databaseStorageRef.read { tx in applyDisappearingMessagesConfiguration(to: builder, tx: tx) return builder.build(transaction: tx) } Self.enqueueSendQueue.enqueue { let stickerDraft: MessageStickerDraft? = SSKEnvironment.shared.databaseStorageRef.read { tx in guard let stickerMetadata = StickerManager.installedStickerMetadata(stickerInfo: stickerInfo, transaction: tx) else { owsFailDebug("Could not find sticker file.") return nil } guard let stickerData = try? stickerMetadata.readStickerData() else { owsFailDebug("Couldn't load sticker data.") return nil } return MessageStickerDraft( info: stickerInfo, stickerData: stickerData, stickerType: stickerMetadata.stickerType, emoji: stickerMetadata.firstEmoji ) } guard let stickerDraft else { return } let stickerDataSource: MessageStickerDataSource do { stickerDataSource = try await DependenciesBridge.shared.messageStickerManager.buildDataSource( fromDraft: stickerDraft ) } catch { owsFailDebug("Failed to build sticker!") return } await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in self.enqueueMessage(message, stickerDataSource: stickerDataSource, thread: thread, tx: tx) } } return message } @discardableResult class func enqueueMessage( withUninstalledSticker stickerMetadata: any StickerMetadata, stickerData: Data, thread: TSThread ) -> TSOutgoingMessage { AssertIsOnMainThread() let builder = TSOutgoingMessageBuilder.outgoingMessageBuilder(thread: thread) let message = SSKEnvironment.shared.databaseStorageRef.read { tx in applyDisappearingMessagesConfiguration(to: builder, tx: tx) return builder.build(transaction: tx) } let stickerDraft = MessageStickerDraft( info: stickerMetadata.stickerInfo, stickerData: stickerData, stickerType: stickerMetadata.stickerType, emoji: stickerMetadata.firstEmoji ) Self.enqueueSendQueue.enqueue { let stickerDataSource: MessageStickerDataSource do { stickerDataSource = try await DependenciesBridge.shared.messageStickerManager.buildDataSource( fromDraft: stickerDraft ) } catch { owsFailDebug("Failed to build sticker!") return } await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in self.enqueueMessage(message, stickerDataSource: stickerDataSource, thread: thread, tx: tx) } } return message } private class func enqueueMessage( _ message: TSOutgoingMessage, stickerDataSource: MessageStickerDataSource, thread: TSThread, tx: DBWriteTransaction ) { AssertNotOnMainThread() // stickers don't have bodies owsPrecondition(message.body == nil) let unpreparedMessage = UnpreparedOutgoingMessage.forMessage( message, body: nil, messageStickerDraft: stickerDataSource ) let preparedMessage: PreparedOutgoingMessage do { preparedMessage = try unpreparedMessage.prepare(tx: tx) } catch { return owsFailDebug("Couldn't prepare message: \(error)") } SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: tx) if let messageForIntent = preparedMessage.messageForIntentDonation(tx: tx) { thread.donateSendMessageIntent(for: messageForIntent, transaction: tx) } } } // MARK: - Profile Whitelist extension ThreadUtil { /// Should we set the universal timer for this contact thread? /// - Note /// Group threads never go through this method, and instead have their /// disappearing-message timer set during group creation. private static func shouldSetUniversalTimer(contactThread: TSContactThread, tx: DBReadTransaction) -> Bool { ThreadFinder().shouldSetDefaultDisappearingMessageTimer( contactThread: contactThread, transaction: tx ) } private static func setUniversalTimer(contactThread: TSContactThread, tx: DBWriteTransaction) { let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore let dmUniversalToken = dmConfigurationStore.fetchOrBuildDefault(for: .universal, tx: tx) let version = dmConfigurationStore.fetchOrBuildDefault( for: .thread(contactThread), tx: tx ).timerVersion let dmResult = dmConfigurationStore.set( token: .init( isEnabled: dmUniversalToken.isEnabled, durationSeconds: dmUniversalToken.durationSeconds, version: version ), for: .thread(contactThread), tx: tx ) OWSDisappearingConfigurationUpdateInfoMessage( contactThread: contactThread, timestamp: MessageTimestampGenerator.sharedInstance.generateTimestamp(), isConfigurationEnabled: dmResult.newConfiguration.isEnabled, configurationDurationSeconds: dmResult.newConfiguration.durationSeconds, createdByRemoteName: nil ).anyInsert(transaction: tx) } private static func shouldAddThreadToProfileWhitelist(_ thread: TSThread, tx: DBReadTransaction) -> Bool { let hasPendingMessageRequest = thread.hasPendingMessageRequest(transaction: tx) // If we're creating this thread or we have a pending message request, // any action we trigger should share our profile. return !thread.shouldThreadBeVisible || hasPendingMessageRequest } @discardableResult public class func addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimerWithSneakyTransaction( _ thread: TSThread ) -> Bool { let threadAsContactThread = thread as? TSContactThread let (shouldSetUniversalTimer, shouldAddToProfileWhitelist) = SSKEnvironment.shared.databaseStorageRef.read { tx -> (Bool, Bool) in let universalTimer: Bool = { guard let threadAsContactThread else { return false } return Self.shouldSetUniversalTimer(contactThread: threadAsContactThread, tx: tx) }() let profileWhitelist = shouldAddThreadToProfileWhitelist(thread, tx: tx) return (universalTimer, profileWhitelist) } if shouldSetUniversalTimer, let threadAsContactThread { SSKEnvironment.shared.databaseStorageRef.write { tx in setUniversalTimer(contactThread: threadAsContactThread, tx: tx) } } if shouldAddToProfileWhitelist { SSKEnvironment.shared.databaseStorageRef.write { tx in SSKEnvironment.shared.profileManagerRef.addThread( toProfileWhitelist: thread, userProfileWriter: .localUser, transaction: tx ) } } return shouldAddToProfileWhitelist } @discardableResult public class func addThreadToProfileWhitelistIfEmptyOrPendingRequest( _ thread: TSThread, setDefaultTimerIfNecessary: Bool, tx: DBWriteTransaction ) -> Bool { if setDefaultTimerIfNecessary, let contactThread = thread as? TSContactThread, shouldSetUniversalTimer(contactThread: contactThread, tx: tx) { setUniversalTimer(contactThread: contactThread, tx: tx) } let shouldAddToProfileWhitelist = shouldAddThreadToProfileWhitelist(thread, tx: tx) if shouldAddToProfileWhitelist { SSKEnvironment.shared.profileManagerRef.addThread( toProfileWhitelist: thread, userProfileWriter: .localUser, transaction: tx ) } return shouldAddToProfileWhitelist } } // MARK: - Sharing Suggestions 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. public func donateSendMessageIntent(for outgoingMessage: TSOutgoingMessage, transaction tx: DBReadTransaction) { // Never donate for story sends or replies, we don't want them as share suggestions if (outgoingMessage is OutgoingStoryMessage) || outgoingMessage.isGroupStoryReply { return } let sendMessageIntentBuilder = _generateSendMessageIntent(context: .outgoingMessage(outgoingMessage), transaction: tx) guard let sendMessageIntentBuilder else { return } // We don't need to wait for names to resolve here because these are // outgoing messages where we should already have a name. let interaction = INInteraction(intent: sendMessageIntentBuilder.value(tx: tx), 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) } func generateSendMessageIntent( context: IntentContext, transaction: DBReadTransaction ) -> ResolvableValue? { let builder = _generateSendMessageIntent(context: context, transaction: transaction) return builder?.resolvableValue( db: SSKEnvironment.shared.databaseStorageRef, profileFetcher: SSKEnvironment.shared.profileFetcherRef, tx: transaction ) } private func _generateSendMessageIntent( context: IntentContext, transaction: DBReadTransaction ) -> ResolvableDisplayNameBuilder? { guard SSKPreferences.areIntentDonationsEnabled(transaction: transaction) else { return nil } let tsAccountManager = DependenciesBridge.shared.tsAccountManager guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: transaction) else { owsFailDebug("Not registered.") return nil } let senderAddress: SignalServiceAddress let message: TSMessage? let recipientAddress: SignalServiceAddress? switch context { case .outgoingMessage(let outgoingMessage): senderAddress = localIdentifiers.aciAddress message = outgoingMessage // For 1:1 outgoing messages, we must populate the recipient of the message, // otherwise sharing suggestions won't be populated correctly. recipientAddress = (self as? TSContactThread)?.contactAddress case .incomingMessage(let incomingMessage): senderAddress = incomingMessage.authorAddress message = incomingMessage recipientAddress = nil case .senderAddress(let address): senderAddress = address message = nil recipientAddress = nil } let isGroupStoryReply = isGroupThread && message?.isGroupStoryReply == true let groupThreadName = (self as? TSGroupThread)?.groupNameOrDefault let formattedGroupThreadName = groupThreadName.map { if isGroupStoryReply { let format = OWSLocalizedString( "QUOTED_REPLY_STORY_AUTHOR_INDICATOR_FORMAT", comment: "Message header when you are quoting a story. Embeds {{ story author name }}" ) return String(format: format, $0) } return $0 } // Uniquely namespace the notifications for group stories. let conversationIdentifier = isGroupStoryReply ? (uniqueId + "_groupStory") : uniqueId var donationMetadata: INSendMessageIntentDonationMetadata? if isGroupThread { donationMetadata = INSendMessageIntentDonationMetadata() donationMetadata?.recipientCount = recipientAddresses(with: transaction).count if let message { let mentionedAddresses = MentionFinder.mentionedAddresses(for: message, transaction: transaction) donationMetadata?.mentionsCurrentUser = mentionedAddresses.contains(localIdentifiers.aciAddress) donationMetadata?.isReplyToCurrentUser = message.quotedMessage?.authorAddress == localIdentifiers.aciAddress } } let speakableGroupNameImage = isGroupThread ? intentThreadAvatarImage(transaction: transaction) : nil let recipientPerson = recipientAddress.map { return Self.buildPerson( address: $0, displayName: SSKEnvironment.shared.contactManagerRef.displayName(for: $0, tx: transaction), tx: transaction ) } return ResolvableDisplayNameBuilder( displayNameForAddress: senderAddress, transformedBy: { senderDisplayName, tx in let senderPerson = Self.buildPerson(address: senderAddress, displayName: senderDisplayName, tx: tx) let sendMessageIntent = INSendMessageIntent( recipients: [recipientPerson].compacted(), outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: formattedGroupThreadName.map(INSpeakableString.init(spokenPhrase:)), conversationIdentifier: conversationIdentifier, serviceName: nil, sender: senderPerson, attachments: nil ) if let speakableGroupNameImage { sendMessageIntent.setImage(speakableGroupNameImage, forParameterNamed: \.speakableGroupName) } if let donationMetadata { sendMessageIntent.donationMetadata = donationMetadata } return sendMessageIntent }, contactManager: SSKEnvironment.shared.contactManagerRef ) } func generateIncomingCallIntent( callerAci: Aci, tx: DBReadTransaction ) -> ResolvableValue? { if self.isGroupThread { // 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(SignalServiceAddress(callerAci)), transaction: tx ) } else { guard SSKPreferences.areIntentDonationsEnabled(transaction: tx) else { return nil } return ResolvableDisplayNameBuilder( displayNameForAddress: SignalServiceAddress(callerAci), transformedBy: { displayName, tx in let caller = Self.buildPerson(address: SignalServiceAddress(callerAci), displayName: displayName, tx: tx) return INStartCallIntent( callRecordFilter: nil, callRecordToCallBack: nil, audioRoute: .unknown, destinationType: .normal, contacts: [caller], callCapability: .unknown ) }, contactManager: SSKEnvironment.shared.contactManagerRef ).resolvableValue( db: SSKEnvironment.shared.databaseStorageRef, profileFetcher: SSKEnvironment.shared.profileFetcherRef, tx: tx ) } } private static func buildPerson( address: SignalServiceAddress, displayName: DisplayName, tx: DBReadTransaction ) -> INPerson { let nameComponents: PersonNameComponents? switch displayName { case .nickname(let nickname): nameComponents = nickname.nameComponents case .systemContactName(let systemContactName): nameComponents = systemContactName.nameComponents case .profileName(let profileNameComponents): nameComponents = profileNameComponents case .phoneNumber, .username, .deletedAccount, .unknown: nameComponents = nil } var filteredNameComponents = PersonNameComponents() filteredNameComponents.givenName = nameComponents?.givenName?.filterForDisplay filteredNameComponents.familyName = nameComponents?.familyName?.filterForDisplay filteredNameComponents.nickname = nameComponents?.nickname?.filterForDisplay // Generate contact handle let handle: INPersonHandle let suggestionType: INPersonSuggestionType if let phoneNumber = address.phoneNumber { handle = INPersonHandle(value: phoneNumber, type: .phoneNumber, label: nil) suggestionType = .none } else { handle = INPersonHandle(value: address.serviceIdUppercaseString, type: .unknown, label: nil) suggestionType = .instantMessageAddress } // Generate avatar let image = intentRecipientAvatarImage(recipient: address, transaction: tx) return INPerson( personHandle: handle, nameComponents: nameComponents, displayName: displayName.resolvedValue(), image: image, contactIdentifier: nil, customIdentifier: nil, isMe: false, suggestionType: suggestionType ) } // 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 * SSKEnvironment.shared.preferencesRef.cachedDeviceScale public func intentStoryAvatarImage(tx: DBReadTransaction) -> INImage? { if let storyThread = self as? TSPrivateStoryThread { if storyThread.isMyStory { guard let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx)?.aciAddress else { Logger.warn("Missing local address") return nil } return Self.intentRecipientAvatarImage(recipient: localAddress, transaction: tx) } else { let rawImage = UIImage(named: "custom-story-light-36") return rawImage?.pngData().map(INImage.init(imageData:)) } } else { return intentThreadAvatarImage(transaction: tx) } } private static func intentRecipientAvatarImage(recipient: SignalServiceAddress, transaction: DBReadTransaction) -> INImage? { // Generate avatar let image: INImage if let contactAvatar = SSKEnvironment.shared.avatarBuilderRef.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: DBReadTransaction) -> INImage? { let image: INImage if let threadAvatar = SSKEnvironment.shared.avatarBuilderRef.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 } }