Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Backups/Archiving/Archivers/Recipient/BackupArchiveGroupRecipientArchiver.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
import LibSignalClient

/// Archives ``TSGroupThread``s as ``BackupProto_Group`` recipients.
///
/// This is a bit confusing, because ``TSThread`` mostly corresponds to
/// ``BackupProto_Chat``, and there will in fact _also_ be a chat for the group
/// thread. Its just that our group thread contains all the metadata
/// corresponding to both the Chat and Recipient parts of the Backup proto.
public class BackupArchiveGroupRecipientArchiver: BackupArchiveProtoStreamWriter {
    typealias GroupId = BackupArchive.GroupId
    typealias RecipientId = BackupArchive.RecipientId
    typealias RecipientAppId = BackupArchive.RecipientArchivingContext.Address

    typealias ArchiveMultiFrameResult = BackupArchive.ArchiveMultiFrameResult<RecipientAppId>
    private typealias ArchiveFrameError = BackupArchive.ArchiveFrameError<RecipientAppId>

    typealias RestoreFrameResult = BackupArchive.RestoreFrameResult<RecipientId>
    private typealias RestoreFrameError = BackupArchive.RestoreFrameError<RecipientId>

    private let avatarDefaultColorManager: AvatarDefaultColorManager
    private let avatarFetcher: BackupArchiveAvatarFetcher
    private let blockingManager: BackupArchive.Shims.BlockingManager
    private let disappearingMessageConfigStore: DisappearingMessagesConfigurationStore
    private let groupsV2: GroupsV2
    private let profileManager: BackupArchive.Shims.ProfileManager
    private let storyStore: BackupArchiveStoryStore
    private let threadStore: BackupArchiveThreadStore

    public init(
        avatarDefaultColorManager: AvatarDefaultColorManager,
        avatarFetcher: BackupArchiveAvatarFetcher,
        blockingManager: BackupArchive.Shims.BlockingManager,
        disappearingMessageConfigStore: DisappearingMessagesConfigurationStore,
        groupsV2: GroupsV2,
        profileManager: BackupArchive.Shims.ProfileManager,
        storyStore: BackupArchiveStoryStore,
        threadStore: BackupArchiveThreadStore,
    ) {
        self.avatarDefaultColorManager = avatarDefaultColorManager
        self.avatarFetcher = avatarFetcher
        self.blockingManager = blockingManager
        self.disappearingMessageConfigStore = disappearingMessageConfigStore
        self.groupsV2 = groupsV2
        self.profileManager = profileManager
        self.storyStore = storyStore
        self.threadStore = threadStore
    }

    func archiveAllGroupRecipients(
        stream: BackupArchiveProtoOutputStream,
        context: BackupArchive.RecipientArchivingContext,
    ) throws(CancellationError) -> ArchiveMultiFrameResult {
        var errors = [ArchiveFrameError]()

        let blockedGroupIds = Set(blockingManager.blockedGroupIds(tx: context.tx))

        do {
            try context.bencher.wrapEnumeration(
                threadStore.enumerateGroupThreads(tx:block:),
                tx: context.tx,
            ) { groupThread, frameBencher in
                try Task.checkCancellation()
                autoreleasepool {
                    self.archiveGroupThread(
                        groupThread,
                        blockedGroupIds: blockedGroupIds,
                        stream: stream,
                        frameBencher: frameBencher,
                        context: context,
                        errors: &errors,
                    )
                }

                return true
            }
        } catch let error as CancellationError {
            throw error
        } catch {
            // The enumeration of threads failed, not the processing of one single thread.
            return .completeFailure(.fatalArchiveError(.threadIteratorError(error)))
        }

        if errors.isEmpty {
            return .success
        } else {
            return .partialSuccess(errors)
        }
    }

