// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import LibSignalClient import SignalCoreKit @objc public class ViewOnceMessages: NSObject { @objc public override init() { super.init() if CurrentAppContext().isMainApp { AppReadiness.runNowOrWhenAppDidBecomeReadySync { Self.appDidBecomeReady() } } } // MARK: - Events private class func nowMs() -> UInt64 { return NSDate.ows_millisecondTimeStamp() } private class func appDidBecomeReady() { AssertIsOnMainThread() DispatchQueue.global().async { self.checkForAutoCompletion() } } // "Check for auto-completion", e.g. complete messages whether or // not they have been read after N days. Also complete outgoing // sent messages. We need to repeat this check periodically while // the app is running. private class func checkForAutoCompletion() { // Find all view-once messages which are not yet complete. // Complete messages if necessary. databaseStorage.write { (transaction) in let messages = ViewOnceMessageFinder() .allMessagesWithViewOnceMessage(transaction: transaction) for message in messages { completeIfNecessary(message: message, transaction: transaction) } } // We need to "check for auto-completion" once per day. DispatchQueue.global().asyncAfter(wallDeadline: .now() + kDayInterval) { self.checkForAutoCompletion() } } @objc public class func completeIfNecessary(message: TSMessage, transaction: SDSAnyWriteTransaction) { guard message.isViewOnceMessage, !message.isViewOnceComplete else { return } // If message should auto-complete, complete. guard !shouldMessageAutoComplete(message) else { markAsComplete(message: message, sendSyncMessages: true, transaction: transaction) return } // If outgoing message and is "sent", complete. guard !isOutgoingSent(message: message) else { markAsComplete(message: message, sendSyncMessages: true, transaction: transaction) return } // Message should not yet complete. } private class func isOutgoingSent(message: TSMessage) -> Bool { guard message.isViewOnceMessage else { owsFailDebug("Unexpected message.") return false } // If outgoing message and is "sent", complete. guard let outgoingMessage = message as? TSOutgoingMessage else { return false } guard outgoingMessage.messageState == .sent else { return false } return true } // We auto-complete messages after 30 days, even if the user hasn't seen them. private class func shouldMessageAutoComplete(_ message: TSMessage) -> Bool { let autoCompleteDeadlineMs = min(message.timestamp, message.receivedAtTimestamp) + 30 * kDayInMs return nowMs() >= autoCompleteDeadlineMs } @objc public class func markAsComplete(message: TSMessage, sendSyncMessages: Bool, transaction: SDSAnyWriteTransaction) { guard message.isViewOnceMessage else { owsFailDebug("Not a view-once message.") return } guard !message.isViewOnceComplete else { // Already completed, no need to complete again. return } message.updateWithViewOnceCompleteAndRemoveRenderableContent(with: transaction) if sendSyncMessages { sendSyncMessage(forMessage: message, transaction: transaction) } } // MARK: - Sync Messages private class func sendSyncMessage(forMessage message: TSMessage, transaction: SDSAnyWriteTransaction) { guard let senderAddress = senderAddress(forMessage: message) else { owsFailDebug("Could not send sync message; no local number.") return } guard let thread = TSContactThread.getOrCreateLocalThread(transaction: transaction) else { owsFailDebug("Missing thread.") return } let readTimestamp: UInt64 = nowMs() let syncMessage = OWSViewOnceMessageReadSyncMessage( thread: thread, senderAddress: senderAddress, message: message, readTimestamp: readTimestamp, transaction: transaction ) // this is the sync that we viewed; it doesn't have the attachment on it. let preparedMessage = PreparedOutgoingMessage.preprepared( transientMessageWithoutAttachments: syncMessage ) SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction) if let incomingMessage = message as? TSIncomingMessage { let circumstance: OWSReceiptCircumstance = thread.hasPendingMessageRequest(transaction: transaction) ? .onThisDeviceWhilePendingMessageRequest : .onThisDevice incomingMessage.markAsViewed( atTimestamp: readTimestamp, thread: thread, circumstance: circumstance, transaction: transaction ) } } public enum ViewOnceSyncMessageProcessingResult { case associatedMessageMissing(senderAci: Aci, associatedMessageTimestamp: UInt64) case invalidSyncMessage case success } public class func processIncomingSyncMessage( _ message: SSKProtoSyncMessageViewOnceOpen, envelope: SSKProtoEnvelope, transaction: SDSAnyWriteTransaction ) -> ViewOnceSyncMessageProcessingResult { guard let messageSender = Aci.parseFrom(aciString: message.senderAci) else { owsFailDebug("Invalid messageSender.") return .invalidSyncMessage } let messageIdTimestamp: UInt64 = message.timestamp guard messageIdTimestamp > 0, SDS.fitsInInt64(messageIdTimestamp) else { owsFailDebug("Invalid messageIdTimestamp.") return .invalidSyncMessage } let filter = { (interaction: TSInteraction) -> Bool in guard interaction.timestamp == messageIdTimestamp else { owsFailDebug("Timestamps don't match: \(interaction.timestamp) != \(messageIdTimestamp)") return false } guard let message = interaction as? TSMessage else { return false } guard let senderAddress = senderAddress(forMessage: message) else { owsFailDebug("Could not process sync message; no local number.") return false } guard senderAddress.serviceId == messageSender else { return false } guard message.isViewOnceMessage else { return false } return true } let interactions: [TSInteraction] do { interactions = try InteractionFinder.interactions(withTimestamp: messageIdTimestamp, filter: filter, transaction: transaction) } catch { owsFailDebug("Couldn't find interactions: \(error)") return .invalidSyncMessage } guard interactions.count > 0 else { return .associatedMessageMissing(senderAci: messageSender, associatedMessageTimestamp: messageIdTimestamp) } if interactions.count > 1 { owsFailDebug("More than one message from the same sender with the same timestamp found.") } for interaction in interactions { guard let message = interaction as? TSMessage else { owsFailDebug("Invalid interaction: \(type(of: interaction))") continue } // Mark as complete. markAsComplete(message: message, sendSyncMessages: false, transaction: transaction) } return .success } private class func senderAddress(forMessage message: TSMessage) -> SignalServiceAddress? { if let incomingMessage = message as? TSIncomingMessage { return incomingMessage.authorAddress } else if message as? TSOutgoingMessage != nil { guard let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress else { owsFailDebug("Could not process sync message; no local number.") return nil } // We also need to send and receive "per-message expiration read" sync // messages for outgoing messages, unlike normal read receipts. return localAddress } else { owsFailDebug("Unexpected message type.") return nil } } } // MARK: - private class ViewOnceMessageFinder { public func allMessagesWithViewOnceMessage(transaction: SDSAnyReadTransaction) -> [TSMessage] { var result: [TSMessage] = [] self.enumerateAllIncompleteViewOnceMessages(transaction: transaction) { message in result.append(message) } return result } private func enumerateAllIncompleteViewOnceMessages( transaction: SDSAnyReadTransaction, block: (TSMessage) -> Void ) { let sql = """ SELECT * FROM \(InteractionRecord.databaseTableName) WHERE \(interactionColumn: .isViewOnceMessage) IS NOT NULL AND \(interactionColumn: .isViewOnceMessage) == TRUE AND \(interactionColumn: .isViewOnceComplete) IS NOT NULL AND \(interactionColumn: .isViewOnceComplete) == FALSE """ let cursor = TSInteraction.grdbFetchCursor( sql: sql, transaction: transaction.unwrapGrdbRead ) // GRDB TODO make cursor.next fail hard to remove this `try!` while let next = try! cursor.next() { guard let message = next as? TSMessage else { owsFailDebug("expecting message but found: \(next)") return } guard message.isViewOnceMessage, !message.isViewOnceComplete else { owsFailDebug("expecting incomplete view-once message but found: \(message)") return } block(message) } } }