diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 55294161a9..c744acd2d9 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -1008,6 +1008,7 @@ F00385FF273F6388000B5ABD /* SubscriptionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00385FC273F6388000B5ABD /* SubscriptionManager.swift */; }; F0038600273F6388000B5ABD /* DonationUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00385FD273F6388000B5ABD /* DonationUtilities.swift */; }; F0038601273F6388000B5ABD /* Stripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00385FE273F6388000B5ABD /* Stripe.swift */; }; + F02564D8274EDF4600D7B48A /* BadgeExpirationSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02564D7274EDF4600D7B48A /* BadgeExpirationSheet.swift */; }; F026749627405BF4005853DC /* SubscriptionReceiptCredentialRedemptionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = F026749527405BF4005853DC /* SubscriptionReceiptCredentialRedemptionJob.swift */; }; F05F51C926A90D6B00861034 /* ContextMenuActionsAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05F51C826A90D6B00861034 /* ContextMenuActionsAccessory.swift */; }; F0B872B6269CF6D900D26481 /* ContextMenuInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B872B5269CF6D900D26481 /* ContextMenuInteraction.swift */; }; @@ -2330,6 +2331,7 @@ F00385FC273F6388000B5ABD /* SubscriptionManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionManager.swift; sourceTree = ""; }; F00385FD273F6388000B5ABD /* DonationUtilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DonationUtilities.swift; sourceTree = ""; }; F00385FE273F6388000B5ABD /* Stripe.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Stripe.swift; sourceTree = ""; }; + F02564D7274EDF4600D7B48A /* BadgeExpirationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeExpirationSheet.swift; sourceTree = ""; }; F026749527405BF4005853DC /* SubscriptionReceiptCredentialRedemptionJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionReceiptCredentialRedemptionJob.swift; sourceTree = ""; }; F05F51C826A90D6B00861034 /* ContextMenuActionsAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuActionsAccessory.swift; sourceTree = ""; }; F0B872B5269CF6D900D26481 /* ContextMenuInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuInteraction.swift; sourceTree = ""; }; @@ -2583,6 +2585,7 @@ 888017912741F33B00346E9A /* BoostViewController.swift */, 8880179327430DDB00346E9A /* BadgeThanksSheet.swift */, 881BB2892743531D00B609B8 /* BadgeDetailsSheet.swift */, + F02564D7274EDF4600D7B48A /* BadgeExpirationSheet.swift */, 887B380725F0355700685845 /* ChatsSettingsViewController.swift */, 3414896825C9B6490098E3ED /* CurrencyPickerViewController.swift */, 887B6DCC25F6C41500E677D4 /* Data Usage */, @@ -6333,6 +6336,7 @@ 4C19A0FC227B356F007A0C7F /* DebugUIMessages+OWS.swift in Sources */, F0B872B8269D079B00D26481 /* ContextMenuConfiguration.swift in Sources */, 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */, + F02564D8274EDF4600D7B48A /* BadgeExpirationSheet.swift in Sources */, 34ACA7E82733159600E47AD4 /* ChangePhoneNumberController.swift in Sources */, 34D1F0841F8678AA0066283D /* ConversationInputToolbar.m in Sources */, 4CBBFE4A2306F5D300B37450 /* LogViewController.swift in Sources */, diff --git a/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.swift b/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.swift index 96c8e9a6b5..93ac218dba 100644 --- a/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.swift +++ b/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.swift @@ -67,16 +67,7 @@ class AppSettingsViewController: OWSTableViewController2 { } private lazy var hasCurrentSubscription: Bool = { - var subscriberID: Data? - SDSDatabaseStorage.shared.read { transaction in - subscriberID = SubscriptionManager.getSubscriberID(transaction: transaction) - } - - guard subscriberID != nil else { - return false - } - - return true + return SubscriptionManager.hasCurrentSubscriptionCached() }() func updateTableContents() { diff --git a/Signal/src/ViewControllers/AppSettings/BadgeExpirationSheet.swift b/Signal/src/ViewControllers/AppSettings/BadgeExpirationSheet.swift new file mode 100644 index 0000000000..dc9e5a2880 --- /dev/null +++ b/Signal/src/ViewControllers/AppSettings/BadgeExpirationSheet.swift @@ -0,0 +1,264 @@ +// +// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// + +import Foundation +import UIKit +import SignalMessaging + +protocol BadgeExpirationSheetDelegate: AnyObject { + func badgeExpirationSheetActionButtonTapped(_ badgeExpirationSheet: BadgeExpirationSheet) + func badgeExpirationSheetNotNowButtonTapped(_ badgeExpirationSheet: BadgeExpirationSheet) +} + +class BadgeExpirationSheet: InteractiveSheetViewController { + override var interactiveScrollViews: [UIScrollView] { [tableViewController.tableView] } + override var renderExternalHandle: Bool { false } + private var shouldMakeVisibleAndPrimary = false + public var delegate: BadgeExpirationSheetDelegate? + + var contentSizeHeight: CGFloat { + tableViewController.tableView.layoutIfNeeded() + return tableViewController.tableView.contentSize.height + tableViewController.tableView.adjustedContentInset.top + } + override var minimizedHeight: CGFloat { + return min(contentSizeHeight, maximizedHeight) + } + override var maximizedHeight: CGFloat { + min(contentSizeHeight, CurrentAppContext().frame.height - (view.safeAreaInsets.top + 32)) + } + + private let tableViewController = OWSTableViewController2() + private let handleContainer = UIView() + + private let badge: ProfileBadge + + public var badgeID: String { + return badge.id + } + + private lazy var isCurrentSustainer = { + return SubscriptionManager.hasCurrentSubscriptionCached() + }() + + required init(badge: ProfileBadge) { + owsAssertDebug(badge.assets != nil) + self.badge = badge + + super.init() + + tableViewController.shouldDeferInitialLoad = false + updateTableContents() + } + + public required init() { + fatalError("init() has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + + addChild(tableViewController) + + contentView.addSubview(tableViewController.view) + tableViewController.view.autoPinEdgesToSuperviewEdges() + + // We add the handle directly to the content view, + // so that it doesn't scroll with the table. + handleContainer.backgroundColor = Theme.tableView2PresentedBackgroundColor + contentView.addSubview(handleContainer) + handleContainer.autoPinWidthToSuperview() + handleContainer.autoPinEdge(toSuperviewEdge: .top) + + let handle = UIView() + handle.backgroundColor = tableViewController.separatorColor + handle.autoSetDimensions(to: CGSize(width: 36, height: 5)) + handle.layer.cornerRadius = 5 / 2 + handleContainer.addSubview(handle) + handle.autoPinHeightToSuperview(withMargin: 12) + handle.autoHCenterInSuperview() + + updateViewState() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + updateViewState() + } + + private var previousMinimizedHeight: CGFloat? + private func updateViewState() { + if minimizedHeight != previousMinimizedHeight { + heightConstraint?.constant = minimizedHeight + previousMinimizedHeight = minimizedHeight + } + } + + override func themeDidChange() { + super.themeDidChange() + handleContainer.backgroundColor = Theme.tableView2PresentedBackgroundColor + updateTableContents() + } + + var titleText: String { + switch badge.id { + case "BOOST": return NSLocalizedString( + "BADGE_EXPIRED_BOOST_TITLE", + comment: "Title for boost on the badge expiration sheet." + ) + default: return NSLocalizedString( + "BADGE_EXPIRED_SUBSCRIPTION_TITLE", + comment: "Title for subscription on the badge expiration sheet." + ) + } + } + + var bodyText: String { + var formatText: String + + switch (badgeID: badge.id, isSustainer: isCurrentSustainer) { + case (badgeID: "BOOST", isSustainer: true): + formatText = NSLocalizedString( + "BADGE_EXIPRED_BOOST_CURRENT_SUSTAINER_BODY_FORMAT", + comment: "String explaing to the user that their boost badge has expired while they are a current subscription sustainer on the badge expiry sheetsheet." + ) + case (badgeID: "BOOST", isSustainer: false): + formatText = NSLocalizedString( + "BADGE_EXIPRED_BOOST_BODY_FORMAT", + comment: "String explaing to the user that their boost badge has expired on the badge expiry sheetsheet." + ) + + default: + formatText = NSLocalizedString( + "BADGE_EXIPRED_SUBSCRIPTION_BODY_FORMAT", + comment: "String explaing to the user that their subscription badge has expired on the badge expiry sheetsheet. Embed {badge name}." + ) + } + + return String(format: formatText, badge.localizedName) + } + + var actionButtonText: String { + switch (badgeID: badge.id, isSustainer: isCurrentSustainer) { + case (badgeID: "BOOST", isSustainer: true): + return NSLocalizedString("BADGE_EXPIRED_BOOST_RENEWAL_BUTTON_SUSTAINER", + comment: "Button title for boost on the badge expiration sheet, used if the user is already a sustainer.") + case (badgeID: "BOOST", isSustainer: false): + return NSLocalizedString("BADGE_EXPIRED_BOOST_RENEWAL_BUTTON", + comment: "Button title for boost on the badge expiration sheet, used if the user is not already a sustainer.") + default: + return NSLocalizedString("BADGE_EXPIRED_SUBSCRIPTION_RENEWAL_BUTTON", + comment: "Button title for subscription on the badge expiration sheet.") + } + } + + private func updateTableContents() { + let contents = OWSTableContents() + defer { tableViewController.contents = contents } + + let headerSection = OWSTableSection() + headerSection.hasBackground = false + headerSection.customHeaderHeight = 1 + contents.addSection(headerSection) + + headerSection.add(.init(customCellBlock: { [weak self] in + let cell = OWSTableItem.newCell() + guard let self = self else { return cell } + cell.selectionStyle = .none + + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .center + stackView.layoutMargins = UIEdgeInsets(hMargin: 24, vMargin: 0) + stackView.isLayoutMarginsRelativeArrangement = true + + cell.contentView.addSubview(stackView) + stackView.autoPinEdgesToSuperviewEdges() + + let badgeImageView = UIImageView() + badgeImageView.image = self.badge.assets?.universal112 + badgeImageView.autoSetDimensions(to: CGSize(square: 112)) + stackView.addArrangedSubview(badgeImageView) + stackView.setCustomSpacing(16, after: badgeImageView) + + let titleLabel = UILabel() + titleLabel.font = .ows_dynamicTypeTitle2.ows_semibold + titleLabel.textColor = Theme.primaryTextColor + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 0 + titleLabel.text = self.titleText + stackView.addArrangedSubview(titleLabel) + stackView.setCustomSpacing(12, after: titleLabel) + + let bodyLabel = UILabel() + bodyLabel.font = .ows_dynamicTypeBody + bodyLabel.textColor = Theme.primaryTextColor + bodyLabel.textAlignment = .center + bodyLabel.numberOfLines = 0 + bodyLabel.text = self.bodyText + stackView.addArrangedSubview(bodyLabel) + stackView.setCustomSpacing(30, after: bodyLabel) + + return cell + }, actionBlock: nil)) + + let buttonSection = OWSTableSection() + buttonSection.hasBackground = false + contents.addSection(buttonSection) + buttonSection.add(.init(customCellBlock: { [weak self] in + let cell = OWSTableItem.newCell() + cell.selectionStyle = .none + guard let self = self else { return cell } + + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .center + stackView.layoutMargins = UIEdgeInsets(top: 30, left: 24, bottom: 0, right: 24) + stackView.spacing = 16 + stackView.isLayoutMarginsRelativeArrangement = true + cell.contentView.addSubview(stackView) + stackView.autoPinEdgesToSuperviewEdges() + + let actionButton = OWSFlatButton.button(title: self.actionButtonText, + font: UIFont.ows_dynamicTypeBody.ows_semibold, + titleColor: .white, + backgroundColor: .ows_accentBlue, + target: self, + selector: #selector(self.didTapAction)) + actionButton.autoSetHeightUsingFont() + actionButton.cornerRadius = 8 + stackView.addArrangedSubview(actionButton) + actionButton.autoPinWidthToSuperviewMargins() + + let notNowButton = OWSButton(title: CommonStrings.notNowButton) { [weak self] in + guard let self = self else { return } + self.didTapNotNow() + } + notNowButton.setTitleColor(Theme.accentBlueColor, for: .normal) + notNowButton.dimsWhenHighlighted = true + stackView.addArrangedSubview(notNowButton) + + return cell + }, actionBlock: nil)) + } + + public override func willDismissInteractively() { + didTapNotNow() + super.willDismissInteractively() + } + + + @objc + func didTapAction() { + if let delegate = delegate { + dismiss(animated: true, completion: nil) + delegate.badgeExpirationSheetActionButtonTapped(self) + } + } + + @objc + func didTapNotNow() { + dismiss(animated: true, completion: nil) + } +} diff --git a/Signal/src/ViewControllers/AppSettings/SubscriptionViewController.swift b/Signal/src/ViewControllers/AppSettings/SubscriptionViewController.swift index e6eb99dc87..e42b65318a 100644 --- a/Signal/src/ViewControllers/AppSettings/SubscriptionViewController.swift +++ b/Signal/src/ViewControllers/AppSettings/SubscriptionViewController.swift @@ -27,7 +27,19 @@ class SubscriptionViewController: OWSTableViewController2 { } else if fetchedSubscriptionStatus == false || subscriptions == nil { return .loading } else { - return currentSubscription != nil ? .subscriptionExists : .subscriptionNotYetSetUp + guard let currentSubscription = currentSubscription else { + return .subscriptionNotYetSetUp + } + + if !ignoreProfileBadgeStateForNewBadgeRedemption && profileSubscriptionBadges.isEmpty { + return .subscriptionNotYetSetUp + } + + if currentSubscription.active { + return .subscriptionExists + } else { + return (subscriptionRedemptionFailureReason == .none) ? .subscriptionExists : .subscriptionNotYetSetUp + } } } @@ -40,12 +52,24 @@ class SubscriptionViewController: OWSTableViewController2 { } } + private var currentSubscription: Subscription? + + private lazy var profileSubscriptionBadges: [OWSUserProfileBadgeInfo] = { + return SubscriptionManager.currentProfileSubscriptionBadges() + }() + private var selectedSubscription: SubscriptionLevel? - private var fetchedSubscriptionStatus = false + private var fetchedSubscriptionStatus = false { + didSet { + if fetchedSubscriptionStatus { + showBadgeExpirySheetIfNeeded() + } + } + } + private var ignoreProfileBadgeStateForNewBadgeRedemption = false private var updatingSubscriptions = false - private var currentSubscription: Subscription? private var persistedSubscriberID: Data? private var persistedSubscriberCurrencyCode: String? @@ -218,11 +242,11 @@ class SubscriptionViewController: OWSTableViewController2 { currencyCode = SubscriptionManager.getSubscriberCurrencyCode(transaction: transaction) } - if let subscripberID = subscriberID, let currencyCode = currencyCode { + if let subscriberID = subscriberID, let currencyCode = currencyCode { self.persistedSubscriberID = subscriberID self.persistedSubscriberCurrencyCode = currencyCode firstly { - SubscriptionManager.getCurrentSubscriptionStatus(for: subscripberID) + SubscriptionManager.getCurrentSubscriptionStatus(for: subscriberID) }.done(on: .main) { subscription in self.fetchedSubscriptionStatus = true self.currentSubscription = subscription @@ -244,6 +268,31 @@ class SubscriptionViewController: OWSTableViewController2 { } } + func showBadgeExpirySheetIfNeeded() { + guard subscriptionViewState == .subscriptionNotYetSetUp, let subscriptions = subscriptions else { + return + } + + let expiredBadgeID = SubscriptionManager.mostRecentlyExpiredBadgeIDWithSneakyTransaction() + guard let expiredBadgeID = expiredBadgeID else { + return + } + + let subscriptionLevel = subscriptions.first { $0.badge.id == expiredBadgeID } + + guard let subscriptionLevel = subscriptionLevel else { + owsFailDebug("Unable to find current subscription level for expired badge") + return + } + + selectedSubscription = subscriptionLevel + updateTableContents() + + let sheet = BadgeExpirationSheet(badge: subscriptionLevel.badge) + sheet.delegate = self + self.present(sheet, animated: true) + } + func updateTableContents() { let contents = OWSTableContents() defer { @@ -473,16 +522,8 @@ class SubscriptionViewController: OWSTableViewController2 { )) // Current support level - if let currentSubscription = currentSubscription, let subscriptions = subscriptions { - let level = currentSubscription.level - var subscriptionLevel: SubscriptionLevel? - for subscription in subscriptions { - if subscription.level == level { - subscriptionLevel = subscription - break - } - } - + let subscriptionLevel = subscriptionLevelForSubscription(currentSubscription) + if let subscriptionLevel = subscriptionLevel, let currentSubscription = currentSubscription { section.add(.init( customCellBlock: { [weak self] in guard let self = self else { return UITableViewCell() } @@ -512,15 +553,10 @@ class SubscriptionViewController: OWSTableViewController2 { containerStackView.addArrangedSubview(stackView) stackView.autoPinWidthToSuperviewMargins() - guard let subscription = subscriptionLevel else { - owsFailDebug("Can't find a matching description") - return cell - } - let imageView = UIImageView() imageView.setContentHuggingHigh() - if let badgeImage = subscription.badge.assets?.universal160 { + if let badgeImage = subscriptionLevel.badge.assets?.universal160 { imageView.image = badgeImage } @@ -543,7 +579,7 @@ class SubscriptionViewController: OWSTableViewController2 { textStackView.alignment = .leading textStackView.spacing = 4 - let localizedBadgeName = subscription.name + let localizedBadgeName = subscriptionLevel.name let titleLabel = UILabel() titleLabel.text = localizedBadgeName titleLabel.font = .ows_dynamicTypeBody.ows_semibold @@ -1217,6 +1253,8 @@ extension SubscriptionViewController: PKPaymentAuthorizationControllerDelegate { throw SubscriptionError.assertion } + self.ignoreProfileBadgeStateForNewBadgeRedemption = true + progressView.stopAnimating(success: true) { backdropView.alpha = 0 } completion: { @@ -1484,3 +1522,15 @@ private class SubscriptionReadMoreSheet: InteractiveSheetViewController { stackView.addArrangedSubview(secondDescriptionBlock) } } + +extension SubscriptionViewController: BadgeExpirationSheetDelegate { + + func badgeExpirationSheetActionButtonTapped(_ badgeExpirationSheet: BadgeExpirationSheet) { + requestApplePayDonation() + } + + func badgeExpirationSheetNotNowButtonTapped(_ badgeExpirationSheet: BadgeExpirationSheet) { + SubscriptionManager.clearMostRecentlyExpiredBadgeIDWithSneakyTransaction() + } + +} diff --git a/Signal/src/ViewControllers/HomeView/HomeViewController.m b/Signal/src/ViewControllers/HomeView/HomeViewController.m index 4313bc441b..7987805a85 100644 --- a/Signal/src/ViewControllers/HomeView/HomeViewController.m +++ b/Signal/src/ViewControllers/HomeView/HomeViewController.m @@ -465,6 +465,8 @@ NSString *const kArchiveButtonPseudoGroup = @"kArchiveButtonPseudoGroup"; [self.searchResultsController viewDidAppear:animated]; + [self showBadgeExpirationSheetIfNeeded]; + self.hasEverAppeared = YES; } diff --git a/Signal/src/ViewControllers/HomeView/HomeViewController.swift b/Signal/src/ViewControllers/HomeView/HomeViewController.swift index cdffb516d5..1177e0317c 100644 --- a/Signal/src/ViewControllers/HomeView/HomeViewController.swift +++ b/Signal/src/ViewControllers/HomeView/HomeViewController.swift @@ -3,6 +3,8 @@ // import Foundation +import SignalCoreKit +import SignalServiceKit @objc public extension HomeViewController { @@ -51,6 +53,57 @@ public extension HomeViewController { // MARK: - + func showBadgeExpirationSheetIfNeeded() { + guard !hasEverAppeared else { // Do this once per launch + return + } + + let expiredBadgeID = SubscriptionManager.mostRecentlyExpiredBadgeIDWithSneakyTransaction() + guard let expiredBadgeID = expiredBadgeID, expiredBadgeID != "BOOST" else { // TODO Add boost support + return + } + + let shouldShow = SDSDatabaseStorage.shared.read { transaction in + SubscriptionManager.showExpirySheetOnHomeScreenKey(transaction: transaction) + } + + guard shouldShow else { + return + } + + // Fetch current subscriptions, required to populate badge assets + firstly { + SubscriptionManager.getSubscriptions() + }.done(on: .global()) { (subscriptions: [SubscriptionLevel]) in + for level in subscriptions { + if level.badge.id == expiredBadgeID { + // Fetch assets + firstly { + self.profileManager.badgeStore.populateAssetsOnBadge(level.badge) + }.done(on: .main) { + + SDSDatabaseStorage.shared.write { transaction in + SubscriptionManager.setShowExpirySheetOnHomeScreenKey(show: false, transaction: transaction) + } + + let badgeSheet = BadgeExpirationSheet(badge: level.badge) + badgeSheet.delegate = self + self.present(badgeSheet, animated: true) + + }.catch { error in + owsFailDebug("Failed to fetch badge assets for expiry \(error)") + } + + break + } + } + }.catch { error in + owsFailDebug("Failed to fetch subscriptions for expiry \(error)") + } + } + + // MARK: - + func configureUnreadPaymentsBannerSingle(_ paymentsReminderView: UIView, paymentModel: TSPaymentModel, transaction: SDSAnyReadTransaction) { @@ -226,6 +279,8 @@ public enum ShowAppSettingsMode { case paymentsTransferIn case appearance case avatarBuilder + case subscriptions + case boost } // MARK: - @@ -299,8 +354,24 @@ public extension HomeViewController { let profile = ProfileSettingsViewController() viewControllers += [ profile ] completion = { profile.presentAvatarSettingsView() } + case .subscriptions: + let subscriptions = SubscriptionViewController() + viewControllers += [ subscriptions ] + case .boost: + let boost = BoostViewController() + viewControllers += [ boost ] } navigationController.setViewControllers(viewControllers, animated: false) presentFormSheet(navigationController, animated: true, completion: completion) } } + +extension HomeViewController: BadgeExpirationSheetDelegate { + func badgeExpirationSheetActionButtonTapped(_ badgeExpirationSheet: BadgeExpirationSheet) { + SubscriptionManager.clearMostRecentlyExpiredBadgeIDWithSneakyTransaction() + let mode: ShowAppSettingsMode = badgeExpirationSheet.badgeID == "BOOST" ? .boost : .subscriptions + showAppSettings(mode: mode) + } + + func badgeExpirationSheetNotNowButtonTapped(_ badgeExpirationSheet: BadgeExpirationSheet) { } +} diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index ac4c377d90..5f9795445d 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -385,6 +385,30 @@ /* Text prompting the user to boost */ "BADGE_DETAILS_GIVE_A_BOOST" = "Give Signal a Boost"; +/* String explaing to the user that their boost badge has expired on the badge expiry sheetsheet. */ +"BADGE_EXIPRED_BOOST_BODY_FORMAT" = "Your Boost badge has expired, and is no longer visible on your profile.\n\nYou can continue using Signal but to support technology that is built for you, please consider becoming a monthly Sustainer."; + +/* String explaing to the user that their boost badge has expired while they are a current subscription sustainer on the badge expiry sheetsheet. */ +"BADGE_EXIPRED_BOOST_CURRENT_SUSTAINER_BODY_FORMAT" = "Your Boost badge has expired, and is no longer visible on your profile.\n\nYou can reactivate your Boost badge for another 30 days with a one-time contribution."; + +/* String explaing to the user that their subscription badge has expired on the badge expiry sheetsheet. Embed {badge name}. */ +"BADGE_EXIPRED_SUBSCRIPTION_BODY_FORMAT" = "Your Sustainer subscription was automatically cancelled because you were inactive for too long. Your %@ badge is no longer visible on your profile.\n\nYou can keep using Signal but to support the app and reactivate your badge, renew now."; + +/* Button title for boost on the badge expiration sheet, used if the user is not already a sustainer. */ +"BADGE_EXPIRED_BOOST_RENEWAL_BUTTON" = "Become a Sustainer"; + +/* Button title for boost on the badge expiration sheet, used if the user is already a sustainer. */ +"BADGE_EXPIRED_BOOST_RENEWAL_BUTTON_SUSTAINER" = "Add a Boost"; + +/* Title for boost on the badge expiration sheet. */ +"BADGE_EXPIRED_BOOST_TITLE" = "Badge Expired"; + +/* Button title for subscription on the badge expiration sheet. */ +"BADGE_EXPIRED_SUBSCRIPTION_RENEWAL_BUTTON" = "Renew Subscription"; + +/* Title for subscription on the badge expiration sheet. */ +"BADGE_EXPIRED_SUBSCRIPTION_TITLE" = "Subscription Cancelled"; + /* Title for boost on the thank you sheet. */ "BADGE_THANKS_BOOST_TITLE" = "Thanks for the Boost!"; diff --git a/SignalMessaging/Jobs/SubscriptionReceiptCredentialRedemptionJob.swift b/SignalMessaging/Jobs/SubscriptionReceiptCredentialRedemptionJob.swift index 74b208f86c..f071dcb44e 100644 --- a/SignalMessaging/Jobs/SubscriptionReceiptCredentialRedemptionJob.swift +++ b/SignalMessaging/Jobs/SubscriptionReceiptCredentialRedemptionJob.swift @@ -165,6 +165,7 @@ public class SubscriptionReceiptCredentailRedemptionOperation: OWSOperation, Dur ) }.done(on: .global()) { Logger.info("[Subscriptions] Successfully redeemed receipt credential presentation") + SubscriptionManager.mostRecentlyExpiredBadgeIDWithSneakyTransaction() self.didSucceed() }.catch(on: .global()) { error in self.reportError(error) diff --git a/SignalMessaging/Subscriptions/SubscriptionManager.swift b/SignalMessaging/Subscriptions/SubscriptionManager.swift index 4ef61868f0..0381ded1e0 100644 --- a/SignalMessaging/Subscriptions/SubscriptionManager.swift +++ b/SignalMessaging/Subscriptions/SubscriptionManager.swift @@ -91,12 +91,17 @@ public class SubscriptionManager: NSObject { public static let SubscriptionJobQueueDidFinishJobNotification = NSNotification.Name("SubscriptionJobQueueDidFinishJobNotification") public static let SubscriptionJobQueueDidFailJobNotification = NSNotification.Name("SubscriptionJobQueueDidFailJobNotification") private static let subscriptionKVS = SDSKeyValueStore(collection: "SubscriptionKeyValueStore") - private static let subscriberIDKey = "subscriberID" - private static let subscriberCurrencyCodeKey = "subscriberCurrencyCode" - private static let lastSubscriptionExpirationKey = "subscriptionExpiration" - private static let lastSubscriptionHeartbeatKey = "subscriptionHeartbeat" - private static let lastSubscriptionReceiptRedemptionFailedKey = "lastSubscriptionReceiptRedemptionFailedKey" - private static let userManuallyCancelledSubscriptionKey = "userManuallyCancelledSubscriptionKey" + + fileprivate static let subscriberIDKey = "subscriberID" + fileprivate static let subscriberCurrencyCodeKey = "subscriberCurrencyCode" + fileprivate static let lastSubscriptionExpirationKey = "subscriptionExpiration" + fileprivate static let lastSubscriptionHeartbeatKey = "subscriptionHeartbeat" + fileprivate static let lastSubscriptionReceiptRedemptionFailedKey = "lastSubscriptionReceiptRedemptionFailedKey" + fileprivate static let userManuallyCancelledSubscriptionKey = "userManuallyCancelledSubscriptionKey" + fileprivate static let knownUserSubscriptionBadgeIDsKey = "knownUserSubscriptionBadgeIDsKey" + fileprivate static let knownUserBoostBadgeIDsKey = "knownUserBoostBadgeIDsKey" + fileprivate static let mostRecentlyExpiredBadgeIDKey = "mostRecentlyExpiredBadgeIDKey" + fileprivate static let showExpirySheetOnHomeScreenKey = "showExpirySheetOnHomeScreenKey" public static var terminateTransactionIfPossible = false @@ -155,8 +160,22 @@ public class SubscriptionManager: NSObject { return false } - return lastSubscriptionExpiryDate.isAfterNow + if currentProfileSubscriptionBadges().count == 0 { + return false + } + return lastSubscriptionExpiryDate.isAfterNow + } + + public class func currentProfileSubscriptionBadges() -> [OWSUserProfileBadgeInfo] { + let snapshot = profileManagerImpl.localProfileSnapshot(shouldIncludeAvatar: true) + let profileBadges = snapshot.profileBadgeInfo ?? [] + return profileBadges.compactMap { (badge: OWSUserProfileBadgeInfo) -> OWSUserProfileBadgeInfo? in + if badge.badgeId != "BOOST" { + return badge + } + return nil + } } public class func getCurrentSubscriptionStatus(for subscriberID: Data) -> Promise { @@ -189,77 +208,6 @@ public class SubscriptionManager: NSObject { // MARK: Subscription management - public static func getSubscriberID(transaction: SDSAnyReadTransaction) -> Data? { - guard let subscriberID = subscriptionKVS.getObject( - forKey: subscriberIDKey, - transaction: transaction - ) as? Data else { - return nil - } - return subscriberID - } - - public static func setSubscriberID(_ subscriberID: Data?, transaction: SDSAnyWriteTransaction) { - subscriptionKVS.setObject(subscriberID, - key: subscriberIDKey, - transaction: transaction) - } - - public static func getSubscriberCurrencyCode(transaction: SDSAnyReadTransaction) -> String? { - guard let subscriberCurrencyCode = subscriptionKVS.getObject( - forKey: subscriberCurrencyCodeKey, - transaction: transaction - ) as? String else { - return nil - } - return subscriberCurrencyCode - } - - public static func setSubscriberCurrencyCode(_ currencyCode: String?, transaction: SDSAnyWriteTransaction) { - subscriptionKVS.setObject(currencyCode, - key: subscriberCurrencyCodeKey, - transaction: transaction) - } - - public static func setLastReceiptRedemptionFailed(failureReason: SubscriptionRedemptionFailureReason, transaction: SDSAnyWriteTransaction) { - subscriptionKVS.setInt(failureReason.rawValue, key: lastSubscriptionReceiptRedemptionFailedKey, transaction: transaction) - } - - public static func lastReceiptRedemptionFailed(transaction: SDSAnyReadTransaction) -> SubscriptionRedemptionFailureReason { - let intValue = subscriptionKVS.getInt(lastSubscriptionReceiptRedemptionFailedKey, transaction: transaction) - guard let intValue = intValue else { - return .none - } - - if let reason = SubscriptionRedemptionFailureReason(rawValue: intValue) { - return reason - } else { - return .none - } - } - - public static func userManuallyCancelledSubscription(transaction: SDSAnyReadTransaction) -> Bool { - return subscriptionKVS.getBool(userManuallyCancelledSubscriptionKey, transaction: transaction) ?? false - } - - private static func setUserManuallyCancelledSubscription(_ userCancelled: Bool, transaction: SDSAnyWriteTransaction) { - subscriptionKVS.setBool(userCancelled, key: userManuallyCancelledSubscriptionKey, transaction: transaction) - } - - private static func lastSubscriptionExpirationDate(transaction: SDSAnyReadTransaction) -> Date? { - return subscriptionKVS.getDate(lastSubscriptionExpirationKey, transaction: transaction) - } - - private static func setLastSubscriptionExpirationDate(_ expirationDate: Date?, transaction: SDSAnyWriteTransaction) { - if let expirationDate = expirationDate { - subscriptionKVS.setDate(expirationDate, key: lastSubscriptionExpirationKey, transaction: transaction) - } else { - subscriptionKVS.removeValue(forKey: lastSubscriptionExpirationKey, transaction: transaction) - } - - OWSSyncManager.shared.sendFetchLatestSubscriptionStatusSyncMessage() - } - private class func setupNewSubscriberID() throws -> Promise { Logger.info("[Subscriptions] Setting up new subscriber ID") let newSubscriberID = generateSubscriberID() @@ -306,6 +254,7 @@ public class SubscriptionManager: NSObject { generatedSubscriberID = subscriberID SDSDatabaseStorage.shared.write { transaction in + self.setUserManuallyCancelledSubscription(false, transaction: transaction) self.setSubscriberID(subscriberID, transaction: transaction) self.setSubscriberCurrencyCode(currencyCode, transaction: transaction) self.storageServiceManager.recordPendingLocalAccountUpdates() @@ -675,11 +624,6 @@ public class SubscriptionManager: NSObject { @objc public class func performSubscriptionKeepAliveIfNecessary() { - guard tsAccountManager.isPrimaryDevice else { - Logger.info("[Subscriptions] Bailing out of heartbeat, this is not the primary device") - return - } - // Kick job queue subscriptionJobQueue.runAnyQueuedRetry() @@ -709,6 +653,14 @@ public class SubscriptionManager: NSObject { Logger.info("[Subscriptions] Performing subscription heartbeat") + // Update badge states first + mostRecentlyExpiredBadgeIDWithSneakyTransaction() + + guard tsAccountManager.isPrimaryDevice else { + Logger.info("[Subscriptions] Bailing out of remaining heartbeat tasks, this is not the primary device") + return + } + guard let subscriberID = subscriberID, currencyCode != nil else { Logger.info("[Subscriptions] No subscription + currency code found") self.updateSubscriptionHeartbeatDate() @@ -751,6 +703,65 @@ public class SubscriptionManager: NSObject { } } + // Reconciles cached non-expired badge IDs with current profile badges, and returns the last expired ID if one exists. + // Also sets mostRecentlyExpiredBadgeID + public static func mostRecentlyExpiredBadgeIDWithSneakyTransaction() -> String? { + Logger.info("[Subscriptions] Checking if current badge set has changed") + + // Get current badges + let snapshot = profileManagerImpl.localProfileSnapshot(shouldIncludeAvatar: true) + let currentBadges: [OWSUserProfileBadgeInfo] = snapshot.profileBadgeInfo ?? [] + let currentSubscriberBadgeIDs = currentBadges.compactMap { (badge: OWSUserProfileBadgeInfo) -> String? in + if badge.badgeId != "BOOST" { + return badge.badgeId + } + return nil + } + + let currentBoostBadgeIDs = currentBadges.compactMap { (badge: OWSUserProfileBadgeInfo) -> String? in + if badge.badgeId == "BOOST" { + return badge.badgeId + } + return nil + } + + var persistedSubscriberBadgeIDs: [String] = [], persistedBoostBadgeIDs: [String] = [], expiringBadgeID: String?, userManuallyCancelled = false, showExpiryOnHomeScreen = false + + SDSDatabaseStorage.shared.read { transaction in + persistedSubscriberBadgeIDs = self.knownUserSubscriptionBadgeIDs(transaction: transaction) + persistedBoostBadgeIDs = self.knownUserBoostBadgeIDs(transaction: transaction) + expiringBadgeID = self.mostRecentlyExpiredBadgeID(transaction: transaction) + userManuallyCancelled = self.userManuallyCancelledSubscription(transaction: transaction) + showExpiryOnHomeScreen = self.showExpirySheetOnHomeScreenKey(transaction: transaction) + } + + var newExpiringBadgeID: String? + if currentSubscriberBadgeIDs.count == 0 && persistedSubscriberBadgeIDs.count > 0 && !userManuallyCancelled { + newExpiringBadgeID = persistedSubscriberBadgeIDs.first + } else if currentBoostBadgeIDs.count == 0 && persistedBoostBadgeIDs.count > 0 { + newExpiringBadgeID = persistedBoostBadgeIDs.first + } + + if let newExpiringBadgeID = newExpiringBadgeID, newExpiringBadgeID != expiringBadgeID { + expiringBadgeID = newExpiringBadgeID + showExpiryOnHomeScreen = true + } + + Logger.info("[Subscriptions] Current sub badges \(currentSubscriberBadgeIDs.count), persisted sub badges \(persistedSubscriberBadgeIDs.count)") + Logger.info("[Subscriptions] Current boost badges \(currentBoostBadgeIDs.count), persisted sub badges \(persistedBoostBadgeIDs.count)") + Logger.info("[Subscriptions] mostRecentlyExpiredBadgeID is \(expiringBadgeID ?? "none")") + + // Persist new values + SDSDatabaseStorage.shared.write { transaction in + self.setKnownUserSubscriptionBadgeIDs(badgeIDs: currentSubscriberBadgeIDs, transaction: transaction) + self.setKnownUserBoostBadgeIDs(badgeIDs: currentBoostBadgeIDs, transaction: transaction) + self.setMostRecentlyExpiredBadgeID(badgeID: expiringBadgeID, transaction: transaction) + self.setShowExpirySheetOnHomeScreenKey(show: showExpiryOnHomeScreen, transaction: transaction) + } + + return expiringBadgeID + } + private static func updateSubscriptionHeartbeatDate() { SDSDatabaseStorage.shared.write { transaction in // Update keepalive @@ -799,6 +810,129 @@ public class SubscriptionManager: NSObject { } } +extension SubscriptionManager { + + public static func getSubscriberID(transaction: SDSAnyReadTransaction) -> Data? { + guard let subscriberID = subscriptionKVS.getObject( + forKey: subscriberIDKey, + transaction: transaction + ) as? Data else { + return nil + } + return subscriberID + } + + public static func setSubscriberID(_ subscriberID: Data?, transaction: SDSAnyWriteTransaction) { + subscriptionKVS.setObject(subscriberID, + key: subscriberIDKey, + transaction: transaction) + } + + public static func getSubscriberCurrencyCode(transaction: SDSAnyReadTransaction) -> String? { + guard let subscriberCurrencyCode = subscriptionKVS.getObject( + forKey: subscriberCurrencyCodeKey, + transaction: transaction + ) as? String else { + return nil + } + return subscriberCurrencyCode + } + + public static func setSubscriberCurrencyCode(_ currencyCode: String?, transaction: SDSAnyWriteTransaction) { + subscriptionKVS.setObject(currencyCode, + key: subscriberCurrencyCodeKey, + transaction: transaction) + } + + public static func setLastReceiptRedemptionFailed(failureReason: SubscriptionRedemptionFailureReason, transaction: SDSAnyWriteTransaction) { + subscriptionKVS.setInt(failureReason.rawValue, key: lastSubscriptionReceiptRedemptionFailedKey, transaction: transaction) + } + + public static func lastReceiptRedemptionFailed(transaction: SDSAnyReadTransaction) -> SubscriptionRedemptionFailureReason { + let intValue = subscriptionKVS.getInt(lastSubscriptionReceiptRedemptionFailedKey, transaction: transaction) + guard let intValue = intValue else { + return .none + } + + if let reason = SubscriptionRedemptionFailureReason(rawValue: intValue) { + return reason + } else { + return .none + } + } + + public static func userManuallyCancelledSubscription(transaction: SDSAnyReadTransaction) -> Bool { + return subscriptionKVS.getBool(userManuallyCancelledSubscriptionKey, transaction: transaction) ?? false + } + + private static func setUserManuallyCancelledSubscription(_ userCancelled: Bool, transaction: SDSAnyWriteTransaction) { + subscriptionKVS.setBool(userCancelled, key: userManuallyCancelledSubscriptionKey, transaction: transaction) + } + + fileprivate static func lastSubscriptionExpirationDate(transaction: SDSAnyReadTransaction) -> Date? { + return subscriptionKVS.getDate(lastSubscriptionExpirationKey, transaction: transaction) + } + + fileprivate static func setLastSubscriptionExpirationDate(_ expirationDate: Date?, transaction: SDSAnyWriteTransaction) { + guard let expirationDate = expirationDate else { + subscriptionKVS.removeValue(forKey: lastSubscriptionExpirationKey, transaction: transaction) + return + } + + subscriptionKVS.setDate(expirationDate, key: lastSubscriptionExpirationKey, transaction: transaction) + } + + fileprivate static func setKnownUserSubscriptionBadgeIDs(badgeIDs: [String], transaction: SDSAnyWriteTransaction) { + subscriptionKVS.setObject(badgeIDs, key: knownUserSubscriptionBadgeIDsKey, transaction: transaction) + } + + fileprivate static func knownUserSubscriptionBadgeIDs(transaction: SDSAnyReadTransaction) -> [String] { + let ids = subscriptionKVS.getObject(forKey: knownUserSubscriptionBadgeIDsKey, transaction: transaction) as? [String] + return ids ?? [] + } + + fileprivate static func setKnownUserBoostBadgeIDs(badgeIDs: [String], transaction: SDSAnyWriteTransaction) { + subscriptionKVS.setObject(badgeIDs, key: knownUserBoostBadgeIDsKey, transaction: transaction) + } + + fileprivate static func knownUserBoostBadgeIDs(transaction: SDSAnyReadTransaction) -> [String] { + guard let ids = subscriptionKVS.getObject(forKey: knownUserBoostBadgeIDsKey, transaction: transaction) as? [String] else { + return [] + } + + return ids + } + + fileprivate static func setMostRecentlyExpiredBadgeID(badgeID: String?, transaction: SDSAnyWriteTransaction) { + guard let badgeID = badgeID else { + subscriptionKVS.removeValue(forKey: mostRecentlyExpiredBadgeIDKey, transaction: transaction) + return + } + + subscriptionKVS.setString(badgeID, key: mostRecentlyExpiredBadgeIDKey, transaction: transaction) + + } + + public static func mostRecentlyExpiredBadgeID(transaction: SDSAnyReadTransaction) -> String? { + subscriptionKVS.getString(mostRecentlyExpiredBadgeIDKey, transaction: transaction) + } + + public static func clearMostRecentlyExpiredBadgeIDWithSneakyTransaction() { + SDSDatabaseStorage.shared.write { transaction in + self.setMostRecentlyExpiredBadgeID(badgeID: nil, transaction: transaction) + } + } + + public static func setShowExpirySheetOnHomeScreenKey(show: Bool, transaction: SDSAnyWriteTransaction) { + subscriptionKVS.setBool(show, key: showExpirySheetOnHomeScreenKey, transaction: transaction) + } + + public static func showExpirySheetOnHomeScreenKey(transaction: SDSAnyReadTransaction) -> Bool { + return subscriptionKVS.getBool(showExpirySheetOnHomeScreenKey, transaction: transaction) ?? false + } + +} + @objc public class OWSRetryableSubscriptionError: NSObject, CustomNSError, IsRetryableProvider { @objc