    private func archiveGroupThread(
        _ groupThread: TSGroupThread,
        blockedGroupIds: Set<Data>,
        stream: BackupArchiveProtoOutputStream,
        frameBencher: BackupArchive.Bencher.FrameBencher,
        context: BackupArchive.RecipientArchivingContext,
        errors: inout [ArchiveFrameError],
    ) {
        guard let groupModel = groupThread.groupModel as? TSGroupModelV2 else {
            return
        }

        let groupId = GroupId(groupModel: groupModel)
        let groupMembership = groupModel.groupMembership

        let groupAppId: RecipientAppId = .group(groupId)

        let groupMasterKey: Data
        do {
            let groupSecretParams = try GroupSecretParams(contents: groupModel.secretParamsData)
            groupMasterKey = try groupSecretParams.getMasterKey().serialize()
        } catch {
            errors.append(.archiveFrameError(.groupMasterKeyError(error), groupAppId))
            return
        }

        var group = BackupProto_Group()
        group.masterKey = groupMasterKey
        group.whitelisted = profileManager.isGroupId(
            inProfileWhitelist: groupId.value,
            tx: context.tx,
        )
        group.blocked = blockedGroupIds.contains(groupId.value)
        do {
            group.hideStory = try storyStore.getOrCreateStoryContextAssociatedData(
                for: groupThread,
                context: context,
            ).isHidden
        } catch let error {
            errors.append(.archiveFrameError(.unableToReadStoryContextAssociatedData(error), groupAppId))
        }
        group.storySendMode = { () -> BackupProto_Group.StorySendMode in
            switch groupThread.storyViewMode {
            case .disabled: return .disabled
            case .explicit, .blockList: return .enabled
            case .default: return .default
            }
        }()
        group.avatarColor = avatarDefaultColorManager.defaultColor(
            useCase: .group(groupId: groupId.value),
            tx: context.tx,
        ).asBackupProtoAvatarColor
        group.snapshot = { () -> BackupProto_Group.GroupSnapshot in
            var groupSnapshot = BackupProto_Group.GroupSnapshot()
            groupSnapshot.avatarURL = groupModel.avatarUrlPath ?? ""
            groupSnapshot.version = groupModel.revision
            groupSnapshot.inviteLinkPassword = groupModel.inviteLinkPassword ?? Data()
            groupSnapshot.announcementsOnly = groupModel.isAnnouncementsOnly
            if let groupName = groupModel.groupName?.nilIfEmpty {
                groupSnapshot.title = .buildTitle(groupName)
            }
            if let groupDescription = groupModel.descriptionText?.nilIfEmpty {
                groupSnapshot.description_p = .buildDescriptionText(groupDescription)
            }
            if
                let dmConfiguration = disappearingMessageConfigStore.fetch(for: .thread(groupThread), tx: context.tx),
                dmConfiguration.isEnabled
            {
                let durationSeconds = dmConfiguration.durationSeconds
                groupSnapshot.disappearingMessagesTimer = .buildDisappearingMessageTimer(durationSeconds)
            }
            groupSnapshot.accessControl = groupModel.access.asBackupProtoAccessControl
            groupSnapshot.members = groupMembership.fullMembers.compactMap { address -> BackupProto_Group.Member? in
                guard
                    let aci = address.aci,
                    let role = groupMembership.role(for: address)
                else {
                    errors.append(.archiveFrameError(.missingRequiredGroupMemberParams, groupAppId))
                    return nil
                }

                return .build(serviceId: aci, role: role, memberLabel: groupMembership.memberLabel(for: aci))
            }
            groupSnapshot.membersPendingProfileKey = groupMembership.invitedMembers.compactMap { address -> BackupProto_Group.MemberPendingProfileKey? in
                guard
                    let serviceId = address.serviceId,
                    let role = groupMembership.role(for: address),
                    let addedByAci = groupMembership.addedByAci(forInvitedMember: address)
                else {
                    errors.append(.archiveFrameError(.missingRequiredGroupMemberParams, groupAppId))
                    return nil
                }

                // iOS doesn't track the timestamp of the invite, so we'll
                // default-populate it.
                var invitedMemberProto = BackupProto_Group.MemberPendingProfileKey()
                invitedMemberProto.addedByUserID = addedByAci.serviceIdBinary
                invitedMemberProto.timestamp = 0
                invitedMemberProto.member = .build(
                    serviceId: serviceId,
                    role: role,
                    memberLabel: nil,
                )
                return invitedMemberProto
            }
            groupSnapshot.membersPendingAdminApproval = groupMembership.requestingMembers.compactMap { address -> BackupProto_Group.MemberPendingAdminApproval? in
                guard let aci = address.aci else {
                    errors.append(.archiveFrameError(.missingRequiredGroupMemberParams, groupAppId))
                    return nil
                }

                // iOS doesn't track the timestamp of the request, so we'll
                // default-populate it.
                var memberPendingAdminApproval = BackupProto_Group.MemberPendingAdminApproval()
                memberPendingAdminApproval.userID = aci.serviceIdBinary
                memberPendingAdminApproval.timestamp = 0

                return memberPendingAdminApproval
            }
            groupSnapshot.membersBanned = groupMembership.bannedMembers.map { aci, bannedAtMillis -> BackupProto_Group.MemberBanned in
                var memberBanned = BackupProto_Group.MemberBanned()
                memberBanned.userID = aci.serviceIdBinary
                memberBanned.timestamp = bannedAtMillis

                return memberBanned
            }

            groupSnapshot.terminated = groupModel.isTerminated

            return groupSnapshot
        }()

        Self.writeFrameToStream(
            stream,
            objectId: groupAppId,
            frameBencher: frameBencher,
            frameBuilder: {
                var recipient = BackupProto_Recipient()
                let recipientId = context.assignRecipientId(to: groupAppId)
                recipient.id = recipientId.value
                recipient.destination = .group(group)

                var frame = BackupProto_Frame()
                frame.item = .recipient(recipient)
                return frame
            },
        ).map { errors.append($0) }
    }

