Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Avatars/AvatarDefaultColorManager.swift
1 views
//
// 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)!
    }
}