567 lines
21 KiB
Swift
567 lines
21 KiB
Swift
//
|
|
// 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: [TSThreadType.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)
|
|
}
|
|
}
|