1339 lines
53 KiB
Swift
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
|