1175 lines
50 KiB
Swift
1175 lines
50 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import PromiseKit
|
|
import ZKGroup
|
|
import SignalClient
|
|
|
|
@objc
|
|
public class GroupsV2Migration: NSObject {
|
|
|
|
private override init() {}
|
|
|
|
// MARK: - Mapping
|
|
|
|
public static func v2GroupId(forV1GroupId v1GroupId: Data) throws -> Data {
|
|
try calculateMigrationMetadata(forV1GroupId: v1GroupId).v2GroupId
|
|
}
|
|
|
|
public static func v2MasterKey(forV1GroupId v1GroupId: Data) throws -> Data {
|
|
try calculateMigrationMetadata(forV1GroupId: v1GroupId).v2MasterKey
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public extension GroupsV2Migration {
|
|
|
|
// MARK: -
|
|
|
|
private static let groupMigrationTimeoutDuration: TimeInterval = 30
|
|
|
|
static func tryManualMigration(groupThread: TSGroupThread) -> Promise<TSGroupThread> {
|
|
Logger.info("request groupId: \(groupThread.groupId.hexadecimalString)")
|
|
guard GroupManager.canManuallyMigrate else {
|
|
return Promise(error: OWSAssertionError("Manual migration not enabled."))
|
|
}
|
|
return firstly {
|
|
self.tryToMigrate(groupThread: groupThread, migrationMode: manualMigrationMode)
|
|
}.timeout(seconds: Self.groupMigrationTimeoutDuration,
|
|
description: "Manual migration") {
|
|
GroupsV2Error.timeout
|
|
}
|
|
}
|
|
|
|
// If there is a v1 group in the database that can be
|
|
// migrated to a v2 group, try to migrate it to a v2
|
|
// group. It might or might not already be migrated on
|
|
// the service.
|
|
static func tryToMigrate(groupThread: TSGroupThread,
|
|
migrationMode: GroupsV2MigrationMode) -> Promise<TSGroupThread> {
|
|
firstly(on: .global()) {
|
|
if migrationMode == .isAlreadyMigratedOnService ||
|
|
migrationMode == .possiblyAlreadyMigratedOnService {
|
|
return true
|
|
}
|
|
|
|
return Self.databaseStorage.read { transaction in
|
|
Self.canGroupBeMigratedByLocalUser(groupThread: groupThread,
|
|
migrationMode: migrationMode,
|
|
transaction: transaction)
|
|
}
|
|
}.then(on: .global()) { (canGroupBeMigrated: Bool) -> Promise<TSGroupThread> in
|
|
guard canGroupBeMigrated else {
|
|
throw GroupsV2Error.groupCannotBeMigrated
|
|
}
|
|
return Self.enqueueMigration(groupId: groupThread.groupModel.groupId,
|
|
migrationMode: migrationMode)
|
|
}
|
|
}
|
|
|
|
// If there is a corresponding v1 group in the local database,
|
|
// update it to reflect the v1 group already on the service.
|
|
static func updateAlreadyMigratedGroupIfNecessary(v2GroupId: Data) -> Promise<Void> {
|
|
firstly(on: .global()) { () -> Promise<Void> in
|
|
guard let groupThread = (Self.databaseStorage.read { transaction in
|
|
TSGroupThread.fetch(groupId: v2GroupId, transaction: transaction)
|
|
}) else {
|
|
// No need to migrate; not in database.
|
|
return Promise.value(())
|
|
}
|
|
guard groupThread.isGroupV1Thread else {
|
|
// No need to migrate; not a v1 group.
|
|
return Promise.value(())
|
|
}
|
|
|
|
return firstly(on: .global()) { () -> Promise<TSGroupThread> in
|
|
Self.enqueueMigration(groupId: v2GroupId,
|
|
migrationMode: .isAlreadyMigratedOnService)
|
|
}.asVoid()
|
|
}
|
|
}
|
|
|
|
@objc
|
|
static func migrationInfoForManualMigration(groupThread: TSGroupThread) -> GroupsV2MigrationInfo {
|
|
databaseStorage.read { transaction in
|
|
migrationInfoForManualMigration(groupThread: groupThread,
|
|
transaction: transaction)
|
|
}
|
|
}
|
|
|
|
// Will return nil if the group cannot be migrated by the local
|
|
// user for any reason.
|
|
static func migrationInfoForManualMigration(groupThread: TSGroupThread,
|
|
transaction: SDSAnyReadTransaction) -> GroupsV2MigrationInfo {
|
|
|
|
guard GroupManager.canManuallyMigrate else {
|
|
return .buildCannotBeMigrated(state: .cantBeMigrated_FeatureNotEnabled)
|
|
}
|
|
return migrationInfo(groupThread: groupThread,
|
|
migrationMode: manualMigrationMode,
|
|
transaction: transaction)
|
|
}
|
|
|
|
private static var manualMigrationMode: GroupsV2MigrationMode {
|
|
owsAssertDebug(GroupManager.canManuallyMigrate)
|
|
|
|
return (GroupManager.areManualMigrationsAggressive
|
|
? .manualMigrationAggressive
|
|
: .manualMigrationPolite)
|
|
}
|
|
|
|
private static var autoMigrationMode: GroupsV2MigrationMode {
|
|
owsAssertDebug(GroupManager.canAutoMigrate)
|
|
|
|
return (GroupManager.areAutoMigrationsAggressive
|
|
? .autoMigrationAggressive
|
|
: .autoMigrationPolite)
|
|
}
|
|
|
|
static func doesUserHaveBothCapabilities(address: SignalServiceAddress,
|
|
transaction: SDSAnyReadTransaction) -> Bool {
|
|
if !GroupManager.doesUserHaveGroupsV2Capability(address: address,
|
|
transaction: transaction) {
|
|
Logger.warn("Member without Groups v2 capability: \(address).")
|
|
return false
|
|
}
|
|
if !GroupManager.doesUserHaveGroupsV2MigrationCapability(address: address,
|
|
transaction: transaction) {
|
|
Logger.warn("Member without migration capability: \(address).")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
static func tryToAutoMigrateAllGroups(shouldLimitBatchSize: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard !DebugFlags.reduceLogChatter else {
|
|
return
|
|
}
|
|
guard FeatureFlags.groupsV2Migrations else {
|
|
return
|
|
}
|
|
guard CurrentAppContext().isMainAppAndActive else {
|
|
// Don't try to migrate groups in app extensions,
|
|
// nor if the app is in the background, e.g.
|
|
// waking up from a VOIP push.
|
|
return
|
|
}
|
|
guard tsAccountManager.isRegisteredAndReady else {
|
|
return
|
|
}
|
|
|
|
DispatchQueue.global().async {
|
|
var groupThreads = [TSGroupThread]()
|
|
Self.databaseStorage.read { transaction in
|
|
TSGroupThread.anyEnumerate(transaction: transaction) { (thread, _) in
|
|
guard let groupThread = thread as? TSGroupThread else {
|
|
return
|
|
}
|
|
guard groupThread.isGroupV1Thread else {
|
|
return
|
|
}
|
|
groupThreads.append(groupThread)
|
|
}
|
|
}
|
|
|
|
var phoneNumbersWithoutUuids = Set<String>()
|
|
for groupThread in groupThreads {
|
|
// We want to fill in missing UUIDs for all members including
|
|
// "dropped" members.
|
|
let groupMembers = (groupThread.groupModel.groupMembership.allMembersOfAnyKind +
|
|
groupThread.groupModel.getDroppedMembers)
|
|
for address in groupMembers {
|
|
guard address.uuid == nil else {
|
|
continue
|
|
}
|
|
guard let phoneNumber = address.phoneNumber else {
|
|
owsFailDebug("Missing phone number.")
|
|
continue
|
|
}
|
|
phoneNumbersWithoutUuids.insert(phoneNumber)
|
|
}
|
|
}
|
|
|
|
let migrationMode: GroupsV2MigrationMode = (GroupManager.canAutoMigrate
|
|
? self.autoMigrationMode
|
|
: .possiblyAlreadyMigratedOnService)
|
|
|
|
// Check up to N groups on every launch.
|
|
let maxCheckCount: Int = 50
|
|
if shouldLimitBatchSize, groupThreads.count > maxCheckCount {
|
|
groupThreads.shuffle()
|
|
groupThreads = Array(groupThreads.prefix(upTo: maxCheckCount))
|
|
}
|
|
|
|
firstly(on: .global()) { () -> Promise<Void> in
|
|
guard !phoneNumbersWithoutUuids.isEmpty else {
|
|
return Promise.value(())
|
|
}
|
|
return ContactDiscoveryTask(phoneNumbers: phoneNumbersWithoutUuids).perform().asVoid()
|
|
}.recover(on: .global()) { (error: Error) -> Promise<Void> in
|
|
owsFailDebugUnlessNetworkFailure(error)
|
|
return Promise.value(())
|
|
}.map(on: .global()) { _ in
|
|
Logger.verbose("")
|
|
|
|
for groupThread in groupThreads {
|
|
firstly {
|
|
Self.tryToMigrate(groupThread: groupThread, migrationMode: migrationMode)
|
|
}.done(on: .global()) { _ in
|
|
Logger.verbose("")
|
|
}.catch(on: .global()) { error in
|
|
if case GroupsV2Error.groupDoesNotExistOnService = error {
|
|
if migrationMode != .possiblyAlreadyMigratedOnService {
|
|
Logger.warn("Error: \(error)")
|
|
}
|
|
} else if case GroupsV2Error.localUserNotInGroup = error {
|
|
if !migrationMode.isAutoMigration {
|
|
Logger.warn("Error: \(error)")
|
|
}
|
|
} else if case GroupsV2Error.groupCannotBeMigrated = error {
|
|
if !migrationMode.isAutoMigration {
|
|
Logger.warn("Error: \(error)")
|
|
}
|
|
} else if case GroupsV2Error.timeout = error {
|
|
Logger.warn("Error: \(error)")
|
|
} else {
|
|
owsFailDebug("Error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}.catch(on: .global()) { error in
|
|
owsFailDebugUnlessNetworkFailure(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(autoMigrateThreadIfNecessary:)
|
|
static func autoMigrateThreadIfNecessary(thread: TSThread) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard FeatureFlags.groupsV2Migrations else {
|
|
return
|
|
}
|
|
guard let groupThread = thread as? TSGroupThread else {
|
|
return
|
|
}
|
|
guard groupThread.isGroupV1Thread else {
|
|
return
|
|
}
|
|
|
|
let migrationMode = (GroupManager.canAutoMigrate
|
|
? self.autoMigrationMode
|
|
: .possiblyAlreadyMigratedOnService)
|
|
firstly {
|
|
return tryToMigrate(groupThread: groupThread, migrationMode: migrationMode)
|
|
}.done(on: .global()) { (_: TSGroupThread) in
|
|
Logger.verbose("")
|
|
}.catch(on: .global()) { error in
|
|
if case GroupsV2Error.groupDoesNotExistOnService = error {
|
|
Logger.warn("Error: \(error)")
|
|
} else if case GroupsV2Error.localUserNotInGroup = error {
|
|
Logger.warn("Error: \(error)")
|
|
} else if case GroupsV2Error.groupCannotBeMigrated = error {
|
|
Logger.info("Error: \(error)")
|
|
} else {
|
|
owsFailDebug("Error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
fileprivate extension GroupsV2Migration {
|
|
public static var verboseLogging: Bool { DebugFlags.internalLogging }
|
|
|
|
private static let migrationQueue: OperationQueue = {
|
|
let operationQueue = OperationQueue()
|
|
operationQueue.name = "GroupsV2MigrationQueue"
|
|
operationQueue.maxConcurrentOperationCount = 1
|
|
return operationQueue
|
|
}()
|
|
|
|
// Ensure only one migration is in flight at a time.
|
|
static func enqueueMigration(groupId: Data,
|
|
migrationMode: GroupsV2MigrationMode) -> Promise<TSGroupThread> {
|
|
if GroupsV2Migration.verboseLogging {
|
|
Logger.info("enqueue groupId: \(groupId.hexadecimalString), migrationMode: \(migrationMode)")
|
|
}
|
|
let operation = MigrateGroupOperation(groupId: groupId, migrationMode: migrationMode)
|
|
migrationQueue.addOperation(operation)
|
|
return operation.promise
|
|
}
|
|
|
|
// groupId might be the v1 or v2 group id.
|
|
static func attemptMigration(groupId: Data,
|
|
migrationMode: GroupsV2MigrationMode) -> Promise<TSGroupThread> {
|
|
|
|
guard tsAccountManager.isRegisteredAndReady else {
|
|
return Promise(error: OWSAssertionError("Not registered."))
|
|
}
|
|
if migrationMode.isAutoMigration {
|
|
guard GroupManager.canAutoMigrate else {
|
|
return Promise(error: OWSAssertionError("Auto migrations not enabled."))
|
|
}
|
|
}
|
|
|
|
return firstly(on: .global()) { () -> Promise<Void> in
|
|
if Self.verboseLogging {
|
|
Logger.info("Step 1: groupId: \(groupId.hexadecimalString), mode: \(migrationMode)")
|
|
}
|
|
return GroupManager.ensureLocalProfileHasCommitmentIfNecessary()
|
|
}.map(on: .global()) { () -> UnmigratedState in
|
|
if Self.verboseLogging {
|
|
Logger.info("Step 2: groupId: \(groupId.hexadecimalString), mode: \(migrationMode)")
|
|
}
|
|
return try Self.loadUnmigratedState(groupId: groupId)
|
|
}.then(on: .global()) { (unmigratedState: UnmigratedState) -> Promise<UnmigratedState> in
|
|
if Self.verboseLogging {
|
|
Logger.info("Step 3: groupId: \(groupId.hexadecimalString), mode: \(migrationMode)")
|
|
let groupName = unmigratedState.groupThread.groupModel.groupName ?? "Unnamed group"
|
|
Logger.verbose("Migrating: \(groupName)")
|
|
}
|
|
|
|
return firstly {
|
|
Self.tryToPrepareMembersForMigration(migrationMode: migrationMode,
|
|
unmigratedState: unmigratedState)
|
|
}.map(on: .global()) {
|
|
unmigratedState
|
|
}
|
|
}.then(on: .global()) { (unmigratedState: UnmigratedState) -> Promise<TSGroupThread> in
|
|
if Self.verboseLogging {
|
|
Logger.info("Step 4: groupId: \(groupId.hexadecimalString), mode: \(migrationMode)")
|
|
}
|
|
|
|
addMigratingGroupId(unmigratedState.migrationMetadata.v1GroupId)
|
|
addMigratingGroupId(unmigratedState.migrationMetadata.v2GroupId)
|
|
|
|
return firstly(on: .global()) { () -> Promise<TSGroupThread> in
|
|
attemptToMigrateByPullingFromService(unmigratedState: unmigratedState,
|
|
migrationMode: migrationMode)
|
|
}.recover(on: .global()) { (error: Error) -> Promise<TSGroupThread> in
|
|
if case GroupsV2Error.groupDoesNotExistOnService = error,
|
|
migrationMode.canMigrateToService {
|
|
if Self.verboseLogging {
|
|
Logger.info("Step 4: groupId: \(groupId.hexadecimalString), mode: \(migrationMode)")
|
|
}
|
|
// If the group is not already on the service, try to
|
|
// migrate by creating on the service.
|
|
return attemptToMigrateByCreatingOnService(unmigratedState: unmigratedState,
|
|
migrationMode: migrationMode)
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// This method tries to fill in missing:
|
|
//
|
|
// * UUIDs.
|
|
// * Capabilities (2).
|
|
// * Profile key credentials.
|
|
static func tryToPrepareMembersForMigration(migrationMode: GroupsV2MigrationMode,
|
|
unmigratedState: UnmigratedState) -> Promise<Void> {
|
|
guard !migrationMode.isOnlyUpdatingIfAlreadyMigrated else {
|
|
// If we're only trying to update the local db to
|
|
// reflect groups that are already migrated, we can
|
|
// skip this step.
|
|
return Promise.value(())
|
|
}
|
|
|
|
let groupMembership = unmigratedState.groupThread.groupModel.groupMembership
|
|
let membersToMigrate = membersToTryToMigrate(groupMembership: groupMembership)
|
|
|
|
return firstly(on: .global()) { () -> Promise<Void> in
|
|
let phoneNumbersWithoutUuids = membersToMigrate.compactMap { (address: SignalServiceAddress) -> String? in
|
|
if address.uuid != nil {
|
|
return nil
|
|
}
|
|
return address.phoneNumber
|
|
}
|
|
guard !phoneNumbersWithoutUuids.isEmpty else {
|
|
return Promise.value(())
|
|
}
|
|
|
|
Logger.info("Trying to fill in missing uuids: \(phoneNumbersWithoutUuids.count)")
|
|
|
|
let discoveryTask = ContactDiscoveryTask(phoneNumbers: Set(phoneNumbersWithoutUuids))
|
|
return firstly {
|
|
discoveryTask.perform().asVoid()
|
|
}.recover(on: .global()) { error -> Promise<Void> in
|
|
owsFailDebugUnlessNetworkFailure(error)
|
|
return Promise.value(())
|
|
}
|
|
}.then(on: .global()) { () -> Promise<Void> in
|
|
var membersToFetchProfiles = Set<SignalServiceAddress>()
|
|
Self.databaseStorage.read { transaction in
|
|
for address in membersToMigrate {
|
|
if !doesUserHaveBothCapabilities(address: address, transaction: transaction) {
|
|
membersToFetchProfiles.insert(address)
|
|
} else if !groupsV2.hasProfileKeyCredential(for: address, transaction: transaction) {
|
|
membersToFetchProfiles.insert(address)
|
|
}
|
|
}
|
|
}
|
|
guard !membersToFetchProfiles.isEmpty else {
|
|
return Promise.value(())
|
|
}
|
|
Logger.info("Fetching profiles: \(membersToFetchProfiles.count)")
|
|
// Profile fetches are rate limited. We don't want to run afoul of those
|
|
// rate limits especially while trying to auto-migrate groups in the
|
|
// background. Therefore we throttle these requests for auto-migrations.
|
|
let profileFetchMode: ProfileFetchMode = (migrationMode.isManualMigration
|
|
? .parallel
|
|
: .serialWithThrottling)
|
|
return fetchProfiles(addresses: Array(membersToFetchProfiles),
|
|
profileFetchMode: profileFetchMode)
|
|
}
|
|
}
|
|
|
|
private enum ProfileFetchMode {
|
|
case serialWithThrottling
|
|
case parallel
|
|
}
|
|
|
|
private static func fetchProfiles(addresses: [SignalServiceAddress],
|
|
profileFetchMode: ProfileFetchMode) -> Promise<Void> {
|
|
func fetchProfilePromise(address: SignalServiceAddress) -> Promise<Void> {
|
|
firstly {
|
|
ProfileFetcherJob.fetchProfilePromise(address: address, ignoreThrottling: false).asVoid()
|
|
}.recover(on: .global()) { error -> Promise<Void> in
|
|
if case ProfileFetchError.throttled = error {
|
|
// Ignore throttling errors.
|
|
return Promise.value(())
|
|
}
|
|
if case ProfileFetchError.missing = error {
|
|
// If a user has no profile, ignore.
|
|
return Promise.value(())
|
|
}
|
|
owsFailDebugUnlessNetworkFailure(error)
|
|
return Promise.value(())
|
|
}
|
|
}
|
|
|
|
switch profileFetchMode {
|
|
case .parallel:
|
|
let promises = addresses.map { fetchProfilePromise(address: $0) }
|
|
return when(fulfilled: promises)
|
|
case .serialWithThrottling:
|
|
guard let firstAddress = addresses.first else {
|
|
// No more profiles to fetch.
|
|
return Promise.value(())
|
|
}
|
|
let remainder = Array(addresses.suffix(from: 1))
|
|
return firstly {
|
|
fetchProfilePromise(address: firstAddress)
|
|
}.then(on: .global()) {
|
|
// We need to throttle these jobs.
|
|
//
|
|
// The profile fetch rate limit is a bucket size of 4320, which
|
|
// refills at a rate of 3 per minute.
|
|
after(seconds: 1.0 / 3.0)
|
|
}.then(on: .global()) {
|
|
// Recurse.
|
|
fetchProfiles(addresses: remainder, profileFetchMode: profileFetchMode)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func attemptToMigrateByPullingFromService(unmigratedState: UnmigratedState,
|
|
migrationMode: GroupsV2MigrationMode) -> Promise<TSGroupThread> {
|
|
|
|
return firstly(on: .global()) { () -> Promise<GroupV2Snapshot> in
|
|
let groupSecretParamsData = unmigratedState.migrationMetadata.v2GroupSecretParams
|
|
return self.groupsV2Impl.fetchCurrentGroupV2Snapshot(groupSecretParamsData: groupSecretParamsData)
|
|
}.recover(on: .global()) { (error: Error) -> Promise<GroupV2Snapshot> in
|
|
if case GroupsV2Error.groupDoesNotExistOnService = error {
|
|
// Convert error if the group is not already on the service.
|
|
throw GroupsV2Error.groupDoesNotExistOnService
|
|
} else if case GroupsV2Error.localUserNotInGroup = error {
|
|
databaseStorage.write { transaction in
|
|
let groupId = unmigratedState.migrationMetadata.v1GroupId
|
|
GroupManager.handleNotInGroup(groupId: groupId, transaction: transaction)
|
|
}
|
|
throw error
|
|
} else {
|
|
throw error
|
|
}
|
|
}.then(on: .global()) { (snapshot: GroupV2Snapshot) throws -> Promise<TSGroupThread> in
|
|
self.migrateGroupUsingSnapshot(unmigratedState: unmigratedState,
|
|
groupV2Snapshot: snapshot)
|
|
}.timeout(seconds: GroupManager.groupUpdateTimeoutDuration,
|
|
description: "Migrate group from service") {
|
|
GroupsV2Error.timeout
|
|
}
|
|
}
|
|
|
|
static func migrateGroupUsingSnapshot(unmigratedState: UnmigratedState,
|
|
groupV2Snapshot: GroupV2Snapshot) -> Promise<TSGroupThread> {
|
|
let localProfileKey = profileManager.localProfileKey()
|
|
guard let localUuid = tsAccountManager.localUuid else {
|
|
return Promise(error: OWSAssertionError("Missing localUuid."))
|
|
}
|
|
|
|
return firstly(on: .global()) { () -> TSGroupModelV2 in
|
|
try self.databaseStorage.write { transaction in
|
|
let builder = try TSGroupModelBuilder.builderForSnapshot(groupV2Snapshot: groupV2Snapshot,
|
|
transaction: transaction)
|
|
return try builder.buildAsV2(transaction: transaction)
|
|
}
|
|
}.then(on: .global()) { (newGroupModelV2: TSGroupModelV2) throws -> Promise<TSGroupThread> in
|
|
let newDisappearingMessageToken = groupV2Snapshot.disappearingMessageToken
|
|
// groupUpdateSourceAddress is nil because we don't know the
|
|
// author(s) of changes reflected in the snapshot.
|
|
let groupUpdateSourceAddress: SignalServiceAddress? = nil
|
|
|
|
return GroupManager.replaceMigratedGroup(groupIdV1: unmigratedState.groupThread.groupModel.groupId,
|
|
groupModelV2: newGroupModelV2,
|
|
disappearingMessageToken: newDisappearingMessageToken,
|
|
groupUpdateSourceAddress: groupUpdateSourceAddress,
|
|
shouldSendMessage: false)
|
|
}.map(on: .global()) { (groupThread: TSGroupThread) throws -> TSGroupThread in
|
|
GroupManager.storeProfileKeysFromGroupProtos(groupV2Snapshot.profileKeys)
|
|
|
|
// If the group state includes a stale profile key for the
|
|
// local user, schedule an update to fix that.
|
|
if let profileKey = groupV2Snapshot.profileKeys[localUuid],
|
|
profileKey != localProfileKey.keyData {
|
|
self.databaseStorage.write { transaction in
|
|
self.groupsV2.updateLocalProfileKeyInGroup(groupId: groupThread.groupModel.groupId,
|
|
transaction: transaction)
|
|
}
|
|
}
|
|
|
|
Logger.info("Group migrated using snapshot service")
|
|
|
|
return groupThread
|
|
}
|
|
}
|
|
|
|
static func attemptToMigrateByCreatingOnService(unmigratedState: UnmigratedState,
|
|
migrationMode: GroupsV2MigrationMode) -> Promise<TSGroupThread> {
|
|
|
|
Logger.info("migrationMode: \(migrationMode)")
|
|
|
|
return firstly(on: .global()) { () -> Promise<TSGroupThread> in
|
|
let groupThread = unmigratedState.groupThread
|
|
guard groupThread.isLocalUserFullMember else {
|
|
throw OWSAssertionError("Local user cannot migrate group; is not a full member.")
|
|
}
|
|
let membersToMigrate = membersToTryToMigrate(groupMembership: groupThread.groupMembership)
|
|
|
|
return firstly(on: .global()) { () -> Promise<Void> in
|
|
GroupManager.tryToEnableGroupsV2(for: Array(membersToMigrate), isBlocking: true, ignoreErrors: true)
|
|
}.then(on: .global()) { () throws -> Promise<Void> in
|
|
self.groupsV2Impl.tryToEnsureProfileKeyCredentials(for: Array(membersToMigrate),
|
|
ignoreMissingProfiles: true)
|
|
}.then(on: .global()) { () throws -> Promise<String?> in
|
|
guard let avatarData = unmigratedState.groupThread.groupModel.groupAvatarData else {
|
|
// No avatar to upload.
|
|
return Promise.value(nil)
|
|
}
|
|
// Upload avatar.
|
|
return firstly(on: .global()) { () -> Promise<String> in
|
|
return self.groupsV2Impl.uploadGroupAvatar(avatarData: avatarData,
|
|
groupSecretParamsData: unmigratedState.migrationMetadata.v2GroupSecretParams)
|
|
}.map(on: .global()) { (avatarUrlPath: String) -> String? in
|
|
return avatarUrlPath
|
|
}
|
|
}.map(on: .global()) { (avatarUrlPath: String?) throws -> TSGroupModelV2 in
|
|
try databaseStorage.read { transaction in
|
|
try Self.deriveMigratedGroupModel(unmigratedState: unmigratedState,
|
|
avatarUrlPath: avatarUrlPath,
|
|
migrationMode: migrationMode,
|
|
transaction: transaction)
|
|
}
|
|
}.then(on: .global()) { (proposedGroupModel: TSGroupModelV2) -> Promise<TSGroupModelV2> in
|
|
Self.migrateGroupOnService(proposedGroupModel: proposedGroupModel,
|
|
disappearingMessageToken: unmigratedState.disappearingMessageToken)
|
|
}.then(on: .global()) { (groupModelV2: TSGroupModelV2) -> Promise<TSGroupThread> in
|
|
guard let localAddress = tsAccountManager.localAddress else {
|
|
throw OWSAssertionError("Missing localAddress.")
|
|
}
|
|
return GroupManager.replaceMigratedGroup(groupIdV1: unmigratedState.groupThread.groupModel.groupId,
|
|
groupModelV2: groupModelV2,
|
|
disappearingMessageToken: unmigratedState.disappearingMessageToken,
|
|
groupUpdateSourceAddress: localAddress,
|
|
shouldSendMessage: true)
|
|
}.map(on: .global()) { (groupThread: TSGroupThread) -> TSGroupThread in
|
|
self.profileManager.addThread(toProfileWhitelist: groupThread)
|
|
|
|
Logger.info("Group migrated to service")
|
|
|
|
return groupThread
|
|
}.timeout(seconds: GroupManager.groupUpdateTimeoutDuration,
|
|
description: "Migrate group") {
|
|
GroupsV2Error.timeout
|
|
}
|
|
}
|
|
}
|
|
|
|
static func deriveMigratedGroupModel(unmigratedState: UnmigratedState,
|
|
avatarUrlPath: String?,
|
|
migrationMode: GroupsV2MigrationMode,
|
|
transaction: SDSAnyReadTransaction) throws -> TSGroupModelV2 {
|
|
|
|
guard let localAddress = tsAccountManager.localAddress else {
|
|
throw OWSAssertionError("Missing localAddress.")
|
|
}
|
|
guard let localUuid = localAddress.uuid else {
|
|
throw OWSAssertionError("Missing localUuid.")
|
|
}
|
|
let v1GroupThread = unmigratedState.groupThread
|
|
let v1GroupModel = v1GroupThread.groupModel
|
|
guard v1GroupModel.groupsVersion == .V1 else {
|
|
throw OWSAssertionError("Invalid group version: \(v1GroupModel.groupsVersion.rawValue).")
|
|
}
|
|
let migrationMetadata = unmigratedState.migrationMetadata
|
|
let v2GroupId = migrationMetadata.v2GroupId
|
|
let v2GroupSecretParams = migrationMetadata.v2GroupSecretParams
|
|
|
|
var groupModelBuilder = v1GroupModel.asBuilder
|
|
|
|
groupModelBuilder.groupId = v2GroupId
|
|
groupModelBuilder.groupAccess = GroupAccess.defaultForV2
|
|
groupModelBuilder.groupsVersion = .V2
|
|
groupModelBuilder.groupV2Revision = 0
|
|
groupModelBuilder.groupSecretParamsData = v2GroupSecretParams
|
|
groupModelBuilder.newGroupSeed = nil
|
|
groupModelBuilder.isPlaceholderModel = false
|
|
|
|
// We should either have both avatarData and avatarUrlPath or neither.
|
|
if let avatarData = v1GroupModel.groupAvatarData,
|
|
let avatarUrlPath = avatarUrlPath {
|
|
groupModelBuilder.avatarData = avatarData
|
|
groupModelBuilder.avatarUrlPath = avatarUrlPath
|
|
} else {
|
|
owsAssertDebug(v1GroupModel.groupAvatarData == nil)
|
|
owsAssertDebug(avatarUrlPath == nil)
|
|
groupModelBuilder.avatarData = nil
|
|
groupModelBuilder.avatarUrlPath = nil
|
|
}
|
|
|
|
// Build member list.
|
|
//
|
|
// The group creator is an administrator;
|
|
// the other members are normal users.
|
|
var v2MembershipBuilder = GroupMembership.Builder()
|
|
let membersToMigrate = membersToTryToMigrate(groupMembership: v1GroupModel.groupMembership)
|
|
for address in membersToMigrate {
|
|
if DebugFlags.groupsV2migrationsDropOtherMembers.get(),
|
|
!address.isLocalAddress {
|
|
Logger.warn("Dropping non-local user.")
|
|
continue
|
|
}
|
|
|
|
guard address.uuid != nil else {
|
|
Logger.warn("Member missing uuid: \(address).")
|
|
owsAssertDebug(migrationMode.canSkipMembersWithoutUuids)
|
|
Logger.warn("Discarding member: \(address).")
|
|
continue
|
|
}
|
|
|
|
if !doesUserHaveBothCapabilities(address: address, transaction: transaction) {
|
|
owsAssertDebug(migrationMode.canSkipMembersWithoutCapabilities)
|
|
continue
|
|
}
|
|
|
|
var isInvited = false
|
|
if DebugFlags.groupsV2migrationsInviteOtherMembers.get() {
|
|
Logger.warn("Inviting user with unknown profile key: \(address).")
|
|
isInvited = true
|
|
} else if !groupsV2.hasProfileKeyCredential(for: address, transaction: transaction) {
|
|
Logger.warn("Inviting user with unknown profile key: \(address).")
|
|
owsAssertDebug(migrationMode.canInviteMembersWithoutProfileKey)
|
|
isInvited = true
|
|
}
|
|
|
|
// All migrated members become admins.
|
|
let role: TSGroupMemberRole = .administrator
|
|
|
|
if isInvited {
|
|
v2MembershipBuilder.addInvitedMember(address, role: role, addedByUuid: localUuid)
|
|
} else {
|
|
v2MembershipBuilder.addFullMember(address, role: role)
|
|
}
|
|
}
|
|
|
|
v2MembershipBuilder.remove(localAddress)
|
|
v2MembershipBuilder.addFullMember(localAddress, role: .administrator)
|
|
groupModelBuilder.groupMembership = v2MembershipBuilder.build()
|
|
|
|
groupModelBuilder.addedByAddress = nil
|
|
|
|
return try groupModelBuilder.buildAsV2(transaction: transaction)
|
|
}
|
|
|
|
static func migrateGroupOnService(proposedGroupModel: TSGroupModelV2,
|
|
disappearingMessageToken: DisappearingMessageToken) -> Promise<TSGroupModelV2> {
|
|
return firstly {
|
|
self.groupsV2Impl.createNewGroupOnService(groupModel: proposedGroupModel,
|
|
disappearingMessageToken: disappearingMessageToken)
|
|
}.then(on: .global()) { _ in
|
|
self.groupsV2Impl.fetchCurrentGroupV2Snapshot(groupModel: proposedGroupModel)
|
|
}.map(on: .global()) { (groupV2Snapshot: GroupV2Snapshot) throws -> TSGroupModelV2 in
|
|
let createdGroupModel = try self.databaseStorage.write { (transaction) throws -> TSGroupModelV2 in
|
|
let builder = try TSGroupModelBuilder.builderForSnapshot(groupV2Snapshot: groupV2Snapshot,
|
|
transaction: transaction)
|
|
return try builder.buildAsV2(transaction: transaction)
|
|
}
|
|
if proposedGroupModel != createdGroupModel {
|
|
Logger.verbose("proposedGroupModel: \(proposedGroupModel.debugDescription)")
|
|
Logger.verbose("createdGroupModel: \(createdGroupModel.debugDescription)")
|
|
if DebugFlags.groupsV2ignoreCorruptInvites {
|
|
Logger.warn("Proposed group model does not match created group model.")
|
|
} else {
|
|
owsFailDebug("Proposed group model does not match created group model.")
|
|
}
|
|
}
|
|
return createdGroupModel
|
|
}
|
|
}
|
|
|
|
static func canGroupBeMigratedByLocalUser(groupThread: TSGroupThread,
|
|
migrationMode: GroupsV2MigrationMode,
|
|
transaction: SDSAnyReadTransaction) -> Bool {
|
|
migrationInfo(groupThread: groupThread,
|
|
migrationMode: migrationMode,
|
|
transaction: transaction).canGroupBeMigrated
|
|
}
|
|
|
|
// This method might be called for any group (v1 or v2).
|
|
// It returns a description of whether the group can be
|
|
// migrated, and if so under what conditions.
|
|
//
|
|
// Will return nil if the group cannot be migrated by the local
|
|
// user for any reason.
|
|
static func migrationInfo(groupThread: TSGroupThread,
|
|
migrationMode: GroupsV2MigrationMode,
|
|
transaction: SDSAnyReadTransaction) -> GroupsV2MigrationInfo {
|
|
|
|
guard groupThread.isGroupV1Thread else {
|
|
return .buildCannotBeMigrated(state: .cantBeMigrated_NotAV1Group)
|
|
}
|
|
let isLocalUserFullMember = groupThread.isLocalUserFullMember
|
|
|
|
guard let localAddress = tsAccountManager.localAddress else {
|
|
owsFailDebug("Missing localAddress.")
|
|
return .buildCannotBeMigrated(state: .cantBeMigrated_NotRegistered)
|
|
}
|
|
|
|
let isGroupInProfileWhitelist = profileManager.isThread(inProfileWhitelist: groupThread,
|
|
transaction: transaction)
|
|
|
|
let groupMembership = groupThread.groupModel.groupMembership
|
|
let membersToMigrate = membersToTryToMigrate(groupMembership: groupMembership)
|
|
|
|
// Inspect member list.
|
|
//
|
|
// The group creator is an administrator;
|
|
// the other members are normal users.
|
|
var membersWithoutUuids = [SignalServiceAddress]()
|
|
var membersWithoutCapabilities = [SignalServiceAddress]()
|
|
var membersWithoutProfileKeys = [SignalServiceAddress]()
|
|
var membersMigrated = [SignalServiceAddress]()
|
|
for address in membersToMigrate {
|
|
if address.isEqualToAddress(localAddress) {
|
|
continue
|
|
}
|
|
|
|
if DebugFlags.groupsV2migrationsDropOtherMembers.get() {
|
|
Logger.warn("Dropping non-local user.")
|
|
membersWithoutCapabilities.append(address)
|
|
continue
|
|
}
|
|
|
|
guard nil != address.uuid else {
|
|
Logger.warn("Member without uuid: \(address).")
|
|
membersWithoutUuids.append(address)
|
|
continue
|
|
}
|
|
|
|
if !doesUserHaveBothCapabilities(address: address, transaction: transaction) {
|
|
membersWithoutCapabilities.append(address)
|
|
continue
|
|
}
|
|
|
|
membersMigrated.append(address)
|
|
|
|
if DebugFlags.groupsV2migrationsInviteOtherMembers.get() ||
|
|
!groupsV2.hasProfileKeyCredential(for: address, transaction: transaction) {
|
|
Logger.warn("Member without profile key: \(address).")
|
|
membersWithoutProfileKeys.append(address)
|
|
continue
|
|
}
|
|
}
|
|
|
|
let hasTooManyMembers = membersMigrated.count > GroupManager.groupsV2MaxGroupSizeHardLimit
|
|
|
|
let state: GroupsV2MigrationState = {
|
|
if !migrationMode.canMigrateIfNotMember,
|
|
!isLocalUserFullMember {
|
|
return .cantBeMigrated_LocalUserIsNotAMember
|
|
}
|
|
if !migrationMode.canMigrateIfNotInProfileWhitelist,
|
|
!isGroupInProfileWhitelist {
|
|
return .cantBeMigrated_NotInProfileWhitelist
|
|
}
|
|
if !migrationMode.canSkipMembersWithoutUuids,
|
|
!membersWithoutUuids.isEmpty {
|
|
return .cantBeMigrated_MembersWithoutUuids
|
|
}
|
|
if !migrationMode.canSkipMembersWithoutCapabilities,
|
|
!membersWithoutCapabilities.isEmpty {
|
|
return .cantBeMigrated_MembersWithoutCapabilities
|
|
}
|
|
if !migrationMode.canInviteMembersWithoutProfileKey,
|
|
!membersWithoutProfileKeys.isEmpty {
|
|
return .cantBeMigrated_MembersWithoutProfileKey
|
|
}
|
|
if !migrationMode.canMigrateWithTooManyMembers,
|
|
hasTooManyMembers {
|
|
return .cantBeMigrated_TooManyMembers
|
|
}
|
|
return .canBeMigrated
|
|
}()
|
|
|
|
Logger.info("Can the group be migrated?: \(state)")
|
|
|
|
return GroupsV2MigrationInfo(isGroupInProfileWhitelist: isGroupInProfileWhitelist,
|
|
membersWithoutUuids: membersWithoutUuids,
|
|
membersWithoutCapabilities: membersWithoutCapabilities,
|
|
membersWithoutProfileKeys: membersWithoutProfileKeys,
|
|
state: state)
|
|
}
|
|
|
|
static func membersToTryToMigrate(groupMembership: GroupMembership) -> Set<SignalServiceAddress> {
|
|
|
|
let allMembers = groupMembership.allMembersOfAnyKind
|
|
let addressesWithoutUuids = Array(allMembers).filter { $0.uuid == nil }
|
|
let knownUndiscoverable = Set(ContactDiscoveryTask.addressesRecentlyMarkedAsUndiscoverableForGroupMigrations(addressesWithoutUuids))
|
|
|
|
var result = Set<SignalServiceAddress>()
|
|
for address in allMembers {
|
|
if nil == address.uuid, knownUndiscoverable.contains(address) {
|
|
Logger.warn("Ignoring unregistered member without uuid: \(address).")
|
|
continue
|
|
}
|
|
result.insert(address)
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public enum GroupsV2MigrationState {
|
|
case canBeMigrated
|
|
case cantBeMigrated_FeatureNotEnabled
|
|
case cantBeMigrated_NotAV1Group
|
|
case cantBeMigrated_NotRegistered
|
|
case cantBeMigrated_LocalUserIsNotAMember
|
|
case cantBeMigrated_NotInProfileWhitelist
|
|
case cantBeMigrated_TooManyMembers
|
|
case cantBeMigrated_MembersWithoutUuids
|
|
case cantBeMigrated_MembersWithoutCapabilities
|
|
case cantBeMigrated_MembersWithoutProfileKey
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
@objc
|
|
public class GroupsV2MigrationInfo: NSObject {
|
|
// These properties only have valid values if canGroupBeMigrated is true.
|
|
public let isGroupInProfileWhitelist: Bool
|
|
public let membersWithoutUuids: [SignalServiceAddress]
|
|
public let membersWithoutCapabilities: [SignalServiceAddress]
|
|
public let membersWithoutProfileKeys: [SignalServiceAddress]
|
|
|
|
// Always consult this property first.
|
|
public let state: GroupsV2MigrationState
|
|
|
|
fileprivate init(isGroupInProfileWhitelist: Bool,
|
|
membersWithoutUuids: [SignalServiceAddress],
|
|
membersWithoutCapabilities: [SignalServiceAddress],
|
|
membersWithoutProfileKeys: [SignalServiceAddress],
|
|
state: GroupsV2MigrationState) {
|
|
self.isGroupInProfileWhitelist = isGroupInProfileWhitelist
|
|
self.membersWithoutUuids = membersWithoutUuids
|
|
self.membersWithoutCapabilities = membersWithoutCapabilities
|
|
self.membersWithoutProfileKeys = membersWithoutProfileKeys
|
|
self.state = state
|
|
}
|
|
|
|
@objc
|
|
public var canGroupBeMigrated: Bool {
|
|
state == .canBeMigrated
|
|
}
|
|
|
|
fileprivate static func buildCannotBeMigrated(state: GroupsV2MigrationState) -> GroupsV2MigrationInfo {
|
|
GroupsV2MigrationInfo(isGroupInProfileWhitelist: false,
|
|
membersWithoutUuids: [],
|
|
membersWithoutCapabilities: [],
|
|
membersWithoutProfileKeys: [],
|
|
state: state)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public enum GroupsV2MigrationMode: String {
|
|
// Manual migration; only available if all users can be
|
|
// added (but not invited).
|
|
case manualMigrationPolite
|
|
// Manual migration; only available if all users can be
|
|
// migrated.
|
|
case manualMigrationAggressive
|
|
// Auto migration; only available if all users can be
|
|
// added.
|
|
case autoMigrationPolite
|
|
// Auto migration; only available if all users can be
|
|
// migrated.
|
|
case autoMigrationAggressive
|
|
// When an incoming message (including sync messages)
|
|
// or storage service update indicates that a group
|
|
// has been migrated to the service, we should update
|
|
// the local DB immediately to reflect the group state
|
|
// on the service.
|
|
case isAlreadyMigratedOnService
|
|
// We need to check periodically if the group has
|
|
// already been migrated to the service by another client.
|
|
// If so, we should update the local DB immediately to
|
|
// reflect the group state on the service.
|
|
case possiblyAlreadyMigratedOnService
|
|
|
|
fileprivate var isManualMigration: Bool {
|
|
self == .manualMigrationPolite || self == .manualMigrationAggressive
|
|
}
|
|
|
|
fileprivate var isAutoMigration: Bool {
|
|
self == .autoMigrationPolite || self == .autoMigrationAggressive
|
|
}
|
|
|
|
fileprivate var isPolite: Bool {
|
|
self == .manualMigrationPolite || self == .autoMigrationPolite
|
|
}
|
|
|
|
fileprivate var isAggressive: Bool {
|
|
self == .manualMigrationAggressive || self == .autoMigrationAggressive
|
|
}
|
|
|
|
fileprivate var isOnlyUpdatingIfAlreadyMigrated: Bool {
|
|
switch self {
|
|
case .isAlreadyMigratedOnService,
|
|
.possiblyAlreadyMigratedOnService:
|
|
return true
|
|
case .manualMigrationPolite,
|
|
.manualMigrationAggressive,
|
|
.autoMigrationPolite,
|
|
.autoMigrationAggressive:
|
|
return false
|
|
}
|
|
}
|
|
|
|
public var canSkipMembersWithoutUuids: Bool {
|
|
isAggressive || isOnlyUpdatingIfAlreadyMigrated
|
|
}
|
|
|
|
public var canSkipMembersWithoutCapabilities: Bool {
|
|
isAggressive || isOnlyUpdatingIfAlreadyMigrated
|
|
}
|
|
|
|
public var canInviteMembersWithoutProfileKey: Bool {
|
|
isManualMigration || isAggressive || isOnlyUpdatingIfAlreadyMigrated
|
|
}
|
|
|
|
public var canMigrateIfNotInProfileWhitelist: Bool {
|
|
isOnlyUpdatingIfAlreadyMigrated
|
|
}
|
|
|
|
public var canMigrateToService: Bool {
|
|
!isOnlyUpdatingIfAlreadyMigrated
|
|
}
|
|
|
|
public var canMigrateIfNotMember: Bool {
|
|
isOnlyUpdatingIfAlreadyMigrated
|
|
}
|
|
|
|
public var canMigrateWithTooManyMembers: Bool {
|
|
isOnlyUpdatingIfAlreadyMigrated
|
|
}
|
|
|
|
fileprivate var queuePriority: Operation.QueuePriority {
|
|
switch self {
|
|
case .isAlreadyMigratedOnService:
|
|
// These migrations block message processing and have high priority.
|
|
return .high
|
|
case .possiblyAlreadyMigratedOnService:
|
|
// These migrations reflect view state and have higher priority than
|
|
// auto-migrations.
|
|
return .normal
|
|
case .manualMigrationPolite,
|
|
.manualMigrationAggressive:
|
|
// Manual migrations block the UI and have the highest priority.
|
|
return .veryHigh
|
|
case .autoMigrationPolite,
|
|
.autoMigrationAggressive:
|
|
return .low
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension GroupsV2Migration {
|
|
|
|
// MARK: - Migrating Group Ids
|
|
|
|
// We track migrating group ids for usage in asserts.
|
|
private static let unfairLock = UnfairLock()
|
|
private static var migratingV2GroupIds = Set<Data>()
|
|
|
|
private static func addMigratingGroupId(_ groupId: Data) {
|
|
_ = unfairLock.withLock {
|
|
migratingV2GroupIds.insert(groupId)
|
|
}
|
|
}
|
|
|
|
public static func isMigratingV2GroupId(_ groupId: Data) -> Bool {
|
|
unfairLock.withLock {
|
|
migratingV2GroupIds.contains(groupId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
fileprivate extension GroupsV2Migration {
|
|
|
|
// MARK: - Mapping
|
|
|
|
static func gv2MasterKey(forV1GroupId v1GroupId: Data) throws -> Data {
|
|
guard GroupManager.isValidGroupId(v1GroupId, groupsVersion: .V1) else {
|
|
throw OWSAssertionError("Invalid v1 group id.")
|
|
}
|
|
let migrationInfo = "GV2 Migration"
|
|
let masterKey = try migrationInfo.utf8.withContiguousStorageIfAvailable {
|
|
try hkdf(outputLength: GroupMasterKey.SIZE, version: 3, inputKeyMaterial: v1GroupId, salt: [], info: $0)
|
|
}!
|
|
return Data(masterKey)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
struct MigrationMetadata {
|
|
let v1GroupId: Data
|
|
let v2GroupId: Data
|
|
let v2MasterKey: Data
|
|
let v2GroupSecretParams: Data
|
|
}
|
|
|
|
private static func calculateMigrationMetadata(forV1GroupId v1GroupId: Data) throws -> MigrationMetadata {
|
|
guard GroupManager.isValidGroupId(v1GroupId, groupsVersion: .V1) else {
|
|
throw OWSAssertionError("Invalid group id: \(v1GroupId.hexadecimalString).")
|
|
}
|
|
let masterKey = try gv2MasterKey(forV1GroupId: v1GroupId)
|
|
let v2GroupSecretParams = try groupsV2Impl.groupSecretParamsData(forMasterKeyData: masterKey)
|
|
let v2GroupId = try groupsV2.groupId(forGroupSecretParamsData: v2GroupSecretParams)
|
|
return MigrationMetadata(v1GroupId: v1GroupId,
|
|
v2GroupId: v2GroupId,
|
|
v2MasterKey: masterKey,
|
|
v2GroupSecretParams: v2GroupSecretParams)
|
|
}
|
|
|
|
private static func calculateMigrationMetadata(for v1GroupModel: TSGroupModel) throws -> MigrationMetadata {
|
|
guard v1GroupModel.groupsVersion == .V1 else {
|
|
throw OWSAssertionError("Invalid group version: \(v1GroupModel.groupsVersion.rawValue).")
|
|
}
|
|
return try calculateMigrationMetadata(forV1GroupId: v1GroupModel.groupId)
|
|
}
|
|
|
|
struct UnmigratedState {
|
|
let groupThread: TSGroupThread
|
|
let disappearingMessagesConfiguration: OWSDisappearingMessagesConfiguration
|
|
let migrationMetadata: MigrationMetadata
|
|
|
|
var disappearingMessageToken: DisappearingMessageToken {
|
|
disappearingMessagesConfiguration.asToken
|
|
}
|
|
}
|
|
|
|
private static func loadUnmigratedState(groupId: Data) throws -> UnmigratedState {
|
|
try databaseStorage.read { transaction in
|
|
guard let groupThread = TSGroupThread.fetch(groupId: groupId, transaction: transaction) else {
|
|
throw OWSAssertionError("Missing group thread.")
|
|
}
|
|
guard groupThread.groupModel.groupsVersion == .V1 else {
|
|
// This can happen due to races, but should be very rare.
|
|
throw OWSGenericError("Unexpected groupsVersion.")
|
|
}
|
|
let disappearingMessagesConfiguration = groupThread.disappearingMessagesConfiguration(with: transaction)
|
|
let migrationMetadata = try Self.calculateMigrationMetadata(for: groupThread.groupModel)
|
|
|
|
return UnmigratedState(groupThread: groupThread,
|
|
disappearingMessagesConfiguration: disappearingMessagesConfiguration,
|
|
migrationMetadata: migrationMetadata)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private class MigrateGroupOperation: OWSOperation {
|
|
|
|
private let groupId: Data
|
|
private let migrationMode: GroupsV2MigrationMode
|
|
|
|
fileprivate let promise: Promise<TSGroupThread>
|
|
fileprivate let resolver: Resolver<TSGroupThread>
|
|
|
|
fileprivate required init(groupId: Data,
|
|
migrationMode: GroupsV2MigrationMode) {
|
|
self.groupId = groupId
|
|
self.migrationMode = migrationMode
|
|
|
|
let (promise, resolver) = Promise<TSGroupThread>.pending()
|
|
self.promise = promise
|
|
self.resolver = resolver
|
|
|
|
super.init()
|
|
|
|
self.queuePriority = migrationMode.queuePriority
|
|
}
|
|
|
|
public override func run() {
|
|
let groupId = self.groupId
|
|
let migrationMode = self.migrationMode
|
|
if GroupsV2Migration.verboseLogging {
|
|
Logger.info("start groupId: \(groupId.hexadecimalString), migrationMode: \(migrationMode)")
|
|
}
|
|
|
|
firstly(on: .global()) {
|
|
GroupsV2Migration.attemptMigration(groupId: groupId,
|
|
migrationMode: migrationMode)
|
|
}.done(on: .global()) { groupThread in
|
|
if GroupsV2Migration.verboseLogging {
|
|
Logger.info("success groupId: \(groupId.hexadecimalString), migrationMode: \(migrationMode)")
|
|
}
|
|
self.reportSuccess()
|
|
self.resolver.fulfill(groupThread)
|
|
}.catch(on: .global()) { error in
|
|
if GroupsV2Migration.verboseLogging {
|
|
Logger.info("failure groupId: \(groupId.hexadecimalString), migrationMode: \(migrationMode), error: \(error)")
|
|
}
|
|
self.reportError(error.asUnretryableError)
|
|
self.resolver.reject(error)
|
|
}
|
|
}
|
|
}
|