Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/Avatars/AvatarHistoryManager.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import UIKit

/// Responsible for persisting the history of user-selected avatars.
///
/// At the time of writing, these include custom images, the stock icons, and
/// custom text overlaid over a colored background. They do not include default
/// avatars, such as "contact initials over a colored background".
///
/// - SeeAlso ``AvatarDefaultColorManager``
class AvatarHistoryManager {
    enum Context {
        case groupId(Data)
        case profile

        fileprivate var key: String {
            switch self {
            case .groupId(let data): return "group.\(data.hexadecimalString)"
            case .profile: return "profile"
            }
        }
    }

    private let db: any DB
    private let keyValueStore: KeyValueStore
    private let imageHistoryDirectory: URL

    init(
        appReadiness: AppReadiness,
        db: any DB,
    ) {
        self.db = db
        self.keyValueStore = KeyValueStore(collection: "AvatarHistory")
        self.imageHistoryDirectory = URL(
            fileURLWithPath: "AvatarHistory",
            isDirectory: true,
            relativeTo: URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()),
        )
    }

    func cleanupOrphanedImages() async {
        guard OWSFileSystem.fileOrFolderExists(url: imageHistoryDirectory) else { return }

        let allRecords: [[AvatarRecord]] = db.read { tx in
            do {
                return try keyValueStore.allCodableValues(transaction: tx)
            } catch {
                owsFailDebug("Failed to decode avatar history for orphan cleanup \(error)")
                return []
            }
        }

        let filesToKeep = allRecords.flatMap { $0.compactMap { $0.imageUrl?.path } }

        let filesInDirectory: [String]
        do {
            filesInDirectory = try OWSFileSystem.recursiveFilesInDirectory(imageHistoryDirectory.path)
        } catch {
            owsFailDebug("Failed to lookup files in image history directory \(error)")
            return
        }

        var orphanCount = 0
        for file in filesInDirectory where !filesToKeep.contains(file) {
            guard OWSFileSystem.deleteFile(file) else {
                owsFailDebug("Failed to delete orphaned avatar image file \(file)")
                continue
            }
            orphanCount += 1
        }

        if orphanCount > 0 {
            Logger.info("Deleted \(orphanCount) orphaned avatar images.")
        }
    }

    func touchedModel(_ model: AvatarModel, in context: Context, tx: DBWriteTransaction) {
        var models = models(for: context, tx: tx)

        models.removeAll { $0.identifier == model.identifier }
        models.insert(model, at: 0)

        let records: [AvatarRecord] = models.map { model in
            switch model.type {
            case .icon(let icon):
                owsAssertDebug(model.identifier == icon.rawValue)
                return AvatarRecord(kind: .icon, identifier: model.identifier, imageUrl: nil, text: nil, theme: model.theme.rawValue)
            case .image(let url):
                return AvatarRecord(kind: .image, identifier: model.identifier, imageUrl: url, text: nil, theme: model.theme.rawValue)
            case .text(let text):
                return AvatarRecord(kind: .text, identifier: model.identifier, imageUrl: nil, text: text, theme: model.theme.rawValue)
            }
        }

        do {
            try keyValueStore.setCodable(records, key: context.key, transaction: tx)
        } catch {
            owsFailDebug("Failed to touch avatar history \(error)")
        }
    }

    func deletedModel(_ model: AvatarModel, in context: Context, tx: DBWriteTransaction) {
        var models = models(for: context, tx: tx)

        models.removeAll { $0.identifier == model.identifier }

        if case .image(let url) = model.type {
            OWSFileSystem.deleteFileIfExists(url.path)
        }

        let records: [AvatarRecord] = models.map { model in
            switch model.type {
            case .icon(let icon):
                owsAssertDebug(model.identifier == icon.rawValue)
                return AvatarRecord(kind: .icon, identifier: model.identifier, imageUrl: nil, text: nil, theme: model.theme.rawValue)
            case .image(let url):
                return AvatarRecord(kind: .image, identifier: model.identifier, imageUrl: url, text: nil, theme: model.theme.rawValue)
            case .text(let text):
                return AvatarRecord(kind: .text, identifier: model.identifier, imageUrl: nil, text: text, theme: model.theme.rawValue)
            }
        }

        do {
            try keyValueStore.setCodable(records, key: context.key, transaction: tx)
        } catch {
            owsFailDebug("Failed to touch avatar history \(error)")
        }
    }

    func recordModelForImage(_ image: UIImage, in context: Context, tx: DBWriteTransaction) -> AvatarModel? {
        OWSFileSystem.ensureDirectoryExists(imageHistoryDirectory.path)

        let identifier = UUID().uuidString
        let url = URL(fileURLWithPath: identifier + ".jpg", relativeTo: imageHistoryDirectory)

        guard let avatarData = OWSProfileManager.avatarData(avatarImage: image) else {
            owsFailDebug("avatarData was nil")
            return nil
        }
        do {
            try avatarData.write(to: url)
        } catch {
            owsFailDebug("Failed to record model for image \(error)")
            return nil
        }

        let model = AvatarModel(identifier: identifier, type: .image(url), theme: .default)
        touchedModel(model, in: context, tx: tx)
        return model
    }

    func models(
        for context: Context,
        tx: DBReadTransaction,
    ) -> [AvatarModel] {
        let records: [AvatarRecord]?

        do {
            records = try keyValueStore.getCodableValue(forKey: context.key, transaction: tx)
        } catch {
            owsFailDebug("Failed to load persisted avatar records \(error)")
            records = nil
        }

        var models = [AvatarModel]()

        for record in records ?? [] {
            switch record.kind {
            case .icon:
                guard let icon = AvatarIcon(rawValue: record.identifier) else {
                    owsFailDebug("Invalid avatar icon \(record.identifier)")
                    continue
                }
                models.append(.init(
                    identifier: record.identifier,
                    type: .icon(icon),
                    theme: AvatarTheme(rawValue: record.theme) ?? .default,
                ))
            case .image:
                guard let imageUrl = record.imageUrl, OWSFileSystem.fileOrFolderExists(url: imageUrl) else {
                    owsFailDebug("Invalid avatar image \(record.identifier)")
                    continue
                }
                models.append(.init(
                    identifier: record.identifier,
                    type: .image(imageUrl),
                    theme: AvatarTheme(rawValue: record.theme) ?? .default,
                ))
            case .text:
                guard let text = record.text else {
                    owsFailDebug("Missing avatar text")
                    continue
                }
                models.append(.init(
                    identifier: record.identifier,
                    type: .text(text),
                    theme: AvatarTheme(rawValue: record.theme) ?? .default,
                ))
            }
        }

        return models
    }
}

// We don't encode an AvatarModel directly to future proof
// us against changes to AvatarIcon, AvatarType, etc. enums
// since Codable is brittle when it encounters things it
// doesn't know about.
private struct AvatarRecord: Codable {
    enum Kind: String, Codable {
        case icon
        case text
        case image
    }

    let kind: Kind
    let identifier: String
    let imageUrl: URL?
    let text: String?
    let theme: String
}