    func restoreGroupRecipientProto(
        _ groupProto: BackupProto_Group,
        recipient: BackupProto_Recipient,
        context: BackupArchive.RecipientRestoringContext,
    ) -> RestoreFrameResult {
        func restoreFrameError(
            _ error: RestoreFrameError.ErrorType,
            line: UInt = #line,
        ) -> RestoreFrameResult {
            return .failure([.restoreFrameError(error, recipient.recipientId, line: line)])
        }

        // MARK: Assemble the group model

        let groupContextInfo: GroupV2ContextInfo
        do {
            groupContextInfo = try GroupV2ContextInfo.deriveFrom(masterKeyData: groupProto.masterKey)
        } catch {
            return restoreFrameError(.invalidProtoData(.invalidGV2MasterKey))
        }

        guard groupProto.hasSnapshot else {
            return restoreFrameError(.invalidProtoData(.missingGV2GroupSnapshot))
        }
        let groupSnapshot = groupProto.snapshot

        var groupMembershipBuilder = GroupMembership.Builder()
        var fullGroupMemberAcis = Set<Aci>()
        for fullMember in groupSnapshot.members {
            guard let aci = try? Aci.parseFrom(serviceIdBinary: fullMember.userID) else {
                return restoreFrameError(.invalidProtoData(.invalidAci(protoClass: BackupProto_Group.Member.self)))
            }
            let role = TSGroupMemberRole(backupProtoRole: fullMember.role)

            groupMembershipBuilder.addFullMember(aci, role: role)
            fullGroupMemberAcis.insert(aci)
        }
        for invitedMember in groupSnapshot.membersPendingProfileKey {
            guard invitedMember.hasMember else {
                return restoreFrameError(.invalidProtoData(.invitedGV2MemberMissingMemberDetails))
            }
            let memberDetails = invitedMember.member
            guard let serviceId = try? ServiceId.parseFrom(serviceIdBinary: memberDetails.userID) else {
                return restoreFrameError(.invalidProtoData(.invalidServiceId(protoClass: BackupProto_Group.MemberPendingProfileKey.self)))
            }
            let role = TSGroupMemberRole(backupProtoRole: memberDetails.role)
            guard let addedByAci = try? Aci.parseFrom(serviceIdBinary: invitedMember.addedByUserID) else {
                return restoreFrameError(.invalidProtoData(.invalidAci(protoClass: BackupProto_Group.MemberPendingProfileKey.self)))
            }

            groupMembershipBuilder.addInvitedMember(
                serviceId,
                role: role,
                addedByAci: addedByAci,
            )
        }
        for requestingMember in groupSnapshot.membersPendingAdminApproval {
            guard let aci = try? Aci.parseFrom(serviceIdBinary: requestingMember.userID) else {
                return restoreFrameError(.invalidProtoData(.invalidAci(protoClass: BackupProto_Group.MemberPendingAdminApproval.self)))
            }

            groupMembershipBuilder.addRequestingMember(aci)
        }
        for bannedMember in groupSnapshot.membersBanned {
            guard let aci = try? Aci.parseFrom(serviceIdBinary: bannedMember.userID) else {
                return restoreFrameError(.invalidProtoData(.invalidAci(protoClass: BackupProto_Group.MemberBanned.self)))
            }
            let bannedAtTimestampMillis = bannedMember.timestamp

            groupMembershipBuilder.addBannedMember(
                aci,
                bannedAtTimestamp: bannedAtTimestampMillis,
            )
        }

        var partialErrors = [BackupArchive.RestoreFrameError<RecipientId>]()

        for member in groupSnapshot.members {
            guard let aci = try? Aci.parseFrom(serviceIdBinary: member.userID) else {
                return restoreFrameError(.invalidProtoData(.invalidAci(protoClass: BackupProto_Group.Member.self)))
            }
            if !member.labelString.isEmpty {
                guard
                    member.labelString.lengthOfBytes(using: .utf8) <= 96,
                    member.labelString.count <= 24,
                    member.labelEmoji.lengthOfBytes(using: .utf8) <= 48
                else {
                    partialErrors.append(.restoreFrameError(.invalidProtoData(.invalidMemberLabel), recipient.recipientId))
                    continue
                }
                let emoji: String? = member.labelEmoji.isEmpty ? nil : member.labelEmoji
                groupMembershipBuilder.setMemberLabel(label: MemberLabel(label: member.labelString, labelEmoji: emoji), aci: aci)
            }
        }

        var groupModelBuilder = TSGroupModelBuilder(secretParams: groupContextInfo.groupSecretParams)
        groupModelBuilder.groupV2Revision = groupSnapshot.version
        groupModelBuilder.name = groupSnapshot.extractTitle
        groupModelBuilder.descriptionText = groupSnapshot.extractDescriptionText
        // We'll try and download the avatar later. For now, leave it explicitly missing.
        groupModelBuilder.avatarDataState = .missing
        groupModelBuilder.avatarUrlPath = groupSnapshot.avatarURL.nilIfEmpty
        groupModelBuilder.groupMembership = groupMembershipBuilder.build()
        groupModelBuilder.groupAccess = GroupAccess(backupProtoAccessControl: groupSnapshot.accessControl)
        groupModelBuilder.inviteLinkPassword = groupSnapshot.inviteLinkPassword.nilIfEmpty
        groupModelBuilder.isAnnouncementsOnly = groupSnapshot.announcementsOnly

        if RemoteConfig.current.groupTerminateReceiveEnabled {
            groupModelBuilder.isTerminated = groupSnapshot.terminated
        }

        guard let groupModel: TSGroupModelV2 = try? groupModelBuilder.buildAsV2() else {
            return restoreFrameError(.invalidProtoData(.failedToBuildGV2GroupModel))
        }

        // MARK: Use the group model to create a group thread

        let isStorySendEnabled: Bool? = {
            switch groupProto.storySendMode {
            case .default, .UNRECOGNIZED:
                // No explicit setting.
                return nil
            case .disabled:
                return false
            case .enabled:
                return true
            }
        }()

        let groupThread: TSGroupThread
        do {
            groupThread = try threadStore.createGroupThread(
                groupModel: groupModel,
                isStorySendEnabled: isStorySendEnabled,
                context: context,
            )
        } catch let error {
            return restoreFrameError(.databaseInsertionFailed(error))
        }

        // MARK: Store group properties that live outside the group model

        do {
            try threadStore.insertFullGroupMemberRecords(
                acis: fullGroupMemberAcis,
                groupThread: groupThread,
                context: context,
            )
        } catch let error {
            return restoreFrameError(.databaseInsertionFailed(error))
        }

        if let disappearingMessageTimer = groupSnapshot.extractDisappearingMessageTimer {
            disappearingMessageConfigStore.set(
                token: .token(
                    forProtoExpireTimerSeconds: disappearingMessageTimer,
                ),
                for: groupThread,
                tx: context.tx,
            )
        }

        if groupProto.whitelisted {
            profileManager.addToWhitelist(groupThread, tx: context.tx)
        }

        if groupProto.blocked {
            blockingManager.addBlockedGroupId(groupContextInfo.groupId.serialize(), tx: context.tx)
        }

        if
            groupProto.hasAvatarColor,
            let defaultAvatarColor: AvatarTheme = .from(backupProtoAvatarColor: groupProto.avatarColor)
        {
            do {
                try avatarDefaultColorManager.persistDefaultColor(
                    defaultAvatarColor,
                    groupId: groupContextInfo.groupId.serialize(),
                    tx: context.tx,
                )
            } catch {
                // Don't fail entirely; colors aren't that important.
                partialErrors.append(.restoreFrameError(.databaseInsertionFailed(error), recipient.recipientId))
            }
        }

        if groupProto.hideStory {
            // We only need to actively hide, since unhidden is the default.
            do {
                try storyStore.createStoryContextAssociatedData(
                    for: groupThread,
                    isHidden: true,
                    context: context,
                )
            } catch let error {
                // Don't fail entirely; the story will just be unhidden.
                partialErrors.append(.restoreFrameError(.databaseInsertionFailed(error), recipient.recipientId))
            }
        }

        // MARK: Return successfully!

        let groupId = GroupId(groupModel: groupModel)
        context[recipient.recipientId] = .group(groupId)
        context[groupId] = groupThread

        if partialErrors.isEmpty {
            return .success
        } else {
            return .partialRestore(partialErrors)
        }
    }
}

