// // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import CryptoKit import GRDB import LibSignalClient public import SignalRingRTC /// Responsible for the colors used for default "initials over colored /// background" avatars. /// /// For new chats, these are locally derived based on some information about the /// chat. However, clients historically performed that derivation using /// different algorithms. We want to display consistent colors for chats using /// the default avatar, so these are synced across clients and stored. public struct AvatarDefaultColorManager { public enum UseCase { case contact(recipient: SignalRecipient) case contactWithoutRecipient(address: SignalServiceAddress) case group(groupId: Data) case callLink(rootKey: CallLinkRootKey) } init() {} /// Derive the default color for the given use case. Callers should prefer /// `defaultColor(useCase:tx:)` unless they know a color is never persisted /// for the given use case. public static func deriveDefaultColor(useCase: UseCase) -> AvatarTheme { guard let index = deriveIndex(useCase: useCase) else { return .default } return .forIndex(index) } public static func deriveGradient(useCase: UseCase) -> AvatarGradient { guard let index = deriveIndex(useCase: useCase) else { return AvatarGradient.gradients[0] } return AvatarGradient.gradients[index % AvatarGradient.gradients.count] } private static func deriveIndex(useCase: UseCase) -> Int? { let seedData: Data switch useCase { case .contact(let recipient): if let aci = recipient.aci { seedData = aci.serviceIdBinary } else if let phoneNumber = recipient.phoneNumber { seedData = Data(phoneNumber.stringValue.utf8) } else if let pni = recipient.pni { seedData = pni.serviceIdBinary } else { return nil } case .contactWithoutRecipient(let address): if let aci = address.serviceId as? Aci { seedData = aci.serviceIdBinary } else if let phoneNumber = address.phoneNumber { seedData = Data(phoneNumber.utf8) } else if let pni = address.serviceId as? Pni { seedData = pni.serviceIdBinary } else { return nil } case .group(let groupId): seedData = groupId case .callLink(let rootKey): // Per spec, these don't go through SHA256 to determine the color. return Int(rootKey.bytes.first!) } // We'll take a SHA256 hash of the seed, and then use the first byte of // the hash as the index of the color to use. var sha256 = SHA256() sha256.update(data: seedData) guard let firstSHA256Byte = Data(sha256.finalize()).first else { owsFailDebug("Unexpectedly empty SHA256!") return nil } // The indexing uses modulo internally, so we can pass an arbitrarily // large index. return Int(firstSHA256Byte) } // MARK: - /// Returns the default avatar color for the given use case. Returns a /// persisted color if one exists, or one derived for the use case if not. public func defaultColor( useCase: UseCase, tx: DBReadTransaction, ) -> AvatarTheme { let persistedColorRecord: AvatarDefaultColorRecord? switch useCase { case .callLink: // At the time of writing we don't persist these. persistedColorRecord = nil case .contactWithoutRecipient: // We only persist for contacts with recipients, which will cover // anyone we've messaged with directly. persistedColorRecord = nil case .contact(let recipient): do { persistedColorRecord = try AvatarDefaultColorRecord .filter(Column(AvatarDefaultColorRecord.CodingKeys.recipientRowId) == recipient.id) .fetchOne(tx.database) } catch let error { owsFailDebug("Failed to fetch default color record for recipient: \(error.grdbErrorForLogging)") persistedColorRecord = nil } case .group(let groupId): do { persistedColorRecord = try AvatarDefaultColorRecord .filter(Column(AvatarDefaultColorRecord.CodingKeys.groupId) == groupId) .fetchOne(tx.database) } catch let error { owsFailDebug("Failed to fetch default color record for group: \(error.grdbErrorForLogging)") persistedColorRecord = nil } } if let persistedColorRecord { return persistedColorRecord.defaultColor } else { // If we haven't persisted something to use instead, we can derive a // value! return Self.deriveDefaultColor(useCase: useCase) } } // MARK: - func persistDefaultColor( _ defaultColor: AvatarTheme, recipientRowId: SignalRecipient.RowId, tx: DBWriteTransaction, ) throws { try persistDefaultColor( record: AvatarDefaultColorRecord( recipientRowId: recipientRowId, defaultColor: defaultColor, ), tx: tx, ) } func persistDefaultColor( _ defaultColor: AvatarTheme, groupId: Data, tx: DBWriteTransaction, ) throws { try persistDefaultColor( record: AvatarDefaultColorRecord( groupId: groupId, defaultColor: defaultColor, ), tx: tx, ) } private func persistDefaultColor( record: AvatarDefaultColorRecord, tx: DBWriteTransaction, ) throws { // These records treat conflict-on-insert as an update, so this is // really an upsert. try record.insert(tx.database) } } // MARK: - private struct AvatarDefaultColorRecord: Codable, PersistableRecord, FetchableRecord { static let databaseTableName: String = "AvatarDefaultColor" /// Part of ``GRDB.MutablePersistableRecord``. If we get a conflict while /// inserting (or updating, although I'm not sure how that can conflict), /// update instead. (In effect, treat `insert` as `upsert`.) static let persistenceConflictPolicy = PersistenceConflictPolicy( insert: .replace, update: .replace, ) enum CodingKeys: String, CodingKey { case recipientRowId case groupId case defaultColorIndex } /// A ``SignalRecipient/id``, whose contact's default avatar color this /// record describes. let recipientRowId: Int64? /// A group ID, whose group's default avatar color this record describes. let groupId: Data? /// An index into the list of default avatar colors. private let defaultColorIndex: Int var defaultColor: AvatarTheme { .forIndex(defaultColorIndex) } init(recipientRowId: Int64, defaultColor: AvatarTheme) { self.init( recipientRowId: recipientRowId, groupId: nil, defaultColor: defaultColor, ) } init(groupId: Data, defaultColor: AvatarTheme) { self.init( recipientRowId: nil, groupId: groupId, defaultColor: defaultColor, ) } private init( recipientRowId: Int64?, groupId: Data?, defaultColor: AvatarTheme, ) { self.recipientRowId = recipientRowId self.groupId = groupId self.defaultColorIndex = AvatarTheme.index(of: defaultColor) } } // MARK: - private extension AvatarTheme { static func forIndex(_ index: Int) -> AvatarTheme { AvatarTheme.allCases[index % AvatarTheme.allCases.count] } static func index(of theme: AvatarTheme) -> Int { AvatarTheme.allCases.firstIndex(of: theme)! } }