669 lines
25 KiB
Swift
669 lines
25 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Contacts
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
|
|
class ExperienceUpgradeManager {
|
|
|
|
private enum StoreKeys {
|
|
static let lastMegaphoneDismissDate = "lastExperienceUpgradeDismissDate"
|
|
}
|
|
|
|
private static var lastPresentedMegaphone: Megaphone?
|
|
private static var lastPresentedMegaphoneView: MegaphoneView?
|
|
|
|
private static var accountKeyStore: AccountKeyStore { DependenciesBridge.shared.accountKeyStore }
|
|
private static let backupSettingsStore = BackupSettingsStore()
|
|
private static let dateProvider: DateProvider = { Date() }
|
|
private static var db: DB { DependenciesBridge.shared.db }
|
|
private static var deviceStore: OWSDeviceStore { DependenciesBridge.shared.deviceStore }
|
|
private static var donationReceiptCredentialResultStore: DonationReceiptCredentialResultStore { DependenciesBridge.shared.donationReceiptCredentialResultStore }
|
|
private static let experienceUpgradeStore = ExperienceUpgradeStore()
|
|
private static var inactiveLinkedDeviceFinder: InactiveLinkedDeviceFinder { DependenciesBridge.shared.inactiveLinkedDeviceFinder }
|
|
private static var inactivePrimaryDeviceStore: InactivePrimaryDeviceStore { DependenciesBridge.shared.inactivePrimaryDeviceStore }
|
|
private static let keyValueStore = NewKeyValueStore(collection: "ExperienceUpgradeManager")
|
|
private static var localUsernameManager: LocalUsernameManager { DependenciesBridge.shared.localUsernameManager }
|
|
private static var networkManager: NetworkManager { SSKEnvironment.shared.networkManagerRef }
|
|
private static var ows2FAManager: OWS2FAManager { SSKEnvironment.shared.ows2FAManagerRef }
|
|
private static var profileManager: ProfileManager { SSKEnvironment.shared.profileManagerRef }
|
|
private static var reachabilityManager: SSKReachabilityManager { SSKEnvironment.shared.reachabilityManagerRef }
|
|
private static var remoteConfigManager: RemoteConfigManager { SSKEnvironment.shared.remoteConfigManagerRef }
|
|
private static var storageServiceManager: StorageServiceManager { SSKEnvironment.shared.storageServiceManagerRef }
|
|
private static var usernameEducationManager: UsernameEducationManager { DependenciesBridge.shared.usernameEducationManager }
|
|
private static var tsAccountManager: TSAccountManager { DependenciesBridge.shared.tsAccountManager }
|
|
private static var usernameSelectionCoordinator: UsernameSelectionCoordinator {
|
|
UsernameSelectionCoordinator(
|
|
currentUsername: nil,
|
|
context: UsernameSelectionCoordinator.Context(
|
|
databaseStorage: db,
|
|
networkManager: networkManager,
|
|
storageServiceManager: storageServiceManager,
|
|
usernameEducationManager: usernameEducationManager,
|
|
localUsernameManager: localUsernameManager,
|
|
),
|
|
)
|
|
}
|
|
|
|
static func reconcilePresentedExperienceUpgrade(fromViewController: UIViewController) {
|
|
let now = Date()
|
|
var shouldClearNewDeviceNotification = false
|
|
var shouldClearBackupsEnabledDetails = false
|
|
|
|
let lastMegaphoneDismissDate: Date
|
|
let nextMegaphone: Megaphone?
|
|
(
|
|
lastMegaphoneDismissDate,
|
|
nextMegaphone,
|
|
) = db.read { tx in
|
|
guard
|
|
let registeredState = try? tsAccountManager.registeredState(tx: tx),
|
|
let registrationDate = tsAccountManager.registrationDate(tx: tx)
|
|
else {
|
|
return (.distantPast, nil)
|
|
}
|
|
|
|
let lastMegaphoneDismissDate = keyValueStore.fetchValue(
|
|
Date.self,
|
|
forKey: StoreKeys.lastMegaphoneDismissDate,
|
|
tx: tx,
|
|
) ?? .distantPast
|
|
|
|
var nextMegaphone: Megaphone?
|
|
for upgrade in allKnownExperienceUpgrades(tx: tx) {
|
|
if nextMegaphone != nil {
|
|
break
|
|
}
|
|
|
|
guard
|
|
!upgrade.isComplete,
|
|
!upgrade.isSnoozed(now: now),
|
|
!upgrade.hasPassedNumberOfDaysToShow(now: now),
|
|
now.timeIntervalSince(registrationDate) > upgrade.manifest.delayAfterRegistration,
|
|
now < upgrade.manifest.expirationDate,
|
|
(registeredState.isPrimary || upgrade.manifest.showOnLinkedDevices)
|
|
else {
|
|
continue
|
|
}
|
|
|
|
switch upgrade.manifest {
|
|
case .introducingPins:
|
|
if checkPreconditionsForIntroducingPins(tx: tx) {
|
|
nextMegaphone = IntroducingPinsMegaphone(
|
|
experienceUpgrade: upgrade,
|
|
fromViewController: fromViewController,
|
|
)
|
|
}
|
|
case .notificationPermissionReminder:
|
|
if checkPreconditionsForNotificationsPermissionsReminder() {
|
|
nextMegaphone = NotificationPermissionReminderMegaphone(
|
|
experienceUpgrade: upgrade,
|
|
fromViewController: fromViewController,
|
|
)
|
|
}
|
|
case .newLinkedDeviceNotification:
|
|
switch checkPreconditionsForNewLinkedDeviceNotification(tx: tx) {
|
|
case .display(let mostRecentlyLinkedDeviceDetails):
|
|
nextMegaphone = NewLinkedDeviceNotificationMegaphone(
|
|
db: db,
|
|
deviceStore: deviceStore,
|
|
experienceUpgrade: upgrade,
|
|
mostRecentlyLinkedDeviceDetails: mostRecentlyLinkedDeviceDetails,
|
|
)
|
|
case .skip:
|
|
break
|
|
case .clearNotification:
|
|
shouldClearNewDeviceNotification = true
|
|
}
|
|
case .createUsernameReminder:
|
|
if checkPreconditionsForCreateUsernameReminder(tx: tx) {
|
|
nextMegaphone = CreateUsernameMegaphone(
|
|
usernameSelectionCoordinator: usernameSelectionCoordinator,
|
|
experienceUpgrade: upgrade,
|
|
fromViewController: fromViewController,
|
|
)
|
|
}
|
|
case .remoteMegaphone(let remoteMegaphoneModel):
|
|
if
|
|
checkPreconditionsForRemoteMegaphone(
|
|
remoteMegaphoneModel: remoteMegaphoneModel,
|
|
now: now,
|
|
tx: tx,
|
|
)
|
|
{
|
|
nextMegaphone = RemoteMegaphone(
|
|
experienceUpgrade: upgrade,
|
|
remoteMegaphoneModel: remoteMegaphoneModel,
|
|
fromViewController: fromViewController,
|
|
)
|
|
}
|
|
case .inactiveLinkedDeviceReminder:
|
|
if let inactiveLinkedDevice = checkPreconditionsForInactiveLinkedDeviceReminder(tx: tx) {
|
|
nextMegaphone = InactiveLinkedDeviceReminderMegaphone(
|
|
inactiveLinkedDevice: inactiveLinkedDevice,
|
|
fromViewController: fromViewController,
|
|
experienceUpgrade: upgrade,
|
|
)
|
|
}
|
|
case .inactivePrimaryDeviceReminder:
|
|
if checkPreconditionsForInactivePrimaryDeviceReminder(tx: tx) {
|
|
nextMegaphone = InactivePrimaryDeviceReminderMegaphone(
|
|
fromViewController: fromViewController,
|
|
experienceUpgrade: upgrade,
|
|
)
|
|
}
|
|
case .pinReminder:
|
|
if checkPreconditionsForPinReminder(tx: tx) {
|
|
nextMegaphone = PinReminderMegaphone(
|
|
experienceUpgrade: upgrade,
|
|
fromViewController: fromViewController,
|
|
)
|
|
}
|
|
case .contactPermissionReminder:
|
|
if checkPreconditionsForContactsPermissionReminder() {
|
|
nextMegaphone = ContactPermissionReminderMegaphone(
|
|
experienceUpgrade: upgrade,
|
|
fromViewController: fromViewController,
|
|
)
|
|
}
|
|
case .backupKeyReminder:
|
|
if checkPreconditionsForRecoveryKeyReminder(tx: tx) {
|
|
nextMegaphone = RecoveryKeyReminderMegaphone(
|
|
experienceUpgrade: upgrade,
|
|
fromViewController: fromViewController,
|
|
)
|
|
}
|
|
case .enableBackupsReminder:
|
|
if checkPreconditionsForBackupEnablementReminder(tx: tx) {
|
|
nextMegaphone = BackupEnablementMegaphone(
|
|
experienceUpgrade: upgrade,
|
|
fromViewController: fromViewController,
|
|
)
|
|
}
|
|
case .haveEnabledBackupsNotification:
|
|
switch checkPreconditionsForEnabledBackupsNotification(
|
|
now: now,
|
|
tx: tx,
|
|
) {
|
|
case .display(let lastBackupEnabledDetails):
|
|
nextMegaphone = BackupsEnabledNotificationMegaphone(
|
|
experienceUpgrade: upgrade,
|
|
fromViewController: fromViewController,
|
|
backupsEnabledTime: lastBackupEnabledDetails.enabledTime,
|
|
db: db,
|
|
backupSettingsStore: backupSettingsStore,
|
|
)
|
|
case .skip:
|
|
break
|
|
case .clearStoredDetails:
|
|
shouldClearBackupsEnabledDetails = true
|
|
}
|
|
case .unrecognized:
|
|
break
|
|
}
|
|
}
|
|
|
|
return (
|
|
lastMegaphoneDismissDate,
|
|
nextMegaphone,
|
|
)
|
|
}
|
|
|
|
if shouldClearNewDeviceNotification {
|
|
db.write { tx in
|
|
deviceStore.clearMostRecentlyLinkedDeviceDetails(tx: tx)
|
|
}
|
|
}
|
|
|
|
if shouldClearBackupsEnabledDetails {
|
|
db.write { tx in
|
|
backupSettingsStore.clearLastBackupEnabledDetails(tx: tx)
|
|
}
|
|
}
|
|
|
|
guard let nextMegaphone else {
|
|
_ = dismissLastPresented(now: now)
|
|
return
|
|
}
|
|
|
|
if
|
|
let lastPresentedMegaphone,
|
|
lastPresentedMegaphone.experienceUpgrade.manifest == nextMegaphone.experienceUpgrade.manifest
|
|
{
|
|
return
|
|
}
|
|
|
|
// If we're dismissing a megaphone, don't immediately present another.
|
|
if dismissLastPresented(now: now) {
|
|
return
|
|
} else if
|
|
now.timeIntervalSince(lastMegaphoneDismissDate) > .day
|
|
{
|
|
let megaphoneView = nextMegaphone.buildView()
|
|
megaphoneView.present(fromViewController: fromViewController)
|
|
|
|
lastPresentedMegaphone = nextMegaphone
|
|
lastPresentedMegaphoneView = megaphoneView
|
|
|
|
db.write { tx in
|
|
experienceUpgradeStore.markAsViewed(
|
|
experienceUpgrade: nextMegaphone.experienceUpgrade,
|
|
tx: tx,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns an array of all recognized ``ExperienceUpgrade``s. Contains the
|
|
/// persisted record if one exists and is applicable, and an in-memory
|
|
/// model otherwise.
|
|
private static func allKnownExperienceUpgrades(
|
|
tx: DBReadTransaction,
|
|
) -> [ExperienceUpgrade] {
|
|
var experienceUpgrades = [ExperienceUpgrade]()
|
|
var localManifestsWithoutRecords = ExperienceUpgradeManifest.wellKnownLocalUpgradeManifests
|
|
|
|
// Load any experience upgrades with persisted records...
|
|
experienceUpgradeStore.enumerateExperienceUpgrades(tx: tx) { experienceUpgrade in
|
|
if case .unrecognized = experienceUpgrade.manifest {
|
|
// Ignore any no-longer-recognized records.
|
|
return
|
|
}
|
|
|
|
guard experienceUpgrade.manifest.shouldSave else {
|
|
// Ignore saved records that we no longer persist.
|
|
return
|
|
}
|
|
|
|
experienceUpgrades.append(experienceUpgrade)
|
|
localManifestsWithoutRecords.remove(experienceUpgrade.manifest)
|
|
}
|
|
|
|
// ...and instantiate new (in-memory) models for any local manifests
|
|
// without persisted records.
|
|
for localManifest in localManifestsWithoutRecords {
|
|
experienceUpgrades.append(ExperienceUpgrade.makeNew(withManifest: localManifest))
|
|
}
|
|
|
|
return ExperienceUpgradeManifest.sortedByImportance(experienceUpgrades)
|
|
}
|
|
|
|
/// - Returns
|
|
/// Whether or not we dismissed a megaphone.
|
|
private static func dismissLastPresented(now: Date) -> Bool {
|
|
guard lastPresentedMegaphone != nil, let lastPresentedMegaphoneView else {
|
|
return false
|
|
}
|
|
|
|
db.write { tx in
|
|
keyValueStore.writeValue(
|
|
now,
|
|
forKey: StoreKeys.lastMegaphoneDismissDate,
|
|
tx: tx,
|
|
)
|
|
}
|
|
|
|
lastPresentedMegaphoneView.dismiss()
|
|
self.lastPresentedMegaphone = nil
|
|
self.lastPresentedMegaphoneView = nil
|
|
return true
|
|
}
|
|
|
|
// MARK: - Megaphone Preconditions
|
|
|
|
private static func checkPreconditionsForIntroducingPins(
|
|
tx: DBReadTransaction,
|
|
) -> Bool {
|
|
// The PIN setup flow requires an internet connection and you to not already have a PIN
|
|
if
|
|
reachabilityManager.isReachable,
|
|
tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice,
|
|
accountKeyStore.getMasterKey(tx: tx) == nil
|
|
{
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private static func checkPreconditionsForNotificationsPermissionsReminder() -> Bool {
|
|
let (promise, future) = Promise<Bool>.pending()
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
|
future.resolve(settings.authorizationStatus == .authorized)
|
|
}
|
|
}
|
|
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
|
|
guard promise.result == nil else { return }
|
|
future.reject(OWSGenericError("timeout fetching notification permissions"))
|
|
}
|
|
|
|
do {
|
|
return !(try promise.wait())
|
|
} catch {
|
|
Logger.warn("failed to query notification permission")
|
|
return false
|
|
}
|
|
}
|
|
|
|
private enum NewLinkedDeviceNotificationResult {
|
|
case display(MostRecentlyLinkedDeviceDetails)
|
|
case skip
|
|
case clearNotification
|
|
}
|
|
|
|
private static func checkPreconditionsForNewLinkedDeviceNotification(
|
|
tx: DBReadTransaction,
|
|
) -> NewLinkedDeviceNotificationResult {
|
|
guard
|
|
let mostRecentlyLinkedDeviceDetails = deviceStore.mostRecentlyLinkedDeviceDetails(tx: tx)
|
|
else {
|
|
return .skip
|
|
}
|
|
|
|
// No need to show a megaphone if notifications are on, which we happen
|
|
// to already check for the notification permission megaphone.
|
|
return if !checkPreconditionsForNotificationsPermissionsReminder() {
|
|
.clearNotification
|
|
} else if Date() > mostRecentlyLinkedDeviceDetails.shouldRemindUserAfter {
|
|
.display(mostRecentlyLinkedDeviceDetails)
|
|
} else {
|
|
.skip
|
|
}
|
|
}
|
|
|
|
private enum BackupsEnabledNotificationResult {
|
|
case display(BackupSettingsStore.LastBackupEnabledDetails)
|
|
case skip
|
|
case clearStoredDetails
|
|
}
|
|
|
|
private static func checkPreconditionsForEnabledBackupsNotification(
|
|
now: Date,
|
|
tx: DBReadTransaction,
|
|
) -> BackupsEnabledNotificationResult {
|
|
guard let lastBackupEnabledDetails = backupSettingsStore.lastBackupEnabledDetails(tx: tx) else {
|
|
return .skip
|
|
}
|
|
|
|
// Don't show the megaphone if notifications are enabled, we'll send
|
|
// a notification instead. Clear the stored details so we don't show
|
|
// a stale megaphone in the future.
|
|
guard checkPreconditionsForNotificationsPermissionsReminder() else {
|
|
return .clearStoredDetails
|
|
}
|
|
|
|
if now > lastBackupEnabledDetails.shouldRemindUserAfter {
|
|
return .display(lastBackupEnabledDetails)
|
|
} else {
|
|
return .skip
|
|
}
|
|
}
|
|
|
|
private static func checkPreconditionsForCreateUsernameReminder(
|
|
tx: DBReadTransaction,
|
|
) -> Bool {
|
|
guard
|
|
localUsernameManager.usernameState(
|
|
tx: tx,
|
|
).isExplicitlyUnset
|
|
else {
|
|
// If we have a username, do not show the reminder.
|
|
return false
|
|
}
|
|
if tsAccountManager.phoneNumberDiscoverability(tx: tx).orDefault.isDiscoverable {
|
|
// If phone number discovery is enabled, do not prompt to create a
|
|
// username.
|
|
return false
|
|
}
|
|
|
|
/// The elapsed interval since the user disabled phone number
|
|
/// discovery. Note that we need to invert the sign as this date will
|
|
/// be in the past.
|
|
let timeIntervalSinceDisabledDiscovery = tsAccountManager
|
|
.lastSetIsDiscoverableByPhoneNumber(tx: tx)
|
|
.timeIntervalSinceNow * -1
|
|
|
|
let requiredDelayAfterDisablingDiscovery: TimeInterval = 3 * .day
|
|
|
|
return timeIntervalSinceDisabledDiscovery > requiredDelayAfterDisablingDiscovery
|
|
}
|
|
|
|
private static func checkPreconditionsForInactiveLinkedDeviceReminder(
|
|
tx: DBReadTransaction,
|
|
) -> InactiveLinkedDevice? {
|
|
return inactiveLinkedDeviceFinder.findLeastActiveLinkedDevice(tx: tx)
|
|
}
|
|
|
|
private static func checkPreconditionsForInactivePrimaryDeviceReminder(
|
|
tx: DBReadTransaction,
|
|
) -> Bool {
|
|
return inactivePrimaryDeviceStore.valueForInactivePrimaryDeviceAlert(transaction: tx)
|
|
}
|
|
|
|
private static func checkPreconditionsForPinReminder(
|
|
tx: DBReadTransaction,
|
|
) -> Bool {
|
|
return ows2FAManager.isDueForV2Reminder(transaction: tx)
|
|
}
|
|
|
|
private static func checkPreconditionsForContactsPermissionReminder() -> Bool {
|
|
switch CNContactStore.authorizationStatus(for: .contacts) {
|
|
case .authorized, .limited:
|
|
return false
|
|
case .restricted:
|
|
// If this isn't allowed by device policy, don't nag.
|
|
return false
|
|
case .denied, .notDetermined:
|
|
return true
|
|
@unknown default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func checkPreconditionsForRecoveryKeyReminder(
|
|
tx: DBReadTransaction,
|
|
) -> Bool {
|
|
guard tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice else {
|
|
return false
|
|
}
|
|
|
|
switch backupSettingsStore.backupPlan(tx: tx) {
|
|
case .disabled, .disabling:
|
|
return false
|
|
case .free, .paid, .paidExpiringSoon, .paidAsTester:
|
|
break
|
|
}
|
|
|
|
guard let firstBackupDate = backupSettingsStore.lastBackupDetails(tx: tx)?.firstBackupDate else {
|
|
return false
|
|
}
|
|
|
|
let lastReminderDate = backupSettingsStore.lastRecoveryKeyReminderDate(tx: tx)
|
|
|
|
let fourteenDaysAgo = Date().addingTimeInterval(-14 * .day)
|
|
guard let lastReminderDate else {
|
|
// Return true if the first backup happened over 2 weeks ago
|
|
// and we haven't shown a reminder yet.
|
|
return firstBackupDate < fourteenDaysAgo
|
|
}
|
|
|
|
// Return true if there's been no reminder within 6 months.
|
|
return lastReminderDate < Date().addingTimeInterval(-6 * .month)
|
|
}
|
|
|
|
private static func checkPreconditionsForBackupEnablementReminder(
|
|
tx: DBReadTransaction,
|
|
) -> Bool {
|
|
guard
|
|
remoteConfigManager.currentConfig().backupsMegaphone,
|
|
tsAccountManager.registrationState(tx: tx).isRegisteredPrimaryDevice
|
|
else {
|
|
return false
|
|
}
|
|
|
|
guard !backupSettingsStore.haveBackupsEverBeenEnabled(tx: tx) else {
|
|
return false
|
|
}
|
|
|
|
return InteractionFinder.outgoingAndIncomingMessageCount(transaction: tx, limit: 1) >= 1
|
|
}
|
|
|
|
// MARK: Remote megaphone
|
|
|
|
private static func checkPreconditionsForRemoteMegaphone(
|
|
remoteMegaphoneModel: RemoteMegaphoneModel,
|
|
now: Date,
|
|
tx: DBReadTransaction,
|
|
) -> Bool {
|
|
let manifest = remoteMegaphoneModel.manifest
|
|
let translation = remoteMegaphoneModel.translation
|
|
|
|
let minimumVersion = AppVersionNumber(manifest.minAppVersion)
|
|
let currentVersion = AppVersionNumber(AppVersionImpl.shared.currentAppVersion)
|
|
guard currentVersion >= minimumVersion else {
|
|
return false
|
|
}
|
|
|
|
guard now.timeIntervalSince1970 > TimeInterval(manifest.dontShowBefore) else {
|
|
return false
|
|
}
|
|
|
|
guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else {
|
|
return false
|
|
}
|
|
|
|
guard
|
|
RemoteConfig.isCountryCodeBucketEnabled(
|
|
csvString: manifest.countries,
|
|
key: manifest.id,
|
|
localIdentifiers: localIdentifiers,
|
|
)
|
|
else {
|
|
return false
|
|
}
|
|
|
|
guard
|
|
validateRemoteMegaphone(
|
|
conditionalCheck: manifest.conditionalCheck,
|
|
tx: tx,
|
|
)
|
|
else {
|
|
return false
|
|
}
|
|
|
|
guard
|
|
validateRemoteMegaphone(
|
|
action: manifest.primaryAction,
|
|
withText: translation.primaryActionText,
|
|
)
|
|
else {
|
|
return false
|
|
}
|
|
|
|
guard
|
|
validateRemoteMegaphone(
|
|
action: manifest.secondaryAction,
|
|
withText: translation.secondaryActionText,
|
|
)
|
|
else {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
private static func validateRemoteMegaphone(
|
|
conditionalCheck: RemoteMegaphoneModel.Manifest.ConditionalCheck?,
|
|
tx: DBReadTransaction,
|
|
) -> Bool {
|
|
guard let conditionalCheck else {
|
|
// Having no conditional check is valid.
|
|
return true
|
|
}
|
|
|
|
switch conditionalCheck {
|
|
case .standardDonate:
|
|
if profileManager.localUserProfile(tx: tx)?.hasBadge == true {
|
|
// Fail the check if we currently have a badge.
|
|
return false
|
|
} else if
|
|
donationReceiptCredentialResultStore
|
|
.hasAnyPaymentsStillProcessing(tx: tx)
|
|
{
|
|
// Fail the check if we have any in-progress payments.
|
|
return false
|
|
}
|
|
|
|
return true
|
|
case .internalUser:
|
|
// Show this megaphone to all internal users, even if they already
|
|
// have a badge.
|
|
return DebugFlags.internalMegaphoneEligible
|
|
case .unrecognized(let conditionalId):
|
|
Logger.warn("Found unrecognized conditional check with ID \(conditionalId), bailing.")
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func validateRemoteMegaphone(
|
|
action: RemoteMegaphoneModel.Manifest.Action?,
|
|
withText text: String?,
|
|
) -> Bool {
|
|
guard let action else {
|
|
// Having no action is valid...
|
|
return true
|
|
}
|
|
|
|
guard action.isRecognized else {
|
|
// ...but we need to recognize it...
|
|
Logger.warn("Found unrecognized action with ID \(action.actionId), bailing.")
|
|
return false
|
|
}
|
|
|
|
guard text != nil else {
|
|
// ...and have text for it.
|
|
Logger.warn("Missing action text for action \(action.actionId)")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private extension RemoteMegaphoneModel.Manifest.Action {
|
|
var isRecognized: Bool {
|
|
if case .unrecognized = self {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private extension DonationReceiptCredentialResultStore {
|
|
/// Do we have any payments that have been initiated, but are still
|
|
/// in-progress?
|
|
func hasAnyPaymentsStillProcessing(tx: DBReadTransaction) -> Bool {
|
|
for requestErrorMode in Mode.allCases {
|
|
if
|
|
let requestError = getRequestError(errorMode: requestErrorMode, tx: tx),
|
|
case .paymentStillProcessing = requestError.errorCode
|
|
{
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
}
|