// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation public import LibSignalClient public enum BlockMode { case remote case restoreFromBackup case localShouldLeaveGroups case localShouldNotLeaveGroups var locallyInitiated: Bool { switch self { case .remote, .restoreFromBackup: return false case .localShouldLeaveGroups, .localShouldNotLeaveGroups: return true } } } // MARK: - public class BlockingManager { private let blockedGroupStore: BlockedGroupStore private let blockedRecipientStore: BlockedRecipientStore private let syncQueue = SerialTaskQueue() #if TESTABLE_BUILD func flushSyncQueueTask() -> Task { return self.syncQueue.enqueue {} } #endif init( appReadiness: AppReadiness, blockedGroupStore: BlockedGroupStore, blockedRecipientStore: BlockedRecipientStore ) { self.blockedGroupStore = blockedGroupStore self.blockedRecipientStore = blockedRecipientStore SwiftSingletons.register(self) appReadiness.runNowOrWhenAppDidBecomeReadyAsync { self.observeNotifications() // Once we're ready to send a message, check to see if we need to sync. self.syncIfNeeded() } } private func syncIfNeeded() { self.syncQueue.enqueue { await self.sendBlockListSyncMessage(force: false) } } private func didUpdate(wasLocallyInitiated: Bool, tx: DBWriteTransaction) { if wasLocallyInitiated { setNeedsSync(tx: tx) } else { clearNeedsSync(tx: tx) } tx.addSyncCompletion { NotificationCenter.default.postOnMainThread(name: Self.blockListDidChange, object: nil) } } private func setNeedsSync(tx: DBWriteTransaction) { setChangeToken(fetchChangeToken(tx: tx) + 1, tx: tx) tx.addSyncCompletion { self.syncQueue.enqueue { await self.sendBlockListSyncMessage(force: false) } } } private func clearNeedsSync(tx: DBWriteTransaction) { setLastSyncedChangeToken(fetchChangeToken(tx: tx), transaction: tx) } public func isAddressBlocked(_ address: SignalServiceAddress, transaction: DBReadTransaction) -> Bool { guard !address.isLocalAddress else { return false } let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable guard let recipientId = recipientDatabaseTable.fetchRecipient(address: address, tx: transaction)?.id else { return false } return (try? blockedRecipientStore.isBlocked(recipientId: recipientId, tx: transaction)) ?? false } public func isGroupIdBlocked(_ groupId: GroupIdentifier, transaction tx: DBReadTransaction) -> Bool { return _isGroupIdBlocked(groupId.serialize(), tx: tx) } public func isGroupIdBlocked_deprecated(_ groupId: Data, tx: DBReadTransaction) -> Bool { return _isGroupIdBlocked(groupId, tx: tx) } private func _isGroupIdBlocked(_ groupId: Data, tx: DBReadTransaction) -> Bool { return (try? blockedGroupStore.isBlocked(groupId: groupId, tx: tx)) ?? false } public func blockedRecipientIds(tx: DBReadTransaction) throws -> Set { return Set(try blockedRecipientStore.blockedRecipientIds(tx: tx)) } public func blockedAddresses(transaction: DBReadTransaction) -> Set { let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable let blockedRecipientIds = (try? self.blockedRecipientIds(tx: transaction)) ?? [] return Set(blockedRecipientIds.compactMap { return recipientDatabaseTable.fetchRecipient(rowId: $0, tx: transaction)?.address }) } public func blockedGroupIds(transaction: DBReadTransaction) throws -> [Data] { return try blockedGroupStore.blockedGroupIds(tx: transaction) } public func addBlockedAci(_ aci: Aci, blockMode: BlockMode, tx: DBWriteTransaction) { self.addBlockedAddress(SignalServiceAddress(aci), blockMode: blockMode, transaction: SDSDB.shimOnlyBridge(tx)) } public func addBlockedAddress( _ address: SignalServiceAddress, blockMode: BlockMode, transaction tx: DBWriteTransaction ) { guard !address.isLocalAddress else { owsFailDebug("Cannot block the local address") return } let recipientFetcher = DependenciesBridge.shared.recipientFetcher let recipient: SignalRecipient if let serviceId = address.serviceId { recipient = recipientFetcher.fetchOrCreate(serviceId: serviceId, tx: tx) } else if let phoneNumber = E164(address.phoneNumber) { recipient = recipientFetcher.fetchOrCreate(phoneNumber: phoneNumber, tx: tx) } else { owsFailDebug("Invalid address: \(address).") return } let isBlocked = failIfThrows { try blockedRecipientStore.isBlocked(recipientId: recipient.id!, tx: tx) } guard !isBlocked else { return } failIfThrows { try blockedRecipientStore.setBlocked(true, recipientId: recipient.id!, tx: tx) } Logger.info("Added blocked address: \(address)") if blockMode.locallyInitiated { SSKEnvironment.shared.storageServiceManagerRef.recordPendingUpdates(updatedAddresses: [address]) } // We will start dropping new stories from the blocked address; // delete any existing ones we already have. if let aci = address.aci { StoryManager.deleteAllStories(forSender: aci, tx: tx) } let storyRecipientManager = DependenciesBridge.shared.storyRecipientManager storyRecipientManager.removeRecipientIdFromAllPrivateStoryThreads( recipient.id!, shouldUpdateStorageService: true, tx: tx ) switch blockMode { case .restoreFromBackup: // If we're restoring from a Backup, avoid the side effect of // inserting a message. One either existed in the backup or not. break case .remote, .localShouldLeaveGroups, .localShouldNotLeaveGroups: // Insert an info message that we blocked this user. let threadStore = DependenciesBridge.shared.threadStore let interactionStore = DependenciesBridge.shared.interactionStore if let contactThread = threadStore.fetchContactThread(recipient: recipient, tx: tx) { interactionStore.insertInteraction( TSInfoMessage(thread: contactThread, messageType: .blockedOtherUser), tx: tx ) } } didUpdate(wasLocallyInitiated: blockMode.locallyInitiated, tx: tx) } public func removeBlockedAddress( _ address: SignalServiceAddress, wasLocallyInitiated: Bool, transaction tx: DBWriteTransaction ) { guard address.isValid else { owsFailDebug("Invalid address: \(address).") return } let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable guard let recipient = recipientDatabaseTable.fetchRecipient(address: address, tx: tx) else { // No need to unblock non-existent recipients. They can't possibly be blocked. return } let isBlocked = failIfThrows { try blockedRecipientStore.isBlocked(recipientId: recipient.id!, tx: tx) } guard isBlocked else { return } failIfThrows { try blockedRecipientStore.setBlocked(false, recipientId: recipient.id!, tx: tx) } Logger.info("Removed blocked address: \(address)") if wasLocallyInitiated { SSKEnvironment.shared.storageServiceManagerRef.recordPendingUpdates(updatedAddresses: [address]) } // Insert an info message that we unblocked this user. let threadStore = DependenciesBridge.shared.threadStore let interactionStore = DependenciesBridge.shared.interactionStore if let contactThread = threadStore.fetchContactThread(recipient: recipient, tx: tx) { interactionStore.insertInteraction( TSInfoMessage(thread: contactThread, messageType: .unblockedOtherUser), tx: tx ) } didUpdate(wasLocallyInitiated: wasLocallyInitiated, tx: tx) } public func addBlockedGroupId(_ groupId: Data, blockMode: BlockMode, transaction: DBWriteTransaction) { guard GroupManager.isValidGroupIdOfAnyKind(groupId) else { owsFailDebug("Can't block invalid groupId: \(groupId.toHex())") return } let isBlocked = failIfThrows { try blockedGroupStore.isBlocked(groupId: groupId, tx: transaction) } guard !isBlocked else { return } failIfThrows { try blockedGroupStore.setBlocked(true, groupId: groupId, tx: transaction) } Logger.info("Added blocked groupId: \(groupId.toHex())") let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) owsAssertDebug(groupThread != nil, "Must have TSGroupThread in order to block it.") if blockMode.locallyInitiated, let masterKey = try? (groupThread?.groupModel as? TSGroupModelV2)?.masterKey() { SSKEnvironment.shared.storageServiceManagerRef.recordPendingUpdates(updatedGroupV2MasterKeys: [masterKey]) } if let groupThread { // Quit the group if we're a member. if blockMode == .localShouldLeaveGroups, groupThread.groupModel.groupMembership.isLocalUserMemberOfAnyKind, groupThread.isLocalUserMemberOfAnyKind { GroupManager.leaveGroupOrDeclineInviteAsyncWithoutUI( groupThread: groupThread, tx: transaction ) } switch blockMode { case .restoreFromBackup: // If we're restoring from a Backup, avoid the side effect of // inserting a message. One either existed in the backup or not. break case .remote, .localShouldLeaveGroups, .localShouldNotLeaveGroups: // Insert an info message that we blocked this group. DependenciesBridge.shared.interactionStore.insertInteraction( TSInfoMessage(thread: groupThread, messageType: .blockedGroup), tx: transaction ) } } didUpdate(wasLocallyInitiated: blockMode.locallyInitiated, tx: transaction) } public func removeBlockedGroup(groupId: Data, wasLocallyInitiated: Bool, transaction: DBWriteTransaction) { let isBlocked = failIfThrows { try blockedGroupStore.isBlocked(groupId: groupId, tx: transaction) } guard isBlocked else { return } failIfThrows { try blockedGroupStore.setBlocked(false, groupId: groupId, tx: transaction) } Logger.info("Removed blocked groupId: \(groupId.toHex())") let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) if wasLocallyInitiated { let masterKey = { () -> GroupMasterKey? in if let groupThread { return try? (groupThread.groupModel as? TSGroupModelV2)?.masterKey() } if GroupManager.isV2GroupId(groupId) { // TODO: Check groups we're still trying to restore from Storage Service. } return nil }() if let masterKey { SSKEnvironment.shared.storageServiceManagerRef.recordPendingUpdates(updatedGroupV2MasterKeys: [masterKey]) } } if let groupThread { // Insert an info message that we unblocked. DependenciesBridge.shared.interactionStore.insertInteraction( TSInfoMessage(thread: groupThread, messageType: .unblockedGroup), tx: transaction ) // Refresh unblocked group. transaction.addSyncCompletion { SSKEnvironment.shared.groupV2UpdatesRef.refreshGroupUpThroughCurrentRevision(groupThread: groupThread, throttle: false) } } didUpdate(wasLocallyInitiated: wasLocallyInitiated, tx: transaction) } // MARK: Other convenience access public func isThreadBlocked(_ thread: TSThread, transaction: DBReadTransaction) -> Bool { if let contactThread = thread as? TSContactThread { return isAddressBlocked(contactThread.contactAddress, transaction: transaction) } else if let groupThread = thread as? TSGroupThread { return _isGroupIdBlocked(groupThread.groupModel.groupId, tx: transaction) } else if thread is TSPrivateStoryThread { return false } else { owsFailDebug("Invalid thread: \(type(of: thread))") return false } } public func addBlockedThread(_ thread: TSThread, blockMode: BlockMode, transaction: DBWriteTransaction) { if let contactThread = thread as? TSContactThread { addBlockedAddress(contactThread.contactAddress, blockMode: blockMode, transaction: transaction) } else if let groupThread = thread as? TSGroupThread { addBlockedGroupId(groupThread.groupId, blockMode: blockMode, transaction: transaction) } else { owsFailDebug("Invalid thread: \(type(of: thread))") } } public func removeBlockedThread(_ thread: TSThread, wasLocallyInitiated: Bool, transaction: DBWriteTransaction) { if let contactThread = thread as? TSContactThread { removeBlockedAddress(contactThread.contactAddress, wasLocallyInitiated: wasLocallyInitiated, transaction: transaction) } else if let groupThread = thread as? TSGroupThread { removeBlockedGroup(groupId: groupThread.groupId, wasLocallyInitiated: wasLocallyInitiated, transaction: transaction) } else { owsFailDebug("Invalid thread: \(type(of: thread))") } } // MARK: - Syncing public func processIncomingSync( blockedPhoneNumbers: Set, blockedAcis: Set, blockedGroupIds: Set, tx transaction: DBWriteTransaction ) { Logger.info("") transaction.addSyncCompletion { NotificationCenter.default.postOnMainThread(name: Self.blockedSyncDidComplete, object: nil) } var didChange = false failIfThrows { let oldBlockedGroupIds = Set(try blockedGroupStore.blockedGroupIds(tx: transaction)) let newBlockedGroupIds = blockedGroupIds try oldBlockedGroupIds.subtracting(newBlockedGroupIds).forEach { didChange = true try blockedGroupStore.setBlocked(false, groupId: $0, tx: transaction) } try newBlockedGroupIds.subtracting(oldBlockedGroupIds).forEach { didChange = true try blockedGroupStore.setBlocked(true, groupId: $0, tx: transaction) } } var blockedRecipientIds = Set() let recipientFetcher = DependenciesBridge.shared.recipientFetcher for blockedAci in blockedAcis { blockedRecipientIds.insert(recipientFetcher.fetchOrCreate(serviceId: blockedAci, tx: transaction).id!) } for blockedPhoneNumber in blockedPhoneNumbers.compactMap(E164.init) { blockedRecipientIds.insert(recipientFetcher.fetchOrCreate(phoneNumber: blockedPhoneNumber, tx: transaction).id!) } failIfThrows { let oldBlockedRecipientIds = Set(try blockedRecipientStore.blockedRecipientIds(tx: transaction)) let newBlockedRecipientIds = blockedRecipientIds try oldBlockedRecipientIds.subtracting(newBlockedRecipientIds).forEach { didChange = true try blockedRecipientStore.setBlocked(false, recipientId: $0, tx: transaction) } try newBlockedRecipientIds.subtracting(oldBlockedRecipientIds).forEach { didChange = true try blockedRecipientStore.setBlocked(true, recipientId: $0, tx: transaction) } } if didChange { didUpdate(wasLocallyInitiated: false, tx: transaction) } } public func syncBlockList() -> Task { return self.syncQueue.enqueue { await self.sendBlockListSyncMessage(force: true) } } private func sendBlockListSyncMessage(force: Bool) async { do { try await _sendBlockListSyncMessage(force: force) } catch { Logger.warn("\(error)") } } private func _sendBlockListSyncMessage(force: Bool) async throws { let tsAccountManager = DependenciesBridge.shared.tsAccountManager guard tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else { throw OWSGenericError("Not registered.") } let sendResult = try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { (tx) -> (sendPromise: Promise, changeToken: UInt64)? in // If we're not forcing a sync, then we only sync if our last synced token is stale // and we're not in the NSE. We'll leaving syncing to the main app. let changeToken = fetchChangeToken(tx: tx) if !force { guard shouldSync(changeToken: changeToken, tx: tx) else { return nil } guard !CurrentAppContext().isNSE else { throw OWSGenericError("Can't send in the NSE.") } } guard let localThread = TSContactThread.getOrCreateLocalThread(transaction: tx) else { throw OWSAssertionError("Missing thread.") } let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable let blockedRecipients = try blockedRecipientStore.blockedRecipientIds(tx: tx).compactMap { return recipientDatabaseTable.fetchRecipient(rowId: $0, tx: tx) } let blockedGroupIds = try blockedGroupStore.blockedGroupIds(tx: tx) let message = OWSBlockedPhoneNumbersMessage( localThread: localThread, phoneNumbers: blockedRecipients.compactMap { $0.phoneNumber?.stringValue }, aciStrings: blockedRecipients.compactMap { $0.aci?.serviceIdString }, groupIds: Array(blockedGroupIds), transaction: tx ) let preparedMessage = PreparedOutgoingMessage.preprepared( transientMessageWithoutAttachments: message ) let sendPromise = SSKEnvironment.shared.messageSenderJobQueueRef.add( .promise, message: preparedMessage, transaction: tx ) return (sendPromise, changeToken) } guard let sendResult else { return } try await sendResult.sendPromise.awaitable() // Record the last block list which we successfully synced.. await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in setLastSyncedChangeToken(sendResult.changeToken, transaction: transaction) } } private func shouldSync(changeToken: UInt64, tx: DBReadTransaction) -> Bool { // If we've ever sync'd with this mechanism, we need only sync again if the // token has changed. if let lastSyncedChangeToken = fetchLastSyncedChangeToken(tx: tx) { return changeToken != lastSyncedChangeToken } // Otherwise, if we've made any change, we must sync. if changeToken > Constants.initialChangeToken { return true } // If we don't have a last synced change token, we can use the existence of // one of our old KVS keys as a hint that we may need to sync. If they // don't exist this is probably a fresh install and we don't need to sync. return PersistenceKey.Legacy.allCases.contains { key in return keyValueStore.hasValue(key.rawValue, transaction: tx) } } // MARK: - Notifications public static let blockListDidChange = Notification.Name("blockListDidChange") public static let blockedSyncDidComplete = Notification.Name("blockedSyncDidComplete") fileprivate func observeNotifications() { AssertIsOnMainThread() NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActive), name: .OWSApplicationDidBecomeActive, object: nil ) } @objc private func applicationDidBecomeActive() { syncIfNeeded() } // MARK: - Persistence private enum Constants { static let initialChangeToken: UInt64 = 1 } private let keyValueStore = KeyValueStore(collection: "kOWSBlockingManager_BlockedPhoneNumbersCollection") enum PersistenceKey: String { case changeTokenKey = "kOWSBlockingManager_ChangeTokenKey" case lastSyncedChangeTokenKey = "kOWSBlockingManager_LastSyncedChangeTokenKey" // No longer in use enum Legacy: String, CaseIterable { case syncedBlockedPhoneNumbersKey = "kOWSBlockingManager_SyncedBlockedPhoneNumbersKey" case syncedBlockedUUIDsKey = "kOWSBlockingManager_SyncedBlockedUUIDsKey" case syncedBlockedGroupIdsKey = "kOWSBlockingManager_SyncedBlockedGroupIdsKey" } } func fetchChangeToken(tx: DBReadTransaction) -> UInt64 { keyValueStore.getUInt64(PersistenceKey.changeTokenKey.rawValue, defaultValue: Constants.initialChangeToken, transaction: tx) } func setChangeToken(_ newValue: UInt64, tx: DBWriteTransaction) { keyValueStore.setUInt64(newValue, key: PersistenceKey.changeTokenKey.rawValue, transaction: tx) } func fetchLastSyncedChangeToken(tx: DBReadTransaction) -> UInt64? { keyValueStore.getUInt64(PersistenceKey.lastSyncedChangeTokenKey.rawValue, transaction: tx) } func setLastSyncedChangeToken(_ newValue: UInt64, transaction writeTx: DBWriteTransaction) { keyValueStore.setUInt64(newValue, key: PersistenceKey.lastSyncedChangeTokenKey.rawValue, transaction: writeTx) } }