TM-SGNL-iOS/SignalServiceKit/Messages/Stories/SystemStoryManager.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

796 lines
32 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import GRDB
// TODO: Support stubbing out OWSFileSystem more generally. This is a temporary
// SystemStoryManager-scoped wrapper to avoid refactoring all usages of OWSFileSystem.
@objc
public class OnboardingStoryManagerFilesystem: NSObject {
public class func fileOrFolderExists(url: URL) -> Bool {
return OWSFileSystem.fileOrFolderExists(url: url)
}
public class func fileSize(of url: URL) -> NSNumber? {
return OWSFileSystem.fileSize(of: url)
}
public class func deleteFile(url: URL) throws {
try OWSFileSystem.deleteFile(url: url)
}
public class func moveFile(from fromUrl: URL, to toUrl: URL) throws {
try OWSFileSystem.moveFile(from: fromUrl, to: toUrl)
}
public class func isValidImage(at url: URL, mimeType: String?) -> Bool {
return Data.ows_isValidImage(at: url, mimeType: mimeType)
}
}
// TODO: Support stubbing out StoryMessage init more generally.
@objc
public class OnboardingStoryManagerStoryMessageFactory: NSObject {
public class func createFromSystemAuthor(
attachmentSource: AttachmentDataSource,
timestamp: UInt64,
transaction: SDSAnyWriteTransaction
) throws -> StoryMessage {
return try StoryMessage.createFromSystemAuthor(
attachmentSource: attachmentSource,
timestamp: timestamp,
transaction: transaction
)
}
public class func validateAttachmentContents(
dataSource: DataSource,
mimeType: String
) throws -> AttachmentDataSource {
return try DependenciesBridge.shared.attachmentContentValidator.validateContents(
dataSource: dataSource,
shouldConsume: true,
mimeType: mimeType,
renderingFlag: .default,
sourceFilename: nil
)
}
}
@objc
public class SystemStoryManager: NSObject, SystemStoryManagerProtocol {
private let fileSystem: OnboardingStoryManagerFilesystem.Type
private let messageProcessor: any Shims.MessageProcessor
private let schedulers: Schedulers
private let storyMessageFactory: OnboardingStoryManagerStoryMessageFactory.Type
private let kvStore = KeyValueStore(collection: "OnboardingStory")
private let overlayKvStore = KeyValueStore(collection: "StoryViewerOnboardingOverlay")
private let groupStoryEducationStore = KeyValueStore(collection: "GroupStoryEducation")
private let queue: Scheduler
internal let chainedPromise: ChainedPromise<Void>
public convenience init(appReadiness: AppReadiness, messageProcessor: MessageProcessor) {
self.init(
appReadiness: appReadiness,
fileSystem: OnboardingStoryManagerFilesystem.self,
messageProcessor: Wrappers.MessageProcessor(messageProcessor),
schedulers: DispatchQueueSchedulers(),
storyMessageFactory: OnboardingStoryManagerStoryMessageFactory.self
)
}
init(
appReadiness: AppReadiness,
fileSystem: OnboardingStoryManagerFilesystem.Type,
messageProcessor: any Shims.MessageProcessor,
schedulers: Schedulers,
storyMessageFactory: OnboardingStoryManagerStoryMessageFactory.Type
) {
self.fileSystem = fileSystem
self.messageProcessor = messageProcessor
self.schedulers = schedulers
self.storyMessageFactory = storyMessageFactory
self.queue = schedulers.queue(label: "org.signal.story.onboarding", qos: .utility)
self.chainedPromise = ChainedPromise<Void>(scheduler: self.queue)
super.init()
if CurrentAppContext().isMainApp {
appReadiness.runNowOrWhenMainAppDidBecomeReadyAsync { [weak self] in
guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
// Observe when the account is ready before we try and download.
self?.observeRegistrationChanges()
return
}
self?.enqueueOnboardingStoryDownload()
}
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - API
@discardableResult
public func enqueueOnboardingStoryDownload() -> Promise<Void> {
return chainedPromise.enqueue { [weak self] in
return (self?.downloadOnboardingStoryIfNeeded() ?? .init(error: OWSAssertionError("SystemStoryManager unretained"))).catch(on: DispatchQueue.global()) { error in
Logger.warn("\(error)")
}
}
}
@discardableResult
public func cleanUpOnboardingStoryIfNeeded() -> Promise<Void> {
return chainedPromise.enqueue { [weak self] () -> Promise<OnboardingStoryDownloadStatus> in
return self?.checkOnboardingStoryDownloadStatus() ?? .init(error: OWSAssertionError("SystemStoryManager unretained"))
}.asVoid()
}
public func isOnboardingStoryRead(transaction: SDSAnyReadTransaction) -> Bool {
if onboardingStoryReadStatus(transaction: transaction) {
return true
}
// If its viewed, that also counts as being read.
return isOnboardingStoryViewed(transaction: transaction)
}
public func isOnboardingStoryViewed(transaction: SDSAnyReadTransaction) -> Bool {
let status = onboardingStoryViewStatus(transaction: transaction)
switch status.status {
case .notViewed:
return false
case .viewedOnThisDevice, .viewedOnAnotherDevice:
return true
}
}
public func setHasReadOnboardingStory(transaction: SDSAnyWriteTransaction, updateStorageService: Bool) {
try? setOnboardingStoryRead(transaction: transaction, updateStorageService: updateStorageService)
}
public func setHasViewedOnboardingStory(
source: OnboardingStoryViewSource,
transaction: SDSAnyWriteTransaction
) throws {
switch source {
case .local(let timestamp, let updateStorageService):
try setOnboardingStoryViewedOnThisDevice(
atTimestamp: timestamp,
shouldUpdateStorageService: updateStorageService,
transaction: transaction
)
case .otherDevice:
setHasViewedOnboardingStoryOnAnotherDevice(transaction: transaction)
}
}
private func setHasViewedOnboardingStoryOnAnotherDevice(transaction: SDSAnyWriteTransaction) {
try? setOnboardingStoryViewedOnAnotherDevice(transaction: transaction)
self.cleanUpOnboardingStoryIfNeeded()
}
// MARK: Group story education
public func isGroupStoryEducationSheetViewed(tx: SDSAnyReadTransaction) -> Bool {
return groupStoryEducationStore.hasValue(
Constants.kvStoreGroupStoryEducationSheetViewedKey,
transaction: tx.asV2Read
)
}
public func setGroupStoryEducationSheetViewed(tx: SDSAnyWriteTransaction) {
groupStoryEducationStore.setBool(
true,
key: Constants.kvStoreGroupStoryEducationSheetViewedKey,
transaction: tx.asV2Write
)
}
// MARK: OnboardingOverlay state
public func isOnboardingOverlayViewed(transaction: SDSAnyReadTransaction) -> Bool {
if overlayKvStore.getBool(Constants.kvStoreOnboardingOverlayViewedKey, defaultValue: false, transaction: transaction.asV2Read) {
return true
}
if isOnboardingStoryViewed(transaction: transaction) {
// We don't sync view state for the onboarding overlay. But we can use
// viewing of the onboarding story as an imperfect proxy; if they viewed it
// that means they also definitely saw the viewer overlay.
return true
}
return false
}
public func setOnboardingOverlayViewed(value: Bool, transaction: SDSAnyWriteTransaction) {
overlayKvStore.setBool(value, key: Constants.kvStoreOnboardingOverlayViewedKey, transaction: transaction.asV2Write)
}
// MARK: Hidden State
private var stateChangeObservers = [SystemStoryStateChangeObserver]()
public func addStateChangedObserver(_ observer: SystemStoryStateChangeObserver) {
stateChangeObservers.append(observer)
}
public func removeStateChangedObserver(_ observer: SystemStoryStateChangeObserver) {
stateChangeObservers.removeAll(where: { $0 == observer })
}
public func setSystemStoriesHidden(_ hidden: Bool, transaction: SDSAnyWriteTransaction) {
var changedRowIds = [Int64]()
defer {
schedulers.main.async {
self.stateChangeObservers.forEach { $0.systemStoryHiddenStateDidChange(rowIds: changedRowIds) }
}
}
// No need to make this serial with the other calls, db transactions cover us.
self.setSystemStoryHidden(hidden, transaction: transaction)
let onboardingStatus = onboardingStoryDownloadStatus(transaction: transaction)
guard
let messageUniqueIds = onboardingStatus.messageUniqueIds,
!messageUniqueIds.isEmpty
else {
return
}
let stories = StoryFinder.listStoriesWithUniqueIds(messageUniqueIds, transaction: transaction)
stories.forEach {
if hidden {
$0.markAsViewed(at: Date().ows_millisecondsSince1970, circumstance: .onThisDevice, transaction: transaction)
}
if let rowId = $0.id {
changedRowIds.append(rowId)
}
}
}
// MARK: - Internal Event Observation
private func observeRegistrationChanges() {
NotificationCenter.default.addObserver(
self,
selector: #selector(registrationStateDidChange),
name: .registrationStateDidChange,
object: nil
)
}
@objc
private func registrationStateDidChange() {
guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
return
}
NotificationCenter.default.removeObserver(self, name: .registrationStateDidChange, object: nil)
_ = self.enqueueOnboardingStoryDownload()
}
private var isObservingOnboardingStoryEvents = false
private var storyMessagesObservation: DatabaseCancellable?
private func beginObservingOnboardingStoryEventsIfNeeded(downloadStatus: OnboardingStoryDownloadStatus) {
guard
CurrentAppContext().isMainApp,
!isObservingOnboardingStoryEvents,
downloadStatus.isDownloaded,
let messageUniqueIds = downloadStatus.messageUniqueIds
else {
return
}
let viewStatus = SSKEnvironment.shared.databaseStorageRef.read {
self.onboardingStoryViewStatus(transaction: $0)
}
switch viewStatus.status {
case .viewedOnThisDevice, .viewedOnAnotherDevice:
// No need to observe if we've already viewed.
return
case .notViewed:
break
}
isObservingOnboardingStoryEvents = true
// Observe app background to opportunistically delete timed out stories.
NotificationCenter.default.addObserver(
self,
selector: #selector(didEnterBackground),
name: .OWSApplicationDidEnterBackground,
object: nil
)
// Observe view state changes for the stories.
let observation = ValueObservation.tracking { db throws in
try StoryMessage
.filter(messageUniqueIds.contains(Column(StoryMessage.columnName(.uniqueId))))
.fetchAll(db)
}
// Ignore the first emission that fires right away, we
// want subsequent updates only.
var hasEmitted = false
storyMessagesObservation?.cancel()
storyMessagesObservation = observation.start(
in: SSKEnvironment.shared.databaseStorageRef.grdbStorage.pool,
onError: { error in
owsFailDebug("Failed to observe story view state: \(error))")
}, onChange: { [weak self] changedModels in
guard hasEmitted else {
hasEmitted = true
return
}
guard
let viewedTimstamp = changedModels
.lazy
.compactMap(\.localUserViewedTimestamp)
.min()
else {
return
}
do {
try SSKEnvironment.shared.databaseStorageRef.write {
try self?.setOnboardingStoryViewedOnThisDevice(
atTimestamp: viewedTimstamp,
shouldUpdateStorageService: true,
transaction: $0
)
}
self?.cleanUpOnboardingStoryIfNeeded()
self?.stopObservingOnboardingStoryEvents()
} catch {
return
}
}
)
}
private func stopObservingOnboardingStoryEvents() {
isObservingOnboardingStoryEvents = false
storyMessagesObservation?.cancel()
storyMessagesObservation = nil
NotificationCenter.default.removeObserver(self, name: .OWSApplicationDidEnterBackground, object: nil)
}
@objc
private func didEnterBackground() {
self.cleanUpOnboardingStoryIfNeeded()
}
// MARK: - Implementation
private func downloadOnboardingStoryIfNeeded() -> Promise<Void> {
let knownViewStatus = SSKEnvironment.shared.databaseStorageRef.read {
self.onboardingStoryViewStatus(transaction: $0)
}
switch knownViewStatus.status {
case .viewedOnAnotherDevice:
// We already know things are viewed, we can stop right away.
return .value(())
case .viewedOnThisDevice:
// Already viewed, take the opportunity to clean up if we have to, but don't force it.
return self.checkOnboardingStoryDownloadStatus().asVoid()
case .notViewed:
// Sync to check if we viewed on another device since last time we synced.
return self.syncOnboardingStoryViewStatus()
.then(on: queue) { [weak self] (viewStatus: OnboardingStoryViewStatus) -> Promise<Void> in
guard let strongSelf = self else {
return .init(error: OWSAssertionError("SystemStoryManager unretained"))
}
switch viewStatus.status {
case .viewedOnAnotherDevice:
// Already viewed, immediately delete anything we already downloaded.
return strongSelf.checkOnboardingStoryDownloadStatus(forceDeletingIfDownloaded: true).asVoid()
case .viewedOnThisDevice:
// Already viewed, take the opportunity to clean up if we have to, but don't force it.
return strongSelf.checkOnboardingStoryDownloadStatus().asVoid()
case .notViewed:
return strongSelf.downloadOnboardingStoryIfUndownloaded()
}
}
}
}
private func syncOnboardingStoryViewStatus() -> Promise<OnboardingStoryViewStatus> {
messageProcessor.waitForFetchingAndProcessing()
.then(on: queue) { SSKEnvironment.shared.storageServiceManagerRef.waitForPendingRestores() }
.then(on: queue) { [weak self] _ -> Promise<OnboardingStoryViewStatus> in
guard let strongSelf = self else {
return .init(error: OWSAssertionError("SystemStoryManager unretained"))
}
// At this point, we will have synced the AccountRecord, which would call
// `SystemStoryManager.setHasViewedOnboardingStoryOnAnotherDevice()` and write
// to the database. Read from the database to get whatever the latest value is.
return .value(SSKEnvironment.shared.databaseStorageRef.read { transaction in
return strongSelf.onboardingStoryViewStatus(transaction: transaction)
})
}
}
private func downloadOnboardingStoryIfUndownloaded() -> Promise<Void> {
let queue = self.queue
return checkOnboardingStoryDownloadStatus()
.then(on: queue) { [weak self] (downloadStatus: OnboardingStoryDownloadStatus) -> Promise<Void> in
guard !downloadStatus.isDownloaded else {
// Already done.
return .value(())
}
guard let strongSelf = self else {
return .init(error: OWSAssertionError("SystemStoryManager unretained"))
}
let urlSession = SSKEnvironment.shared.signalServiceRef.urlSessionForUpdates2()
return strongSelf.fetchFilenames(urlSession: urlSession)
.then(on: queue) { [weak self] (fileNames: [String]) -> Promise<[AttachmentDataSource]> in
let promises = fileNames.compactMap {
self?.downloadOnboardingAsset(urlSession: urlSession, url: $0)
}
return Promise.when(on: SyncScheduler(), fulfilled: promises)
}
.then(on: queue) { [weak self] (attachmentSources: [AttachmentDataSource]) -> Promise<Void> in
guard let strongSelf = self else {
return .init(error: OWSAssertionError("SystemStoryManager unretained"))
}
do {
return .value(try SSKEnvironment.shared.databaseStorageRef.write { transaction in
let uniqueIds = try strongSelf.createStoryMessages(
attachmentSources: attachmentSources,
transaction: transaction
)
try strongSelf.markOnboardingStoryDownloaded(
messageUniqueIds: uniqueIds,
transaction: transaction
)
})
} catch {
return .init(error: error)
}
}
}
}
private func checkOnboardingStoryDownloadStatus(forceDeletingIfDownloaded: Bool = false) -> Promise<OnboardingStoryDownloadStatus> {
let status = SSKEnvironment.shared.databaseStorageRef.write { transaction -> OnboardingStoryDownloadStatus in
let status = self.onboardingStoryDownloadStatus(transaction: transaction)
if status.isDownloaded {
// clean up opportunistically.
try? self.cleanUpOnboardingStoriesIfNeeded(
messageUniqueIds: status.messageUniqueIds,
forceDeleteIfDownloaded: forceDeletingIfDownloaded,
transaction: transaction
)
}
return status
}
schedulers.main.async {
self.beginObservingOnboardingStoryEventsIfNeeded(downloadStatus: status)
}
return .value(status)
}
// MARK: Story Deletion
private func cleanUpOnboardingStoriesIfNeeded(
messageUniqueIds: [String]?,
forceDeleteIfDownloaded: Bool,
transaction: SDSAnyWriteTransaction
) throws {
var forceDelete = forceDeleteIfDownloaded
let viewStatus = self.onboardingStoryViewStatus(transaction: transaction)
let viewedTimestamp: UInt64?
var markViewedIfNotFound = false
switch viewStatus.status {
case .notViewed:
// Legacy clients might have viewed stories from before we recorded viewed status.
viewedTimestamp = nil
markViewedIfNotFound = true
case .viewedOnAnotherDevice:
// Delete right away.
forceDelete = true
viewedTimestamp = nil
case .viewedOnThisDevice:
guard let timestamp = viewStatus.viewedTimestamp else {
throw OWSAssertionError("Invalid view status")
}
viewedTimestamp = timestamp
}
let isExpired = viewedTimestamp.map {
Date().timeIntervalSince(Date(millisecondsSince1970: $0)) >= Constants.postViewingTimeout
} ?? false
guard isExpired || forceDelete || markViewedIfNotFound else {
return
}
guard let messageUniqueIds = messageUniqueIds, !messageUniqueIds.isEmpty else {
throw OWSAssertionError("No messages")
}
let stories = StoryFinder.listStoriesWithUniqueIds(messageUniqueIds, transaction: transaction)
guard !stories.isEmpty else {
if markViewedIfNotFound {
// this is a legacy client with stories that were viewed before
// we kept track of viewed state independently.
try self.setOnboardingStoryViewedOnThisDevice(
atTimestamp: 0,
shouldUpdateStorageService: true,
transaction: transaction
)
}
return
}
guard isExpired || forceDelete else {
return
}
stories.forEach {
$0.anyRemove(transaction: transaction)
}
}
// MARK: Downloading
private func fetchFilenames(
urlSession: OWSURLSessionProtocol
) -> Promise<[String]> {
return Promise.wrapAsync {
return try await urlSession.performRequest(Constants.manifestPath, method: .get)
}.map(on: queue) { (response: HTTPResponse) throws -> [String] in
guard
let json = response.responseBodyJson,
let responseDictionary = json as? [String: AnyObject],
let version = responseDictionary[Constants.manifestVersionKey] as? String,
let languages = responseDictionary[Constants.manifestLanguagesKey] as? [String: AnyObject]
else {
throw OWSAssertionError("Missing or invalid JSON")
}
guard
let assetFilenames = Locale.current.languageCode.map({ languageCode in
languages[languageCode] as? [String]
}) ?? (languages[Constants.fallbackLanguageCode] as? [String])
else {
throw OWSAssertionError("Unable to locate onboarding image set")
}
return assetFilenames.map {
return Constants.imagePath(version: version, filename: $0)
}
}
}
private func downloadOnboardingAsset(
urlSession: OWSURLSessionProtocol,
url: String
) -> Promise<AttachmentDataSource> {
return Promise.wrapAsync {
return try await urlSession.performDownload(url, method: .get)
}.map(on: self.queue) { [fileSystem, storyMessageFactory] result in
let resultUrl = result.downloadUrl
guard fileSystem.fileOrFolderExists(url: resultUrl) else {
throw OWSAssertionError("Onboarding story url missing")
}
guard
fileSystem.isValidImage(at: resultUrl, mimeType: Constants.imageExtension)
else {
throw OWSAssertionError("Invalid onboarding asset")
}
let dataSource = try DataSourcePath(
fileUrl: resultUrl,
shouldDeleteOnDeallocation: CurrentAppContext().isRunningTests.negated
)
return try storyMessageFactory.validateAttachmentContents(
dataSource: dataSource,
mimeType: Constants.imageMimeType
)
}
}
/// Returns unique Ids for the created messages. Fails if any one message creation fails.
private func createStoryMessages(
attachmentSources: [AttachmentDataSource],
transaction: SDSAnyWriteTransaction
) throws -> [String] {
let baseTimestamp = Date().ows_millisecondsSince1970
let ids = try attachmentSources.lazy.enumerated().map { (i, attachmentSource) throws -> String in
let message = try storyMessageFactory.createFromSystemAuthor(
attachmentSource: attachmentSource,
// Ensure timestamps are unique since they are sometimes used for uniquing.
timestamp: baseTimestamp + UInt64(i),
transaction: transaction
)
return message.uniqueId
}
return ids
}
// MARK: - KV Store
// MARK: Onboarding Story Read Status
private func onboardingStoryReadStatus(transaction: SDSAnyReadTransaction) -> Bool {
return kvStore.getBool(Constants.kvStoreOnboardingStoryIsReadKey, defaultValue: false, transaction: transaction.asV2Read)
}
private func setOnboardingStoryRead(transaction: SDSAnyWriteTransaction, updateStorageService: Bool) throws {
guard !onboardingStoryReadStatus(transaction: transaction) else {
return
}
kvStore.setBool(true, key: Constants.kvStoreOnboardingStoryIsReadKey, transaction: transaction.asV2Write)
if updateStorageService {
SSKEnvironment.shared.storageServiceManagerRef.recordPendingLocalAccountUpdates()
}
NotificationCenter.default.postNotificationNameAsync(.onboardingStoryStateDidChange, object: nil)
}
// MARK: Onboarding Story View Status
private struct OnboardingStoryViewStatus: Codable {
enum Status: Int, Codable {
case notViewed
case viewedOnThisDevice
case viewedOnAnotherDevice
}
let status: Status
// only set for viewedOnThisDevice
let viewedTimestamp: UInt64?
}
private func onboardingStoryViewStatus(transaction: SDSAnyReadTransaction) -> OnboardingStoryViewStatus {
guard
let rawStatus = kvStore.getData(Constants.kvStoreOnboardingStoryViewStatusKey, transaction: transaction.asV2Read),
let status = try? JSONDecoder().decode(OnboardingStoryViewStatus.self, from: rawStatus)
else {
return OnboardingStoryViewStatus(status: .notViewed, viewedTimestamp: nil)
}
return status
}
private func setOnboardingStoryViewedOnAnotherDevice(transaction: SDSAnyWriteTransaction) throws {
try kvStore.setData(
JSONEncoder().encode(OnboardingStoryViewStatus(status: .viewedOnAnotherDevice, viewedTimestamp: nil)),
key: Constants.kvStoreOnboardingStoryViewStatusKey,
transaction: transaction.asV2Write
)
NotificationCenter.default.postNotificationNameAsync(.onboardingStoryStateDidChange, object: nil)
}
private func setOnboardingStoryViewedOnThisDevice(
atTimestamp timestamp: UInt64,
shouldUpdateStorageService: Bool,
transaction: SDSAnyWriteTransaction
) throws {
let oldStatus = onboardingStoryViewStatus(transaction: transaction)
guard oldStatus.status == .notViewed else {
return
}
try kvStore.setData(
JSONEncoder().encode(OnboardingStoryViewStatus(status: .viewedOnThisDevice, viewedTimestamp: timestamp)),
key: Constants.kvStoreOnboardingStoryViewStatusKey,
transaction: transaction.asV2Write
)
if shouldUpdateStorageService {
SSKEnvironment.shared.storageServiceManagerRef.recordPendingLocalAccountUpdates()
}
NotificationCenter.default.postNotificationNameAsync(.onboardingStoryStateDidChange, object: nil)
}
// MARK: Onboarding Story Download Status
private struct OnboardingStoryDownloadStatus: Codable {
var messageUniqueIds: [String]?
var isDownloaded: Bool { return messageUniqueIds?.isEmpty == false }
static var requiresDownload: Self { return .init(messageUniqueIds: nil) }
}
private func onboardingStoryDownloadStatus(transaction: SDSAnyReadTransaction) -> OnboardingStoryDownloadStatus {
guard
let rawStatus = kvStore.getData(Constants.kvStoreOnboardingStoryDownloadStatusKey, transaction: transaction.asV2Read),
let status = try? JSONDecoder().decode(OnboardingStoryDownloadStatus.self, from: rawStatus)
else {
return .requiresDownload
}
return status
}
internal func markOnboardingStoryDownloaded(
messageUniqueIds: [String],
transaction: SDSAnyWriteTransaction
) throws {
let status = OnboardingStoryDownloadStatus(messageUniqueIds: messageUniqueIds)
try kvStore.setData(
JSONEncoder().encode(status),
key: Constants.kvStoreOnboardingStoryDownloadStatusKey,
transaction: transaction.asV2Write
)
DispatchQueue.main.async {
self.beginObservingOnboardingStoryEventsIfNeeded(downloadStatus: status)
NotificationCenter.default.post(name: .onboardingStoryStateDidChange, object: nil)
}
}
// MARK: System Story Hidden Status
public func areSystemStoriesHidden(transaction: SDSAnyReadTransaction) -> Bool {
// No need to make this serial with the other calls, db transactions cover us.
kvStore.getBool(Constants.kvStoreHiddenStateKey, defaultValue: false, transaction: transaction.asV2Read)
}
private func setSystemStoryHidden(_ hidden: Bool, transaction: SDSAnyWriteTransaction) {
kvStore.setBool(hidden, key: Constants.kvStoreHiddenStateKey, transaction: transaction.asV2Write)
NotificationCenter.default.postNotificationNameAsync(.onboardingStoryStateDidChange, object: nil)
}
internal enum Constants {
static let kvStoreOnboardingStoryIsReadKey = "OnboardingStoryIsRead"
static let kvStoreOnboardingStoryViewStatusKey = "OnboardingStoryViewStatus"
static let kvStoreOnboardingStoryDownloadStatusKey = "OnboardingStoryStatus"
static let kvStoreHiddenStateKey = "SystemStoriesAreHidden"
static let kvStoreOnboardingOverlayViewedKey = "hasSeenStoryViewerOnboardingOverlay" // leading 'h' lowercase for legacy reasons
static let kvStoreGroupStoryEducationSheetViewedKey = "GroupStoryEducationSheetViewed"
static let manifestPath = "dynamic/ios/stories/onboarding/manifest.json"
static let manifestVersionKey = "version"
static let manifestLanguagesKey = "languages"
static let fallbackLanguageCode = "en"
static func imagePath(version: String, filename: String) -> String {
return "static/ios/stories/onboarding"
.appendingPathComponent(version)
.appendingPathComponent(filename)
+ Constants.imageExtension
}
static let imageExtension = ".jpg"
static let imageMimeType = MimeType.imageJpeg.rawValue
static let imageWidth = 1125
static let imageHeight = 1998
static let postViewingTimeout: TimeInterval = 24 /* hrs */ * 60 * 60
}
}
// MARK: - Shims
extension SystemStoryManager {
public enum Shims {
public typealias MessageProcessor = _SystemStoryManager_MessageProcessorShim
}
public enum Wrappers {
public typealias MessageProcessor = _SystemStoryManager_MessageProcessorWrapper
}
}
// MARK: MessageProcessor
public protocol _SystemStoryManager_MessageProcessorShim {
func waitForFetchingAndProcessing() -> Guarantee<Void>
}
public class _SystemStoryManager_MessageProcessorWrapper: _SystemStoryManager_MessageProcessorShim {
private let messageProcessor: MessageProcessor
public init(_ messageProcessor: MessageProcessor) {
self.messageProcessor = messageProcessor
}
public func waitForFetchingAndProcessing() -> Guarantee<Void> {
return self.messageProcessor.waitForFetchingAndProcessing()
}
}