// MARK: -

private extension Aes256Key {
    /// Is this profile key comprised of all-zeroes?
    ///
    /// It's possible that other clients may not have a persisted profile key
    /// for a user, and consequently when they build the group snapshot to put
    /// in a backup they'll be unable to populate the profile key for some
    /// members. In those cases, they'll put all-zero data for the profile key
    /// as a sentinel value, and we should not persist it.
    var isAllZeroes: Bool {
        return keyData.allSatisfy { $0 == 0 }
    }
}

// MARK: -

private extension BackupProto_Group.GroupAttributeBlob {
    static func buildTitle(_ title: String) -> BackupProto_Group.GroupAttributeBlob {
        var blob = BackupProto_Group.GroupAttributeBlob()
        blob.content = .title(title)
        return blob
    }

    static func buildDescriptionText(_ descriptionText: String) -> BackupProto_Group.GroupAttributeBlob {
        var blob = BackupProto_Group.GroupAttributeBlob()
        blob.content = .descriptionText(descriptionText)
        return blob
    }

    static func buildDisappearingMessageTimer(_ disappearingMessageDuration: UInt32) -> BackupProto_Group.GroupAttributeBlob {
        var blob = BackupProto_Group.GroupAttributeBlob()
        blob.content = .disappearingMessagesDuration(disappearingMessageDuration)
        return blob
    }
}

