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

import CryptoKit
import Foundation
public import LibSignalClient

@objc
public final class TSGroupModelV2: TSGroupModel {
    override public class var supportsSecureCoding: Bool { true }

    public required init?(coder: NSCoder) {
        self.access = coder.decodeObject(of: GroupAccess.self, forKey: "access") ?? .defaultForV2
        self.avatarDataFailedToFetchFromCDN = coder.decodeObject(of: NSNumber.self, forKey: "avatarDataFailedToFetchFromCDN")?.boolValue ?? false
        self.avatarUrlPath = coder.decodeObject(of: NSString.self, forKey: "avatarUrlPath") as String?
        self.descriptionText = coder.decodeObject(of: NSString.self, forKey: "descriptionText") as String?
        self.didJustAddSelfViaGroupLink = coder.decodeObject(of: NSNumber.self, forKey: "didJustAddSelfViaGroupLink")?.boolValue ?? false
        self.inviteLinkPassword = coder.decodeObject(of: NSData.self, forKey: "inviteLinkPassword") as Data?
        self.isAnnouncementsOnly = coder.decodeObject(of: NSNumber.self, forKey: "isAnnouncementsOnly")?.boolValue ?? false
        self.isJoinRequestPlaceholder = coder.decodeObject(of: NSNumber.self, forKey: "isPlaceholderModel")?.boolValue ?? false
        self.lowTrustAvatarDownloadWasBlocked = coder.decodeObject(of: NSNumber.self, forKey: "lowTrustAvatarDownloadWasBlocked")?.boolValue ?? false
        self.membership = coder.decodeObject(of: GroupMembership.self, forKey: "membership") ?? .empty
        self.revision = coder.decodeObject(of: NSNumber.self, forKey: "revision")?.uint32Value ?? 0
        self.secretParamsData = coder.decodeObject(of: NSData.self, forKey: "secretParamsData") as Data? ?? Data()
        self.wasJustMigrated = coder.decodeObject(of: NSNumber.self, forKey: "wasJustMigrated")?.boolValue ?? false
        self.isTerminated = coder.decodeObject(of: NSNumber.self, forKey: "isTerminated")?.boolValue ?? false
        super.init(coder: coder)
    }

    override public func encode(with coder: NSCoder) {
        super.encode(with: coder)
        coder.encode(self.access, forKey: "access")
        coder.encode(NSNumber(value: self.avatarDataFailedToFetchFromCDN), forKey: "avatarDataFailedToFetchFromCDN")
        if let avatarUrlPath {
            coder.encode(avatarUrlPath, forKey: "avatarUrlPath")
        }
        if let descriptionText {
            coder.encode(descriptionText, forKey: "descriptionText")
        }
        coder.encode(NSNumber(value: self.didJustAddSelfViaGroupLink), forKey: "didJustAddSelfViaGroupLink")
        if let inviteLinkPassword {
            coder.encode(inviteLinkPassword, forKey: "inviteLinkPassword")
        }
        coder.encode(NSNumber(value: self.isAnnouncementsOnly), forKey: "isAnnouncementsOnly")
        coder.encode(NSNumber(value: self.isJoinRequestPlaceholder), forKey: "isPlaceholderModel")
        coder.encode(NSNumber(value: self.lowTrustAvatarDownloadWasBlocked), forKey: "lowTrustAvatarDownloadWasBlocked")
        coder.encode(self.membership, forKey: "membership")
        coder.encode(NSNumber(value: self.revision), forKey: "revision")
        coder.encode(self.secretParamsData, forKey: "secretParamsData")
        coder.encode(NSNumber(value: self.wasJustMigrated), forKey: "wasJustMigrated")
        coder.encode(NSNumber(value: self.isTerminated), forKey: "isTerminated")
    }

    override public var hash: Int {
        var hasher = Hasher()
        hasher.combine(super.hash)
        hasher.combine(access)
        hasher.combine(avatarDataFailedToFetchFromCDN)
        hasher.combine(avatarUrlPath)
        hasher.combine(descriptionText)
        hasher.combine(didJustAddSelfViaGroupLink)
        hasher.combine(inviteLinkPassword)
        hasher.combine(isAnnouncementsOnly)
        hasher.combine(isJoinRequestPlaceholder)
        hasher.combine(lowTrustAvatarDownloadWasBlocked)
        hasher.combine(membership)
        hasher.combine(revision)
        hasher.combine(secretParamsData)
        hasher.combine(wasJustMigrated)
        hasher.combine(isTerminated)
        return hasher.finalize()
    }

