// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import LibSignalClient // Stickers // // * Stickers are either "installed" or not. // * We can only send installed stickers. // * When we receive a sticker, we download it like any other attachment... // ...unless we have it installed in which case we skip the download as // an optimization. // // Sticker Packs // // * Some "default" sticker packs ship in the app. See DefaultStickerPack. // Some "default" packs auto-install, others don't. // * Other packs can be installed from "sticker pack shares" and "sticker pack URLs". // * There are also "known" packs, e.g. packs the client knows about because // we received a sticker from that pack. // * All of the above (default packs, packs installed from shares, known packs) // show up in the sticker management view. Those that are installed are // shown as such; the others are shown as "available". // * We download pack manifests & covers for "default" but not "known" packs. // Once we've download the manifest the pack is "saved" but not "installed". // * We discard sticker and pack info once it is no longer in use. @objc public class StickerManager: NSObject { // MARK: - Constants @objc public static let packKeyLength: UInt = 32 // MARK: - Notifications public static let packsDidChange = Notification.Name("packsDidChange") public static let stickersOrPacksDidChange = Notification.Name("stickersOrPacksDidChange") public static let recentStickersDidChange = Notification.Name("recentStickersDidChange") private static let packsDidChangeEvent: DebouncedEvent = DebouncedEvents.build( mode: .firstLast, maxFrequencySeconds: 0.5, onQueue: .global(), notifyBlock: { NotificationCenter.default.postOnMainThread(name: packsDidChange, object: nil) NotificationCenter.default.postOnMainThread(name: stickersOrPacksDidChange, object: nil) } ) private static let stickersDidChangeEvent: DebouncedEvent = DebouncedEvents.build( mode: .firstLast, maxFrequencySeconds: 0.5, onQueue: .global(), notifyBlock: { NotificationCenter.default.postOnMainThread(name: stickersOrPacksDidChange, object: nil) } ) // MARK: - Properties public static let store = KeyValueStore(collection: "recentStickers") public static let emojiMapStore = KeyValueStore(collection: "emojiMap") public enum InstallMode: Int { case doNotInstall case install // For default packs that should be auto-installed, // we only want to install the first time we save them. // If a user subsequently uninstalls the pack, we want // to honor that. case installIfUnsaved } private let queueLoader: TaskQueueLoader // MARK: - Initializers init( appReadiness: AppReadiness, dateProvider: @escaping DateProvider ) { // Task queue to install any sticker packs restored from a backup self.queueLoader = TaskQueueLoader( maxConcurrentTasks: 4, dateProvider: dateProvider, db: DependenciesBridge.shared.db, runner: StickerPackDownloadTaskRunner( store: StickerPackDownloadTaskRecordStore( store: BackupStickerPackDownloadStoreImpl() ) ) ) super.init() // Resume sticker and sticker pack downloads when app is ready. appReadiness.runNowOrWhenMainAppDidBecomeReadyAsync { if DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered { Task { // This will return once all restored sticker packs have been downloaded try await self.queueLoader.loadAndRunTasks() // Refresh contents after pending downloads complete StickerManager.refreshContents() } } } } // Attempt to download any sticker packs restored via backup. public static func downloadPendingSickerPacks() async throws { try await SSKEnvironment.shared.stickerManagerRef.queueLoader.loadAndRunTasks() } // The sticker manager is responsible for downloading more than one kind // of content; those downloads can fail. Therefore the sticker manager // retries those downloads, sometimes in response to user activity. public class func refreshContents() { // Try to download the manifests for "default" sticker packs. tryToDownloadDefaultStickerPacks() // Try to download the stickers for "installed" sticker packs. ensureAllStickerDownloadsAsync() } // MARK: - Paths private class func ensureCacheDirUrl() -> URL { var url = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) url.appendPathComponent("StickerManager") OWSFileSystem.ensureDirectoryExists(url.path) return url } private static let _cacheDirUrl = { return ensureCacheDirUrl() }() public class func cacheDirUrl() -> URL { // In test we need to compute the sticker cache dir every time we use it, // since it will change from test to test. // // In production, we should only ensure it once because ensuring: // // * Makes sure it exists on disk which is expensive. // * Makes sure it is protected on disk which is expensive. // * Does some logging which really clutters up the logs once you have // a bunch of stickers installed. if CurrentAppContext().isRunningTests { return ensureCacheDirUrl() } else { return _cacheDirUrl } } // MARK: - Sticker Packs public class func allStickerPacks() -> [StickerPack] { var result = [StickerPack]() SSKEnvironment.shared.databaseStorageRef.read { (transaction) in result += allStickerPacks(transaction: transaction) } return result } public class func allStickerPacks(transaction: DBReadTransaction) -> [StickerPack] { return StickerPack.anyFetchAll(transaction: transaction) } public class func installedStickerPacks(transaction: DBReadTransaction) -> [StickerPack] { return allStickerPacks(transaction: transaction).filter { $0.isInstalled } } public class func availableStickerPacks(transaction: DBReadTransaction) -> [StickerPack] { return allStickerPacks(transaction: transaction).filter { !$0.isInstalled } } public class func isStickerPackSaved(stickerPackInfo: StickerPackInfo) -> Bool { return SSKEnvironment.shared.databaseStorageRef.read { (transaction) in return isStickerPackSaved(stickerPackInfo: stickerPackInfo, transaction: transaction) } } public class func isStickerPackSaved(stickerPackInfo: StickerPackInfo, transaction: DBReadTransaction) -> Bool { return nil != fetchStickerPack(stickerPackInfo: stickerPackInfo, transaction: transaction) } public class func uninstallStickerPack( stickerPackInfo: StickerPackInfo, wasLocallyInitiated: Bool, transaction: DBWriteTransaction ) { guard let stickerPack = fetchStickerPack(stickerPackInfo: stickerPackInfo, transaction: transaction) else { Logger.info("Skipping uninstall; not saved or installed.") return } let isDefaultStickerPack = DefaultStickerPack.isDefaultStickerPack(packId: stickerPackInfo.packId) let shouldRemove = !isDefaultStickerPack if shouldRemove { uninstallSticker(stickerInfo: stickerPack.coverInfo, transaction: transaction) for stickerInfo in stickerPack.stickerInfos { if stickerInfo == stickerPack.coverInfo { // Don't uninstall the cover for saved packs. continue } uninstallSticker(stickerInfo: stickerInfo, transaction: transaction) } stickerPack.anyRemove(transaction: transaction) } else { stickerPack.update(withIsInstalled: false, transaction: transaction) } if wasLocallyInitiated { enqueueStickerSyncMessage( operationType: .remove, packs: [stickerPackInfo], transaction: transaction ) } transaction.addSyncCompletion { packsDidChangeEvent.requestNotify() } } public class func installStickerPack( stickerPack: StickerPack, wasLocallyInitiated: Bool, transaction: DBWriteTransaction ) { upsertStickerPack( stickerPack: stickerPack, installMode: .install, wasLocallyInitiated: wasLocallyInitiated, transaction: transaction ) } public class func fetchStickerPack(stickerPackInfo: StickerPackInfo) -> StickerPack? { return SSKEnvironment.shared.databaseStorageRef.read { (transaction) in return fetchStickerPack(stickerPackInfo: stickerPackInfo, transaction: transaction) } } public class func fetchStickerPack(stickerPackInfo: StickerPackInfo, transaction: DBReadTransaction) -> StickerPack? { let uniqueId = StickerPack.uniqueId(for: stickerPackInfo) return StickerPack.anyFetch(uniqueId: uniqueId, transaction: transaction) } private class func tryToDownloadAndSaveStickerPack( stickerPackInfo: StickerPackInfo, installMode: InstallMode, wasLocallyInitiated: Bool ) { tryToDownloadStickerPack(stickerPackInfo: stickerPackInfo).done(on: DispatchQueue.global()) { (stickerPack) in self.upsertStickerPack( stickerPack: stickerPack, installMode: installMode, wasLocallyInitiated: wasLocallyInitiated ) }.cauterize() } private let packOperationQueue = ConcurrentTaskQueue(concurrentLimit: 3) private func tryToDownloadStickerPack(stickerPackInfo: StickerPackInfo) -> Promise { return Promise.wrapAsync { [packOperationQueue] in return try await packOperationQueue.run { return try await DownloadStickerPackOperation.run(stickerPackInfo: stickerPackInfo) } } } // This method is public so that we can download "transient" (uninstalled) sticker packs. public class func tryToDownloadStickerPack(stickerPackInfo: StickerPackInfo) -> Promise { return SSKEnvironment.shared.stickerManagerRef.tryToDownloadStickerPack(stickerPackInfo: stickerPackInfo) } private class func upsertStickerPack( stickerPack: StickerPack, installMode: InstallMode, wasLocallyInitiated: Bool ) { SSKEnvironment.shared.databaseStorageRef.write { (transaction) in upsertStickerPack( stickerPack: stickerPack, installMode: installMode, wasLocallyInitiated: wasLocallyInitiated, transaction: transaction ) } } private class func upsertStickerPack( stickerPack stickerPackParam: StickerPack, installMode: InstallMode, wasLocallyInitiated: Bool, transaction: DBWriteTransaction ) { // If we re-insert a sticker pack, make sure that it // has a new row id. let stickerPack = stickerPackParam.copy() as! StickerPack stickerPack.clearRowId() let oldCopy = fetchStickerPack(stickerPackInfo: stickerPack.info, transaction: transaction) let wasSaved = oldCopy != nil // Preserve old mutable state. if let oldCopy = oldCopy { stickerPack.update(withIsInstalled: oldCopy.isInstalled, transaction: transaction) } else { stickerPack.anyInsert(transaction: transaction) } if stickerPack.isInstalled, wasLocallyInitiated { enqueueStickerSyncMessage( operationType: .install, packs: [stickerPack.info], transaction: transaction ) } // If the pack is already installed, make sure all stickers are installed. let promise: Promise if stickerPack.isInstalled { promise = installStickerPackContents(stickerPack: stickerPack, transaction: transaction) } else { switch installMode { case .doNotInstall: promise = .value(()) case .install: promise = self.markSavedStickerPackAsInstalled( stickerPack: stickerPack, wasLocallyInitiated: wasLocallyInitiated, transaction: transaction ) case .installIfUnsaved: if !wasSaved { promise = self.markSavedStickerPackAsInstalled( stickerPack: stickerPack, wasLocallyInitiated: wasLocallyInitiated, transaction: transaction ) } else { promise = .value(()) } } } transaction.addSyncCompletion { _ = promise.ensure { packsDidChangeEvent.requestNotify() } } } private class func markSavedStickerPackAsInstalled( stickerPack: StickerPack, wasLocallyInitiated: Bool, transaction: DBWriteTransaction ) -> Promise { if stickerPack.isInstalled { return .value(()) } stickerPack.update(withIsInstalled: true, transaction: transaction) let promise = installStickerPackContents(stickerPack: stickerPack, transaction: transaction) if wasLocallyInitiated { enqueueStickerSyncMessage( operationType: .install, packs: [stickerPack.info], transaction: transaction ) } return promise } private class func installStickerPackContents( stickerPack: StickerPack, transaction: DBReadTransaction, onlyInstallCover: Bool = false ) -> Promise { // Note: It's safe to kick off downloads of stickers that are already installed. var fetches = [Promise]() var needsNotify = false // The cover. let coverFetch = firstly { tryToDownloadAndInstallSticker( stickerPack: stickerPack, item: stickerPack.cover, transaction: transaction ) }.done { (shouldNotify: Bool) in if shouldNotify { stickersDidChangeEvent.requestNotify() needsNotify = false } } fetches.append(coverFetch) guard !onlyInstallCover else { return Promise.when(fulfilled: fetches) } // The stickers. for item in stickerPack.items { fetches.append( firstly { tryToDownloadAndInstallSticker( stickerPack: stickerPack, item: item, transaction: transaction ) }.done { shouldNotify in if shouldNotify, coverFetch.isSealed { // We should only notify for changes once we've fetched the cover // Some views will assume that an installed pack always has a cover // and faildebug otherwise stickersDidChangeEvent.requestNotify() needsNotify = false } else if shouldNotify { needsNotify = true } } ) } return Promise.when(fulfilled: fetches).ensure { if needsNotify { stickersDidChangeEvent.requestNotify() } } } private class func tryToDownloadDefaultStickerPacks() { DispatchQueue.global().async { self.tryToDownloadStickerPacks( stickerPacks: DefaultStickerPack.packsToAutoInstall, installMode: .installIfUnsaved ) self.tryToDownloadStickerPacks( stickerPacks: DefaultStickerPack.packsToNotAutoInstall, installMode: .doNotInstall ) } } public class func installedStickers( forStickerPack stickerPack: StickerPack, verifyExists: Bool ) -> [StickerInfo] { return SSKEnvironment.shared.databaseStorageRef.read { (transaction) in return self.installedStickers( forStickerPack: stickerPack, verifyExists: verifyExists, transaction: transaction ) } } public class func installedStickers( forStickerPack stickerPack: StickerPack, verifyExists: Bool, transaction: DBReadTransaction ) -> [StickerInfo] { var result = [StickerInfo]() for stickerInfo in stickerPack.stickerInfos { let uniqueId = InstalledSticker.uniqueId(for: stickerInfo) guard let installedSticker = InstalledSticker.anyFetch(uniqueId: uniqueId, transaction: transaction) else { continue } if verifyExists, self.stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: verifyExists) == nil { continue } result.append(stickerInfo) } return result } public class func isDefaultStickerPack(packId: Data) -> Bool { return DefaultStickerPack.isDefaultStickerPack(packId: packId) } public class func isStickerPackInstalled(stickerPackInfo: StickerPackInfo) -> Bool { return SSKEnvironment.shared.databaseStorageRef.read { (transaction) in return isStickerPackInstalled(stickerPackInfo: stickerPackInfo, transaction: transaction) } } public class func isStickerPackInstalled(stickerPackInfo: StickerPackInfo, transaction: DBReadTransaction) -> Bool { guard let pack = fetchStickerPack(stickerPackInfo: stickerPackInfo, transaction: transaction) else { return false } return pack.isInstalled } // MARK: - Stickers public static func stickerType(forContentType contentType: String?) -> StickerType { return StickerType.stickerType(forContentType: contentType) } public class func installedStickerMetadataWithSneakyTransaction(stickerInfo: StickerInfo) -> (any StickerMetadata)? { return SSKEnvironment.shared.databaseStorageRef.read { transaction in return self.installedStickerMetadata(stickerInfo: stickerInfo, transaction: transaction) } } public class func installedStickerMetadata( stickerInfo: StickerInfo, transaction: DBReadTransaction ) -> (any StickerMetadata)? { let uniqueId = InstalledSticker.uniqueId(for: stickerInfo) guard let installedSticker = InstalledSticker.anyFetch(uniqueId: uniqueId, transaction: transaction) else { return nil } return installedStickerMetadata(installedSticker: installedSticker, transaction: transaction) } public class func installedStickerMetadata( installedSticker: InstalledSticker, transaction: DBReadTransaction ) -> (any StickerMetadata)? { let stickerInfo = installedSticker.info let stickerType = StickerType.stickerType(forContentType: installedSticker.contentType) guard let stickerDataUrl = self.stickerDataUrl(stickerInfo: stickerInfo, stickerType: stickerType, verifyExists: true) else { return nil } return DecryptedStickerMetadata( stickerInfo: stickerInfo, stickerType: stickerType, stickerDataUrl: stickerDataUrl, emojiString: installedSticker.emojiString ) } public class func stickerDataUrlWithSneakyTransaction(stickerInfo: StickerInfo, verifyExists: Bool) -> URL? { guard let installedSticker = fetchInstalledStickerWithSneakyTransaction(stickerInfo: stickerInfo) else { return nil } return self.stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: verifyExists) } private class func stickerDataUrl(stickerInfo: StickerInfo, contentType: String?, verifyExists: Bool) -> URL? { let stickerType = StickerType.stickerType(forContentType: contentType) return stickerDataUrl(stickerInfo: stickerInfo, stickerType: stickerType, verifyExists: verifyExists) } public class func stickerDataUrl(forInstalledSticker installedSticker: InstalledSticker, verifyExists: Bool) -> URL? { let stickerInfo = installedSticker.info let stickerType = StickerType.stickerType(forContentType: installedSticker.contentType) return stickerDataUrl(stickerInfo: stickerInfo, stickerType: stickerType, verifyExists: verifyExists) } private class func stickerDataUrl(stickerInfo: StickerInfo, stickerType: StickerType, verifyExists: Bool) -> URL? { let uniqueId = InstalledSticker.uniqueId(for: stickerInfo) var url = cacheDirUrl() // Not all stickers are .webp. url.appendPathComponent("\(uniqueId).\(stickerType.fileExtension)") if verifyExists && !OWSFileSystem.fileOrFolderExists(url: url) { return nil } return url } public class func filePathsForAllInstalledStickers(transaction: DBReadTransaction) -> [String] { var filePaths = [String]() InstalledSticker.anyEnumerate(transaction: transaction) { (installedSticker, _) in if let stickerDataUrl = stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: false) { filePaths.append(stickerDataUrl.path) } } return filePaths } public class func isStickerInstalled(stickerInfo: StickerInfo) -> Bool { return SSKEnvironment.shared.databaseStorageRef.read { (transaction) in return isStickerInstalled(stickerInfo: stickerInfo, transaction: transaction) } } public class func isStickerInstalled(stickerInfo: StickerInfo, transaction: DBReadTransaction) -> Bool { let uniqueId = InstalledSticker.uniqueId(for: stickerInfo) // We use anyFetch(...) instead of anyExists(...) to // leverage the model cache. return InstalledSticker.anyFetch(uniqueId: uniqueId, transaction: transaction) != nil } internal typealias CleanupCompletion = () -> Void internal class func uninstallSticker(stickerInfo: StickerInfo, transaction: DBWriteTransaction) { guard let installedSticker = fetchInstalledSticker(stickerInfo: stickerInfo, transaction: transaction) else { Logger.info("Skipping uninstall; not installed.") return } installedSticker.anyRemove(transaction: transaction) removeFromRecentStickers(stickerInfo, transaction: transaction) removeStickerFromEmojiMap(installedSticker, tx: transaction) guard let stickerDataUrl = self.stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: false) else { owsFailDebug("Could not generate sticker data URL.") return } // Cleans up the sticker data on disk. We want to do these deletions // after the transaction is complete so that other transactions aren't // blocked. transaction.addSyncCompletion { Task { do { try OWSFileSystem.deleteFileIfExists(url: stickerDataUrl) } catch { owsFailDebug("Error: \(error)") } } } // No need to post stickersOrPacksDidChange; caller will do that. } public class func fetchInstalledStickerWithSneakyTransaction(stickerInfo: StickerInfo) -> InstalledSticker? { return SSKEnvironment.shared.databaseStorageRef.read { transaction in return self.fetchInstalledSticker(stickerInfo: stickerInfo, transaction: transaction) } } public class func fetchInstalledSticker(stickerInfo: StickerInfo, transaction: DBReadTransaction) -> InstalledSticker? { let uniqueId = InstalledSticker.uniqueId(for: stickerInfo) return InstalledSticker.anyFetch(uniqueId: uniqueId, transaction: transaction) } public class func fetchInstalledSticker( packId: Data, stickerId: UInt32, transaction: DBReadTransaction ) -> InstalledSticker? { let uniqueId = StickerInfo.key(withPackId: packId, stickerId: stickerId) return InstalledSticker.anyFetch(uniqueId: uniqueId, transaction: transaction) } public class func installSticker( stickerInfo: StickerInfo, stickerUrl stickerTemporaryUrl: URL, contentType: String?, emojiString: String? ) -> Bool { guard nil == fetchInstalledStickerWithSneakyTransaction(stickerInfo: stickerInfo) else { // Sticker already installed, skip. return false } let installedSticker = InstalledSticker( info: stickerInfo, contentType: contentType, emojiString: emojiString ) guard let stickerDataUrl = self.stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: false) else { owsFailDebug("Could not generate sticker data URL.") return false } do { // We copy the file rather than move it as some transient data sources // could be trying to access the temp file at this point. Stickers are // generally very small so this shouldn't be a big perf hit. try OWSFileSystem.deleteFileIfExists(url: stickerDataUrl) try FileManager.default.copyItem(at: stickerTemporaryUrl, to: stickerDataUrl) } catch CocoaError.fileWriteFileExists { // We hit a race condition and somebody else put the file here after we // deleted it. It's fine to continue... } catch let error { owsFailDebug("File write failed: \(error)") return false } return SSKEnvironment.shared.databaseStorageRef.write { (transaction) -> Bool in guard nil == fetchInstalledSticker(stickerInfo: stickerInfo, transaction: transaction) else { // RACE: sticker has already been installed between now and when we last checked. // // Initially we check for a stickers presence with a read transaction, to avoid opening // an unnecessary write transaction. However, it's possible a race has occurred and the // sticker has since been installed, in which case there's nothing more for us to do. return false } installedSticker.anyInsert(transaction: transaction) do { // For every attachment referencing this sticker, we can now enqueue a "download" // that's really just a local file clone, from the newly installed sticker. let attachmentIds = try DependenciesBridge.shared.attachmentStore.allAttachmentIdsForSticker( stickerInfo, tx: transaction ) try attachmentIds.forEach { attachmentId in try DependenciesBridge.shared.attachmentDownloadStore.enqueueDownloadOfAttachment( withId: attachmentId, // source is irrelevant with localClone priority source: .transitTier, priority: .localClone, tx: transaction ) } } catch { owsFailDebug("Failed to enqueue attachments \(error.grdbErrorForLogging)") } self.addStickerToEmojiMap(installedSticker, tx: transaction) return true } } private class func tryToDownloadAndInstallSticker( stickerPack: StickerPack, item: StickerPackItem, transaction: DBReadTransaction ) -> Promise { let stickerInfo: StickerInfo = item.stickerInfo(with: stickerPack) let emojiString = item.emojiString guard !self.isStickerInstalled(stickerInfo: stickerInfo, transaction: transaction) else { // Skipping redundant sticker install. return .value(false) } return firstly { tryToDownloadSticker(stickerPack: stickerPack, stickerInfo: stickerInfo) }.map(on: DispatchQueue.global()) { stickerUrl in self.installSticker( stickerInfo: stickerInfo, stickerUrl: stickerUrl, contentType: item.contentType, emojiString: emojiString ) } } private struct StickerDownload { let promise: Promise let future: Future init() { let (promise, future) = Promise.pending() self.promise = promise self.future = future } } private let stickerDownloads = AtomicValue<[String: StickerDownload]>([:], lock: .init()) private let stickerOperationQueue = ConcurrentTaskQueue(concurrentLimit: 4) private func tryToDownloadSticker(stickerPack: StickerPack, stickerInfo: StickerInfo) -> Promise { let (stickerDownload, shouldStartTask) = stickerDownloads.update { if let stickerDownload = $0[stickerInfo.asKey()] { return (stickerDownload, false) } let stickerDownload = StickerDownload() $0[stickerInfo.asKey()] = stickerDownload return (stickerDownload, true) } if shouldStartTask { Task { [stickerDownloads] in let result = await Result { try await DownloadStickerOperation.run(stickerInfo: stickerInfo) } stickerDownloads.update { $0.removeValue(forKey: stickerInfo.asKey()) } switch result { case .success(let url): stickerDownload.future.resolve(url) case .failure(let error): stickerDownload.future.reject(error) } } } return stickerDownload.promise } // This method is public so that we can download "transient" (uninstalled) stickers. public class func tryToDownloadSticker(stickerPack: StickerPack, stickerInfo: StickerInfo) -> Promise { SSKEnvironment.shared.stickerManagerRef.tryToDownloadSticker(stickerPack: stickerPack, stickerInfo: stickerInfo) } // MARK: - Emoji static func allEmoji(in emojiString: String) -> LazyFilterSequence.Elements> { return emojiString.lazy.filter { $0.unicodeScalars.containsOnlyEmoji() } } static func firstEmoji(in emojiString: String) -> String? { return allEmoji(in: emojiString).first.map(String.init) } private class func addStickerToEmojiMap(_ installedSticker: InstalledSticker, tx: DBWriteTransaction) { guard let emojiString = installedSticker.emojiString else { return } let stickerId = installedSticker.uniqueId for emoji in allEmoji(in: emojiString) { emojiMapStore.prependToOrderedUniqueArray(key: String(emoji), value: stickerId, tx: tx) } } private class func removeStickerFromEmojiMap(_ installedSticker: InstalledSticker, tx: DBWriteTransaction) { guard let emojiString = installedSticker.emojiString else { return } let stickerId = installedSticker.uniqueId for emoji in allEmoji(in: emojiString) { emojiMapStore.removeFromOrderedUniqueArray(key: String(emoji), value: stickerId, tx: tx) } } public static func suggestedStickerEmoji(chatBoxText: String) -> Character? { // We must have a Character, we must have no other Characters, and it must // contain only emoji. guard let firstCharacter = chatBoxText.first, chatBoxText.dropFirst().isEmpty, firstCharacter.unicodeScalars.containsOnlyEmoji() else { return nil } return firstCharacter } public class func suggestedStickers(for emoji: Character, tx: DBReadTransaction) -> [InstalledSticker] { let stickerIds = emojiMapStore.orderedUniqueArray(forKey: String(emoji), tx: tx) return stickerIds.compactMap { (stickerId) in guard let installedSticker = InstalledSticker.anyFetch(uniqueId: stickerId, transaction: tx) else { owsFailDebug("Missing installed sticker.") return nil } return installedSticker } } // MARK: - Known Sticker Packs public struct DatedStickerPackInfo { public let timestamp: UInt64 public let info: StickerPackInfo } public class func knownStickerPacksFromMessages(transaction: DBReadTransaction) -> [DatedStickerPackInfo] { do { return try DependenciesBridge.shared.attachmentStore .oldestStickerPackReferences(tx: transaction) .compactMap { stickerReferenceMetadata in // Join to the message so we can get the sticker pack key. guard let interaction = DependenciesBridge.shared.interactionStore .fetchInteraction( rowId: stickerReferenceMetadata.messageRowId, tx: transaction ), let messageSticker = (interaction as? TSMessage)?.messageSticker else { owsFailDebug("Missing message for sticker") return nil } return DatedStickerPackInfo( timestamp: stickerReferenceMetadata.receivedAtTimestamp, info: StickerPackInfo( packId: messageSticker.packId, packKey: messageSticker.packKey ) ) } } catch { owsFailDebug("Failed fetching sticker attachments \(error)") return [] } } private class func tryToDownloadStickerPacks(stickerPacks: [StickerPackInfo], installMode: InstallMode) { var stickerPacksToDownload = [StickerPackInfo]() SSKEnvironment.shared.databaseStorageRef.read { (transaction) in for stickerPackInfo in stickerPacks { if !StickerManager.isStickerPackSaved(stickerPackInfo: stickerPackInfo, transaction: transaction) { stickerPacksToDownload.append(stickerPackInfo) } } } for stickerPackInfo in stickerPacksToDownload { StickerManager.tryToDownloadAndSaveStickerPack( stickerPackInfo: stickerPackInfo, installMode: installMode, wasLocallyInitiated: true ) } } // MARK: - Missing Packs // Track which sticker packs downloads have failed permanently. private static var missingStickerPacks = Set() public class func markStickerPackAsMissing(stickerPackInfo: StickerPackInfo) { DispatchQueue.main.async { self.missingStickerPacks.insert(stickerPackInfo.asKey) } } public class func isStickerPackMissing(stickerPackInfo: StickerPackInfo) -> Bool { AssertIsOnMainThread() return missingStickerPacks.contains(stickerPackInfo.asKey) } // MARK: - Recents private static var kRecentStickersKey: String { "recentStickers" } private static let kRecentStickersMaxCount: Int = 25 public class func stickerWasSent(_ stickerInfo: StickerInfo, transaction: DBWriteTransaction) { guard isStickerInstalled(stickerInfo: stickerInfo, transaction: transaction) else { return } store.prependToOrderedUniqueArray( key: kRecentStickersKey, value: stickerInfo.asKey(), maxCount: kRecentStickersMaxCount, tx: transaction ) NotificationCenter.default.postOnMainThread(name: recentStickersDidChange, object: nil) } private class func removeFromRecentStickers( _ stickerInfo: StickerInfo, transaction: DBWriteTransaction ) { store.removeFromOrderedUniqueArray(key: kRecentStickersKey, value: stickerInfo.asKey(), tx: transaction) NotificationCenter.default.postOnMainThread(name: recentStickersDidChange, object: nil) } // Returned in descending order of recency. // // Only returns installed stickers. public class func recentStickers() -> [StickerInfo] { var result = [StickerInfo]() SSKEnvironment.shared.databaseStorageRef.read { (transaction) in result = self.recentStickers(transaction: transaction) } return result } // Returned in descending order of recency. // // Only returns installed stickers. private class func recentStickers(transaction: DBReadTransaction) -> [StickerInfo] { let keys = store.orderedUniqueArray(forKey: kRecentStickersKey, tx: transaction) var result = [StickerInfo]() for key in keys { guard let installedSticker = InstalledSticker.anyFetch(uniqueId: key, transaction: transaction) else { owsFailDebug("Couldn't fetch sticker") continue } guard nil != self.stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: true) else { owsFailDebug("Missing sticker data for installed sticker.") continue } result.append(installedSticker.info) } return result } // MARK: - Misc. // URL might be a sticker or a sticker pack manifest. public class func decrypt(at url: URL, packKey: Data) throws -> URL { guard packKey.count == packKeyLength else { owsFailDebug("Invalid pack key length: \(packKey.count).") throw StickerError.invalidInput } let stickerKeyLength = 64 let stickerKey = try hkdf(outputLength: stickerKeyLength, inputKeyMaterial: packKey, salt: [], info: Data("Sticker Pack".utf8)) let attachmentKey = try! AttachmentKey(combinedKey: stickerKey) let temporaryDecryptedFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true) try Cryptography.decryptFile(at: url, metadata: DecryptionMetadata(key: attachmentKey), output: temporaryDecryptedFile) return temporaryDecryptedFile } private class func ensureAllStickerDownloadsAsync() { DispatchQueue.global().async { SSKEnvironment.shared.databaseStorageRef.read { (transaction) in for stickerPack in self.allStickerPacks(transaction: transaction) { ensureDownloads(forStickerPack: stickerPack, transaction: transaction) } } } } public class func ensureDownloadsAsync(forStickerPack stickerPack: StickerPack) -> Promise { let (promise, future) = Promise.pending() DispatchQueue.global().async { SSKEnvironment.shared.databaseStorageRef.read { (transaction) in _ = firstly { ensureDownloads(forStickerPack: stickerPack, transaction: transaction) }.done { future.resolve() }.catch { (error) in future.reject(error) } } } return promise } @discardableResult private class func ensureDownloads(forStickerPack stickerPack: StickerPack, transaction: DBReadTransaction) -> Promise { // TODO: As an optimization, we could flag packs as "complete" if we know all // of their stickers are installed. // Install the covers for available sticker packs. let onlyInstallCover = !stickerPack.isInstalled return installStickerPackContents(stickerPack: stickerPack, transaction: transaction, onlyInstallCover: onlyInstallCover) } public static func hasOrphanedData(tx: DBReadTransaction) -> Bool { let (packsToRemove, stickersToRemove) = fetchOrphanedPacksAndStickers(tx: tx) return !packsToRemove.isEmpty || !stickersToRemove.isEmpty } public static func cleanUpOrphanedData(tx: DBWriteTransaction) { // We re-compute the orphaned packs within the write transaction. It's // possible that a new pack was being saved during the read transaction, // and it's possible that an orphaned pack is no longer orphaned. Most of // the time we don't expect to have any orphans, so the 2x performance // overhead shouldn't matter. If we don't have any orphans by the time we // reach this point, the code will do the correct thing (which is nothing). let (packsToRemove, stickersToRemove) = fetchOrphanedPacksAndStickers(tx: tx) for stickerPack in packsToRemove { owsFailDebug("Removing orphan pack") stickerPack.anyRemove(transaction: tx) } if !stickersToRemove.isEmpty { Logger.warn("Removing \(stickersToRemove.count) orphan stickers.") } for sticker in stickersToRemove { self.uninstallSticker(stickerInfo: sticker.info, transaction: tx) } } private static func fetchOrphanedPacksAndStickers(tx: DBReadTransaction) -> ([StickerPack], [InstalledSticker]) { var stickerPacks = [String: StickerPack]() var packsToRemove = [StickerPack]() for stickerPack in StickerPack.anyFetchAll(transaction: tx) { if stickerPack.isInstalled || self.isDefaultStickerPack(packId: stickerPack.info.packId) { stickerPacks[stickerPack.info.asKey] = stickerPack } else { packsToRemove.append(stickerPack) } } var stickersToRemove = [InstalledSticker]() InstalledSticker.anyEnumerate(transaction: tx) { (sticker, _) in let shouldKeepSticker: Bool = { guard let stickerPack = stickerPacks[sticker.info.packInfo.asKey] else { return false } return stickerPack.isInstalled || stickerPack.coverInfo == sticker.info }() if !shouldKeepSticker { stickersToRemove.append(sticker) } } return (packsToRemove, stickersToRemove) } // MARK: - Sync Messages private class func enqueueStickerSyncMessage( operationType: StickerPackOperationType, packs: [StickerPackInfo], transaction: DBWriteTransaction ) { guard DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction).isRegistered else { return } guard let thread = TSContactThread.getOrCreateLocalThread(transaction: transaction) else { owsFailDebug("Missing thread.") return } let message = OWSStickerPackSyncMessage( localThread: thread, packs: packs, operationType: operationType, transaction: transaction ) // The sync message doesn't include the actual stickers on it as attachments. let preparedMessage = PreparedOutgoingMessage.preprepared( transientMessageWithoutAttachments: message ) SSKEnvironment.shared.messageSenderJobQueueRef.add(message: preparedMessage, transaction: transaction) } public class func syncAllInstalledPacks(transaction: DBWriteTransaction) { guard DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction).isRegistered else { return } let stickerPackInfos = installedStickerPacks(transaction: transaction).map { $0.info } guard stickerPackInfos.count > 0 else { return } enqueueStickerSyncMessage( operationType: .install, packs: stickerPackInfos, transaction: transaction ) } public class func processIncomingStickerPackOperation( _ proto: SSKProtoSyncMessageStickerPackOperation, transaction: DBWriteTransaction ) { let packID: Data = proto.packID let packKey: Data = proto.packKey guard let stickerPackInfo = StickerPackInfo.parse(packId: packID, packKey: packKey) else { owsFailDebug("Invalid pack info.") return } guard let type = proto.type else { owsFailDebug("Pack operation missing type.") return } switch type { case .install: tryToDownloadAndSaveStickerPack( stickerPackInfo: stickerPackInfo, installMode: .install, wasLocallyInitiated: false ) case .remove: uninstallStickerPack(stickerPackInfo: stickerPackInfo, wasLocallyInitiated: false, transaction: transaction) @unknown default: owsFailDebug("Unknown type.") return } } // MARK: - StickerPack Download Task Queue private struct StickerPackDownloadTaskRecord: TaskRecord { let id: Int64 let record: QueuedBackupStickerPackDownload } private class StickerPackDownloadTaskRecordStore: TaskRecordStore { typealias Record = StickerPackDownloadTaskRecord private let store: BackupStickerPackDownloadStore init(store: BackupStickerPackDownloadStore) { self.store = store } func peek(count: UInt, tx: DBReadTransaction) throws -> [StickerPackDownloadTaskRecord] { return try store.peek(count: count, tx: tx).map { return .init(id: $0.id!, record: $0) } } func removeRecord(_ record: StickerPackDownloadTaskRecord, tx: DBWriteTransaction) throws { try store.removeRecordFromQueue(record: record.record, tx: tx) } } private final class StickerPackDownloadTaskRunner: TaskRecordRunner { typealias Record = StickerPackDownloadTaskRecord typealias Store = StickerPackDownloadTaskRecordStore let store: StickerPackDownloadTaskRecordStore init(store: StickerPackDownloadTaskRecordStore) { self.store = store } func runTask(record: Record, loader: TaskQueueLoader) async -> TaskRecordResult { let stickerPackInfo = StickerPackInfo(packId: record.record.packId, packKey: record.record.packKey) do { guard !StickerManager.isStickerPackInstalled(stickerPackInfo: stickerPackInfo) else { return .success } try await StickerManager.tryToDownloadStickerPack( stickerPackInfo: stickerPackInfo ).done(on: DispatchQueue.global()) { stickerPack in StickerManager.upsertStickerPack( stickerPack: stickerPack, installMode: .installIfUnsaved, wasLocallyInitiated: true ) }.awaitable() return .success } catch { Logger.error("Failed to download sticker: \(error)") return .unretryableError(error) } } func didSucceed(record: Record, tx: DBWriteTransaction) throws { } func didFail(record: Record, error: any Error, isRetryable: Bool, tx: DBWriteTransaction) throws { } func didCancel(record: Record, tx: DBWriteTransaction) throws { } } } // MARK: - // These methods are used to maintain a "string set": // A set (no duplicates) of strings stored as a list. // As a bonus, the set is stored in order of descending recency. private extension KeyValueStore { func prependToOrderedUniqueArray( key: String, value: String, maxCount: Int? = nil, tx: DBWriteTransaction ) { // Prepend value to ensure descending order of recency. var orderedArray = [value] if let storedValue = getStringArray(key, transaction: tx) { orderedArray += storedValue.filter { $0 != value } } if let maxCount { orderedArray = Array(orderedArray.prefix(maxCount)) } setObject(orderedArray, key: key, transaction: tx) } func removeFromOrderedUniqueArray(key: String, value: String, tx: DBWriteTransaction) { var orderedArray = [String]() if let storedValue = getStringArray(key, transaction: tx) { guard storedValue.contains(value) else { // No work to do. return } orderedArray += storedValue.filter { $0 != value } } setObject(orderedArray, key: key, transaction: tx) } func orderedUniqueArray(forKey key: String, tx: DBReadTransaction) -> [String] { return getStringArray(key, transaction: tx) ?? [] } }