534 lines
22 KiB
Swift
534 lines
22 KiB
Swift
//
|
|
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
public import LibSignalClient
|
|
|
|
@objc
|
|
public class StoryManager: NSObject {
|
|
public static let storyLifetimeMillis = kDayInMs
|
|
|
|
public class func setup(appReadiness: AppReadiness) {
|
|
cacheAreStoriesEnabled()
|
|
cacheAreViewReceiptsEnabled()
|
|
|
|
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
|
|
SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in
|
|
// Create My Story thread if necessary
|
|
TSPrivateStoryThread.getOrCreateMyStory(transaction: transaction)
|
|
|
|
if CurrentAppContext().isMainApp {
|
|
DependenciesBridge.shared.privateStoryThreadDeletionManager
|
|
.cleanUpDeletedTimestamps(tx: transaction.asV2Write)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public class func processIncomingStoryMessage(
|
|
_ storyMessage: SSKProtoStoryMessage,
|
|
timestamp: UInt64,
|
|
author: Aci,
|
|
localIdentifiers: LocalIdentifiers,
|
|
transaction: SDSAnyWriteTransaction
|
|
) throws {
|
|
guard StoryFinder.story(
|
|
timestamp: timestamp,
|
|
author: author,
|
|
transaction: transaction
|
|
) == nil else {
|
|
Logger.warn("Dropping story message with duplicate timestamp \(timestamp) from author \(author)")
|
|
return
|
|
}
|
|
|
|
guard !SSKEnvironment.shared.blockingManagerRef.isAddressBlocked(SignalServiceAddress(author), transaction: transaction) else {
|
|
Logger.warn("Dropping story message with timestamp \(timestamp) from blocked or hidden author \(author)")
|
|
return
|
|
}
|
|
|
|
if DependenciesBridge.shared.recipientHidingManager.isHiddenAddress(SignalServiceAddress(author), tx: transaction.asV2Read) {
|
|
Logger.warn("Dropping story message with timestamp \(timestamp) from hidden author \(author)")
|
|
return
|
|
}
|
|
|
|
if let masterKey = storyMessage.group?.masterKey {
|
|
let contextInfo = try GroupV2ContextInfo.deriveFrom(masterKeyData: masterKey)
|
|
|
|
guard !SSKEnvironment.shared.blockingManagerRef.isGroupIdBlocked(contextInfo.groupId, transaction: transaction) else {
|
|
Logger.warn("Dropping story message with timestamp \(timestamp) in blocked group")
|
|
return
|
|
}
|
|
|
|
guard
|
|
let groupThread = TSGroupThread.fetch(groupId: contextInfo.groupId, transaction: transaction),
|
|
groupThread.groupMembership.isFullMember(author)
|
|
else {
|
|
Logger.warn("Dropping story message with timestamp \(timestamp) from author \(author) not in group")
|
|
return
|
|
}
|
|
|
|
if
|
|
let groupModel = groupThread.groupModel as? TSGroupModelV2,
|
|
groupModel.isAnnouncementsOnly,
|
|
!groupModel.groupMembership.isFullMemberAndAdministrator(author)
|
|
{
|
|
Logger.warn("Dropping story message with timestamp \(timestamp) from non-admin author \(author) in announcement only group")
|
|
return
|
|
}
|
|
|
|
} else {
|
|
guard SSKEnvironment.shared.profileManagerRef.isUser(inProfileWhitelist: SignalServiceAddress(author), transaction: transaction) else {
|
|
Logger.warn("Dropping story message with timestamp \(timestamp) from unapproved author \(author).")
|
|
return
|
|
}
|
|
}
|
|
|
|
if let profileKey = storyMessage.profileKey {
|
|
SSKEnvironment.shared.profileManagerRef.setProfileKeyData(
|
|
profileKey,
|
|
for: author,
|
|
onlyFillInIfMissing: false,
|
|
shouldFetchProfile: true,
|
|
userProfileWriter: .localUser,
|
|
localIdentifiers: localIdentifiers,
|
|
authedAccount: .implicit(),
|
|
tx: transaction.asV2Write
|
|
)
|
|
}
|
|
|
|
guard let message = try StoryMessage.create(
|
|
withIncomingStoryMessage: storyMessage,
|
|
timestamp: timestamp,
|
|
receivedTimestamp: Date().ows_millisecondsSince1970,
|
|
author: author,
|
|
transaction: transaction
|
|
) else { return }
|
|
|
|
switch message.context {
|
|
case .authorAci(let authorAci):
|
|
// Make sure the thread exists for the contact who sent us this story.
|
|
_ = TSContactThread.getOrCreateThread(withContactAddress: SignalServiceAddress(authorAci), transaction: transaction)
|
|
case .groupId, .privateStory, .none:
|
|
break
|
|
}
|
|
|
|
startAutomaticDownloadIfNecessary(for: message, transaction: transaction)
|
|
|
|
SSKEnvironment.shared.disappearingMessagesJobRef.scheduleRun(by: message.timestamp + storyLifetimeMillis)
|
|
|
|
SSKEnvironment.shared.earlyMessageManagerRef.applyPendingMessages(for: message, transaction: transaction)
|
|
}
|
|
|
|
public class func processStoryMessageTranscript(
|
|
_ proto: SSKProtoSyncMessageSent,
|
|
transaction: SDSAnyWriteTransaction
|
|
) throws {
|
|
let existingStory = StoryFinder.story(
|
|
timestamp: proto.timestamp,
|
|
author: DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction.asV2Read)!.aci,
|
|
transaction: transaction
|
|
)
|
|
|
|
if proto.isRecipientUpdate {
|
|
if let existingStory = existingStory {
|
|
existingStory.updateRecipients(proto.storyMessageRecipients, transaction: transaction)
|
|
|
|
// If there are no recipients remaining for a private story, delete the story model
|
|
if existingStory.groupId == nil,
|
|
case .outgoing(let recipientStates) = existingStory.manifest,
|
|
recipientStates.values.flatMap({ $0.contexts }).isEmpty {
|
|
Logger.info("Deleting story with timestamp \(existingStory.timestamp) with no remaining contexts")
|
|
existingStory.anyRemove(transaction: transaction)
|
|
}
|
|
} else {
|
|
owsFailDebug("Missing existing story for recipient update with timestamp \(proto.timestamp)")
|
|
}
|
|
} else if existingStory == nil {
|
|
let message = try StoryMessage.create(withSentTranscript: proto, transaction: transaction)
|
|
|
|
DependenciesBridge.shared.attachmentDownloadManager.enqueueDownloadOfAttachmentsForStoryMessage(message, tx: transaction.asV2Write)
|
|
|
|
SSKEnvironment.shared.disappearingMessagesJobRef.scheduleRun(by: message.timestamp + storyLifetimeMillis)
|
|
|
|
SSKEnvironment.shared.earlyMessageManagerRef.applyPendingMessages(for: message, transaction: transaction)
|
|
} else {
|
|
owsFailDebug("Ignoring sync transcript for story with timestamp \(proto.timestamp)")
|
|
}
|
|
}
|
|
|
|
public class func deleteAllStories(forSender senderAci: Aci, tx: SDSAnyWriteTransaction) {
|
|
StoryFinder.enumerateStories(fromSender: senderAci, tx: tx) { storyMessage, _ in
|
|
storyMessage.anyRemove(transaction: tx)
|
|
}
|
|
}
|
|
|
|
public class func deleteAllStories(forGroupId groupId: Data, tx: SDSAnyWriteTransaction) {
|
|
StoryFinder.enumerateStoriesForContext(.groupId(groupId), transaction: tx) { storyMessage, _ in
|
|
storyMessage.anyRemove(transaction: tx)
|
|
}
|
|
}
|
|
|
|
/// Removes a given address from any TSPrivateStoryThread(s) that have it as an _explicit_ address, whether by exclusion or
|
|
/// inclusion.
|
|
public class func removeAddressFromAllPrivateStoryThreads(_ address: SignalServiceAddress, tx: SDSAnyWriteTransaction) {
|
|
// We don't have a mapping from recipient to the set of TSPrivateStoryThreads they
|
|
// are a part of, so the best we can do is index over all of them and find
|
|
// the recipient if present. If this becomes an issue, we can consider adding such a lookup table.
|
|
// In practice, since private story threads are generated exclusively by the user themselves,
|
|
// and explicit memberships are a subset, the count is going to be very low.
|
|
ThreadFinder().storyThreads(
|
|
includeImplicitGroupThreads: false,
|
|
transaction: tx
|
|
).forEach { thread in
|
|
guard let storyThread = thread as? TSPrivateStoryThread else {
|
|
return
|
|
}
|
|
switch storyThread.storyViewMode {
|
|
case .default, .disabled:
|
|
return
|
|
case .explicit, .blockList:
|
|
var finalAddresses = storyThread.addresses
|
|
finalAddresses.removeAll(where: { $0 == address })
|
|
if finalAddresses.count != storyThread.addresses.count {
|
|
// Remove the recipient from the private story thread.
|
|
storyThread.updateWithStoryViewMode(
|
|
storyThread.storyViewMode,
|
|
addresses: finalAddresses,
|
|
updateStorageService: true,
|
|
transaction: tx
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public class func nextExpirationTimestamp(transaction: SDSAnyReadTransaction) -> UInt64? {
|
|
guard let timestamp = StoryFinder.oldestExpirableTimestamp(transaction: transaction) else { return nil }
|
|
return timestamp + storyLifetimeMillis
|
|
}
|
|
|
|
private static let hasSetMyStoriesPrivacyKey = "hasSetMyStoriesPrivacyKey"
|
|
|
|
@objc
|
|
public class func hasSetMyStoriesPrivacy(transaction: SDSAnyReadTransaction) -> Bool {
|
|
return keyValueStore.getBool(hasSetMyStoriesPrivacyKey, defaultValue: false, transaction: transaction.asV2Read)
|
|
}
|
|
|
|
@objc
|
|
public class func setHasSetMyStoriesPrivacy(
|
|
_ hasSet: Bool,
|
|
shouldUpdateStorageService: Bool,
|
|
transaction: SDSAnyWriteTransaction
|
|
) {
|
|
guard hasSet != hasSetMyStoriesPrivacy(transaction: transaction) else {
|
|
// Don't trigger account record updates unneccesarily!
|
|
return
|
|
}
|
|
keyValueStore.setBool(hasSet, key: hasSetMyStoriesPrivacyKey, transaction: transaction.asV2Write)
|
|
if shouldUpdateStorageService {
|
|
SSKEnvironment.shared.storageServiceManagerRef.recordPendingLocalAccountUpdates()
|
|
}
|
|
}
|
|
|
|
private static let perContextAutomaticDownloadLimit = 3
|
|
private static let recentContextAutomaticDownloadLimit: UInt = 20
|
|
|
|
/// We automatically download incoming stories IFF:
|
|
/// * The context has been recently interacted with (sent message to group, 1:1, viewed story, etc), is associated with a pinned thread, or has been recently viewed
|
|
/// * We have not already exceeded the limit for how many unviewed stories we should download for this context
|
|
private class func startAutomaticDownloadIfNecessary(for message: StoryMessage, transaction: SDSAnyWriteTransaction) {
|
|
|
|
let attachmentPointerToDownload: AttachmentTransitPointer?
|
|
switch message.attachment {
|
|
case .media:
|
|
let attachment = message.id.map { rowId in
|
|
return DependenciesBridge.shared.attachmentStore
|
|
.fetchFirstReferencedAttachment(
|
|
for: .storyMessageMedia(storyMessageRowId: rowId),
|
|
tx: transaction.asV2Read
|
|
)?.attachment
|
|
} ?? nil
|
|
if attachment?.asStream() != nil {
|
|
// Already downloaded!
|
|
return
|
|
} else {
|
|
attachmentPointerToDownload = attachment?.asTransitTierPointer()
|
|
}
|
|
case .text:
|
|
// We always auto-download non-file story attachments, this will generally only be link preview thumbnails.
|
|
Logger.info("Automatically enqueueing download of non-file based story with timestamp \(message.timestamp)")
|
|
DependenciesBridge.shared.attachmentDownloadManager.enqueueDownloadOfAttachmentsForStoryMessage(message, tx: transaction.asV2Write)
|
|
return
|
|
}
|
|
|
|
guard
|
|
let attachmentPointer = attachmentPointerToDownload,
|
|
attachmentPointer.attachment.asStream() == nil
|
|
else {
|
|
// Already downloaded or couldn't find it, nothing to do.
|
|
return
|
|
}
|
|
|
|
var unviewedDownloadedStoriesForContext = 0
|
|
StoryFinder.enumerateUnviewedIncomingStoriesForContext(message.context, transaction: transaction) { otherMessage, stop in
|
|
guard otherMessage.uniqueId != message.uniqueId else { return }
|
|
switch otherMessage.attachment {
|
|
case .text:
|
|
unviewedDownloadedStoriesForContext += 1
|
|
case .media:
|
|
guard let attachment = otherMessage.fileAttachment(tx: transaction) else {
|
|
owsFailDebug("Missing attachment")
|
|
return
|
|
}
|
|
if attachment.attachment.asStream() != nil {
|
|
unviewedDownloadedStoriesForContext += 1
|
|
} else if
|
|
let pointer = attachment.attachment.asTransitTierPointer(),
|
|
pointer.downloadState(tx: transaction.asV2Read) == .enqueuedOrDownloading
|
|
{
|
|
unviewedDownloadedStoriesForContext += 1
|
|
}
|
|
}
|
|
|
|
if unviewedDownloadedStoriesForContext >= perContextAutomaticDownloadLimit {
|
|
stop.pointee = true
|
|
}
|
|
}
|
|
|
|
guard unviewedDownloadedStoriesForContext < perContextAutomaticDownloadLimit else {
|
|
Logger.info("Skipping automatic download of attachments for story with timestamp \(message.timestamp), automatic download limit exceeded for context \(message.context)")
|
|
return
|
|
}
|
|
|
|
// See if the context has been recently active
|
|
|
|
let pinnedThreads = DependenciesBridge.shared.pinnedThreadManager.pinnedThreads(tx: transaction.asV2Read)
|
|
let recentlyInteractedThreads = ThreadFinder().threadsWithRecentInteractions(limit: recentContextAutomaticDownloadLimit, transaction: transaction)
|
|
let recentlyViewedContexts = StoryFinder.associatedDatasWithRecentlyViewedStories(
|
|
limit: Int(recentContextAutomaticDownloadLimit),
|
|
transaction: transaction
|
|
).map(\.sourceContext.asStoryContext)
|
|
let autoDownloadContexts = (pinnedThreads + recentlyInteractedThreads).map { $0.storyContext } + recentlyViewedContexts
|
|
|
|
if autoDownloadContexts.contains(message.context) || autoDownloadContexts.contains(.authorAci(message.authorAci)) {
|
|
Logger.info("Automatically downloading attachments for story with timestamp \(message.timestamp) and context \(message.context)")
|
|
|
|
Self.enqueueDownloadOfAttachmentsForStoryMessage(message, tx: transaction.asV2Write)
|
|
} else {
|
|
Logger.info("Skipping automatic download of attachments for story with timestamp \(message.timestamp), context \(message.context) not recently active")
|
|
}
|
|
}
|
|
|
|
// Exposed for testing
|
|
internal class func enqueueDownloadOfAttachmentsForStoryMessage(
|
|
_ message: StoryMessage,
|
|
tx: DBWriteTransaction
|
|
) {
|
|
DependenciesBridge.shared.attachmentDownloadManager.enqueueDownloadOfAttachmentsForStoryMessage(
|
|
message,
|
|
tx: tx
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public extension Notification.Name {
|
|
static let storiesEnabledStateDidChange = Notification.Name("storiesEnabledStateDidChange")
|
|
}
|
|
|
|
extension StoryManager {
|
|
private static let keyValueStore = KeyValueStore(collection: "StoryManager")
|
|
private static let areStoriesEnabledKey = "areStoriesEnabled"
|
|
|
|
private static var areStoriesEnabledCache = AtomicBool(true, lock: .sharedGlobal)
|
|
|
|
/// A cache of if stories are enabled for the local user. For convenience, this also factors in whether the overall feature is available to the user.
|
|
@objc
|
|
public static var areStoriesEnabled: Bool { areStoriesEnabledCache.get() }
|
|
|
|
public static func setAreStoriesEnabled(_ areStoriesEnabled: Bool, shouldUpdateStorageService: Bool = true, transaction: SDSAnyWriteTransaction) {
|
|
keyValueStore.setBool(areStoriesEnabled, key: areStoriesEnabledKey, transaction: transaction.asV2Write)
|
|
areStoriesEnabledCache.set(areStoriesEnabled)
|
|
|
|
if shouldUpdateStorageService {
|
|
SSKEnvironment.shared.storageServiceManagerRef.recordPendingLocalAccountUpdates()
|
|
}
|
|
|
|
transaction.addAsyncCompletionOnMain {
|
|
NotificationCenter.default.post(name: .storiesEnabledStateDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
/// Have stories been enabled by the local user. This never factors in any remote information, like is the feature available to the user.
|
|
public static func areStoriesEnabled(transaction: SDSAnyReadTransaction) -> Bool {
|
|
keyValueStore.getBool(areStoriesEnabledKey, defaultValue: true, transaction: transaction.asV2Read)
|
|
}
|
|
|
|
private static func cacheAreStoriesEnabled() {
|
|
AssertIsOnMainThread()
|
|
|
|
let areStoriesEnabled = SSKEnvironment.shared.databaseStorageRef.read { Self.areStoriesEnabled(transaction: $0) }
|
|
areStoriesEnabledCache.set(areStoriesEnabled)
|
|
|
|
if !areStoriesEnabled {
|
|
NotificationCenter.default.post(name: .storiesEnabledStateDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
public static func appendStoryHeaders(to request: TSRequest) {
|
|
for (key, value) in buildStoryHeaders() {
|
|
request.setValue(value, forHTTPHeaderField: key)
|
|
}
|
|
}
|
|
|
|
public static func buildStoryHeaders() -> [String: String] {
|
|
["X-Signal-Receive-Stories": areStoriesEnabled ? "true" : "false"]
|
|
}
|
|
|
|
// MARK: - Story Thread Name
|
|
|
|
public static func storyName(for thread: TSThread) -> String {
|
|
if let groupThread = thread as? TSGroupThread {
|
|
return groupThread.groupNameOrDefault
|
|
} else if let story = thread as? TSPrivateStoryThread {
|
|
return story.name
|
|
} else {
|
|
owsFailDebug("Unexpected thread type \(type(of: thread))")
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension StoryManager {
|
|
private static let areViewReceiptsEnabledKey = "areViewReceiptsEnabledKey"
|
|
|
|
@objc
|
|
@Atomic public private(set) static var areViewReceiptsEnabled: Bool = false
|
|
|
|
public static func areViewReceiptsEnabled(transaction: SDSAnyReadTransaction) -> Bool {
|
|
keyValueStore.getBool(areViewReceiptsEnabledKey, transaction: transaction.asV2Read) ?? OWSReceiptManager.areReadReceiptsEnabled(transaction: transaction)
|
|
}
|
|
|
|
// TODO: should this live on OWSReceiptManager?
|
|
public static func setAreViewReceiptsEnabled(_ enabled: Bool, shouldUpdateStorageService: Bool = true, transaction: SDSAnyWriteTransaction) {
|
|
keyValueStore.setBool(enabled, key: areViewReceiptsEnabledKey, transaction: transaction.asV2Write)
|
|
areViewReceiptsEnabled = enabled
|
|
|
|
if shouldUpdateStorageService {
|
|
SSKEnvironment.shared.storageServiceManagerRef.recordPendingLocalAccountUpdates()
|
|
}
|
|
}
|
|
|
|
private static func cacheAreViewReceiptsEnabled() {
|
|
areViewReceiptsEnabled = SSKEnvironment.shared.databaseStorageRef.read { areViewReceiptsEnabled(transaction: $0) }
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public enum StoryContext: Equatable, Hashable {
|
|
case groupId(Data)
|
|
case authorAci(Aci)
|
|
case privateStory(String)
|
|
case none
|
|
}
|
|
|
|
public extension TSThread {
|
|
var storyContext: StoryContext {
|
|
if let groupThread = self as? TSGroupThread {
|
|
return .groupId(groupThread.groupId)
|
|
} else if let contactThread = self as? TSContactThread, let authorAci = contactThread.contactAddress.serviceId as? Aci {
|
|
return .authorAci(authorAci)
|
|
} else if let privateStoryThread = self as? TSPrivateStoryThread {
|
|
return .privateStory(privateStoryThread.uniqueId)
|
|
} else {
|
|
return .none
|
|
}
|
|
}
|
|
}
|
|
|
|
public extension StoryContext {
|
|
|
|
var asAssociatedDataContext: StoryContextAssociatedData.SourceContext? {
|
|
switch self {
|
|
case .groupId(let data):
|
|
return .group(groupId: data)
|
|
case .authorAci(let authorAci):
|
|
return .contact(contactAci: authorAci)
|
|
case .privateStory:
|
|
return nil
|
|
case .none:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func threadUniqueId(transaction: SDSAnyReadTransaction) -> String? {
|
|
switch self {
|
|
case .groupId(let data):
|
|
return TSGroupThread.threadId(
|
|
forGroupId: data,
|
|
transaction: transaction
|
|
)
|
|
case .authorAci(let authorAci):
|
|
return TSContactThread.getWithContactAddress(
|
|
SignalServiceAddress(authorAci),
|
|
transaction: transaction
|
|
)?.uniqueId
|
|
case .privateStory(let uniqueId):
|
|
return uniqueId
|
|
case .none:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func thread(transaction: SDSAnyReadTransaction) -> TSThread? {
|
|
switch self {
|
|
case .groupId(let data):
|
|
return TSGroupThread.fetch(groupId: data, transaction: transaction)
|
|
case .authorAci(let authorAci):
|
|
return TSContactThread.getWithContactAddress(
|
|
SignalServiceAddress(authorAci),
|
|
transaction: transaction
|
|
)
|
|
case .privateStory(let uniqueId):
|
|
return TSPrivateStoryThread.anyFetchPrivateStoryThread(uniqueId: uniqueId, transaction: transaction)
|
|
case .none:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Returns nil only for outgoing contexts (private story contexts) which have no associated data.
|
|
/// For valid contact and group contexts where the associated data does not yet exists, creates and returns a default one.
|
|
func associatedData(transaction: SDSAnyReadTransaction) -> StoryContextAssociatedData? {
|
|
guard let source = self.asAssociatedDataContext else {
|
|
return nil
|
|
}
|
|
return StoryContextAssociatedData.fetchOrDefault(sourceContext: source, transaction: transaction)
|
|
}
|
|
|
|
func isHidden(
|
|
transaction: SDSAnyReadTransaction
|
|
) -> Bool {
|
|
if self == .authorAci(StoryMessage.systemStoryAuthor) {
|
|
return SSKEnvironment.shared.systemStoryManagerRef.areSystemStoriesHidden(transaction: transaction)
|
|
}
|
|
return self.associatedData(transaction: transaction)?.isHidden ?? false
|
|
}
|
|
}
|
|
|
|
public extension StoryContextAssociatedData.SourceContext {
|
|
|
|
var asStoryContext: StoryContext {
|
|
switch self {
|
|
case .contact(let contactAci):
|
|
return .authorAci(contactAci)
|
|
case .group(let groupId):
|
|
return .groupId(groupId)
|
|
}
|
|
}
|
|
}
|