    override public func isEqual(_ object: Any?) -> Bool {
        guard let object = object as? Self else { return false }
        guard super.isEqual(object) else { return false }
        guard self.access == object.access else { return false }
        guard self.avatarDataFailedToFetchFromCDN == object.avatarDataFailedToFetchFromCDN else { return false }
        guard self.avatarUrlPath == object.avatarUrlPath else { return false }
        guard self.descriptionText == object.descriptionText else { return false }
        guard self.didJustAddSelfViaGroupLink == object.didJustAddSelfViaGroupLink else { return false }
        guard self.inviteLinkPassword == object.inviteLinkPassword else { return false }
        guard self.isAnnouncementsOnly == object.isAnnouncementsOnly else { return false }
        guard self.isJoinRequestPlaceholder == object.isJoinRequestPlaceholder else { return false }
        guard self.lowTrustAvatarDownloadWasBlocked == object.lowTrustAvatarDownloadWasBlocked else { return false }
        guard self.membership == object.membership else { return false }
        guard self.revision == object.revision else { return false }
        guard self.secretParamsData == object.secretParamsData else { return false }
        guard self.wasJustMigrated == object.wasJustMigrated else { return false }
        guard self.isTerminated == object.isTerminated else { return false }
        return true
    }

    override public func copy(with zone: NSZone? = nil) -> Any {
        let result = super.copy(with: zone) as! Self
        result.access = self.access
        result.avatarDataFailedToFetchFromCDN = self.avatarDataFailedToFetchFromCDN
        result.avatarUrlPath = self.avatarUrlPath
        result.descriptionText = self.descriptionText
        result.didJustAddSelfViaGroupLink = self.didJustAddSelfViaGroupLink
        result.inviteLinkPassword = self.inviteLinkPassword
        result.isAnnouncementsOnly = self.isAnnouncementsOnly
        result.isJoinRequestPlaceholder = self.isJoinRequestPlaceholder
        result.lowTrustAvatarDownloadWasBlocked = self.lowTrustAvatarDownloadWasBlocked
        result.membership = self.membership
        result.revision = self.revision
        result.secretParamsData = self.secretParamsData
        result.wasJustMigrated = self.wasJustMigrated
        result.isTerminated = self.isTerminated
        return result
    }

    var membership: GroupMembership
    public var access: GroupAccess
    public var secretParamsData: Data
    public var revision: UInt32
    public var avatarUrlPath: String?
    public var inviteLinkPassword: Data?
    public var isAnnouncementsOnly: Bool
    public var descriptionText: String?
    public private(set) var isTerminated: Bool

    /// Whether this group model is a placeholder for a group we've requested to
    /// join, but don't yet have access to on the service. Other fields on this
    /// group model may not be populated.
    public var isJoinRequestPlaceholder: Bool
    public var wasJustMigrated: Bool
    public var didJustAddSelfViaGroupLink: Bool

    public var avatarDataFailedToFetchFromCDN: Bool = false
    public var lowTrustAvatarDownloadWasBlocked: Bool = false

    public init(
        groupId: Data,
        name: String?,
        descriptionText: String?,
        avatarDataState: AvatarDataState,
        groupMembership: GroupMembership,
        groupAccess: GroupAccess,
        revision: UInt32,
        secretParamsData: Data,
        avatarUrlPath: String?,
        inviteLinkPassword: Data?,
        isAnnouncementsOnly: Bool,
        isJoinRequestPlaceholder: Bool,
        wasJustMigrated: Bool,
        didJustAddSelfViaGroupLink: Bool,
        addedByAddress: SignalServiceAddress?,
        isTerminated: Bool,
    ) {
        self.descriptionText = descriptionText
        self.membership = groupMembership
        self.secretParamsData = secretParamsData
        self.access = groupAccess
        self.revision = revision
        self.avatarUrlPath = avatarUrlPath
        self.inviteLinkPassword = inviteLinkPassword
        self.isAnnouncementsOnly = isAnnouncementsOnly
        self.isJoinRequestPlaceholder = isJoinRequestPlaceholder
        self.wasJustMigrated = wasJustMigrated
        self.didJustAddSelfViaGroupLink = didJustAddSelfViaGroupLink
        self.isTerminated = isTerminated

        let avatarData: Data?
        switch avatarDataState {
        case .available(let _avatarData):
            avatarData = _avatarData
        case .missing, .skipped:
            avatarData = nil
        case .failedToFetchFromCDN:
            avatarData = nil
            avatarDataFailedToFetchFromCDN = true
        case .lowTrustDownloadWasBlocked:
            avatarData = nil
            lowTrustAvatarDownloadWasBlocked = true
        }

        super.init(
            groupId: groupId,
            name: name,
            avatarData: avatarData,
            members: [],
            addedBy: addedByAddress,
        )
    }