private extension BackupProto_Group.GroupSnapshot {
    var extractTitle: String? {
        switch title.content {
        case .title(let title): return title
        case nil, .avatar, .descriptionText, .disappearingMessagesDuration: return nil
        }
    }

    var extractDescriptionText: String? {
        switch description_p.content {
        case .descriptionText(let descriptionText): return descriptionText
        case nil, .title, .avatar, .disappearingMessagesDuration: return nil
        }
    }

    var extractDisappearingMessageTimer: UInt32? {
        switch disappearingMessagesTimer.content {
        case .disappearingMessagesDuration(let disappearingMessageDuration): return disappearingMessageDuration
        case nil, .title, .avatar, .descriptionText: return nil
        }
    }
}

// MARK: -

private extension BackupProto_Group.Member {
    static func build(
        serviceId: ServiceId,
        role: TSGroupMemberRole,
        memberLabel: MemberLabel?,
    ) -> BackupProto_Group.Member {
        // iOS doesn't track the joinedAtRevision, so we'll default-populate it.
        var member = BackupProto_Group.Member()
        member.userID = serviceId.serviceIdBinary
        member.role = role.asBackupProtoRole
        member.joinedAtVersion = 0
        if let labelString = memberLabel?.label {
            member.labelString = labelString
        }
        if let labelEmoji = memberLabel?.labelEmoji {
            member.labelEmoji = labelEmoji
        }
        return member
    }
}

