TM-SGNL-iOS/SignalServiceKit/Util/Avatars/AvatarBuilder.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

1339 lines
53 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import CryptoKit
import Foundation
// AvatarBuilder has responsibility for building and caching contact and group avatars.
//
// It ensure that avatars update to reflect changes to any state that can affect avatars.
// In some cases this is ensured via cache evacuation, in other cases it is ensured
// by using cache keys that will change if the content changes.
//
// It internally DRYs up handling of:
//
// * Light/dark theme.
// * Avatar blurring.
// * Scaling avatar to reflect view size (honoring pixels vs. points).
// * Changes to avatar colors.
// * LocalUserDisplayMode (should local user appear as "note to self" or as a user?).
//
// Internally AvatarBuilder uses two caches / two types of cache keys:
//
// * Requests: the type of avatar that a view is trying to display, e.g.
// a contact avatar for user X.
// * Content: the specific content that is used to build an avatar image, e.g.
// a profile image for user X, a "contact avatar" from system contacts for
// user X, a default avatar using the initials Y for user X.
//
// Avatars are expensive to build. By caching requests / content separately,
// we can avoid building avatars unnecessarily while ensuring that avatars
// update correctly without worrying about cache evacuation.
public class AvatarBuilder: NSObject {
public static var shared: AvatarBuilder { SSKEnvironment.shared.avatarBuilderRef }
public static let smallAvatarSizePoints: UInt = 36
public static let standardAvatarSizePoints: UInt = 48
public static let mediumAvatarSizePoints: UInt = 68
public static let largeAvatarSizePoints: UInt = 96
public static var smallAvatarSizePixels: CGFloat { CGFloat(smallAvatarSizePoints).pointsAsPixels }
public static var standardAvatarSizePixels: CGFloat { CGFloat(standardAvatarSizePoints).pointsAsPixels }
public static var mediumAvatarSizePixels: CGFloat { CGFloat(mediumAvatarSizePoints).pointsAsPixels }
public static var largeAvatarSizePixels: CGFloat { CGFloat(largeAvatarSizePoints).pointsAsPixels }
// MARK: -
public init(appReadiness: AppReadiness) {
super.init()
SwiftSingletons.register(self)
appReadiness.runNowOrWhenAppWillBecomeReady {
self.addObservers()
}
}
// MARK: - Notifications
private func addObservers() {
NotificationCenter.default.addObserver(
self,
selector: #selector(otherUsersProfileDidChange(notification:)),
name: UserProfileNotifications.otherUsersProfileDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(contactsDidChange(notification:)),
name: .OWSContactsManagerContactsDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(localUsersProfileDidChange(notification:)),
name: UserProfileNotifications.localProfileDidChange,
object: nil
)
}
@objc
private func contactsDidChange(notification: Notification) {
AssertIsOnMainThread()
requestToContentCache.removeAllObjects()
}
@objc
private func otherUsersProfileDidChange(notification: Notification) {
AssertIsOnMainThread()
if let address = notification.userInfo?[UserProfileNotifications.profileAddressKey] as? SignalServiceAddress {
addressToAvatarIdentifierCache.removeObject(forKey: address)
}
}
@objc
private func localUsersProfileDidChange(notification: Notification) {
AssertIsOnMainThread()
if let address = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress {
addressToAvatarIdentifierCache.removeObject(forKey: address)
}
}
// MARK: -
public func avatarImage(
forThread thread: TSThread,
diameterPoints: UInt,
localUserDisplayMode: LocalUserDisplayMode,
transaction: SDSAnyReadTransaction
) -> UIImage? {
return avatarImage(
forThread: thread,
diameterPixels: CGFloat(diameterPoints).pointsAsPixels,
localUserDisplayMode: localUserDisplayMode,
transaction: transaction
)
}
public func avatarImage(
forThread thread: TSThread,
diameterPixels: CGFloat,
localUserDisplayMode: LocalUserDisplayMode,
transaction: SDSAnyReadTransaction
) -> UIImage? {
guard let request = buildRequest(
forThread: thread,
diameterPixels: diameterPixels,
localUserDisplayMode: localUserDisplayMode,
transaction: transaction
) else {
return nil
}
return avatarImage(forRequest: request, transaction: transaction)
}
public func avatarImageWithSneakyTransaction(
forAddress address: SignalServiceAddress,
diameterPoints: UInt,
localUserDisplayMode: LocalUserDisplayMode
) -> UIImage? {
SSKEnvironment.shared.databaseStorageRef.read { transaction in
avatarImage(
forAddress: address,
diameterPoints: diameterPoints,
localUserDisplayMode: localUserDisplayMode,
transaction: transaction
)
}
}
private func request(
forAddress address: SignalServiceAddress,
diameterPixels: CGFloat,
localUserDisplayMode: LocalUserDisplayMode,
transaction: SDSAnyReadTransaction
) -> Request {
let shouldBlurAvatar = SSKEnvironment.shared.contactManagerImplRef.shouldBlurContactAvatar(address: address, transaction: transaction)
let requestType: RequestType = .contactAddress(address: address, localUserDisplayMode: localUserDisplayMode)
return Request(requestType: requestType, diameterPixels: diameterPixels, shouldBlurAvatar: shouldBlurAvatar)
}
public func avatarImage(
forAddress address: SignalServiceAddress,
diameterPoints: UInt,
localUserDisplayMode: LocalUserDisplayMode,
transaction: SDSAnyReadTransaction
) -> UIImage? {
let request = request(
forAddress: address,
diameterPixels: CGFloat(diameterPoints).pointsAsPixels,
localUserDisplayMode: localUserDisplayMode,
transaction: transaction
)
return avatarImage(forRequest: request, transaction: transaction)
}
public func avatarImage(
forAddress address: SignalServiceAddress,
diameterPixels: CGFloat,
localUserDisplayMode: LocalUserDisplayMode,
transaction: SDSAnyReadTransaction
) -> UIImage? {
let request = request(
forAddress: address,
diameterPixels: diameterPixels,
localUserDisplayMode: localUserDisplayMode,
transaction: transaction
)
return avatarImage(forRequest: request, transaction: transaction)
}
// Never builds; only returns an avatar if there is already a copy
// in a cache.
public func precachedAvatarImage(
forAddress address: SignalServiceAddress,
diameterPoints: UInt,
localUserDisplayMode: LocalUserDisplayMode,
transaction: SDSAnyReadTransaction
) -> UIImage? {
let request = request(
forAddress: address,
diameterPixels: CGFloat(diameterPoints).pointsAsPixels,
localUserDisplayMode: localUserDisplayMode,
transaction: transaction
)
guard let requestCacheKey = request.cacheKey else {
return nil
}
guard let content = requestToContentCache.object(forKey: requestCacheKey) else {
return nil
}
return contentToImageCache.object(forKey: content.cacheKey)
}
private func request(
forGroupThread groupThread: TSGroupThread,
diameterPoints: UInt,
transaction tx: SDSAnyReadTransaction
) -> Request {
let diameterPixels = CGFloat(diameterPoints).pointsAsPixels
let shouldBlurAvatar = SSKEnvironment.shared.contactManagerImplRef.shouldBlurGroupAvatar(groupThread: groupThread, transaction: tx)
let requestType = buildRequestType(forGroupThread: groupThread, diameterPixels: diameterPixels, transaction: tx)
return Request(requestType: requestType, diameterPixels: diameterPixels, shouldBlurAvatar: shouldBlurAvatar)
}
public func avatarImage(
forGroupThread groupThread: TSGroupThread,
diameterPoints: UInt,
transaction: SDSAnyReadTransaction
) -> UIImage? {
let request = request(forGroupThread: groupThread, diameterPoints: diameterPoints, transaction: transaction)
return avatarImage(forRequest: request, transaction: transaction)
}
// Never builds; only returns an avatar if there is already a copy
// in a cache.
public func precachedAvatarImage(
forGroupThread groupThread: TSGroupThread,
diameterPoints: UInt,
transaction: SDSAnyReadTransaction
) -> UIImage? {
let request = request(forGroupThread: groupThread, diameterPoints: diameterPoints, transaction: transaction)
guard let requestCacheKey = request.cacheKey else {
return nil
}
guard let content = requestToContentCache.object(forKey: requestCacheKey) else {
return nil
}
return contentToImageCache.object(forKey: content.cacheKey)
}
public func avatarImageForLocalUserWithSneakyTransaction(
diameterPoints: UInt,
localUserDisplayMode: LocalUserDisplayMode
) -> UIImage? {
SSKEnvironment.shared.databaseStorageRef.read { transaction in
return avatarImageForLocalUser(
diameterPoints: diameterPoints,
localUserDisplayMode: localUserDisplayMode,
transaction: transaction
)
}
}
public func avatarImageForLocalUser(
diameterPoints: UInt,
localUserDisplayMode: LocalUserDisplayMode,
transaction: SDSAnyReadTransaction
) -> UIImage? {
let diameterPixels = CGFloat(diameterPoints).pointsAsPixels
guard let address = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction.asV2Read)?.aciAddress else {
owsFailDebug("Missing localAddress.")
return nil
}
let shouldBlurAvatar = SSKEnvironment.shared.contactManagerImplRef.shouldBlurContactAvatar(address: address, transaction: transaction)
let requestType: RequestType = .contactAddress(address: address, localUserDisplayMode: localUserDisplayMode)
let request = Request(requestType: requestType, diameterPixels: diameterPixels, shouldBlurAvatar: shouldBlurAvatar)
return avatarImage(forRequest: request, transaction: transaction)
}
public func avatarImage(
personNameComponents: PersonNameComponents,
address: SignalServiceAddress? = nil,
diameterPoints: UInt,
transaction: SDSAnyReadTransaction
) -> UIImage? {
let diameterPixels = CGFloat(diameterPoints).pointsAsPixels
let shouldBlurAvatar = false
let theme: AvatarTheme
if let address = address {
theme = .forAddress(address)
} else {
theme = .default
}
let requestType: RequestType = {
if let initials = Self.contactInitials(for: personNameComponents) {
return .text(text: initials, theme: theme)
} else {
return .contactDefaultIcon(theme: theme)
}
}()
let request = Request(requestType: requestType, diameterPixels: diameterPixels, shouldBlurAvatar: shouldBlurAvatar)
return avatarImage(forRequest: request, transaction: transaction)
}
public func avatarImage(forGroupId groupId: Data, diameterPoints: UInt) -> UIImage? {
let diameterPixels = CGFloat(diameterPoints).pointsAsPixels
return avatarImage(forGroupId: groupId, diameterPixels: UInt(diameterPixels))
}
public func avatarImage(forGroupId groupId: Data, diameterPixels: UInt) -> UIImage? {
let shouldBlurAvatar = false
let requestType: RequestType = .groupDefaultIcon(groupId: groupId)
let request = Request(
requestType: requestType,
diameterPixels: CGFloat(diameterPixels),
shouldBlurAvatar: shouldBlurAvatar
)
let avatarContentType: AvatarContentType = .groupDefault(theme: .forGroupId(groupId))
let avatarContent = AvatarContent(
request: request,
contentType: avatarContentType,
failoverContentType: nil,
diameterPixels: request.diameterPixels,
shouldBlurAvatar: request.shouldBlurAvatar
)
return avatarImage(forAvatarContent: avatarContent, transaction: nil)
}
public func defaultAvatarImageForLocalUser(
diameterPoints: UInt,
transaction: SDSAnyReadTransaction
) -> UIImage? {
let diameterPixels = CGFloat(diameterPoints).pointsAsPixels
return defaultAvatarImageForLocalUser(
diameterPixels: UInt(diameterPixels),
transaction: transaction
)
}
public func defaultAvatarImageForLocalUser(
diameterPixels: UInt,
transaction: SDSAnyReadTransaction
) -> UIImage? {
let requestType: RequestType = {
guard let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction.asV2Read)?.aciAddress else {
return .contactDefaultIcon(theme: .default)
}
let theme = AvatarTheme.forAddress(localAddress)
let displayName = SSKEnvironment.shared.contactManagerRef.displayName(for: localAddress, tx: transaction)
if let contactInitials = Self.contactInitials(for: displayName) {
return .text(text: contactInitials, theme: theme)
} else {
return .contactDefaultIcon(theme: theme)
}
}()
let request = Request(
requestType: requestType,
diameterPixels: CGFloat(diameterPixels),
shouldBlurAvatar: false
)
return avatarImage(forRequest: request, transaction: transaction)
}
public func avatarImage(model: AvatarModel, diameterPoints: UInt) -> UIImage? {
let diameterPixels = CGFloat(diameterPoints).pointsAsPixels
return avatarImage(model: model, diameterPixels: UInt(diameterPixels))
}
public func avatarImage(model: AvatarModel, diameterPixels: UInt) -> UIImage? {
let request = Request(
requestType: .model(model),
diameterPixels: CGFloat(diameterPixels),
shouldBlurAvatar: false
)
let avatarContentType: AvatarContentType
switch model.type {
case .icon(let icon):
avatarContentType = .avatarIcon(icon: icon, theme: model.theme)
case .image(let url):
avatarContentType = .file(fileUrl: url, shouldValidate: false)
case .text(let text):
avatarContentType = .text(text: text, theme: model.theme)
}
let avatarContent = AvatarContent(
request: request,
contentType: avatarContentType,
failoverContentType: nil,
diameterPixels: request.diameterPixels,
shouldBlurAvatar: request.shouldBlurAvatar
)
return avatarImage(forAvatarContent: avatarContent, transaction: nil)
}
// MARK: - Requests
// We can't enumerate cache keys to selectively remove certain objects
// Instead we add a layer of indirection to map known SignalServiceAddress to ephemeral UUID
// That way, we can index into this indirect cache to remove the ephemeral UUID used to construct
// the request cacheKey, in effect, removing the item from the final cache.
private let addressToAvatarIdentifierCache = LRUCache<SignalServiceAddress, String>(maxSize: 256, nseMaxSize: 0)
public enum RequestType {
case contactAddress(address: SignalServiceAddress, localUserDisplayMode: LocalUserDisplayMode)
case text(text: String, theme: AvatarTheme)
case contactDefaultIcon(theme: AvatarTheme)
case group(groupId: Data, avatarData: Data, digestString: String)
case groupDefaultIcon(groupId: Data)
case model(AvatarModel)
fileprivate var cacheKey: String? {
switch self {
case .contactAddress(let address, let localUserDisplayMode):
let indirectAvatarIdentifier = AvatarBuilder.shared.addressToAvatarIdentifierCache[address] ?? {
let newId = UUID().uuidString
AvatarBuilder.shared.addressToAvatarIdentifierCache[address] = newId
return newId
}()
return "contactAddress.\(indirectAvatarIdentifier).\(localUserDisplayMode.rawValue)"
case .text(let text, let theme):
return "text.\(text).\(theme.rawValue)"
case .contactDefaultIcon(let theme):
return "contactDefaultIcon.\(theme.rawValue)"
case .group(let groupId, _, let digestString):
return "group.\(groupId.hexadecimalString).\(digestString)"
case .groupDefaultIcon(let groupId):
return "groupDefaultIcon.\(groupId.hexadecimalString)"
case .model(let avatarModel):
switch avatarModel.type {
case .icon(let icon):
return "icon.\(icon.rawValue).\(avatarModel.theme.rawValue)"
case .text(let text):
return "text.\(text).\(avatarModel.theme.rawValue)"
case .image(let url):
return "file.\(url.path)"
}
}
}
}
public struct Request {
let requestType: RequestType
let diameterPixels: CGFloat
let shouldBlurAvatar: Bool
fileprivate var cacheKey: String? {
guard let typeKey = requestType.cacheKey else {
owsFailDebug("Missing typeKey.")
return nil
}
return "\(typeKey).\(diameterPixels).\(shouldBlurAvatar)"
}
}
private func buildRequest(
forThread thread: TSThread,
diameterPixels: CGFloat,
localUserDisplayMode: LocalUserDisplayMode,
transaction: SDSAnyReadTransaction
) -> Request? {
func buildRequestType() -> (RequestType, Bool)? {
if let contactThread = thread as? TSContactThread {
let requestType: RequestType = .contactAddress(
address: contactThread.contactAddress,
localUserDisplayMode: localUserDisplayMode
)
let shouldBlurAvatar = SSKEnvironment.shared.contactManagerImplRef.shouldBlurContactAvatar(
address: contactThread.contactAddress,
transaction: transaction
)
return (requestType, shouldBlurAvatar)
} else if let groupThread = thread as? TSGroupThread {
let requestType = self.buildRequestType(
forGroupThread: groupThread,
diameterPixels: diameterPixels,
transaction: transaction
)
let shouldBlurAvatar = SSKEnvironment.shared.contactManagerImplRef.shouldBlurGroupAvatar(
groupThread: groupThread,
transaction: transaction
)
return (requestType, shouldBlurAvatar)
} else {
owsFailDebug("Invalid thread.")
return nil
}
}
guard let (requestType, shouldBlurAvatar) = buildRequestType() else {
return nil
}
return Request(requestType: requestType, diameterPixels: diameterPixels, shouldBlurAvatar: shouldBlurAvatar)
}
private func buildRequestType(
forGroupThread groupThread: TSGroupThread,
diameterPixels: CGFloat,
transaction tx: SDSAnyReadTransaction
) -> RequestType {
func requestTypeForGroup(groupThread: TSGroupThread) -> RequestType {
if let avatarData = groupThread.groupModel.avatarData, avatarData.ows_isValidImage {
let digestString = avatarData.sha1HexadecimalDigestString
return .group(groupId: groupThread.groupId, avatarData: avatarData, digestString: digestString)
} else {
return .groupDefaultIcon(groupId: groupThread.groupId)
}
}
if let latestGroupThread = TSGroupThread.anyFetchGroupThread(uniqueId: groupThread.uniqueId, transaction: tx) {
return requestTypeForGroup(groupThread: latestGroupThread)
} else {
owsFailDebug("Missing groupThread.")
return requestTypeForGroup(groupThread: groupThread)
}
}
// MARK: -
private static func contactInitials(for displayName: DisplayName) -> String? {
let nameComponents: PersonNameComponents
switch displayName {
case .nickname(let nickname):
nameComponents = nickname.nameComponents
case .systemContactName(let systemContactName):
nameComponents = systemContactName.nameComponents
case .profileName(let profileNameComponents):
nameComponents = profileNameComponents
case .phoneNumber, .username, .deletedAccount, .unknown:
return nil
}
return contactInitials(for: nameComponents)
}
private static func contactInitials(for nameComponents: PersonNameComponents) -> String? {
let formattedAbbreviation = OWSFormat.formatNameComponents(nameComponents, style: .abbreviated)
guard let formattedAbbreviation = formattedAbbreviation.filterForDisplay.nilIfEmpty else {
Logger.warn("Could not abbreviate name.")
return nil
}
// Some languages, such as Arabic, don't natively support abbreviations or
// have default abbreviations that are too long. In this case, we will not
// show an abbreviation. This matches the behavior of iMessage.
guard formattedAbbreviation.count < 4 else {
Logger.warn("Abbreviation too long: \(formattedAbbreviation.count).")
return nil
}
return formattedAbbreviation
}
// MARK: - Content
private enum AvatarContentType: Equatable {
case file(fileUrl: URL, shouldValidate: Bool)
case data(imageData: Data, digestString: String, shouldValidate: Bool)
case text(text: String, theme: AvatarTheme)
case tintedImage(name: String, theme: AvatarTheme)
case avatarIcon(icon: AvatarIcon, theme: AvatarTheme)
case cachedContact(address: SignalServiceAddress, cacheKey: String)
static func noteToSelf(theme: AvatarTheme) -> Self {
return .tintedImage(name: "note-resizable", theme: theme)
}
static func groupDefault(theme: AvatarTheme) -> Self {
return .tintedImage(name: "group-resizable", theme: theme)
}
static func contactDefaultIcon(theme: AvatarTheme) -> Self {
return .tintedImage(name: "person-resizable", theme: theme)
}
fileprivate var cacheKey: String {
switch self {
case .file(let fileUrl, _):
return "file.\(fileUrl.path)"
case .data(_, let digestString, _):
return "data.\(digestString)"
case .text(let text, let theme):
return "text.\(text).\(theme.rawValue)"
case .tintedImage(let name, let theme):
return "tintedImage.\(name).\(theme.rawValue)"
case .avatarIcon(let icon, let theme):
return "avatarIcon.\(icon.rawValue).\(theme.rawValue)"
case .cachedContact(_, let cacheKey):
return cacheKey
}
}
}
private class AvatarContent: NSObject {
// We track the first request used to build this content for debugging purposes.
let request: Request
let contentType: AvatarContentType
let failoverContentType: AvatarContentType?
let diameterPixels: CGFloat
let shouldBlurAvatar: Bool
init(
request: Request,
contentType: AvatarContentType,
failoverContentType: AvatarContentType?,
diameterPixels: CGFloat,
shouldBlurAvatar: Bool
) {
self.request = request
self.contentType = contentType
self.failoverContentType = failoverContentType
self.diameterPixels = diameterPixels
self.shouldBlurAvatar = shouldBlurAvatar
}
fileprivate var cacheKey: String {
"\(contentType.cacheKey).\(diameterPixels).\(shouldBlurAvatar)"
}
}
// MARK: -
private func avatarImage(forRequest request: Request, transaction: SDSAnyReadTransaction) -> UIImage? {
let avatarContent = avatarContent(forRequest: request, transaction: transaction)
return avatarImage(forAvatarContent: avatarContent, transaction: transaction)
}
// This cache needs to be evacuated whenever anything that
// would affect AvatarContent for the request changes.
private let requestToContentCache = LRUCache<String, AvatarContent>(maxSize: 128, nseMaxSize: 0)
private func avatarContent(forRequest request: Request, transaction: SDSAnyReadTransaction) -> AvatarContent {
if let cacheKey = request.cacheKey, let avatarContent = requestToContentCache.object(forKey: cacheKey) {
return avatarContent
}
let avatarContent = buildAvatarContent(forRequest: request, transaction: transaction)
if let cacheKey = request.cacheKey {
requestToContentCache.setObject(avatarContent, forKey: cacheKey)
}
return avatarContent
}
// This cache never needs to be evacuated. The cache keys will change
// whenever state in the content changes that would affect the image.
private let contentToImageCache = LRUCache<String, UIImage>(maxSize: 128, nseMaxSize: 0)
private static let avatarCacheDirectory = URL(
fileURLWithPath: "Library/Caches/AvatarBuilder",
isDirectory: true,
relativeTo: URL(
fileURLWithPath: CurrentAppContext().appSharedDataDirectoryPath(),
isDirectory: true
)
)
private static let contactCacheKeys = KeyValueStore(collection: "AvatarBuilder.contactCacheKeys")
private func avatarImage(forAvatarContent avatarContent: AvatarContent, transaction: SDSAnyReadTransaction?) -> UIImage? {
let cacheKey = avatarContent.cacheKey
if let image = contentToImageCache.object(forKey: cacheKey) {
return image
}
func saveCacheKeyForNSE() {
if
case .contactAddress(address: let address, localUserDisplayMode: _) = avatarContent.request.requestType,
let serviceIdString = address.serviceIdUppercaseString,
let transaction = transaction
{
let contentCacheKey = avatarContent.contentType.cacheKey
if contentCacheKey != Self.contactCacheKeys.getString(serviceIdString, transaction: transaction.asV2Read) {
SSKEnvironment.shared.databaseStorageRef.asyncWrite { writeTransaction in
Self.contactCacheKeys.setString(contentCacheKey, key: serviceIdString, transaction: writeTransaction.asV2Write)
}
}
}
}
// We use the digest of the cache key for the filename, to ensure it's safe
// for use in the filename (it's a hexadecimal string so only 0-9a-f).
let cachedImageUrl = URL(fileURLWithPath: cacheKey.sha1HexadecimalDigestString + ".png", relativeTo: Self.avatarCacheDirectory)
if let image = UIImage(contentsOfFile: cachedImageUrl.path) {
memoryCacheAvatarImageIfEligible(image, cacheKey: cacheKey)
saveCacheKeyForNSE()
return image
}
// We never build avatars in the NSE, as it's a very expensive operation.
guard !CurrentAppContext().isNSE else { return nil }
guard let image = Self.buildOrLoadImage(forAvatarContent: avatarContent, transaction: transaction) else {
return nil
}
memoryCacheAvatarImageIfEligible(image, cacheKey: cacheKey)
saveCacheKeyForNSE()
// Always cache the avatar image to disk.
OWSFileSystem.ensureDirectoryExists(Self.avatarCacheDirectory.path)
if let pngData = image.pngData() {
do {
try pngData.write(to: cachedImageUrl)
} catch {
owsFailDebug("Failed to cache avatar image to disk \(error)")
}
} else {
owsFailDebug("Failed to determine png data for avatar")
}
return image
}
private func memoryCacheAvatarImageIfEligible(_ image: UIImage, cacheKey: String) {
// The avatars in our hot code paths which are 36-56pt. At 3x scale,
// a threshold of 200 will include these avatars.
let maxCacheSizePixels = 200
let canCacheAvatarImage: Bool = (
image.pixelWidth <= maxCacheSizePixels
&& image.pixelHeight <= maxCacheSizePixels
)
guard canCacheAvatarImage else { return }
contentToImageCache.setObject(image, forKey: cacheKey)
}
// MARK: - Building Content
private func buildAvatarContent(forRequest request: Request,
transaction: SDSAnyReadTransaction) -> AvatarContent {
struct AvatarContentTypes {
let contentType: AvatarContentType
let failoverContentType: AvatarContentType?
}
func buildAvatarContentTypes() -> AvatarContentTypes {
switch request.requestType {
case .contactAddress(let address, let localUserDisplayMode):
guard address.isValid else {
owsFailDebug("Invalid address.")
return AvatarContentTypes(contentType: .contactDefaultIcon(theme: .default), failoverContentType: nil)
}
let theme = AvatarTheme.forAddress(address)
if address.isLocalAddress, localUserDisplayMode == .noteToSelf {
return AvatarContentTypes(
contentType: .noteToSelf(theme: theme),
failoverContentType: .contactDefaultIcon(theme: theme)
)
}
if CurrentAppContext().isNSE {
// We don't jump to using cached data outside the NSE because we don't want to use an *old* avatar
// for someone who's updated theirs. (This is the code path where we discover it's been updated!)
if
let serviceIdString = address.serviceIdUppercaseString,
let cacheKey = Self.contactCacheKeys.getString(serviceIdString, transaction: transaction.asV2Read)
{
return AvatarContentTypes(
contentType: .cachedContact(address: address, cacheKey: cacheKey),
failoverContentType: .contactDefaultIcon(theme: theme)
)
}
} else {
let imageData = SSKEnvironment.shared.contactManagerImplRef.avatarImageData(forAddress: address, shouldValidate: true, transaction: transaction)
if let imageData {
let digestString = imageData.sha1HexadecimalDigestString
return AvatarContentTypes(
contentType: .data(imageData: imageData, digestString: digestString, shouldValidate: false),
failoverContentType: .contactDefaultIcon(theme: theme)
)
}
let displayName = SSKEnvironment.shared.contactManagerRef.displayName(for: address, tx: transaction)
if let contactInitials = Self.contactInitials(for: displayName) {
return AvatarContentTypes(
contentType: .text(text: contactInitials, theme: theme),
failoverContentType: .contactDefaultIcon(theme: theme)
)
}
}
return AvatarContentTypes(contentType: .contactDefaultIcon(theme: theme), failoverContentType: nil)
case .text(let text, let theme):
return AvatarContentTypes(
contentType: .text(text: text, theme: theme),
failoverContentType: .contactDefaultIcon(theme: theme)
)
case .contactDefaultIcon(let theme):
return AvatarContentTypes(
contentType: .contactDefaultIcon(theme: theme),
failoverContentType: nil
)
case .group(let groupId, let avatarData, let digestString):
let theme = AvatarTheme.forGroupId(groupId)
return AvatarContentTypes(
contentType: .data(imageData: avatarData, digestString: digestString, shouldValidate: false),
failoverContentType: .groupDefault(theme: theme)
)
case .groupDefaultIcon(let groupId):
let theme = AvatarTheme.forGroupId(groupId)
return AvatarContentTypes(contentType: .groupDefault(theme: theme), failoverContentType: nil)
case .model(let model):
switch model.type {
case .icon(let icon):
return AvatarContentTypes(
contentType: .avatarIcon(icon: icon, theme: model.theme),
failoverContentType: nil
)
case .image(let url):
return AvatarContentTypes(
contentType: .file(fileUrl: url, shouldValidate: false),
failoverContentType: nil
)
case .text(let text):
return AvatarContentTypes(
contentType: .text(text: text, theme: model.theme),
failoverContentType: nil
)
}
}
}
let contentTypes = buildAvatarContentTypes()
return AvatarContent(
request: request,
contentType: contentTypes.contentType,
failoverContentType: contentTypes.failoverContentType,
diameterPixels: request.diameterPixels,
shouldBlurAvatar: request.shouldBlurAvatar
)
}
// MARK: - Building Images
// TODO: We could modify this method to always return some kind of
// default avatar.
private static func buildOrLoadImage(
forAvatarContent avatarContent: AvatarContent,
transaction: SDSAnyReadTransaction?
) -> UIImage? {
func buildOrLoadWithContentType(_ contentType: AvatarContentType) -> UIImage? {
switch contentType {
case .file(let fileUrl, let shouldValidate):
return loadAndResizeAvatarFile(
avatarContent: avatarContent,
fileUrl: fileUrl,
shouldValidate: shouldValidate
)
case .data(let imageData, _, let shouldValidate):
return loadAndResizeAvatarImageData(
avatarContent: avatarContent,
imageData: imageData,
shouldValidate: shouldValidate
)
case .cachedContact(let contactAddress, _):
guard let transaction = transaction else {
owsFailDebug("tried to build a contact avatar without a transaction")
return nil
}
guard let imageData = SSKEnvironment.shared.contactManagerImplRef.avatarImageData(
forAddress: contactAddress,
shouldValidate: true,
transaction: transaction
) else {
return nil
}
return loadAndResizeAvatarImageData(
avatarContent: avatarContent,
imageData: imageData,
shouldValidate: false
)
case .text(let text, let theme):
return buildAvatar(avatarContent: avatarContent, text: text, theme: theme)
case .tintedImage(let name, let theme):
return buildAvatar(avatarContent: avatarContent, tintedImageName: name, theme: theme)
case .avatarIcon(let icon, let theme):
return buildAvatar(avatarContent: avatarContent, avatarIcon: icon, theme: theme)
}
}
func buildOrLoad() -> UIImage? {
if let image = buildOrLoadWithContentType(avatarContent.contentType) {
return image
}
if
let failoverContentType = avatarContent.failoverContentType,
let image = buildOrLoadWithContentType(failoverContentType)
{
return image
}
owsFailDebug("Could not build avatar.")
return nil
}
// Ensure image scale matches main screen scale.
guard let image = normalizeImageScale(buildOrLoad()) else {
return nil
}
// Output should always be square.
owsAssertDebug(image.pixelWidth == image.pixelHeight)
// Output should always be target size or smaller.
owsAssertDebug(CGFloat(image.pixelWidth) <= avatarContent.diameterPixels)
owsAssertDebug(CGFloat(image.pixelHeight) <= avatarContent.diameterPixels)
if avatarContent.shouldBlurAvatar {
guard let blurredImage = SSKEnvironment.shared.contactManagerImplRef.blurAvatar(image) else {
owsFailDebug("Could not blur image.")
return nil
}
// Output should always be square.
owsAssertDebug(blurredImage.pixelWidth == blurredImage.pixelHeight)
// Output should always be target size or smaller.
owsAssertDebug(CGFloat(blurredImage.pixelWidth) <= avatarContent.diameterPixels)
owsAssertDebug(CGFloat(blurredImage.pixelHeight) <= avatarContent.diameterPixels)
return blurredImage
} else {
return image
}
}
// Ensure image scale matches main screen scale.
private static func normalizeImageScale(_ image: UIImage?) -> UIImage? {
guard let image = image, let cgImage = image.cgImage else {
owsFailDebug("Missing or invalid image.")
return nil
}
if image.scale == UIScreen.main.scale {
return image
} else {
return UIImage(cgImage: cgImage, scale: UIScreen.main.scale, orientation: image.imageOrientation)
}
}
private static func loadAndResizeAvatarFile(
avatarContent: AvatarContent,
fileUrl: URL,
shouldValidate: Bool
) -> UIImage? {
let diameterPixels = avatarContent.diameterPixels
if shouldValidate {
guard Data.ows_isValidImage(atPath: fileUrl.path) else {
owsFailDebug("Invalid image.")
return nil
}
}
guard let sourceImage = UIImage(contentsOfFile: fileUrl.path) else {
owsFailDebug("Missing or invalid sourceImage.")
return nil
}
let pixelWidth = sourceImage.pixelWidth
let pixelHeight = sourceImage.pixelHeight
if CGFloat(pixelWidth) > diameterPixels || CGFloat(pixelHeight) > diameterPixels {
// Resize to target size.
return sourceImage.resizedImage(toFillPixelSize: .square(diameterPixels))
}
if pixelWidth != pixelHeight {
// Crop to square.
let pixelSize = min(pixelWidth, pixelHeight)
return sourceImage.resizedImage(toFillPixelSize: .square(CGFloat(pixelSize)))
}
return sourceImage
}
private static func loadAndResizeAvatarImageData(
avatarContent: AvatarContent,
imageData: Data,
shouldValidate: Bool
) -> UIImage? {
let diameterPixels = avatarContent.diameterPixels
if shouldValidate {
guard imageData.ows_isValidImage else {
owsFailDebug("Invalid imageData.")
return nil
}
}
guard let sourceImage = UIImage(data: imageData) else {
owsFailDebug("Missing or invalid sourceImage.")
return nil
}
let pixelWidth = sourceImage.pixelWidth
let pixelHeight = sourceImage.pixelHeight
if CGFloat(pixelWidth) > diameterPixels || CGFloat(pixelHeight) > diameterPixels {
// Resize to target size.
return sourceImage.resizedImage(toFillPixelSize: .square(diameterPixels))
}
if pixelWidth != pixelHeight {
// Crop to square.
let pixelSize = min(pixelWidth, pixelHeight)
return sourceImage.resizedImage(toFillPixelSize: .square(CGFloat(pixelSize)))
}
return sourceImage
}
private static func buildAvatar(
avatarContent: AvatarContent,
text: String,
theme: AvatarTheme
) -> UIImage? {
let diameterPixels = avatarContent.diameterPixels
return buildAvatar(
text: text,
textColor: theme.foregroundColor,
backgroundColor: theme.backgroundColor,
diameterPixels: diameterPixels
)
}
private static func buildAvatar(
avatarContent: AvatarContent,
tintedImageName: String,
theme: AvatarTheme
) -> UIImage? {
guard let image = UIImage(named: tintedImageName) else {
owsFailDebug("Missing icon with name \(tintedImageName)")
return nil
}
let diameterPixels = avatarContent.diameterPixels
let margin = avatarImageMargins(diameter: diameterPixels)
let totalIconDiamterPixels = diameterPixels - margin.totalWidth
return buildAvatar(
diameterPixels: diameterPixels,
backgroundColor: theme.backgroundColor
) { context in
drawIconInAvatar(
icon: image,
iconSizePixels: CGSize(square: totalIconDiamterPixels),
iconColor: theme.foregroundColor,
diameterPixels: diameterPixels,
context: context
)
}
}
private static func buildAvatar(
avatarContent: AvatarContent,
avatarIcon: AvatarIcon,
theme: AvatarTheme
) -> UIImage? {
let diameterPixels = avatarContent.diameterPixels
return buildAvatar(
diameterPixels: diameterPixels,
backgroundColor: theme.backgroundColor
) { context in
drawIconInAvatar(
icon: avatarIcon.image,
iconSizePixels: CGSize(square: diameterPixels),
diameterPixels: diameterPixels,
context: context
)
}
}
private static func buildAvatar(
text: String,
textColor: UIColor,
backgroundColor: UIColor,
diameterPixels: CGFloat
) -> UIImage? {
return buildAvatar(
diameterPixels: diameterPixels,
backgroundColor: backgroundColor
) { context in
drawTextInAvatar(
text: text,
textColor: textColor,
diameterPixels: diameterPixels,
context: context
)
}
}
private static func buildAvatar(
diameterPixels: CGFloat,
backgroundColor: UIColor,
drawBlock: (CGContext) -> Void
) -> UIImage? {
let diameterPixels = diameterPixels
guard diameterPixels > 0 else {
owsFailDebug("Invalid diameter.")
return nil
}
let frame = CGRect(origin: .zero, size: .square(diameterPixels))
UIGraphicsBeginImageContextWithOptions(frame.size, false, 1)
guard let context = UIGraphicsGetCurrentContext() else {
owsFailDebug("Missing context.")
return nil
}
defer { UIGraphicsEndImageContext() }
context.setFillColor(backgroundColor.cgColor)
context.fill(frame)
context.saveGState()
drawBlock(context)
context.restoreGState()
guard let image = UIGraphicsGetImageFromCurrentImageContext() else {
owsFailDebug("Missing image.")
return nil
}
return normalizeImageScale(image)
}
public static func avatarMaxFont(diameter: CGFloat, isEmojiOnly: Bool) -> UIFont {
// We use the "Inter" font for text based avatars, so they look
// the same across all platforms. The font is scaled relative to
// the height of the avatar. By sizing it to half the dimater, it
// will always be at least big enough to scale down to fit within
// the avatar.
return UIFont(name: "Inter-Regular_Medium", size: diameter * (isEmojiOnly ? 0.6 : 0.45))!
}
public static func avatarImageMargins(diameter: CGFloat) -> UIEdgeInsets {
UIEdgeInsets(margin: diameter * 0.2)
}
public static func avatarTextMargins(diameter: CGFloat) -> UIEdgeInsets {
UIEdgeInsets(margin: diameter * 0.1)
}
private static func drawTextInAvatar(
text: String,
textColor: UIColor,
diameterPixels: CGFloat,
context: CGContext
) {
guard let text = text.strippedOrNil else {
owsFailDebug("Invalid text.")
return
}
guard diameterPixels > 0 else {
owsFailDebug("Invalid diameter.")
return
}
let frame = CGRect(origin: .zero, size: .square(diameterPixels))
let font = avatarMaxFont(diameter: diameterPixels, isEmojiOnly: text.containsOnlyEmoji)
let margins = avatarTextMargins(diameter: diameterPixels)
let textAttributesForMeasurement: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: textColor
]
let options: NSStringDrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading]
let baseTextSize = (text).boundingRect(
with: CGSize(width: .max, height: .max),
options: options,
attributes: textAttributesForMeasurement,
context: nil
).size
// Ensure that the text fits within the avatar bounds, with a margin.
guard baseTextSize.isNonEmpty else {
owsFailDebug("Text has invalid bounds.")
return
}
let maxTextDiameterPixels = diameterPixels - margins.totalWidth
let textSizePixels = baseTextSize.largerAxis
let scaling = (maxTextDiameterPixels / textSizePixels).clamp01()
let textAttributesForDrawing: [NSAttributedString.Key: Any] = [
.font: font.withSize(font.pointSize * scaling),
.foregroundColor: textColor
]
let textSizeScaled = (text).boundingRect(
with: frame.size,
options: options,
attributes: textAttributesForDrawing,
context: nil
).size
let locationPixels = (frame.size.asPoint - textSizeScaled.asPoint) * 0.5
(text).draw(at: locationPixels, withAttributes: textAttributesForDrawing)
}
private static func drawIconInAvatar(
icon: UIImage,
iconSizePixels: CGSize,
iconColor: UIColor? = nil,
diameterPixels: CGFloat,
context: CGContext
) {
context.saveGState()
defer { context.restoreGState() }
guard iconSizePixels.isNonEmpty else {
owsFailDebug("Invalid iconSize.")
return
}
guard diameterPixels > 0 else {
owsFailDebug("Invalid diameter.")
return
}
if let iconColor = iconColor {
// There is a bug with "Preserve Vector Data" when operating
// on the underlying cgImage rather than drawing the UIImage
// object directly (as we do in the untinted path below) that
// results in the image rendering fuzzy when rendered at sizes
// larger than the original, even though vector data is available.
// To combat this, we draw the UIImage into a UIImage of the size
// we actually need before proceeding to create the mask with the
// underlying cgImage. This ensures a sharp output at a small additional
// perf cost.
let resizedImage = icon.resizedImage(toFillPixelSize: iconSizePixels)
guard let icon = resizedImage.cgImage else {
owsFailDebug("Invalid icon.")
return
}
// UIKit uses an ULO coordinate system (upper-left-origin).
// Core Graphics uses an LLO coordinate system (lower-left-origin).
let flipVertical = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: diameterPixels)
context.concatenate(flipVertical)
let diameterSizePixels = CGSize.square(diameterPixels)
// The programmatic equivalent of UIImageRenderingModeAlwaysTemplate/tintColor.
context.setBlendMode(.normal)
let offsetPixels = (diameterSizePixels.asPoint - iconSizePixels.asPoint) * 0.5
let maskRect = CGRect(origin: offsetPixels, size: iconSizePixels)
context.clip(to: maskRect, mask: icon)
context.setFillColor(iconColor.cgColor)
context.fill(CGRect(origin: .zero, size: diameterSizePixels))
} else {
let iconRect = CGRect(
origin: CGPoint(
x: (diameterPixels - iconSizePixels.width) / 2,
y: (diameterPixels - iconSizePixels.height) / 2
),
size: iconSizePixels
)
icon.draw(in: iconRect)
}
}
}
// MARK: -
fileprivate extension Data {
var sha1HexadecimalDigestString: String {
Data(Insecure.SHA1.hash(data: self)).hexadecimalString
}
}
fileprivate extension String {
var sha1HexadecimalDigestString: String {
data(using: .utf8)!.sha1HexadecimalDigestString
}
}
// MARK: -
#if USE_DEBUG_UI
extension AvatarBuilder {
public static func buildNoiseAvatar(diameterPoints: UInt) -> UIImage? {
let diameterPixels = CGFloat(diameterPoints).pointsAsPixels
let backgroundColor = UIColor(rgbHex: 0xaca6633)
return Self.buildAvatar(diameterPixels: diameterPixels, backgroundColor: backgroundColor) { context in
Self.drawNoiseInAvatar(diameterPixels: diameterPixels, context: context)
}
}
private static func drawNoiseInAvatar(diameterPixels: CGFloat, context: CGContext) {
context.saveGState()
defer { context.restoreGState() }
let diameterPixels = UInt(diameterPixels)
guard diameterPixels > 0 else {
owsFailDebug("Invalid diameter.")
return
}
let stride: UInt = 1
var x: UInt = 0
while x < diameterPixels {
var y: UInt = 0
while y < diameterPixels {
let color = UIColor.ows_randomColor(isAlphaRandom: false)
context.setFillColor(color.cgColor)
let rect = CGRect(origin: CGPoint(x: CGFloat(x), y: CGFloat(y)), size: .square(CGFloat(stride)))
context.fill(rect)
y += stride
}
x += stride
}
}
public static func buildRandomAvatar(diameterPoints: UInt) -> UIImage? {
let eyes = [ ":", "=", "8", "B" ]
let mouths = [ "3", ")", "(", "|", "\\", "P", "D", "o" ]
// eyebrows are rare
let eyebrows = [ ">", "", "", "", "" ]
let randomEye = eyes.shuffled().first!
let randomMouth = mouths.shuffled().first!
let randomEyebrow = eyebrows.shuffled().first!
let face = "\(randomEyebrow)\(randomEye)\(randomMouth)"
let diameterPixels = CGFloat(diameterPoints).pointsAsPixels
let theme = AvatarTheme.allCases.randomElement()!
return buildAvatar(
diameterPixels: diameterPixels,
backgroundColor: theme.backgroundColor
) { context in
context.translateBy(x: +diameterPixels / 2, y: +diameterPixels / 2)
context.rotate(by: CGFloat.halfPi)
context.translateBy(x: -diameterPixels / 2, y: -diameterPixels / 2)
drawTextInAvatar(
text: face,
textColor: theme.foregroundColor,
diameterPixels: diameterPixels,
context: context
)
}
}
}
#endif