    public func secretParams() throws -> GroupSecretParams {
        return try GroupSecretParams(contents: self.secretParamsData)
    }

    public func masterKey() throws -> GroupMasterKey {
        return try secretParams().getMasterKey()
    }

    public func groupInviteLinkUrl() throws -> URL {
        guard let inviteLinkPassword, !inviteLinkPassword.isEmpty else {
            throw OWSAssertionError("Missing password.")
        }
        let masterKey = try self.masterKey()

        var contentsV1Builder = GroupsProtoGroupInviteLinkGroupInviteLinkContentsV1.builder()
        contentsV1Builder.setGroupMasterKey(masterKey.serialize())
        contentsV1Builder.setInviteLinkPassword(inviteLinkPassword)

        var builder = GroupsProtoGroupInviteLink.builder()
        builder.setContents(GroupsProtoGroupInviteLinkOneOfContents.contentsV1(contentsV1Builder.buildInfallibly()))
        let protoData = try builder.buildSerializedData()

        let protoBase64Url = protoData.asBase64Url

        let urlString = "https://signal.group/#\(protoBase64Url)"
        guard let url = URL(string: urlString) else {
            throw OWSAssertionError("Could not construct url.")
        }
        return url
    }

    // MARK: -

    @objc
    override public var groupsVersion: GroupsVersion {
        return .V2
    }

    @objc
    override public var groupMembership: GroupMembership {
        return membership
    }

    @objc
    override public var groupMembers: [SignalServiceAddress] {
        return Array(groupMembership.fullMembers)
    }

    public func showInfoMessageForChangeComparedTo(
        to otherGroupModel: TSGroupModelV2,
    ) -> Bool {
        if self === otherGroupModel {
            return false
        }

        let avatarChangeRequiresInfoMessage: Bool
        if avatarHash == otherGroupModel.avatarHash {
            avatarChangeRequiresInfoMessage = false
        } else if
            otherGroupModel.lowTrustAvatarDownloadWasBlocked,
            !self.lowTrustAvatarDownloadWasBlocked
        {
            // Avatar unblurred. No info message needed
            avatarChangeRequiresInfoMessage = false
        } else {
            avatarChangeRequiresInfoMessage = true
        }

        let membershipChangeRequiresInfoMessage = membership.showInfoMessageForChangeComparedTo(to: otherGroupModel.membership)

        guard
            groupName == otherGroupModel.groupName,
            !avatarChangeRequiresInfoMessage,
            addedByAddress == otherGroupModel.addedByAddress,
            descriptionText == otherGroupModel.descriptionText,
            !membershipChangeRequiresInfoMessage,
            access == otherGroupModel.access,
            isAnnouncementsOnly == otherGroupModel.isAnnouncementsOnly,
            inviteLinkPassword == otherGroupModel.inviteLinkPassword,
            isTerminated == otherGroupModel.isTerminated
        else {
            return true
        }

        return false
    }