// MARK: -

private extension TSGroupMemberRole {
    init(backupProtoRole: BackupProto_Group.Member.Role) {
        switch backupProtoRole {
        case .unknown, .UNRECOGNIZED:
            // Fallback to normal (default)
            self = .normal
        case .default: self = .normal
        case .administrator: self = .administrator
        }
    }

    var asBackupProtoRole: BackupProto_Group.Member.Role {
        switch self {
        case .normal: return .default
        case .administrator: return .administrator
        }
    }
}

// MARK: -

private extension GroupV2Access {
    init(backupProtoAccessRequired: BackupProto_Group.AccessControl.AccessRequired) {
        switch backupProtoAccessRequired {
        case .unknown, .UNRECOGNIZED: self = .unknown
        case .any: self = .any
        case .member: self = .member
        case .administrator: self = .administrator
        case .unsatisfiable: self = .unsatisfiable
        }
    }

    var asBackupProtoAccessRequired: BackupProto_Group.AccessControl.AccessRequired {
        switch self {
        case .unknown: return .unknown
        case .any: return .any
        case .member: return .member
        case .administrator: return .administrator
        case .unsatisfiable: return .unsatisfiable
        }
    }
}

private extension GroupAccess {
    convenience init(backupProtoAccessControl: BackupProto_Group.AccessControl) {
        self.init(
            members: GroupV2Access(backupProtoAccessRequired: backupProtoAccessControl.members),
            attributes: GroupV2Access(backupProtoAccessRequired: backupProtoAccessControl.attributes),
            addFromInviteLink: GroupV2Access(backupProtoAccessRequired: backupProtoAccessControl.addFromInviteLink),
            memberLabels: GroupV2Access(backupProtoAccessRequired: backupProtoAccessControl.memberLabel),
        )
    }

    var asBackupProtoAccessControl: BackupProto_Group.AccessControl {
        var accessControl = BackupProto_Group.AccessControl()
        accessControl.attributes = attributes.asBackupProtoAccessRequired
        accessControl.members = members.asBackupProtoAccessRequired
        accessControl.addFromInviteLink = addFromInviteLink.asBackupProtoAccessRequired
        accessControl.memberLabel = memberLabels.asBackupProtoAccessRequired
        return accessControl
    }
}