TM-SGNL-iOS/SignalServiceKit/MessageBackup/Archivers/Chat/MessageBackupChatStyleArchiver.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

720 lines
27 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public class MessageBackupChatStyleArchiver: MessageBackupProtoArchiver {
private let attachmentManager: AttachmentManager
private let attachmentStore: AttachmentStore
private let backupAttachmentDownloadManager: BackupAttachmentDownloadManager
private let chatColorSettingStore: ChatColorSettingStore
private let dateProvider: DateProvider
private let wallpaperStore: WallpaperStore
public init(
attachmentManager: AttachmentManager,
attachmentStore: AttachmentStore,
backupAttachmentDownloadManager: BackupAttachmentDownloadManager,
chatColorSettingStore: ChatColorSettingStore,
dateProvider: @escaping DateProvider,
wallpaperStore: WallpaperStore
) {
self.attachmentManager = attachmentManager
self.attachmentStore = attachmentStore
self.backupAttachmentDownloadManager = backupAttachmentDownloadManager
self.chatColorSettingStore = chatColorSettingStore
self.dateProvider = dateProvider
self.wallpaperStore = wallpaperStore
}
// MARK: - Custom Chat Colors
func archiveCustomChatColors(
context: MessageBackup.CustomChatColorArchivingContext
) -> MessageBackup.ArchiveSingleFrameResult<[BackupProto_ChatStyle.CustomChatColor], MessageBackup.AccountDataId> {
var partialErrors = [MessageBackup.ArchiveFrameError<CustomChatColor.Key>]()
var protos = [BackupProto_ChatStyle.CustomChatColor]()
for (key, customChatColor) in chatColorSettingStore.fetchCustomValues(tx: context.tx) {
let protoColor: BackupProto_ChatStyle.CustomChatColor.OneOf_Color
switch customChatColor.colorSetting {
case .themedColor(let color, _):
// Themes should be impossible with custom chat colors; add an error
// but just take the light theme color and keep going.
partialErrors.append(.archiveFrameError(
.themedCustomChatColor,
key
))
fallthrough
case .solidColor(let color):
protoColor = .solid(color.asARGBHex())
case .themedGradient(let gradientColor1, let gradientColor2, _, _, let angleRadians):
// Themes should be impossible with custom chat colors; add an error
// but just take the light theme colors and keep going.
partialErrors.append(.archiveFrameError(
.themedCustomChatColor,
key
))
fallthrough
case .gradient(let gradientColor1, let gradientColor2, var angleRadians):
var gradient = BackupProto_ChatStyle.Gradient()
/// Convert radians to degrees. We manually round since the
/// float math is slightly lossy and sometimes gives back
/// `N.99999999999`; we want to return `N+1`, but the `UInt32`
/// conversion always rounds down.
while angleRadians < 0 {
angleRadians += .pi * 2
}
gradient.angle = UInt32(round(angleRadians * 180 / .pi))
/// iOS only supports 2 "positions"; hardcode them.
gradient.positions = [0, 1]
gradient.colors = [gradientColor1.asARGBHex(), gradientColor2.asARGBHex()]
protoColor = .gradient(gradient)
}
var proto = BackupProto_ChatStyle.CustomChatColor()
proto.id = context.assignCustomChatColorId(to: key).value
proto.color = protoColor
protos.append(proto)
}
if !partialErrors.isEmpty {
// Just log these errors, but count as success and proceed.
MessageBackup
.collapse(partialErrors
.map { MessageBackup.LoggableErrorAndProto(error: $0, wasFatal: false) }
)
.forEach { $0.log() }
}
return .success(protos)
}
func restoreCustomChatColors(
_ chatColorProtos: [BackupProto_ChatStyle.CustomChatColor],
context: MessageBackup.CustomChatColorRestoringContext
) -> MessageBackup.RestoreFrameResult<MessageBackup.AccountDataId> {
var partialErrors = [MessageBackup.RestoreFrameError<MessageBackup.AccountDataId>]()
/// We track a `creationTimestamp` for custom chat colors. In practice
/// that value isn't used for anything beyond sorting; however, because
/// we want the persisted sort order to be consistent with the ordering
/// in the Backup, we can't use the same timestamp for all colors. To
/// that end, we'll start with "now" and increment as we create more
/// colors.
var chatColorCreationTimestamp = dateProvider().ows_millisecondsSince1970
for chatColorProto in chatColorProtos {
let customChatColorId = MessageBackup.CustomChatColorId(value: chatColorProto.id)
let colorOrGradientSetting: ColorOrGradientSetting
switch chatColorProto.color {
case .none:
partialErrors.append(.restoreFrameError(
.invalidProtoData(.unrecognizedCustomChatStyleColor),
.forCustomChatColorError(chatColorId: customChatColorId)
))
continue
case .solid(let colorARGBHex):
colorOrGradientSetting = .solidColor(
color: OWSColor.fromARGBHex(colorARGBHex)
)
case .gradient(let gradient):
// iOS only supports 2 "positions". We take the first
// and the last colors and call it a day.
guard
gradient.colors.count > 0,
let firstColorARGBHex = gradient.colors.first,
let lastColorARGBHex = gradient.colors.last
else {
partialErrors.append(.restoreFrameError(
.invalidProtoData(.chatStyleGradientSingleOrNoColors),
.forCustomChatColorError(chatColorId: customChatColorId)
))
continue
}
// Angle is in degrees; convert to radians.
let angleRadians = CGFloat(gradient.angle) * .pi / 180
colorOrGradientSetting = .gradient(
gradientColor1: OWSColor.fromARGBHex(firstColorARGBHex),
gradientColor2: OWSColor.fromARGBHex(lastColorARGBHex),
angleRadians: angleRadians
)
}
let customChatColorKey = CustomChatColor.Key.generateRandom()
chatColorSettingStore.upsertCustomValue(
CustomChatColor(
colorSetting: colorOrGradientSetting,
// These dates don't really matter for anything other than sorting;
// they're not in the proto so just use the current date which is
// just as well.
creationTimestamp: {
let retVal = chatColorCreationTimestamp
chatColorCreationTimestamp += 1
return retVal
}()
),
for: customChatColorKey,
tx: context.tx
)
context.mapCustomChatColorId(
customChatColorId,
to: customChatColorKey
)
}
if partialErrors.isEmpty {
return .success
} else {
return .partialRestore(partialErrors)
}
}
// MARK: - Chat Style
/// Returns a nil result if no field has been explicitly set and therefore the default chat style
/// should be _unset_ on the settings proto.
func archiveDefaultChatStyle(
context: MessageBackup.CustomChatColorArchivingContext
) -> MessageBackup.ArchiveSingleFrameResult<BackupProto_ChatStyle?, MessageBackup.AccountDataId> {
return _archiveChatStyle(
thread: nil,
context: context,
errorId: .localUser
)
}
func archiveChatStyle(
thread: MessageBackup.ChatThread,
context: MessageBackup.CustomChatColorArchivingContext
) -> MessageBackup.ArchiveSingleFrameResult<BackupProto_ChatStyle?, MessageBackup.ThreadUniqueId> {
return _archiveChatStyle(
thread: thread,
context: context,
errorId: thread.tsThread.uniqueThreadIdentifier
)
}
/// thread = nil for the default global setting
private func _archiveChatStyle<IDType>(
thread: MessageBackup.ChatThread?,
context: MessageBackup.CustomChatColorArchivingContext,
errorId: IDType
) -> MessageBackup.ArchiveSingleFrameResult<BackupProto_ChatStyle?, IDType> {
var proto = BackupProto_ChatStyle()
// If none of the things that feed the fields of the chat style are
// _explicitly_ set, don't generate a chat style.
var hasAnExplicitlySetField = false
if let wallpaper = wallpaperStore.fetchWallpaper(for: thread?.tsThread.uniqueId, tx: context.tx) {
var protoWallpaper: BackupProto_ChatStyle.OneOf_Wallpaper?
if let preset = wallpaper.asBackupProto() {
protoWallpaper = .wallpaperPreset(preset)
} else if wallpaper == .photo {
switch self.archiveWallpaperAttachment(
thread: thread,
errorId: errorId,
context: context
) {
case .success(.some(let wallpaperAttachmentProto)):
protoWallpaper = .wallpaperPhoto(wallpaperAttachmentProto)
case .success(nil):
// No wallpaper found; don't set.
break
case .failure(let error):
return .failure(error)
}
} else {
return .failure(.archiveFrameError(
.unknownWallpaper,
errorId
))
}
if let protoWallpaper {
hasAnExplicitlySetField = true
proto.wallpaper = protoWallpaper
/// We'll set this to `.auto` for now, so it's never unset. If
/// we have an explicit bubble color we'll overwrite this below.
proto.bubbleColor = .autoBubbleColor(BackupProto_ChatStyle.AutomaticBubbleColor())
}
}
let hasBubbleStyle = chatColorSettingStore.hasChatColorSetting(
for: thread?.tsThread,
tx: context.tx
)
if hasBubbleStyle {
hasAnExplicitlySetField = true
let bubbleStyle = chatColorSettingStore.chatColorSetting(
for: thread?.tsThread,
tx: context.tx
)
switch bubbleStyle {
case .auto:
proto.bubbleColor = .autoBubbleColor(BackupProto_ChatStyle.AutomaticBubbleColor())
case .builtIn(let paletteChatColor):
proto.bubbleColor = .bubbleColorPreset(paletteChatColor.asBackupProto())
case .custom(let key, _):
guard let customColorId = context[key] else {
return .failure(.archiveFrameError(
.referencedCustomChatColorMissing(key),
errorId
))
}
proto.bubbleColor = .customColorID(customColorId.value)
}
}
let dimWallpaperInDarkMode = wallpaperStore.fetchOptionalDimInDarkMode(
for: thread?.tsThread.uniqueId,
tx: context.tx
)
if let dimWallpaperInDarkMode {
hasAnExplicitlySetField = true
proto.dimWallpaperInDarkMode = dimWallpaperInDarkMode
}
if hasAnExplicitlySetField {
return .success(proto)
} else {
return .success(nil)
}
}
/// - parameter chatStyleProto: Nil if unset in the parent proto (hasFoo is false)
func restoreDefaultChatStyle(
_ chatStyleProto: BackupProto_ChatStyle?,
context: MessageBackup.CustomChatColorRestoringContext
) -> MessageBackup.RestoreFrameResult<MessageBackup.AccountDataId> {
return _restoreChatStyle(
chatStyleProto,
thread: nil,
context: context,
errorId: .localUser
)
}
/// - parameter chatStyleProto: Nil if unset in the parent proto (hasFoo is false)
func restoreChatStyle(
_ chatStyleProto: BackupProto_ChatStyle?,
thread: MessageBackup.ChatThread,
chatId: MessageBackup.ChatId,
context: MessageBackup.CustomChatColorRestoringContext
) -> MessageBackup.RestoreFrameResult<MessageBackup.ChatId> {
return _restoreChatStyle(
chatStyleProto,
thread: thread,
context: context,
errorId: chatId
)
}
/// - parameter chatStyleProto: Nil if unset in the parent proto (hasFoo is false)
/// - parameter thread: Nil for the default global setting
private func _restoreChatStyle<IDType>(
_ chatStyleProto: BackupProto_ChatStyle?,
thread: MessageBackup.ChatThread?,
context: MessageBackup.CustomChatColorRestoringContext,
errorId: IDType
) -> MessageBackup.RestoreFrameResult<IDType> {
var partialErrors = [MessageBackup.RestoreFrameError<IDType>]()
if let chatStyleProto {
switch chatStyleProto.bubbleColor {
case .none:
// We can't differentiate between unset bubble color
// and an unknown type of bubble color oneof case. In
// either case, treat it as auto.
fallthrough
case .autoBubbleColor:
// Nothing to do! Auto is the default.
break
case .bubbleColorPreset(let bubbleColorPreset):
guard let palette = bubbleColorPreset.asPaletteChatColor() else {
return .failure([.restoreFrameError(
.invalidProtoData(.unrecognizedChatStyleBubbleColorPreset),
errorId
)])
}
chatColorSettingStore.setChatColorSetting(
ChatColorSetting.builtIn(palette),
for: thread?.tsThread,
tx: context.tx
)
case .customColorID(let customColorIdRaw):
let customColorId = MessageBackup.CustomChatColorId(value: customColorIdRaw)
guard let customColorKey = context[customColorId] else {
return .failure([.restoreFrameError(
.invalidProtoData(.customChatColorNotFound(customColorId)),
errorId
)])
}
guard
let customColor = chatColorSettingStore.fetchCustomValue(
for: customColorKey,
tx: context.tx
)
else {
return .failure([.restoreFrameError(
.referencedCustomChatColorNotFound(customColorKey),
errorId
)])
}
chatColorSettingStore.setChatColorSetting(
ChatColorSetting.custom(
customColorKey,
customColor
),
for: thread?.tsThread,
tx: context.tx
)
}
}
if let chatStyleProto {
wallpaperStore.setDimInDarkMode(
chatStyleProto.dimWallpaperInDarkMode,
for: thread?.tsThread.uniqueId,
tx: context.tx
)
}
if let chatStyleProto {
switch chatStyleProto.wallpaper {
case .none:
// We can't differentiate between unset wallpaper
// and an unknown type of wallpaper oneof case. In
// either case, leave the wallpaper unset.
break
case .wallpaperPreset(let wallpaperPreset):
guard let wallpaper = wallpaperPreset.asWallpaper() else {
return .failure([.restoreFrameError(
.invalidProtoData(.unrecognizedChatStyleWallpaperPreset),
errorId
)])
}
wallpaperStore.setWallpaperType(
wallpaper,
for: thread?.tsThread.uniqueId,
tx: context.tx
)
case .wallpaperPhoto(let filePointer):
wallpaperStore.setWallpaperType(
.photo,
for: thread?.tsThread.uniqueId,
tx: context.tx
)
let attachmentResult = restoreWallpaperAttachment(
filePointer,
thread: thread,
errorId: errorId,
context: context
)
switch attachmentResult {
case .success:
break
case .partialRestore(let errors):
partialErrors.append(contentsOf: errors)
case .failure(let errors):
partialErrors.append(contentsOf: errors)
return .failure(partialErrors)
}
}
}
if partialErrors.isEmpty {
return .success
} else {
return .partialRestore(partialErrors)
}
}
// MARK: - Wallpaper Images
private func archiveWallpaperAttachment<IDType>(
thread: MessageBackup.ChatThread?,
errorId: IDType,
context: MessageBackup.ArchivingContext
) -> MessageBackup.ArchiveSingleFrameResult<BackupProto_FilePointer?, IDType> {
let owner: AttachmentReference.OwnerId
if let thread {
owner = .threadWallpaperImage(threadRowId: thread.threadRowId)
} else {
owner = .globalThreadWallpaperImage
}
guard
let referencedAttachment = attachmentStore.fetchFirstReferencedAttachment(
for: owner,
tx: context.tx
)
else {
return .success(nil)
}
let isFreeTierBackup = MessageBackupMessageAttachmentArchiver.isFreeTierBackup()
if !isFreeTierBackup {
do {
try context.enqueueAttachmentForUploadIfNeeded(referencedAttachment)
} catch {
// Just log these errors, but count as success and proceed.
// The wallpaper just won't upload.
MessageBackup.collapse([.init(
error: MessageBackup.ArchiveFrameError<IDType>.archiveFrameError(
.failedToEnqueueAttachmentForUpload,
errorId
),
wasFatal: false
)]).forEach { $0.log() }
}
}
return .success(referencedAttachment.asBackupFilePointer(isFreeTierBackup: isFreeTierBackup))
}
private func restoreWallpaperAttachment<IDType>(
_ attachment: BackupProto_FilePointer,
thread: MessageBackup.ChatThread?,
errorId: IDType,
context: MessageBackup.CustomChatColorRestoringContext
) -> MessageBackup.RestoreFrameResult<IDType> {
let uploadEra: String
switch context.uploadEra {
case .fromProtoSubscriberId(let value), .random(let value):
uploadEra = value
case nil:
return .failure([.restoreFrameError(
.invalidProtoData(.accountDataNotFound),
errorId
)])
}
let ownedAttachment = OwnedAttachmentBackupPointerProto(
proto: attachment,
// Wallpapers never have any flag or client id
renderingFlag: .default,
clientUUID: nil,
owner: {
if let thread {
return .threadWallpaperImage(threadRowId: thread.threadRowId)
} else {
return .globalThreadWallpaperImage
}
}())
let errors = attachmentManager.createAttachmentPointers(
from: [ownedAttachment],
uploadEra: uploadEra,
tx: context.tx
)
guard errors.isEmpty else {
// Treat attachment failures as non-catastrophic; a thread without
// a wallpaper still works.
return .partialRestore(errors.map { error in
return .restoreFrameError(
.fromAttachmentCreationError(error),
errorId
)
})
}
let results = attachmentStore.fetchReferencedAttachments(owners: [ownedAttachment.owner.id], tx: context.tx)
if results.isEmpty {
return .partialRestore([.restoreFrameError(
.failedToCreateAttachment,
errorId
)])
}
do {
try results.forEach {
try backupAttachmentDownloadManager.enqueueIfNeeded($0, tx: context.tx)
}
} catch {
return .partialRestore([.restoreFrameError(
.failedToEnqueueAttachmentDownload(error),
errorId
)])
}
return .success
}
}
// MARK: - Converters
// MARK: Wallpaper presets
fileprivate extension Wallpaper {
func asBackupProto() -> BackupProto_ChatStyle.WallpaperPreset? {
// These don't match names exactly because...well nobody knows why
// the iOS enum names were defined this way. They're persisted to the
// db now, so we just gotta keep the mapping.
return switch self {
case .blush: .solidBlush
case .copper: .solidCopper
case .zorba: .solidDust
case .envy: .solidCeladon
case .sky: .solidPacific
case .wildBlueYonder: .solidFrost
case .lavender: .solidLilac
case .shocking: .solidPink
case .gray: .solidSilver
case .eden: .solidRainforest
case .violet: .solidNavy
case .eggplant: .solidEggplant
case .starshipGradient: .gradientSunset
case .woodsmokeGradient: .gradientNoir
case .coralGradient: .gradientHeatmap
case .ceruleanGradient: .gradientAqua
case .roseGradient: .gradientIridescent
case .aquamarineGradient: .gradientMonstera
case .tropicalGradient: .gradientBliss
case .blueGradient: .gradientSky
case .bisqueGradient: .gradientPeach
case .photo: nil
}
}
}
fileprivate extension BackupProto_ChatStyle.WallpaperPreset {
func asWallpaper() -> Wallpaper? {
// These don't match names exactly because...well nobody knows why
// the iOS enum names were defined this way. They're persisted to the
// db now, so we just gotta keep the mapping.
return switch self {
// NOTE: This should only return nil for unrecognized/unknown cases.
case .unknownWallpaperPreset: nil
case .UNRECOGNIZED: nil
case .solidBlush: .blush
case .solidCopper: .copper
case .solidDust: .zorba
case .solidCeladon: .envy
case .solidRainforest: .eden
case .solidPacific: .sky
case .solidFrost: .wildBlueYonder
case .solidNavy: .violet
case .solidLilac: .lavender
case .solidPink: .shocking
case .solidEggplant: .eggplant
case .solidSilver: .gray
case .gradientSunset: .starshipGradient
case .gradientNoir: .woodsmokeGradient
case .gradientHeatmap: .coralGradient
case .gradientAqua: .ceruleanGradient
case .gradientIridescent: .roseGradient
case .gradientMonstera: .aquamarineGradient
case .gradientBliss: .tropicalGradient
case .gradientSky: .blueGradient
case .gradientPeach: .bisqueGradient
}
}
}
// MARK: Bubble Color Presets
fileprivate extension PaletteChatColor {
func asBackupProto() -> BackupProto_ChatStyle.BubbleColorPreset {
return switch self {
case .ultramarine: .solidUltramarine
case .crimson: .solidCrimson
case .vermilion: .solidVermilion
case .burlap: .solidBurlap
case .forest: .solidForest
case .wintergreen: .solidWintergreen
case .teal: .solidTeal
case .blue: .solidBlue
case .indigo: .solidIndigo
case .violet: .solidViolet
case .plum: .solidPlum
case .taupe: .solidTaupe
case .steel: .solidSteel
case .ember: .gradientEmber
case .midnight: .gradientMidnight
case .infrared: .gradientInfrared
case .lagoon: .gradientLagoon
case .fluorescent: .gradientFluorescent
case .basil: .gradientBasil
case .sublime: .gradientSublime
case .sea: .gradientSea
case .tangerine: .gradientTangerine
}
}
}
fileprivate extension BackupProto_ChatStyle.BubbleColorPreset {
func asPaletteChatColor() -> PaletteChatColor? {
return switch self {
// NOTE: This should only return nil for unrecognized/unknown cases.
case .unknownBubbleColorPreset: nil
case .UNRECOGNIZED: nil
case .solidUltramarine: .ultramarine
case .solidCrimson: .crimson
case .solidVermilion: .vermilion
case .solidBurlap: .burlap
case .solidForest: .forest
case .solidWintergreen: .wintergreen
case .solidTeal: .teal
case .solidBlue: .blue
case .solidIndigo: .indigo
case .solidViolet: .violet
case .solidPlum: .plum
case .solidTaupe: .taupe
case .solidSteel: .steel
case .gradientEmber: .ember
case .gradientMidnight: .midnight
case .gradientInfrared: .infrared
case .gradientLagoon: .lagoon
case .gradientFluorescent: .fluorescent
case .gradientBasil: .basil
case .gradientSublime: .sublime
case .gradientSea: .sea
case .gradientTangerine: .tangerine
}
}
}
// MARK: OWSColor
private extension OWSColor {
/// Returns this color as an `0xAARRGGBB` hex value.
func asARGBHex() -> UInt32 {
let alphaComponent = UInt32(255) << 24
let redComponent = UInt32(round(red * 255)) << 16
let greenComponent = UInt32(round(green * 255)) << 8
let blueComponent = UInt32(round(blue * 255)) << 0
return alphaComponent | redComponent | greenComponent | blueComponent
}
/// Builds a color from an `0xAARRGGBB` hex value.
static func fromARGBHex(_ value: UInt32) -> OWSColor {
// let alpha = CGFloat(((value >> 24) & 0xff)) / 255.0
let red = CGFloat(((value >> 16) & 0xff)) / 255.0
let green = CGFloat(((value >> 8) & 0xff)) / 255.0
let blue = CGFloat(((value >> 0) & 0xff)) / 255.0
return OWSColor(red: red, green: green, blue: blue)
}
}