    @objc
    override public var debugDescription: String {
        var result = "["
        result += "groupId: \(groupId.hexadecimalString),\n"
        result += "groupsVersion: \(groupsVersion),\n"
        result += "groupName: \(String(describing: groupName)),\n"
        result += "avatarHash: \(String(describing: avatarHash)),\n"
        result += "membership: \(groupMembership.debugDescription),\n"
        result += "access: \(access.debugDescription),\n"
        result += "secretParamsData: \(secretParamsData.hexadecimalString.prefix(32)),\n"
        result += "revision: \(revision),\n"
        result += "avatarUrlPath: \(String(describing: avatarUrlPath)),\n"
        result += "inviteLinkPassword: \(inviteLinkPassword?.hexadecimalString ?? "None"),\n"
        result += "isAnnouncementsOnly: \(isAnnouncementsOnly),\n"
        result += "addedByAddress: \(addedByAddress?.debugDescription ?? "None"),\n"
        result += "isJoinRequestPlaceholder: \(isJoinRequestPlaceholder),\n"
        result += "wasJustMigrated: \(wasJustMigrated),\n"
        result += "didJustAddSelfViaGroupLink: \(didJustAddSelfViaGroupLink),\n"
        result += "descriptionText: \(String(describing: descriptionText)),\n"
        result += "isTerminated: \(isTerminated),\n"
        result += "]"
        return result
    }

    // MARK: -

    @objc
    public var groupInviteLinkMode: GroupsV2LinkMode {
        guard
            let inviteLinkPassword,
            !inviteLinkPassword.isEmpty
        else {
            return .disabled
        }

        switch access.addFromInviteLink {
        case .any:
            return .enabledWithoutApproval
        case .administrator:
            return .enabledWithApproval
        default:
            return .disabled
        }
    }

    @objc
    public var isGroupInviteLinkEnabled: Bool {
        if
            let inviteLinkPassword,
            !inviteLinkPassword.isEmpty,
            access.canJoinFromInviteLink
        {
            return true
        }
        return false
    }
}

// MARK: -

extension TSGroupModel {
    @objc
    public var isPlaceholder: Bool {
        guard let groupModelV2 = self as? TSGroupModelV2 else {
            return false
        }
        return groupModelV2.isJoinRequestPlaceholder
    }

    @objc
    public var wasJustMigratedToV2: Bool {
        guard let groupModelV2 = self as? TSGroupModelV2 else {
            return false
        }
        return groupModelV2.wasJustMigrated
    }

    @objc
    public var didJustAddSelfViaGroupLinkV2: Bool {
        guard let groupModelV2 = self as? TSGroupModelV2 else {
            return false
        }
        return groupModelV2.didJustAddSelfViaGroupLink
    }

    // MARK: -

    private static let avatarsCache = LRUCache<String, Data>(maxSize: 16, nseMaxSize: 0)

    @objc
    public func persistAvatarData(_ data: Data) throws {
        guard !data.isEmpty else {
            self.avatarHash = nil
            return
        }

        guard Self.isValidGroupAvatarData(data) else {
            throw OWSAssertionError("Invalid group avatar")
        }

        let hash = try Self.hash(forAvatarData: data)

        OWSFileSystem.ensureDirectoryExists(Self.avatarsDirectory.path)

        let filePath = Self.avatarFilePath(forHash: hash)
        guard !OWSFileSystem.fileOrFolderExists(url: filePath) else {
            // Avatar is already persisted.
            self.avatarHash = hash
            return
        }

        try data.write(to: filePath)
        Self.avatarsCache.set(key: hash, value: data)

        // Note: Old avatars are explicitly not cleaned up from the file
        // system at this point, as multiple instances of a group model
        // may be floating around referencing different versions of
        // the avatar. We only purge old avatars from the file system
        // when orphan data cleaner deems it safe to do so.

        self.avatarHash = hash
    }

    private static let kMaxAvatarDimension = 1024

    public static func isValidGroupAvatarData(_ imageData: Data) -> Bool {
        guard imageData.count <= kMaxAvatarSize else {
            return false
        }
        guard let metadata = DataImageSource(imageData).imageMetadata() else {
            return false
        }
        return
            metadata.pixelSize.height <= CGFloat(kMaxAvatarDimension)
                && metadata.pixelSize.width <= CGFloat(kMaxAvatarDimension)

    }

