// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation public import SignalServiceKit // Supplies sticker pack data public protocol StickerPackDataSourceDelegate: AnyObject { func stickerPackDataDidChange() } // MARK: - // Supplies sticker pack data public protocol StickerPackDataSource: AnyObject { func add(delegate: StickerPackDataSourceDelegate) // This will be nil for the "recents" source. var info: StickerPackInfo? { get } var title: String? { get } var author: String? { get } func getStickerPack() -> StickerPackRecord? var installedCoverInfo: StickerInfo? { get } var installedStickerInfos: [StickerInfo] { get } func metadata(forSticker stickerInfo: StickerInfo) -> (any StickerMetadata)? } // MARK: - // A base class for StickerPackDataSource. public class BaseStickerPackDataSource: NSObject { // MARK: Delegates private var delegates = [Weak]() public func add(delegate: StickerPackDataSourceDelegate) { AssertIsOnMainThread() delegates.append(Weak(value: delegate)) } private lazy var didChangeEvent: DebouncedEvent = { DebouncedEvents.build( mode: .firstLast, maxFrequencySeconds: 0.5, onQueue: .main, ) { [weak self] in AssertIsOnMainThread() guard let self else { return } // Inform any observing views or data sources that they of the change. // We do this async since we are likely inside of a transaction // to avoid opening another transaction within it. let delegates = self.delegates DispatchQueue.main.async { for delegate in delegates { delegate.value?.stickerPackDataDidChange() } } } }() func fireDidChange() { AssertIsOnMainThread() didChangeEvent.requestNotify() } // MARK: Properties // This should only be set if the cover is available. // It might not be available if: // // * We're still downloading the manifest. // * We're still downloading the cover sticker data. // * There is no cover associated with this data source (e.g. "recent stickers"). fileprivate var coverInfo: StickerInfo? { didSet { AssertIsOnMainThread() if oldValue == nil, coverInfo != nil { fireDidChange() } } } // This should only be set for stickers which are available. // See comment on coverInfo. fileprivate var stickerInfos = [StickerInfo]() { didSet { AssertIsOnMainThread() if oldValue.count != stickerInfos.count { fireDidChange() } else { let oldKeySet = oldValue.map { $0.packId } if !stickerInfos.allSatisfy({ oldKeySet.contains($0.packId) }) { fireDidChange() } } } } } // MARK: - // Supplies sticker pack data for installed sticker packs. public class InstalledStickerPackDataSource: BaseStickerPackDataSource { // MARK: Properties private let stickerPackInfo: StickerPackInfo fileprivate var stickerPack: StickerPackRecord? { didSet { AssertIsOnMainThread() if oldValue == nil, stickerPack != nil { ensureDownloads() fireDidChange() } } } public init(stickerPackInfo: StickerPackInfo) { self.stickerPackInfo = stickerPackInfo super.init() NotificationCenter.default.addObserver( self, selector: #selector(stickersOrPacksDidChange), name: StickerManager.stickersOrPacksDidChange, object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(didBecomeActive), name: .OWSApplicationDidBecomeActive, object: nil, ) ensureState() } func ensureState() { SSKEnvironment.shared.databaseStorageRef.read { readTx in let stateTuple = Self.fetchInstalledState(for: self.stickerPackInfo, readTx: readTx) guard let stickerPack = stateTuple.stickerPack, stickerPack.isInstalled else { self.stickerPack = nil self.coverInfo = nil self.stickerInfos = [] return } self.stickerPack = stickerPack self.stickerInfos = stateTuple.installedStickers if self.coverInfo == nil, let coverInfo = stateTuple.installedCoverInfo { self.coverInfo = coverInfo } } } func ensureStateAsync(completion: (() -> Void)? = nil) { DispatchQueue.sharedUserInitiated.async { let stateTuple = SSKEnvironment.shared.databaseStorageRef.read { readTx in return Self.fetchInstalledState(for: self.stickerPackInfo, readTx: readTx) } DispatchQueue.main.async { guard let stickerPack = stateTuple.stickerPack, stickerPack.isInstalled else { self.stickerPack = nil self.coverInfo = nil self.stickerInfos = [] return } self.stickerPack = stickerPack self.stickerInfos = stateTuple.installedStickers if self.coverInfo == nil, let coverInfo = stateTuple.installedCoverInfo { self.coverInfo = coverInfo } completion?() } } } private static func fetchInstalledState(for stickerPackInfo: StickerPackInfo, readTx: DBReadTransaction) -> ( stickerPack: StickerPackRecord?, installedCoverInfo: StickerInfo?, installedStickers: [StickerInfo], ) { // Update Sticker Pack. guard let stickerPack = StickerManager.fetchStickerPack( stickerPackInfo: stickerPackInfo, transaction: readTx, ) else { return (nil, nil, []) } guard stickerPack.isInstalled else { // Ignore sticker packs which are "saved" but not "installed". return (nil, nil, []) } // Update Stickers. let coverInfo: StickerInfo? if StickerManager.isStickerInstalled(stickerInfo: stickerPack.coverInfo, transaction: readTx) { coverInfo = stickerPack.coverInfo } else { coverInfo = nil } let stickerInfos = StickerManager.installedStickers( forStickerPack: stickerPack, verifyExists: false, transaction: readTx, ) return (stickerPack, coverInfo, stickerInfos) } private func ensureDownloads() { guard let stickerPack else { return } // Download any missing stickers. _ = StickerManager.ensureDownloadsAsync(forStickerPack: stickerPack) } // MARK: Events @objc private func stickersOrPacksDidChange() { AssertIsOnMainThread() ensureStateAsync() } @objc private func didBecomeActive() { AssertIsOnMainThread() ensureStateAsync { self.ensureDownloads() } } } // MARK: - extension InstalledStickerPackDataSource: StickerPackDataSource { public var info: StickerPackInfo? { return stickerPackInfo } public var title: String? { return stickerPack?.title } public var author: String? { return stickerPack?.author } public func getStickerPack() -> StickerPackRecord? { return stickerPack } public var installedCoverInfo: StickerInfo? { AssertIsOnMainThread() return coverInfo } public var installedStickerInfos: [StickerInfo] { AssertIsOnMainThread() return stickerInfos } public func metadata(forSticker stickerInfo: StickerInfo) -> (any StickerMetadata)? { AssertIsOnMainThread() // This logic is perf-sensitive and on the main thread; // don't bother checking that the sticker data resides on disk. return StickerManager.installedStickerMetadataWithSneakyTransaction(stickerInfo: stickerInfo) } } // MARK: - // Supplies sticker pack data for NON-installed sticker packs. // // It uses a InstalledStickerPackDataSource internally so that // we use any installed data, if possible. public class TransientStickerPackDataSource: BaseStickerPackDataSource { // MARK: Properties private let stickerPackInfo: StickerPackInfo // If false, only download manifest and cover. private let shouldDownloadAllStickers: Bool fileprivate var stickerPack: StickerPackRecord? { didSet { AssertIsOnMainThread() guard stickerPack != nil else { owsFailDebug("Missing stickerPack.") return } fireDidChange() } } // If the pack is installed, we should use that data wherever possible. private let installedDataSource: InstalledStickerPackDataSource // This should only be accessed on the main thread. private var stickerMetadataMap = [String: any StickerMetadata]() private var temporaryFileUrls = [URL]() public init( stickerPackInfo: StickerPackInfo, shouldDownloadAllStickers: Bool, ) { self.stickerPackInfo = stickerPackInfo self.shouldDownloadAllStickers = shouldDownloadAllStickers self.installedDataSource = InstalledStickerPackDataSource(stickerPackInfo: stickerPackInfo) super.init() self.installedDataSource.add(delegate: self) NotificationCenter.default.addObserver( self, selector: #selector(didBecomeActive), name: .OWSApplicationDidBecomeActive, object: nil, ) ensureState() } deinit { // Eagerly clean up temp files. let temporaryFileUrls = self.temporaryFileUrls DispatchQueue.sharedUtility.async { for fileUrl in temporaryFileUrls { do { try OWSFileSystem.deleteFileIfExists(url: fileUrl) } catch { owsFailDebug("Error: \(error)") } } } } private func ensureState() { AssertIsOnMainThread() if installedDataSource.getStickerPack() != nil { // If the "installed" data source has data, // don't bother loading the manifest & sticker data. return } // If necessary, download and parse the pack's manifest. guard let stickerPack else { downloadStickerPack() return } // Try to download sticker data, if necessary. if ensureStickerDownload(stickerPack: stickerPack, stickerInfo: stickerPack.coverInfo) { self.coverInfo = stickerPack.coverInfo } else { self.coverInfo = nil } if shouldDownloadAllStickers { var downloadedStickerInfos = [StickerInfo]() for stickerInfo in stickerPack.stickerInfos() { if ensureStickerDownload(stickerPack: stickerPack, stickerInfo: stickerInfo) { downloadedStickerInfos.append(stickerInfo) } } self.stickerInfos = downloadedStickerInfos } else { self.stickerInfos = [] } } // This should only be accessed on the main thread. private var downloadKeySet = Set() private func downloadStickerPack() { AssertIsOnMainThread() let key = stickerPackInfo.asKey guard !downloadKeySet.contains(key) else { // Download already in flight. return } downloadKeySet.insert(key) StickerManager.tryToDownloadStickerPack(stickerPackInfo: stickerPackInfo) .done(on: DispatchQueue.main) { [weak self] stickerPack in guard let self else { return } guard self.stickerPack == nil else { return } self.stickerPack = stickerPack assert(self.downloadKeySet.contains(key)) self.downloadKeySet.remove(key) self.ensureState() self.fireDidChange() }.catch { [weak self] error in owsFailDebug("error: \(error)") guard let self else { return } assert(self.downloadKeySet.contains(key)) self.downloadKeySet.remove(key) // Sticker pack downloads may fail permanently, // which affects StickerManager state // so nudge the view to update even though the // the data source change may not have changed. self.fireDidChange() } } // Returns true if sticker is already downloaded. // If not, kicks off the download. private func ensureStickerDownload(stickerPack: StickerPackRecord, stickerInfo: StickerInfo) -> Bool { AssertIsOnMainThread() guard let stickerPackItem = stickerPack.stickerPackItem(forStickerInfo: stickerInfo) else { owsFailDebug("Couldn't find item for sticker info.") return false } guard nil == self.metadata(forSticker: stickerInfo) else { // This sticker is already downloaded. return true } let key = stickerInfo.asKey() guard !downloadKeySet.contains(key) else { // Download already in flight. return false } downloadKeySet.insert(key) // This sticker is not downloaded; try to download now. firstly(on: DispatchQueue.global()) { StickerManager.tryToDownloadSticker(stickerInfo: stickerInfo) }.done(on: DispatchQueue.main) { [weak self] temporaryFileUrl in guard let self else { return } self.temporaryFileUrls.append(temporaryFileUrl) assert(self.downloadKeySet.contains(key)) self.downloadKeySet.remove(key) self.set(temporaryFileUrl: temporaryFileUrl, stickerInfo: stickerInfo, stickerPackItem: stickerPackItem) }.catch { [weak self] error in owsFailDebug("error: \(error)") guard let self else { return } assert(self.downloadKeySet.contains(key)) self.downloadKeySet.remove(key) } return false } private func set( temporaryFileUrl: URL, stickerInfo: StickerInfo, stickerPackItem: StickerPackItem, ) { AssertIsOnMainThread() let key = stickerInfo.asKey() guard nil == stickerMetadataMap[key] else { return } let stickerType = StickerManager.stickerType(forContentType: stickerPackItem.contentType) let stickerMetadata = DecryptedStickerMetadata( stickerInfo: stickerInfo, stickerType: stickerType, stickerDataUrl: temporaryFileUrl, emojiString: stickerPackItem.emojiString, ) stickerMetadataMap[key] = stickerMetadata ensureState() fireDidChange() } // MARK: Events @objc private func didBecomeActive() { AssertIsOnMainThread() ensureState() } } // MARK: - extension TransientStickerPackDataSource: StickerPackDataSource { public var info: StickerPackInfo? { AssertIsOnMainThread() return stickerPackInfo } public var title: String? { AssertIsOnMainThread() if let stickerPack = installedDataSource.getStickerPack() { return stickerPack.title } return stickerPack?.title } public var author: String? { AssertIsOnMainThread() if let stickerPack = installedDataSource.getStickerPack() { return stickerPack.author } return stickerPack?.author } public func getStickerPack() -> StickerPackRecord? { AssertIsOnMainThread() if let stickerPack = installedDataSource.getStickerPack() { return stickerPack } return stickerPack } public var installedCoverInfo: StickerInfo? { AssertIsOnMainThread() if let coverInfo = installedDataSource.installedCoverInfo { return coverInfo } return coverInfo } public var installedStickerInfos: [StickerInfo] { AssertIsOnMainThread() let installedStickerInfos = installedDataSource.installedStickerInfos if installedStickerInfos.count > 0 { return installedStickerInfos } return stickerInfos } public func metadata(forSticker stickerInfo: StickerInfo) -> (any StickerMetadata)? { AssertIsOnMainThread() let key = stickerInfo.asKey() if let stickerMetadata = stickerMetadataMap[key] { return stickerMetadata } guard let stickerMetadata = StickerManager.installedStickerMetadataWithSneakyTransaction(stickerInfo: stickerInfo) else { return nil } stickerMetadataMap[key] = stickerMetadata return stickerMetadata } } // MARK: - extension TransientStickerPackDataSource: StickerPackDataSourceDelegate { public func stickerPackDataDidChange() { ensureState() } } // MARK: - // Supplies sticker pack data for recently used stickers. public class RecentStickerPackDataSource: BaseStickerPackDataSource { override public init() { super.init() NotificationCenter.default.addObserver( self, selector: #selector(recentStickersDidChange), name: StickerManager.recentStickersDidChange, object: nil, ) ensureState() } private func ensureState() { stickerInfos = StickerManager.recentStickers() } // MARK: Events @objc private func recentStickersDidChange() { AssertIsOnMainThread() ensureState() } } // MARK: - extension RecentStickerPackDataSource: StickerPackDataSource { public var info: StickerPackInfo? { owsFailDebug("This method should never be called.") return nil } public var title: String? { owsFailDebug("This method should never be called.") return nil } public var author: String? { owsFailDebug("This method should never be called.") return nil } public func getStickerPack() -> StickerPackRecord? { owsFailDebug("This method should never be called.") return nil } public var installedCoverInfo: StickerInfo? { owsFailDebug("This method should never be called.") return nil } public var installedStickerInfos: [StickerInfo] { AssertIsOnMainThread() return stickerInfos } public func metadata(forSticker stickerInfo: StickerInfo) -> (any StickerMetadata)? { AssertIsOnMainThread() // This logic is perf-sensitive and on the main thread; // don't bother checking that the sticker data resides on disk. return StickerManager.installedStickerMetadataWithSneakyTransaction(stickerInfo: stickerInfo) } } // MARK: - extension StickerPackRecord { func stickerPackItem(forStickerInfo stickerInfo: StickerInfo) -> StickerPackItem? { if cover.stickerId == stickerInfo.stickerId { return cover } for item in items { if item.stickerId == stickerInfo.stickerId { return item } } return nil } } // MARK: - extension StickerPackItem { var stickerType: StickerType { StickerManager.stickerType(forContentType: contentType) } }