// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation // Like TSGroupModel, TSGroupModelV2 is intended to be immutable. // // NOTE: This class is tightly coupled to TSGroupModelBuilder. // If you modify this class - especially if you // add any new properties - make sure to update // TSGroupModelBuilder. @objc public class TSGroupModelV2: TSGroupModel { // These properties TSGroupModel, TSGroupModelV2 is intended to be immutable. @objc var membership: GroupMembership @objc public var access: GroupAccess = .defaultForV2 @objc public var secretParamsData: Data = Data() @objc public var revision: UInt32 = 0 @objc public var avatarUrlPath: String? @objc public var inviteLinkPassword: Data? @objc public var isAnnouncementsOnly: Bool = false /// 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. /// /// - Important /// The @objc name must remain as-is, so as to correctly deserialize /// existing models that were ``NSKeyedArchiver``-ed in the past. @objc(isPlaceholderModel) public var isJoinRequestPlaceholder: Bool = false @objc public var wasJustMigrated: Bool = false @objc public var didJustAddSelfViaGroupLink: Bool = false @objc public var droppedMembers = [SignalServiceAddress]() @objc public var descriptionText: String? @objc public init(groupId: Data, name: String?, descriptionText: String?, avatarData: Data?, groupMembership: GroupMembership, groupAccess: GroupAccess, revision: UInt32, secretParamsData: Data, avatarUrlPath: String?, inviteLinkPassword: Data?, isAnnouncementsOnly: Bool, isJoinRequestPlaceholder: Bool, wasJustMigrated: Bool, didJustAddSelfViaGroupLink: Bool, addedByAddress: SignalServiceAddress?, droppedMembers: [SignalServiceAddress]) { 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.droppedMembers = droppedMembers super.init(groupId: groupId, name: name, avatarData: avatarData, members: [], addedBy: addedByAddress) } // MARK: - MTLModel @objc required public init?(coder aDecoder: NSCoder) { self.membership = .empty super.init(coder: aDecoder) } @objc public required init(dictionary dictionaryValue: [String: Any]!) throws { self.membership = .empty try super.init(dictionary: dictionaryValue) } public override class func storageBehaviorForProperty(withKey propertyKey: String) -> MTLPropertyStorage { if propertyKey == #keyPath(groupMembers) { // This is included in groupMembership. return MTLPropertyStorageNone } return super.storageBehaviorForProperty(withKey: propertyKey) } // MARK: - @objc public override var groupsVersion: GroupsVersion { return .V2 } @objc public override var groupMembership: GroupMembership { return membership } @objc public override var groupMembers: [SignalServiceAddress] { return Array(groupMembership.fullMembers) } public func hasUserFacingChangeCompared( to otherGroupModel: TSGroupModelV2 ) -> Bool { if self === otherGroupModel { return false } guard groupName == otherGroupModel.groupName, avatarHash == otherGroupModel.avatarHash, addedByAddress == otherGroupModel.addedByAddress, descriptionText == otherGroupModel.descriptionText, membership == otherGroupModel.membership, access == otherGroupModel.access, isAnnouncementsOnly == otherGroupModel.isAnnouncementsOnly, inviteLinkPassword == otherGroupModel.inviteLinkPassword else { return true } return false } @objc public override 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 += "droppedMembers: \(droppedMembers),\n" result += "descriptionText: \(String(describing: descriptionText)),\n" result += "]" return result } } // MARK: - @objc public extension TSGroupModelV2 { var groupInviteLinkMode: GroupsV2LinkMode { guard let inviteLinkPassword = inviteLinkPassword, !inviteLinkPassword.isEmpty else { return .disabled } switch access.addFromInviteLink { case .any: return .enabledWithoutApproval case .administrator: return .enabledWithApproval default: return .disabled } } var isGroupInviteLinkEnabled: Bool { if let inviteLinkPassword = inviteLinkPassword, !inviteLinkPassword.isEmpty, access.canJoinFromInviteLink { return true } return false } } // MARK: - @objc public extension TSGroupModel { var isPlaceholder: Bool { guard let groupModelV2 = self as? TSGroupModelV2 else { return false } return groupModelV2.isJoinRequestPlaceholder } var wasJustMigratedToV2: Bool { guard let groupModelV2 = self as? TSGroupModelV2 else { return false } return groupModelV2.wasJustMigrated } var didJustAddSelfViaGroupLinkV2: Bool { guard let groupModelV2 = self as? TSGroupModelV2 else { return false } return groupModelV2.didJustAddSelfViaGroupLink } var getDroppedMembers: [SignalServiceAddress] { guard let groupModelV2 = self as? TSGroupModelV2 else { return [] } return groupModelV2.droppedMembers } } @objc public extension TSGroupModel { private static let appSharedDataDirectory = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) static let avatarsDirectory = URL(fileURLWithPath: "GroupAvatars", isDirectory: true, relativeTo: appSharedDataDirectory) @nonobjc private static let avatarsCache = LRUCache(maxSize: 16, nseMaxSize: 0) func attemptToMigrateLegacyAvatarDataToDisk() throws { guard let legacyAvatarData = legacyAvatarData, !legacyAvatarData.isEmpty else { self.legacyAvatarData = nil return } guard Self.isValidGroupAvatarData(legacyAvatarData) else { owsFailDebug("Invalid legacy avatar data. Removing it completely") self.avatarHash = nil self.legacyAvatarData = nil return } try persistAvatarData(legacyAvatarData) self.legacyAvatarData = nil } 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: Self.avatarFilePath(forHash: hash)) 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 } class func hash(forAvatarData avatarData: Data) throws -> String { guard let digest = Cryptography.computeSHA256Digest(avatarData) else { throw OWSAssertionError("Unexpectedly failed to calculate avatar digest") } return digest.hexadecimalString } var avatarData: Data? { if let avatarHash = avatarHash, let cachedData = Self.avatarsCache.object(forKey: avatarHash) { return cachedData } guard let fileName = avatarFileName else { return nil } let filePath = URL(fileURLWithPath: fileName, relativeTo: Self.avatarsDirectory) let avatarData: Data do { avatarData = try Data(contentsOf: filePath) } catch { owsFailDebug("Failed to read group avatar data \(error)") return nil } guard avatarData.ows_isValidImage else { owsFailDebug("Invalid group avatar data.") return nil } return avatarData } var avatarImage: UIImage? { guard let avatarData = avatarData else { return nil } return UIImage(data: avatarData) } var avatarFileName: String? { guard let hash = avatarHash else { return nil } return Self.avatarFileName(forHash: hash) } static func avatarFileName(forHash hash: String) -> String { // All group avatars are PNGs, use the appropriate file extension. return "\(hash).png" } static func avatarFilePath(forHash hash: String) -> URL { URL(fileURLWithPath: avatarFileName(forHash: hash), relativeTo: avatarsDirectory) } class func allGroupAvatarFilePaths(transaction: SDSAnyReadTransaction) throws -> Set { let cursor = TSThread.grdbFetchCursor( sql: "SELECT * FROM \(ThreadRecord.databaseTableName) WHERE \(threadColumn: .recordType) = \(SDSRecordType.groupThread.rawValue)", transaction: transaction.unwrapGrdbRead ) var filePaths = Set() while let thread = try cursor.next() as? TSGroupThread { guard let avatarHash = thread.groupModel.avatarHash else { continue } filePaths.insert(avatarFilePath(forHash: avatarHash).path) } return filePaths } }