TM-SGNL-iOS/SignalUI/Stickers/StickerPackDataSource.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

687 lines
20 KiB
Swift

//
// 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() -> StickerPack?
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<StickerPackDataSourceDelegate>]()
public func add(delegate: StickerPackDataSourceDelegate) {
AssertIsOnMainThread()
delegates.append(Weak(value: delegate))
}
private lazy var didChangeEvent: DebouncedEvent = {
DebouncedEvents.build(mode: .firstLast,
maxFrequencySeconds: 0.5,
onQueue: .asyncOnQueue(queue: .main)) { [weak self] in
AssertIsOnMainThread()
guard let self = 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: StickerPack? {
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: SDSAnyReadTransaction) -> (
stickerPack: StickerPack?,
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 = 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() -> StickerPack? {
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: StickerPack? {
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 = 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<String>()
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 = 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 = 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: StickerPack,
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(stickerPack: stickerPack, stickerInfo: stickerInfo)
}.done(on: DispatchQueue.main) { [weak self] (temporaryFileUrl) in
guard let self = 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 = 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() -> StickerPack? {
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 {
public override 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() -> StickerPack? {
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 StickerPack {
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)
}
}