Signal-iOS/SignalServiceKit/Messages/Stickers/StickerManager.swift

1316 lines
53 KiB
Swift

//
// 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
@objc
public static let packsDidChange = Notification.Name("packsDidChange")
@objc
public static let stickersOrPacksDidChange = Notification.Name("stickersOrPacksDidChange")
@objc
public static let recentStickersDidChange = Notification.Name("recentStickersDidChange")
private static var packsDidChangeEvent: DebouncedEvent {
DebouncedEvents.build(mode: .firstLast,
maxFrequencySeconds: 0.5,
onQueue: .asyncOnQueue(queue: .global())) {
NotificationCenter.default.postNotificationNameAsync(packsDidChange, object: nil)
NotificationCenter.default.postNotificationNameAsync(stickersOrPacksDidChange, object: nil)
}
}
private static var stickersDidChangeEvent: DebouncedEvent {
DebouncedEvents.build(mode: .firstLast,
maxFrequencySeconds: 0.5,
onQueue: .asyncOnQueue(queue: .global())) {
NotificationCenter.default.postNotificationNameAsync(stickersOrPacksDidChange, object: nil)
}
}
// MARK: - Properties
public static let store = SDSKeyValueStore(collection: "recentStickers")
public static let emojiMapStore = SDSKeyValueStore(collection: "emojiMap")
@objc
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
}
// MARK: - Initializers
@objc
public override init() {
super.init()
// Resume sticker and sticker pack downloads when app is ready.
AppReadiness.runNowOrWhenMainAppDidBecomeReadyAsync {
if DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered {
StickerManager.refreshContents()
}
}
}
// 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.
@objc
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()
}()
@objc
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
@objc
public class func allStickerPacks() -> [StickerPack] {
var result = [StickerPack]()
databaseStorage.read { (transaction) in
result += allStickerPacks(transaction: transaction)
}
return result
}
@objc
public class func allStickerPacks(transaction: SDSAnyReadTransaction) -> [StickerPack] {
return StickerPack.anyFetchAll(transaction: transaction)
}
@objc
public class func installedStickerPacks(transaction: SDSAnyReadTransaction) -> [StickerPack] {
return allStickerPacks(transaction: transaction).filter {
$0.isInstalled
}
}
@objc
public class func availableStickerPacks(transaction: SDSAnyReadTransaction) -> [StickerPack] {
return allStickerPacks(transaction: transaction).filter {
!$0.isInstalled
}
}
@objc
public class func isStickerPackSaved(stickerPackInfo: StickerPackInfo) -> Bool {
var result = false
databaseStorage.read { (transaction) in
result = isStickerPackSaved(stickerPackInfo: stickerPackInfo,
transaction: transaction)
}
return result
}
@objc
public class func isStickerPackSaved(stickerPackInfo: StickerPackInfo,
transaction: SDSAnyReadTransaction) -> Bool {
return nil != fetchStickerPack(stickerPackInfo: stickerPackInfo, transaction: transaction)
}
@objc
public class func uninstallStickerPack(stickerPackInfo: StickerPackInfo,
wasLocallyInitiated: Bool,
transaction: SDSAnyWriteTransaction) {
uninstallStickerPack(stickerPackInfo: stickerPackInfo,
uninstallEverything: false,
wasLocallyInitiated: wasLocallyInitiated,
transaction: transaction)
}
private class func uninstallStickerPack(stickerPackInfo: StickerPackInfo,
uninstallEverything: Bool,
wasLocallyInitiated: Bool,
transaction: SDSAnyWriteTransaction) {
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 = uninstallEverything || !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.addAsyncCompletionOffMain {
packsDidChangeEvent.requestNotify()
}
}
@objc
public class func installStickerPack(stickerPack: StickerPack,
wasLocallyInitiated: Bool,
transaction: SDSAnyWriteTransaction) {
upsertStickerPack(stickerPack: stickerPack,
installMode: .install,
wasLocallyInitiated: wasLocallyInitiated,
transaction: transaction)
}
@objc
public class func fetchStickerPack(stickerPackInfo: StickerPackInfo) -> StickerPack? {
var result: StickerPack?
databaseStorage.read { (transaction) in
result = fetchStickerPack(stickerPackInfo: stickerPackInfo,
transaction: transaction)
}
return result
}
@objc
public class func fetchStickerPack(stickerPackInfo: StickerPackInfo,
transaction: SDSAnyReadTransaction) -> 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: OperationQueue = {
let operationQueue = OperationQueue()
operationQueue.name = "StickerManager-Pack"
operationQueue.maxConcurrentOperationCount = 3
return operationQueue
}()
private func tryToDownloadStickerPack(stickerPackInfo: StickerPackInfo) -> Promise<StickerPack> {
let (promise, future) = Promise<StickerPack>.pending()
let operation = DownloadStickerPackOperation(stickerPackInfo: stickerPackInfo,
success: future.resolve,
failure: future.reject)
packOperationQueue.addOperation(operation)
return promise
}
// This method is public so that we can download "transient" (uninstalled) sticker packs.
public class func tryToDownloadStickerPack(stickerPackInfo: StickerPackInfo) -> Promise<StickerPack> {
return shared.tryToDownloadStickerPack(stickerPackInfo: stickerPackInfo)
}
private class func upsertStickerPack(stickerPack: StickerPack,
installMode: InstallMode,
wasLocallyInitiated: Bool) {
databaseStorage.write { (transaction) in
upsertStickerPack(stickerPack: stickerPack,
installMode: installMode,
wasLocallyInitiated: wasLocallyInitiated,
transaction: transaction)
}
}
private class func upsertStickerPack(stickerPack stickerPackParam: StickerPack,
installMode: InstallMode,
wasLocallyInitiated: Bool,
transaction: SDSAnyWriteTransaction) {
// 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<Void>
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.addAsyncCompletionOffMain {
_ = promise.ensure {
packsDidChangeEvent.requestNotify()
}
}
}
private class func markSavedStickerPackAsInstalled(stickerPack: StickerPack,
wasLocallyInitiated: Bool,
transaction: SDSAnyWriteTransaction) -> Promise<Void> {
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: SDSAnyReadTransaction,
onlyInstallCover: Bool = false) -> Promise<Void> {
// Note: It's safe to kick off downloads of stickers that are already installed.
var fetches = [Promise<Void>]()
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)
}
}
@objc
public class func installedStickers(forStickerPack stickerPack: StickerPack,
verifyExists: Bool) -> [StickerInfo] {
var result = [StickerInfo]()
databaseStorage.read { (transaction) in
result = self.installedStickers(forStickerPack: stickerPack,
verifyExists: verifyExists,
transaction: transaction)
}
return result
}
@objc
public class func installedStickers(forStickerPack stickerPack: StickerPack,
verifyExists: Bool,
transaction: SDSAnyReadTransaction) -> [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,
nil == self.stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: verifyExists) {
continue
}
result.append(stickerInfo)
}
return result
}
@objc
public class func isDefaultStickerPack(packId: Data) -> Bool {
return DefaultStickerPack.isDefaultStickerPack(packId: packId)
}
@objc
public class func isStickerPackInstalled(stickerPackInfo: StickerPackInfo) -> Bool {
var result = false
databaseStorage.read { (transaction) in
result = isStickerPackInstalled(stickerPackInfo: stickerPackInfo,
transaction: transaction)
}
return result
}
@objc
public class func isStickerPackInstalled(stickerPackInfo: StickerPackInfo,
transaction: SDSAnyReadTransaction) -> Bool {
guard let pack = fetchStickerPack(stickerPackInfo: stickerPackInfo, transaction: transaction) else {
return false
}
return pack.isInstalled
}
// MARK: - Stickers
@objc
public static func stickerType(forContentType contentType: String?) -> StickerType {
StickerType.stickerType(forContentType: contentType)
}
@objc(installedStickerMetadataWithSneakyTransaction:)
public class func installedStickerMetadataWithSneakyTransaction(stickerInfo: StickerInfo) -> StickerMetadata? {
databaseStorage.read { transaction in
self.installedStickerMetadata(stickerInfo: stickerInfo,
transaction: transaction)
}
}
@objc
public class func installedStickerMetadata(stickerInfo: StickerInfo,
transaction: SDSAnyReadTransaction) -> 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)
}
@objc
public class func installedStickerMetadata(installedSticker: InstalledSticker,
transaction: SDSAnyReadTransaction) -> 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 StickerMetadata(stickerInfo: stickerInfo,
stickerType: stickerType,
stickerDataUrl: stickerDataUrl,
emojiString: installedSticker.emojiString)
}
@objc
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
}
@objc
public class func filePathsForAllInstalledStickers(transaction: SDSAnyReadTransaction) -> [String] {
var filePaths = [String]()
InstalledSticker.anyEnumerate(transaction: transaction) { (installedSticker, _) in
if let stickerDataUrl = stickerDataUrl(forInstalledSticker: installedSticker, verifyExists: false) {
filePaths.append(stickerDataUrl.path)
}
}
return filePaths
}
@objc
public class func isStickerInstalled(stickerInfo: StickerInfo) -> Bool {
var result = false
databaseStorage.read { (transaction) in
result = isStickerInstalled(stickerInfo: stickerInfo,
transaction: transaction)
}
return result
}
@objc
public class func isStickerInstalled(stickerInfo: StickerInfo,
transaction: SDSAnyReadTransaction) -> 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: SDSAnyWriteTransaction) {
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 {
DispatchQueue.sharedUtility.async {
do {
try OWSFileSystem.deleteFileIfExists(url: stickerDataUrl)
} catch {
owsFailDebug("Error: \(error)")
}
}
}
// No need to post stickersOrPacksDidChange; caller will do that.
}
@objc
public class func fetchInstalledStickerWithSneakyTransaction(stickerInfo: StickerInfo) -> InstalledSticker? {
databaseStorage.read { transaction in
self.fetchInstalledSticker(stickerInfo: stickerInfo, transaction: transaction)
}
}
@objc
public class func fetchInstalledSticker(stickerInfo: StickerInfo,
transaction: SDSAnyReadTransaction) -> InstalledSticker? {
let uniqueId = InstalledSticker.uniqueId(for: stickerInfo)
return InstalledSticker.anyFetch(uniqueId: uniqueId, transaction: transaction)
}
@objc
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
}
guard OWSFileSystem.fileOrFolderExists(url: stickerTemporaryUrl) else {
owsFailDebug("Missing sticker file.")
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 let error as NSError {
if OWSFileSystem.fileOrFolderExists(url: stickerDataUrl) {
// Races can occur; ignore and proceed.
Logger.warn("File already exists: \(error)")
} else {
owsFailDebug("File write failed: \(error)")
return false
}
}
return databaseStorage.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)
#if DEBUG
guard self.isStickerInstalled(stickerInfo: stickerInfo, transaction: transaction) else {
owsFailDebug("Skipping redundant sticker install.")
return false
}
if !OWSFileSystem.fileOrFolderExists(url: stickerDataUrl) {
owsFailDebug("Missing sticker data for installed sticker.")
return false
}
#endif
self.addStickerToEmojiMap(installedSticker, tx: transaction)
return true
}
}
private class func tryToDownloadAndInstallSticker(stickerPack: StickerPack,
item: StickerPackItem,
transaction: SDSAnyReadTransaction) -> Promise<Bool> {
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<URL>
let future: Future<URL>
init() {
let (promise, future) = Promise<URL>.pending()
self.promise = promise
self.future = future
}
}
private let stickerDownloadQueue = DispatchQueue(label: "org.signal.sticker-manager.download")
// This property should only be accessed on stickerDownloadQueue.
private var stickerDownloadMap = [String: StickerDownload]()
private let stickerOperationQueue: OperationQueue = {
let operationQueue = OperationQueue()
operationQueue.name = "StickerManager"
operationQueue.maxConcurrentOperationCount = 4
return operationQueue
}()
private func tryToDownloadSticker(stickerPack: StickerPack,
stickerInfo: StickerInfo) -> Promise<URL> {
if let stickerUrl = DownloadStickerOperation.cachedUrl(for: stickerInfo) {
return Promise.value(stickerUrl)
}
return stickerDownloadQueue.sync { () -> Promise<URL> in
if let stickerDownload = stickerDownloadMap[stickerInfo.asKey()] {
return stickerDownload.promise
}
let stickerDownload = StickerDownload()
stickerDownloadMap[stickerInfo.asKey()] = stickerDownload
let operation = DownloadStickerOperation(stickerInfo: stickerInfo,
success: { [weak self] data in
guard let self = self else {
return
}
_ = self.stickerDownloadQueue.sync {
self.stickerDownloadMap.removeValue(forKey: stickerInfo.asKey())
}
stickerDownload.future.resolve(data)
},
failure: { [weak self] error in
guard let self = self else {
return
}
_ = self.stickerDownloadQueue.sync {
self.stickerDownloadMap.removeValue(forKey: stickerInfo.asKey())
}
stickerDownload.future.reject(error)
})
self.stickerOperationQueue.addOperation(operation)
return stickerDownload.promise
}
}
// This method is public so that we can download "transient" (uninstalled) stickers.
public class func tryToDownloadSticker(stickerPack: StickerPack,
stickerInfo: StickerInfo) -> Promise<URL> {
shared.tryToDownloadSticker(stickerPack: stickerPack, stickerInfo: stickerInfo)
}
// MARK: - Emoji
@objc
public class func allEmoji(inEmojiString emojiString: String?) -> [String] {
guard let emojiString = emojiString else {
return []
}
return emojiString.map(String.init).filter {
$0.containsOnlyEmoji
}
}
@objc
public class func firstEmoji(inEmojiString emojiString: String?) -> String? {
return allEmoji(inEmojiString: emojiString).first
}
private class func addStickerToEmojiMap(_ installedSticker: InstalledSticker, tx: SDSAnyWriteTransaction) {
guard let emojiString = installedSticker.emojiString else {
return
}
let stickerId = installedSticker.uniqueId
for emoji in allEmoji(inEmojiString: emojiString) {
emojiMapStore.prependToOrderedUniqueArray(key: emoji, value: stickerId, tx: tx)
}
shared.clearSuggestedStickersCache()
}
private class func removeStickerFromEmojiMap(_ installedSticker: InstalledSticker, tx: SDSAnyWriteTransaction) {
guard let emojiString = installedSticker.emojiString else {
return
}
let stickerId = installedSticker.uniqueId
for emoji in allEmoji(inEmojiString: emojiString) {
emojiMapStore.removeFromOrderedUniqueArray(key: emoji, value: stickerId, tx: tx)
}
shared.clearSuggestedStickersCache()
}
private static let cacheQueue = DispatchQueue(label: "org.signal.sticker-manager.cache")
// This cache should only be accessed on cacheQueue.
private var suggestedStickersCache = LRUCache<String, [InstalledSticker]>(maxSize: 5)
// We clear the cache every time we install or uninstall a sticker.
private func clearSuggestedStickersCache() {
StickerManager.cacheQueue.sync {
self.suggestedStickersCache.removeAllObjects()
}
}
@objc
public func suggestedStickers(forTextInput textInput: String) -> [InstalledSticker] {
return StickerManager.cacheQueue.sync {
if let suggestions = suggestedStickersCache.object(forKey: textInput) {
return suggestions
}
let suggestions = StickerManager.suggestedStickers(forTextInput: textInput)
suggestedStickersCache.setObject(suggestions, forKey: textInput)
return suggestions
}
}
internal class func suggestedStickers(forTextInput textInput: String) -> [InstalledSticker] {
var result = [InstalledSticker]()
databaseStorage.read { (transaction) in
result = self.suggestedStickers(forTextInput: textInput, transaction: transaction)
}
return result
}
internal class func suggestedStickers(forTextInput textInput: String,
transaction: SDSAnyReadTransaction) -> [InstalledSticker] {
guard let emoji = firstEmoji(inEmojiString: textInput) else {
// Text input contains no emoji.
return []
}
guard emoji == textInput else {
// Text input contains more than just a single emoji.
return []
}
let stickerIds = emojiMapStore.orderedUniqueArray(forKey: emoji, tx: transaction)
return stickerIds.compactMap { (stickerId) in
guard let installedSticker = InstalledSticker.anyFetch(uniqueId: stickerId, transaction: transaction) else {
owsFailDebug("Missing installed sticker.")
return nil
}
return installedSticker
}
}
// MARK: - Known Sticker Packs
@objc
public class func addKnownStickerInfo(_ stickerInfo: StickerInfo,
transaction: SDSAnyWriteTransaction) {
let packInfo = stickerInfo.packInfo
let uniqueId = KnownStickerPack.uniqueId(for: packInfo)
if let existing = KnownStickerPack.anyFetch(uniqueId: uniqueId, transaction: transaction) {
let pack = existing
pack.anyUpdate(transaction: transaction) { pack in
pack.referenceCount += 1
}
} else {
let pack = KnownStickerPack(info: packInfo)
pack.referenceCount += 1
pack.anyInsert(transaction: transaction)
}
}
@objc
public class func removeKnownStickerInfo(_ stickerInfo: StickerInfo,
transaction: SDSAnyWriteTransaction) {
let packInfo = stickerInfo.packInfo
let uniqueId = KnownStickerPack.uniqueId(for: packInfo)
guard let pack = KnownStickerPack.anyFetch(uniqueId: uniqueId, transaction: transaction) else {
owsFailDebug("Missing known sticker pack.")
return
}
if pack.referenceCount <= 1 {
pack.anyRemove(transaction: transaction)
} else {
pack.anyUpdate(transaction: transaction) { (pack) in
pack.referenceCount -= 1
}
}
}
@objc
public class func allKnownStickerPackInfos(transaction: SDSAnyReadTransaction) -> [StickerPackInfo] {
return allKnownStickerPacks(transaction: transaction).map { $0.info }
}
@objc
public class func allKnownStickerPacks(transaction: SDSAnyReadTransaction) -> [KnownStickerPack] {
var result = [KnownStickerPack]()
KnownStickerPack.anyEnumerate(transaction: transaction) { (knownStickerPack, _) in
result.append(knownStickerPack)
}
return result
}
private class func tryToDownloadStickerPacks(stickerPacks: [StickerPackInfo],
installMode: InstallMode) {
var stickerPacksToDownload = [StickerPackInfo]()
StickerManager.databaseStorage.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<String>()
@objc
public class func markStickerPackAsMissing(stickerPackInfo: StickerPackInfo) {
DispatchQueue.main.async {
self.missingStickerPacks.insert(stickerPackInfo.asKey)
}
}
@objc
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: SDSAnyWriteTransaction) {
guard isStickerInstalled(stickerInfo: stickerInfo, transaction: transaction) else {
return
}
store.prependToOrderedUniqueArray(
key: kRecentStickersKey,
value: stickerInfo.asKey(),
maxCount: kRecentStickersMaxCount,
tx: transaction
)
NotificationCenter.default.postNotificationNameAsync(recentStickersDidChange, object: nil)
}
private class func removeFromRecentStickers(
_ stickerInfo: StickerInfo,
transaction: SDSAnyWriteTransaction
) {
store.removeFromOrderedUniqueArray(key: kRecentStickersKey, value: stickerInfo.asKey(), tx: transaction)
NotificationCenter.default.postNotificationNameAsync(recentStickersDidChange, object: nil)
}
// Returned in descending order of recency.
//
// Only returns installed stickers.
@objc
public class func recentStickers() -> [StickerInfo] {
var result = [StickerInfo]()
databaseStorage.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: SDSAnyReadTransaction) -> [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 stickerKeyInfo = "Sticker Pack"
let stickerKeyLength = 64
let stickerKey = try stickerKeyInfo.utf8.withContiguousStorageIfAvailable {
try hkdf(outputLength: stickerKeyLength, inputKeyMaterial: packKey, salt: [], info: $0)
}!
let temporaryDecryptedFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
try Cryptography.decryptFile(at: url, metadata: .init(key: Data(stickerKey)), output: temporaryDecryptedFile)
return temporaryDecryptedFile
}
private class func ensureAllStickerDownloadsAsync() {
DispatchQueue.global().async {
databaseStorage.read { (transaction) in
for stickerPack in self.allStickerPacks(transaction: transaction) {
ensureDownloads(forStickerPack: stickerPack, transaction: transaction)
}
}
}
}
public class func ensureDownloadsAsync(forStickerPack stickerPack: StickerPack) -> Promise<Void> {
let (promise, future) = Promise<Void>.pending()
DispatchQueue.global().async {
databaseStorage.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: SDSAnyReadTransaction) -> Promise<Void> {
// 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)
}
@objc
static func hasOrphanedData(tx: SDSAnyReadTransaction) -> Bool {
let (packsToRemove, stickersToRemove) = fetchOrphanedPacksAndStickers(tx: tx)
return !packsToRemove.isEmpty || !stickersToRemove.isEmpty
}
@objc
static func cleanUpOrphanedData(tx: SDSAnyWriteTransaction) {
// 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: SDSAnyReadTransaction) -> ([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: SDSAnyWriteTransaction) {
guard DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction.asV2Read).isRegistered else {
return
}
guard let thread = TSContactThread.getOrCreateLocalThread(transaction: transaction) else {
owsFailDebug("Missing thread.")
return
}
let message = OWSStickerPackSyncMessage(
thread: 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)
}
@objc
public class func syncAllInstalledPacks(transaction: SDSAnyWriteTransaction) {
guard DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction.asV2Read).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: SDSAnyWriteTransaction) {
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: - Debug
// This is only intended for use while debugging.
#if DEBUG
@objc
public class func uninstallAllStickerPacks() {
databaseStorage.write { (transaction) in
let stickerPacks = installedStickerPacks(transaction: transaction)
for stickerPack in stickerPacks {
uninstallStickerPack(stickerPackInfo: stickerPack.info,
wasLocallyInitiated: true,
transaction: transaction)
}
}
}
@objc
public class func removeAllStickerPacks() {
databaseStorage.write { (transaction) in
let stickerPacks = allStickerPacks(transaction: transaction)
for stickerPack in stickerPacks {
uninstallStickerPack(stickerPackInfo: stickerPack.info,
uninstallEverything: true,
wasLocallyInitiated: true,
transaction: transaction)
stickerPack.anyRemove(transaction: transaction)
}
}
}
@objc
public class func tryToInstallAllAvailableStickerPacks() {
databaseStorage.write { (transaction) in
for stickerPack in self.availableStickerPacks(transaction: transaction) {
self.installStickerPack(stickerPack: stickerPack, wasLocallyInitiated: true, transaction: transaction)
}
}
tryToDownloadDefaultStickerPacks()
}
#endif
}
// 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 SDSKeyValueStore {
func prependToOrderedUniqueArray(
key: String,
value: String,
maxCount: Int? = nil,
tx: SDSAnyWriteTransaction
) {
// Prepend value to ensure descending order of recency.
var orderedArray = [value]
if let storedValue = getObject(forKey: key, transaction: tx) as? [String] {
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: SDSAnyWriteTransaction) {
var orderedArray = [String]()
if let storedValue = getObject(forKey: key, transaction: tx) as? [String] {
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: SDSAnyReadTransaction) -> [String] {
guard let object = getObject(forKey: key, transaction: tx) else {
return []
}
guard let orderedArray = object as? [String] else {
owsFailDebug("Value has unexpected type \(type(of: object)).")
return []
}
return orderedArray
}
}