    public static func dataForGroupAvatar(_ image: UIImage) -> Data? {
        var image = image

        // First, resize the image if necessary
        if image.pixelWidth > kMaxAvatarDimension || image.pixelHeight > kMaxAvatarDimension {
            let thumbnailSizePixels = min(kMaxAvatarDimension, min(image.pixelWidth, image.pixelHeight))
            image = image.resizedImage(toFillPixelSize: CGSize(width: thumbnailSizePixels, height: thumbnailSizePixels))
        }
        if image.pixelWidth > kMaxAvatarDimension || image.pixelHeight > kMaxAvatarDimension {
            owsFailDebug("Could not resize group avatar.")
            return nil
        }

        // Then, convert the image to jpeg. Try to use 0.6 compression quality, but we'll ratchet down if the
        // image is still too large.
        let kMaxQuality = 0.6 as CGFloat
        for targetQuality in stride(from: kMaxQuality, through: 0, by: -0.1) {
            let avatarData = image.jpegData(compressionQuality: targetQuality)

            guard let avatarData else {
                owsFailDebug("Failed to generate jpeg representation with quality \(targetQuality)")
                return nil
            }

            if avatarData.count <= kMaxAvatarSize {
                guard isValidGroupAvatarData(avatarData) else {
                    owsFailDebug("Invalid image")
                    return nil
                }
                return avatarData
            }
        }
        owsFailDebug("All quality levels produced an avatar that was too large")
        return nil
    }

    // MARK: -

    public enum AvatarDataState {
        case available(Data)
        case missing
        case failedToFetchFromCDN
        case lowTrustDownloadWasBlocked
        case skipped

        init(avatarData: Data?) {
            if let avatarData {
                self = .available(avatarData)
            } else {
                self = .missing
            }
        }

        public var dataIfPresent: Data? {
            switch self {
            case .available(let data): return data
            default: return nil
            }
        }
    }

    public var avatarDataState: AvatarDataState {
        if let selfAsV2 = self as? TSGroupModelV2 {
            if selfAsV2.avatarDataFailedToFetchFromCDN {
                return .failedToFetchFromCDN
            }
            if selfAsV2.lowTrustAvatarDownloadWasBlocked {
                return .lowTrustDownloadWasBlocked
            }
        }

        if let dataFromDisk = readAvatarDataFromDisk() {
            return .available(dataFromDisk)
        } else {
            return .missing
        }
    }

    /// Reads the data for this group's avatar from disk. Only present if an
    /// `avatarUrlPath` is also present, and the data from that URL was
    /// successfully fetched and determined to be valid.
    private func readAvatarDataFromDisk() -> Data? {
        guard let avatarHash else {
            // We write this when we persist data, so if it's missing we don't
            // have persisted data.
            return nil
        }

        if let cachedData = Self.avatarsCache.object(forKey: avatarHash) {
            return cachedData
        }

        let avatarData: Data
        do {
            let filePath = Self.avatarFilePath(forHash: avatarHash)
            avatarData = try Data(contentsOf: filePath)
        } catch {
            owsFailDebug("Failed to read group avatar data \(error)")
            return nil
        }

        guard DataImageSource(avatarData).ows_isValidImage else {
            owsFailDebug("Invalid group avatar data.")
            return nil
        }

        return avatarData
    }

    // MARK: -

    private static func avatarFilePath(forHash hash: String) -> URL {
        return URL(fileURLWithPath: "\(hash).png", relativeTo: avatarsDirectory)
    }

    public static let avatarsDirectory = URL(
        fileURLWithPath: "GroupAvatars",
        isDirectory: true,
        relativeTo: URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()),
    )

    public static func hash(forAvatarData avatarData: Data) throws -> String {
        return Data(SHA256.hash(data: avatarData)).hexadecimalString
    }

    public static func allGroupAvatarFilePaths(transaction: DBReadTransaction) -> Set<String> {
        var filePaths = Set<String>()
        TSThread.anyEnumerate(
            transaction: transaction,
            sql: "SELECT * FROM \(TSThread.databaseTableName) WHERE \(threadColumn: .recordType) = ?",
            arguments: [SDSRecordType.groupThread.rawValue],
            block: { thread, stop in
                // [SDS] TODO: Fetch TSGroupThreads directly.
                guard let avatarHash = (thread as? TSGroupThread)?.groupModel.avatarHash else {
                    return
                }
                filePaths.insert(avatarFilePath(forHash: avatarHash).path)
            },
        )
        return filePaths
    }

    // MARK: -

    static func generateRandomGroupId(_ version: GroupsVersion) -> Data {
        let length = switch version {
        case .V1: kGroupIdLengthV1
        case .V2: kGroupIdLengthV2
        }

        return Randomness.generateRandomBytes(length)
    }
}