Signal-iOS/SignalMessaging/groups/GroupsV2ProfileKeyUpdater.swift

333 lines
15 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import Foundation
import PromiseKit
import SignalServiceKit
// Whenever we rotate our profile key, we need to update all
// v2 groups of which we are a non-pending member.
// This is laborious, but important. It is too expensive to
// do unless necessary (e.g. we don't want to check every
// group on launch), but important enough to do durably.
//
// This class has responsibility for tracking which groups
// need to be updated and for updating them.
class GroupsV2ProfileKeyUpdater: Dependencies {
public required init() {
NotificationCenter.default.addObserver(self,
selector: #selector(reachabilityChanged),
name: SSKReachability.owsReachabilityDidChange,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(didBecomeActive),
name: .OWSApplicationDidBecomeActive,
object: nil)
}
// MARK: -
@objc func didBecomeActive() {
AssertIsOnMainThread()
AppReadiness.runNowOrWhenAppDidBecomeReadyAsync {
self.tryToUpdateNext()
}
}
@objc func reachabilityChanged() {
AssertIsOnMainThread()
AppReadiness.runNowOrWhenAppDidBecomeReadyAsync {
self.tryToUpdateNext()
}
}
// MARK: -
// Stores the list of v2 groups that we need to update with our latest profile key.
private let keyValueStore = SDSKeyValueStore(collection: "GroupsV2ProfileKeyUpdater")
private func key(for groupId: Data) -> String {
return groupId.hexadecimalString
}
@objc
public func updateLocalProfileKeyInGroup(groupId: Data, transaction: SDSAnyWriteTransaction) {
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
owsFailDebug("Missing groupThread.")
return
}
self.tryToScheduleGroupForProfileKeyUpdate(groupThread: groupThread,
transaction: transaction)
transaction.addAsyncCompletion {
self.tryToUpdateNext()
}
}
public func scheduleAllGroupsV2ForProfileKeyUpdate(transaction: SDSAnyWriteTransaction) {
TSGroupThread.anyEnumerate(transaction: transaction) { (thread, _) in
guard let groupThread = thread as? TSGroupThread,
groupThread.isGroupV2Thread else {
return
}
self.tryToScheduleGroupForProfileKeyUpdate(groupThread: groupThread,
transaction: transaction)
}
}
private func tryToScheduleGroupForProfileKeyUpdate(groupThread: TSGroupThread,
transaction: SDSAnyWriteTransaction) {
guard !CurrentAppContext().isRunningTests,
tsAccountManager.isRegisteredAndReady,
tsAccountManager.isPrimaryDevice else {
return
}
guard let localAddress = tsAccountManager.localAddress else {
owsFailDebug("missing local address")
return
}
let groupMembership = groupThread.groupModel.groupMembership
// We only need to update v2 groups of which we are a full member.
guard groupThread.isGroupV2Thread,
groupMembership.isFullMember(localAddress) else {
return
}
let groupId = groupThread.groupModel.groupId
let key = self.key(for: groupId)
self.keyValueStore.setData(groupId, key: key, transaction: transaction)
}
@objc
public func processProfileKeyUpdates() {
tryToUpdateNext()
}
private let serialQueue = DispatchQueue(label: "GroupsV2ProfileKeyUpdater", qos: .background)
// This property should only be accessed on serialQueue.
private var isUpdating = false
private func tryToUpdateNext(retryDelay: TimeInterval = 1) {
guard CurrentAppContext().isMainAppAndActive,
!CurrentAppContext().isRunningTests,
tsAccountManager.isRegisteredAndReady,
tsAccountManager.isPrimaryDevice else {
return
}
guard reachabilityManager.isReachable else {
return
}
serialQueue.async {
guard !self.isUpdating else {
// Only one update should be in flight at a time.
return
}
guard let groupId = (self.databaseStorage.read { transaction in
return self.keyValueStore.anyDataValue(transaction: transaction)
}) else {
return
}
self.isUpdating = true
firstly(on: .global()) { () -> Promise<Void> in
self.tryToUpdate(groupId: groupId)
}.done(on: .global() ) { _ in
Logger.verbose("Updated profile key in group.")
self.didSucceed(groupId: groupId)
}.catch(on: .global() ) { error in
Logger.warn("Failed: \(error).")
guard !IsNetworkConnectivityFailure(error) else {
// Retry later.
return self.didFail(groupId: groupId, retryDelay: retryDelay)
}
switch error {
case GroupsV2Error.unexpectedRevision:
// Race with another client; retry later.
return self.didFail(groupId: groupId, retryDelay: retryDelay)
case GroupsV2Error.shouldDiscard:
// If a non-recoverable error occurs (e.g. we've
// delete the thread from the database), give up.
self.markAsComplete(groupId: groupId)
case GroupsV2Error.redundantChange:
// If the update is no longer necessary, skip it.
self.markAsComplete(groupId: groupId)
case is NetworkManagerError:
if let statusCode = error.httpStatusCode,
400 <= statusCode && statusCode <= 599 {
// If a non-recoverable error occurs (e.g. we've been kicked
// out of the group), give up.
Logger.info("Failed: \(statusCode)")
self.markAsComplete(groupId: groupId)
} else {
// Retry later.
self.didFail(groupId: groupId, retryDelay: retryDelay)
}
default:
// This should never occur. If it does, we don't want
// to get stuck in a retry loop.
owsFailDebug("Unexpected error: \(error)")
self.markAsComplete(groupId: groupId)
}
}
}
}
private func didSucceed(groupId: Data) {
markAsComplete(groupId: groupId)
}
private func markAsComplete(groupId: Data) {
serialQueue.async {
self.databaseStorage.write { transaction in
let key = self.key(for: groupId)
self.keyValueStore.removeValue(forKey: key, transaction: transaction)
}
self.isUpdating = false
self.tryToUpdateNext()
}
}
private func didFail(groupId: Data, retryDelay: TimeInterval) {
serialQueue.asyncAfter(deadline: DispatchTime.now() + retryDelay) {
self.isUpdating = false
// Retry with exponential backoff.
self.tryToUpdateNext(retryDelay: retryDelay * 2)
}
}
private func tryToUpdate(groupId: Data) -> Promise<Void> {
let profileKeyData = profileManager.localProfileKey().keyData
guard let localAddress = tsAccountManager.localAddress,
let localUuid = tsAccountManager.localUuid else {
owsFailDebug("missing local address")
return Promise(error: GroupsV2Error.shouldDiscard)
}
return firstly {
self.messageProcessor.fetchingAndProcessingCompletePromise()
}.map(on: .global()) { () throws -> TSGroupThread in
try self.databaseStorage.read { transaction in
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
throw GroupsV2Error.shouldDiscard
}
return groupThread
}
}.then(on: .global()) { (groupThread: TSGroupThread) throws -> Promise<(TSGroupThread, UInt32)> in
// Get latest group state from service and verify that this update is still necessary.
firstly { () throws -> Promise<GroupV2Snapshot> in
guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else {
throw OWSAssertionError("Invalid group model.")
}
return self.groupsV2Impl.fetchCurrentGroupV2Snapshot(groupModel: groupModel)
}.map(on: .global()) { (groupV2Snapshot: GroupV2Snapshot) throws -> (TSGroupThread, UInt32) in
guard groupV2Snapshot.groupMembership.isFullMember(localAddress) else {
// We're not a full member, no need to update profile key.
throw GroupsV2Error.redundantChange
}
guard !groupV2Snapshot.profileKeys.values.contains(profileKeyData) else {
// Group state already has our current key.
throw GroupsV2Error.redundantChange
}
if DebugFlags.internalLogging {
for (uuid, profileKey) in groupV2Snapshot.profileKeys {
Logger.info("Existing profile key: \(profileKey.hexadecimalString), for uuid: \(uuid), is local: \(uuid == localUuid)")
}
}
let checkedRevision = groupV2Snapshot.revision
return (groupThread, checkedRevision)
}
}.then(on: .global()) { (groupThread: TSGroupThread, checkedRevision: UInt32) throws -> Promise<Void> in
if DebugFlags.internalLogging {
Logger.info("Updating profile key for group: \(groupThread.groupId.hexadecimalString), profileKey: \(profileKeyData.hexadecimalString), localUuid: \(localUuid), checkedRevision: \(checkedRevision)")
} else {
Logger.info("Updating profile key for group.")
}
return firstly {
GroupManager.ensureLocalProfileHasCommitmentIfNecessary()
}.then(on: .global()) { () throws -> Promise<Void> in
// Before we can update the group state on the service,
// we need to ensure that the group state in the local
// database reflects the latest group state on the service.
guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else {
owsFailDebug("Invalid group model.")
throw GroupsV2Error.shouldDiscard
}
let dbRevision = groupModel.revision
guard dbRevision != checkedRevision else {
// Revisions match, so we can proceed immediately with
// the profile update.
return Promise.value(())
}
// If the revisions don't match, we want to update the group
// state in the local database before proceeding. It's not
// safe to do so until we've finished message processing,
// but we've already blocked on fetchingAndProcessingCompletePromise
// above.
let groupId = groupThread.groupModel.groupId
let groupSecretParamsData = groupModel.secretParamsData
return Self.groupV2Updates.tryToRefreshV2GroupUpToCurrentRevisionImmediately(groupId: groupId,
groupSecretParamsData: groupSecretParamsData).asVoid()
}.map(on: .global()) { () throws -> GroupsV2OutgoingChanges in
let groupId = groupThread.groupModel.groupId
guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else {
owsFailDebug("Invalid group model.")
throw GroupsV2Error.shouldDiscard
}
let groupSecretParamsData = groupModel.secretParamsData
let changes = GroupsV2OutgoingChangesImpl(groupId: groupId,
groupSecretParamsData: groupSecretParamsData)
changes.setShouldUpdateLocalProfileKey()
return changes
}.then(on: DispatchQueue.global()) { (changes: GroupsV2OutgoingChanges) -> Promise<TSGroupThread> in
self.groupsV2Impl.updateExistingGroupOnService(changes: changes,
requiredRevision: checkedRevision)
}.then(on: .global()) { (groupThread: TSGroupThread) -> Promise<TSGroupThread> in
// Confirm that the updated snapshot has the new profile key.
firstly(on: .global()) { () -> Promise<GroupV2Snapshot> in
guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else {
throw OWSAssertionError("Invalid group model.")
}
return self.groupsV2Impl.fetchCurrentGroupV2Snapshot(groupModel: groupModel)
}.map(on: .global()) { (groupV2Snapshot: GroupV2Snapshot) throws -> Void in
if DebugFlags.internalLogging {
Logger.info("updated revision: \(groupV2Snapshot.revision)")
for (uuid, profileKey) in groupV2Snapshot.profileKeys {
Logger.info("Existing profile key: \(profileKey.hexadecimalString), for uuid: \(uuid), is local: \(uuid == localUuid)")
}
}
guard groupV2Snapshot.groupMembership.isFullMember(localAddress) else {
owsFailDebug("Not a full member.")
return
}
guard groupV2Snapshot.profileKeys.values.contains(profileKeyData) else {
owsFailDebug("Update failed.")
self.databaseStorage.write { transaction in
self.versionedProfiles.clearProfileKeyCredential(for: localAddress,
transaction: transaction)
}
return
}
}.map(on: .global()) { () -> TSGroupThread in
groupThread
}
}.asVoid()
}
}
}