// // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import LibSignalClient import SignalCoreKit public enum BlockMode: UInt { 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: NSObject { private let lock = UnfairLock() private var state = State() public override init() { super.init() SwiftSingletons.register(self) AppReadiness.runNowOrWhenAppWillBecomeReady { self.loadStateOnLaunch() } } public func warmCaches() { owsAssertDebug(GRDBSchemaMigrator.areMigrationsComplete) loadStateOnLaunch() } private func loadStateOnLaunch() { // Pre-warm our cached state databaseStorage.read { withCurrentState(transaction: $0) { _ in } } // Once we're ready to send a message, check to see if we need to sync. AppReadiness.runNowOrWhenAppDidBecomeReadyAsync { DispatchQueue.global().async { self.sendBlockListSyncMessage(force: false) } } observeNotifications() } fileprivate func withCurrentState(transaction: SDSAnyReadTransaction, _ handler: (State) -> T) -> T { lock.withLock { state.reloadIfNecessary(transaction) return handler(state) } } @discardableResult fileprivate func updateCurrentState(transaction: SDSAnyWriteTransaction, wasLocallyInitiated: Bool, _ handler: (inout State) -> Void) -> Bool { lock.withLock { state.reloadIfNecessary(transaction) handler(&state) let didUpdate = state.persistIfNecessary(transaction) if didUpdate { if !wasLocallyInitiated { State.setLastSyncedChangeToken(state.changeToken, transaction: transaction) } transaction.addAsyncCompletionOffMain { Logger.info("blockListDidChange") if wasLocallyInitiated { self.sendBlockListSyncMessage(force: false) } NotificationCenter.default.postNotificationNameAsync(Self.blockListDidChange, object: nil) } } return didUpdate } } } // MARK: - Public block state accessors extension BlockingManager { // MARK: Readers @objc public func isAddressBlocked(_ address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> Bool { guard !address.isLocalAddress else { return false } return withCurrentState(transaction: transaction) { state in if let phoneNumber = address.phoneNumber, state.blockedPhoneNumbers.contains(phoneNumber) { return true } if let aci = address.serviceId as? Aci, state.blockedAcis.contains(aci) { return true } return false } } @objc public func isGroupIdBlocked(_ groupId: Data, transaction: SDSAnyReadTransaction) -> Bool { withCurrentState(transaction: transaction) { state in state.blockedGroupMap[groupId] != nil } } @objc public func blockedAddresses(transaction: SDSAnyReadTransaction) -> Set { // TODO UUID - optimize this. Maybe blocking manager should store a SignalServiceAddressSet as // it's state instead of the two separate sets. withCurrentState(transaction: transaction) { state in var addressSet = Set() state.blockedPhoneNumbers.forEach { let address = SignalServiceAddress.legacyAddress(serviceId: nil, phoneNumber: $0) if address.isValid { addressSet.insert(address) } } state.blockedAcis.forEach { addressSet.insert(SignalServiceAddress($0)) } return addressSet } } public func blockedGroupModels(transaction: SDSAnyReadTransaction) -> [TSGroupModel] { withCurrentState(transaction: transaction) { state in Array(state.blockedGroupMap.values) } } // MARK: Writers public func addBlockedAddress(_ address: SignalServiceAddress, blockMode: BlockMode, transaction: SDSAnyWriteTransaction) { guard address.isValid else { owsFailDebug("Invalid address: \(address).") return } guard !address.isLocalAddress else { owsFailDebug("Cannot block the local address") return } updateCurrentState(transaction: transaction, wasLocallyInitiated: blockMode.locallyInitiated) { state in let didAdd = state.addBlockedAddress(address) if didAdd && blockMode.locallyInitiated { storageServiceManager.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: transaction) } StoryManager.removeAddressFromAllPrivateStoryThreads(address, tx: transaction) } public func removeBlockedAddress(_ address: SignalServiceAddress, wasLocallyInitiated: Bool, transaction: SDSAnyWriteTransaction) { guard address.isValid else { owsFailDebug("Invalid address: \(address).") return } guard !address.isLocalAddress else { owsFailDebug("Cannot unblock the local address") return } updateCurrentState(transaction: transaction, wasLocallyInitiated: wasLocallyInitiated) { state in let didRemove = state.removeBlockedAddress(address) if didRemove && wasLocallyInitiated { storageServiceManager.recordPendingUpdates(updatedAddresses: [address]) } } } public func addBlockedGroup(groupModel: TSGroupModel, blockMode: BlockMode, transaction: SDSAnyWriteTransaction) { let groupId = groupModel.groupId guard GroupManager.isValidGroupIdOfAnyKind(groupId) else { owsFailDebug("Invalid group: \(groupId)") return } updateCurrentState(transaction: transaction, wasLocallyInitiated: blockMode.locallyInitiated) { state in let didInsert = state.addBlockedGroup(groupModel) if didInsert { Logger.info("Added blocked groupId: \(groupId.hexadecimalString)") if blockMode.locallyInitiated { storageServiceManager.recordPendingUpdates(groupModel: groupModel) } // Quit the group if we're a member. if blockMode == .localShouldLeaveGroups, groupModel.groupMembership.isLocalUserMemberOfAnyKind, let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction), groupThread.isLocalUserMemberOfAnyKind { GroupManager.leaveGroupOrDeclineInviteAsyncWithoutUI(groupThread: groupThread, transaction: transaction, success: nil) } } } } public func removeBlockedGroup(groupId: Data, wasLocallyInitiated: Bool, transaction: SDSAnyWriteTransaction) { guard GroupManager.isValidGroupIdOfAnyKind(groupId) else { owsFailDebug("Invalid group: \(groupId)") return } updateCurrentState(transaction: transaction, wasLocallyInitiated: wasLocallyInitiated) { state in if let unblockedGroup = state.removeBlockedGroup(groupId) { Logger.info("Removed blocked groupId: \(groupId.hexadecimalString)") if wasLocallyInitiated { storageServiceManager.recordPendingUpdates(groupModel: unblockedGroup) } // Refresh unblocked group. if let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) { groupV2Updates.tryToRefreshV2GroupUpToCurrentRevisionAfterMessageProcessingWithoutThrottling(groupThread) } } } } // MARK: Other convenience access public func isThreadBlocked(_ thread: TSThread, transaction: SDSAnyReadTransaction) -> 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, transaction: 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: SDSAnyWriteTransaction) { if let contactThread = thread as? TSContactThread { addBlockedAddress(contactThread.contactAddress, blockMode: blockMode, transaction: transaction) } else if let groupThread = thread as? TSGroupThread { addBlockedGroup(groupModel: groupThread.groupModel, blockMode: blockMode, transaction: transaction) } else { owsFailDebug("Invalid thread: \(type(of: thread))") } } public func removeBlockedThread(_ thread: TSThread, wasLocallyInitiated: Bool, transaction: SDSAnyWriteTransaction) { 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))") } } public func addBlockedGroup(groupId: Data, blockMode: BlockMode, transaction: SDSAnyWriteTransaction) { // Since we're in a write transaction, current state shouldn't have updated between this read // and the following write. I'm just using the `withCurrentState` method here to avoid reenterancy // that'd require having a separate helper implementation. let groupModelToUse: TSGroupModel? = withCurrentState(transaction: transaction) { state in if let existingModel = state.blockedGroupMap[groupId] { return existingModel } else if let currentThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) { return currentThread.groupModel } else { return GroupManager.fakeGroupModel(groupId: groupId) } } if let groupModelToUse = groupModelToUse { addBlockedGroup(groupModel: groupModelToUse, blockMode: blockMode, transaction: transaction) } } } // MARK: - Syncing extension BlockingManager { public func processIncomingSync( blockedPhoneNumbers: Set, blockedAcis: Set, blockedGroupIds: Set, tx transaction: SDSAnyWriteTransaction ) { Logger.info("") transaction.addAsyncCompletionOnMain { NotificationCenter.default.post(name: Self.blockedSyncDidComplete, object: nil) } updateCurrentState(transaction: transaction, wasLocallyInitiated: false) { state in // We store the list of blocked groups as GroupModels (not group ids) // so that we can display the group names in the block list UI, if // possible. // // * If we have an existing group model, we use it to preserve the group name. // * If we can find the group thread, we use it to preserve the group name. // * If we only know the group id, we use a "fake" group model with only the group id. let newBlockedGroups: [Data: TSGroupModel] = blockedGroupIds.dictionaryMappingToValues { (blockedGroupId: Data) -> TSGroupModel? in if let existingModel = state.blockedGroupMap[blockedGroupId] { return existingModel } else if let currentThread = TSGroupThread.fetch(groupId: blockedGroupId, transaction: transaction) { return currentThread.groupModel } else { return GroupManager.fakeGroupModel(groupId: blockedGroupId) } }.compactMapValues { $0 } state.replace(blockedPhoneNumbers: blockedPhoneNumbers, blockedAcis: blockedAcis, blockedGroups: newBlockedGroups) } } @objc public func syncBlockList(completion: @escaping () -> Void) { DispatchQueue.global().async { self.sendBlockListSyncMessage(force: true) completion() } } private func sendBlockListSyncMessage(force: Bool) { guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else { return } databaseStorage.write { transaction in withCurrentState(transaction: transaction) { state 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. if !force { guard state.needsSync(transaction: transaction) else { return } guard !CurrentAppContext().isNSE else { return } } let possibleThread = TSContactThread.getOrCreateLocalThread(transaction: transaction) guard let thread = possibleThread else { owsFailDebug("Missing thread.") return } let outgoingChangeToken = state.changeToken let message = OWSBlockedPhoneNumbersMessage( thread: thread, phoneNumbers: Array(state.blockedPhoneNumbers), aciStrings: state.blockedAcis.map { $0.serviceIdString }, groupIds: Array(state.blockedGroupMap.keys), transaction: transaction ) if TestingFlags.optimisticallyCommitSyncToken { // Tests can opt in to setting this token early. This won't be executed in production. State.setLastSyncedChangeToken(outgoingChangeToken, transaction: transaction) } let preparedMessage = PreparedOutgoingMessage.preprepared( transientMessageWithoutAttachments: message ) SSKEnvironment.shared.messageSenderJobQueueRef.add( .promise, message: preparedMessage, transaction: transaction ).done(on: DispatchQueue.global()) { Logger.info("Successfully sent blocked phone numbers sync message") // Record the last block list which we successfully synced.. Self.databaseStorage.write { transaction in State.setLastSyncedChangeToken(outgoingChangeToken, transaction: transaction) } }.catch { error in owsFailDebugUnlessNetworkFailure(error) } } } } } // MARK: - Notifications extension BlockingManager { @objc public static let blockListDidChange = Notification.Name("blockListDidChange") @objc public static let blockedSyncDidComplete = Notification.Name("blockedSyncDidComplete") fileprivate func observeNotifications() { AssertIsOnMainThread() NotificationCenter.default.removeObserver(self) NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActive), name: .OWSApplicationDidBecomeActive, object: nil ) } @objc fileprivate func applicationDidBecomeActive() { AppReadiness.runNowOrWhenMainAppDidBecomeReadyAsync { DispatchQueue.global().async { self.sendBlockListSyncMessage(force: false) } } } } // MARK: - Persistence extension BlockingManager { struct State { private(set) var isDirty: Bool private(set) var changeToken: UInt64 private(set) var blockedPhoneNumbers: Set private(set) var blockedAcis: Set private(set) var blockedGroupMap: [Data: TSGroupModel] // GroupId -> GroupModel static let invalidChangeToken: UInt64 = 0 static let initialChangeToken: UInt64 = 1 fileprivate init() { // We're okay initializing with empty data since it'll be reloaded on first access // Only non-zero change tokens should ever be stored in the database, so we'll pick up on the mismatch isDirty = false changeToken = Self.invalidChangeToken blockedPhoneNumbers = Set() blockedAcis = Set() blockedGroupMap = [:] } func needsSync(transaction readTx: SDSAnyReadTransaction) -> Bool { owsAssertDebug(changeToken != Self.invalidChangeToken) if let lastSyncedToken = State.fetchLastSyncedChangeToken(readTx) { return changeToken != lastSyncedToken } else if changeToken > Self.initialChangeToken { // If we've made changes and we don't have a last synced token, we must require a sync return true } else { // 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. let hasOldKey = PersistenceKey.Legacy.allCases.contains { key in Self.keyValueStore.hasValue(forKey: key.rawValue, transaction: readTx) } return hasOldKey } } // MARK: - Mutation mutating func replace( blockedPhoneNumbers newBlockedPhoneNumbers: Set, blockedAcis newBlockedAcis: Set, blockedGroups newBlockedGroups: [Data: TSGroupModel] ) { owsAssertDebug(changeToken != Self.invalidChangeToken) var blockedAddresses = [SignalServiceAddress]() blockedAddresses.append(contentsOf: newBlockedPhoneNumbers.lazy.compactMap { phoneNumber in let address = SignalServiceAddress.legacyAddress(serviceId: nil, phoneNumber: phoneNumber) return address.isValid ? address : nil }) blockedAddresses.append(contentsOf: newBlockedAcis.map { SignalServiceAddress($0) }) let oldBlockedPhoneNumbers = self.blockedPhoneNumbers let oldBlockedAcis = self.blockedAcis let oldBlockedGroupMap = self.blockedGroupMap self.blockedPhoneNumbers = Set(blockedAddresses.lazy.compactMap { $0.phoneNumber }) self.blockedAcis = Set(blockedAddresses.lazy.compactMap { $0.aci }) self.blockedGroupMap = newBlockedGroups isDirty = isDirty || (oldBlockedPhoneNumbers != self.blockedPhoneNumbers) isDirty = isDirty || (oldBlockedAcis != self.blockedAcis) isDirty = isDirty || (oldBlockedGroupMap != self.blockedGroupMap) } @discardableResult mutating func addBlockedAddress(_ address: SignalServiceAddress) -> Bool { owsAssertDebug(changeToken != Self.invalidChangeToken) var didInsert = false if let phoneNumber = address.phoneNumber { let result = blockedPhoneNumbers.insert(phoneNumber) didInsert = didInsert || result.inserted } if let aci = address.aci { let result = blockedAcis.insert(aci) didInsert = didInsert || result.inserted } isDirty = isDirty || didInsert return didInsert } @discardableResult mutating func removeBlockedAddress(_ address: SignalServiceAddress) -> Bool { owsAssertDebug(changeToken != Self.invalidChangeToken) var didRemove = false if let phoneNumber = address.phoneNumber, blockedPhoneNumbers.contains(phoneNumber) { blockedPhoneNumbers.remove(phoneNumber) didRemove = true } if let aci = address.aci, blockedAcis.contains(aci) { blockedAcis.remove(aci) didRemove = true } isDirty = isDirty || didRemove return didRemove } @discardableResult mutating func addBlockedGroup(_ model: TSGroupModel) -> Bool { owsAssertDebug(changeToken != Self.invalidChangeToken) var didInsert = false if blockedGroupMap[model.groupId] == nil { blockedGroupMap[model.groupId] = model didInsert = true } isDirty = didInsert || isDirty return didInsert } @discardableResult mutating func removeBlockedGroup(_ groupId: Data) -> TSGroupModel? { owsAssertDebug(changeToken != Self.invalidChangeToken) let oldValue = blockedGroupMap.removeValue(forKey: groupId) isDirty = isDirty || (oldValue != nil) return oldValue } // MARK: Persistence static let keyValueStore = SDSKeyValueStore(collection: "kOWSBlockingManager_BlockedPhoneNumbersCollection") enum PersistenceKey: String { case changeTokenKey = "kOWSBlockingManager_ChangeTokenKey" case lastSyncedChangeTokenKey = "kOWSBlockingManager_LastSyncedChangeTokenKey" case blockedPhoneNumbersKey = "kOWSBlockingManager_BlockedPhoneNumbersKey" case blockedAciStringsKey = "kOWSBlockingManager_BlockedUUIDsKey" case blockedGroupMapKey = "kOWSBlockingManager_BlockedGroupMapKey" // No longer in use enum Legacy: String, CaseIterable { case syncedBlockedPhoneNumbersKey = "kOWSBlockingManager_SyncedBlockedPhoneNumbersKey" case syncedBlockedUUIDsKey = "kOWSBlockingManager_SyncedBlockedUUIDsKey" case syncedBlockedGroupIdsKey = "kOWSBlockingManager_SyncedBlockedGroupIdsKey" } } mutating func reloadIfNecessary(_ transaction: SDSAnyReadTransaction) { owsAssertDebug(isDirty == false) let databaseChangeToken: UInt64 = Self.keyValueStore.getUInt64( PersistenceKey.changeTokenKey.rawValue, defaultValue: Self.initialChangeToken, transaction: transaction) if databaseChangeToken != changeToken { func fetchObject(of type: T.Type, key: String, defaultValue: T) -> T { if let storedObject = Self.keyValueStore.getObject(forKey: key, transaction: transaction) { owsAssertDebug(storedObject is T) return (storedObject as? T) ?? defaultValue } else { return defaultValue } } changeToken = Self.keyValueStore.getUInt64( PersistenceKey.changeTokenKey.rawValue, defaultValue: Self.initialChangeToken, transaction: transaction ) blockedPhoneNumbers = Set(fetchObject(of: [String].self, key: PersistenceKey.blockedPhoneNumbersKey.rawValue, defaultValue: [])) let blockedAciStrings = fetchObject(of: [String].self, key: PersistenceKey.blockedAciStringsKey.rawValue, defaultValue: []) blockedAcis = Set(blockedAciStrings.lazy.compactMap { Aci.parseFrom(aciString: $0) }) blockedGroupMap = fetchObject(of: [Data: TSGroupModel].self, key: PersistenceKey.blockedGroupMapKey.rawValue, defaultValue: [:]) isDirty = false } } mutating func persistIfNecessary(_ transaction: SDSAnyWriteTransaction) -> Bool { guard changeToken != Self.invalidChangeToken else { owsFailDebug("Attempting to persist an unfetched change token. Aborting...") return false } if isDirty { let databaseChangeToken = Self.keyValueStore.getUInt64( PersistenceKey.changeTokenKey.rawValue, defaultValue: Self.initialChangeToken, transaction: transaction ) owsAssertDebug(databaseChangeToken == changeToken) changeToken = databaseChangeToken + 1 Self.keyValueStore.setUInt64(changeToken, key: PersistenceKey.changeTokenKey.rawValue, transaction: transaction) Self.keyValueStore.setObject(Array(blockedPhoneNumbers), key: PersistenceKey.blockedPhoneNumbersKey.rawValue, transaction: transaction) Self.keyValueStore.setObject(blockedAcis.map { $0.serviceIdUppercaseString }, key: PersistenceKey.blockedAciStringsKey.rawValue, transaction: transaction) Self.keyValueStore.setObject(blockedGroupMap, key: PersistenceKey.blockedGroupMapKey.rawValue, transaction: transaction) isDirty = false return true } else { return false } } static func fetchLastSyncedChangeToken(_ readTx: SDSAnyReadTransaction) -> UInt64? { Self.keyValueStore.getUInt64(PersistenceKey.lastSyncedChangeTokenKey.rawValue, transaction: readTx) } static func setLastSyncedChangeToken(_ newValue: UInt64, transaction writeTx: SDSAnyWriteTransaction) { Self.keyValueStore.setUInt64(newValue, key: PersistenceKey.lastSyncedChangeTokenKey.rawValue, transaction: writeTx) } } } // MARK: - Testing Helpers extension BlockingManager { enum TestingFlags { // Usually, we wait until after MessageSender finishes sending before commiting our last-sync // token. It's easier to just expose a knob for tests to force an early commit than having a test wait // for some nonexistent send. #if TESTABLE_BUILD static var optimisticallyCommitSyncToken = false #else static let optimisticallyCommitSyncToken = false #endif } } #if TESTABLE_BUILD extension BlockingManager { func _testingOnly_needsSyncMessage(_ readTx: SDSAnyReadTransaction) -> Bool { state.needsSync(transaction: readTx) } func _testingOnly_clearNeedsSyncMessage(_ writeTx: SDSAnyWriteTransaction) { State.setLastSyncedChangeToken(state.changeToken, transaction: writeTx) } } extension BlockingManager.State { static func _testing_createEmpty() -> BlockingManager.State { return .init() } mutating func _testingOnly_resetDirtyBit() { isDirty = false } } #endif