From 08ae6b3e0798aa999a2e35894b35891c8cb20ec1 Mon Sep 17 00:00:00 2001 From: Sasha Weiss Date: Mon, 1 Jun 2026 13:24:08 -0700 Subject: [PATCH] Make Megaphone construction one step instead of two --- Signal.xcodeproj/project.pbxproj | 8 - .../Megaphones/ExperienceUpgradeManager.swift | 764 ++++++++++++------ .../UserInterface/MegaphoneView.swift | 15 +- .../UsernameSelectionCoordinator.swift | 2 +- .../UsernameSelectionViewController.swift | 2 +- .../Devices/InactiveLinkedDeviceFinder.swift | 7 - .../Environment/RemoteConfigManager.swift | 2 +- .../ExperienceUpgradeManifest.swift | 342 -------- .../Megaphones/RemoteMegaphoneModel.swift | 20 +- .../InactiveLinkedDeviceFinderTest.swift | 4 +- ...ckupEnablementReminderMegaphoneTests.swift | 173 ---- .../BackupKeyReminderMegaphoneTests.swift | 129 --- 12 files changed, 539 insertions(+), 929 deletions(-) delete mode 100644 SignalServiceKit/tests/MessageBackup/BackupEnablementReminderMegaphoneTests.swift delete mode 100644 SignalServiceKit/tests/MessageBackup/BackupKeyReminderMegaphoneTests.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 0096a48ba2..2386f8008f 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -77,7 +77,6 @@ 046092262FBCD2DA00A8765F /* SafetyTipsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046092232FBCC7E700A8765F /* SafetyTipsManager.swift */; }; 046926092E8EBAAE00B1FC74 /* TSInfoMessage+Polls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046926082E8EBAA800B1FC74 /* TSInfoMessage+Polls.swift */; }; 0477BE322FA4FC41002F9B47 /* TSReleaseNotesThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0477BE312FA4FC38002F9B47 /* TSReleaseNotesThread.swift */; }; - 047A6DD02E00B5720048EDF4 /* BackupKeyReminderMegaphoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */; }; 0480F0002E57C51A006CBB29 /* BackupsEnabledNotificationMegaphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0480EFFF2E57C513006CBB29 /* BackupsEnabledNotificationMegaphone.swift */; }; 0484CECE2F44B7BE009AB2CB /* AdminDeleteRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0484CECD2F44B7BB009AB2CB /* AdminDeleteRecord.swift */; }; 0484CED02F44BD00009AB2CB /* AdminDeleteManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0484CECF2F44BCFB009AB2CB /* AdminDeleteManager.swift */; }; @@ -93,7 +92,6 @@ 04A573702E4D4BD50019651F /* OWSPoll.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04A5736F2E4D4BD30019651F /* OWSPoll.swift */; }; 04A573722E53A3BF0019651F /* SupportKeyValueStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04A573712E53A3B40019651F /* SupportKeyValueStore.swift */; }; 04A573762E75B00B0019651F /* DebugLogPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04A573752E75B00A0019651F /* DebugLogPreviewViewController.swift */; }; - 04AA85BC2E0EFC5C0051C0A7 /* BackupEnablementReminderMegaphoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041A5F082E05D3AC00FAED05 /* BackupEnablementReminderMegaphoneTests.swift */; }; 04AB61C62E5E37A800405699 /* PollRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AB61C52E5E37A400405699 /* PollRecord.swift */; }; 04AB61C82E5E399700405699 /* PollOptionRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AB61C72E5E399400405699 /* PollOptionRecord.swift */; }; 04AB61CA2E5E449100405699 /* PollManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AB61C92E5E448A00405699 /* PollManagerTest.swift */; }; @@ -4166,7 +4164,6 @@ 040507142F8063A40078B769 /* RemoteReleaseNotesFetchingManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteReleaseNotesFetchingManagerTests.swift; sourceTree = ""; }; 04127D902F23B3B000B4E95B /* CVCapsuleLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVCapsuleLabel.swift; sourceTree = ""; }; 041A5F062E05B3F000FAED05 /* BackupEnablementMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEnablementMegaphone.swift; sourceTree = ""; }; - 041A5F082E05D3AC00FAED05 /* BackupEnablementReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEnablementReminderMegaphoneTests.swift; sourceTree = ""; }; 042223B92EDF30B300158556 /* OutgoingUnpinMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingUnpinMessage.swift; sourceTree = ""; }; 0426758A2EC4D5BF00124C5F /* PinnedMessageIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessageIconView.swift; sourceTree = ""; }; 0426758F2EC529F500124C5F /* TSInfoMessage+PinnedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+PinnedMessage.swift"; sourceTree = ""; }; @@ -4224,7 +4221,6 @@ 046092232FBCC7E700A8765F /* SafetyTipsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyTipsManager.swift; sourceTree = ""; }; 046926082E8EBAA800B1FC74 /* TSInfoMessage+Polls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+Polls.swift"; sourceTree = ""; }; 0477BE312FA4FC38002F9B47 /* TSReleaseNotesThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSReleaseNotesThread.swift; sourceTree = ""; }; - 047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupKeyReminderMegaphoneTests.swift; sourceTree = ""; }; 0480EFFF2E57C513006CBB29 /* BackupsEnabledNotificationMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsEnabledNotificationMegaphone.swift; sourceTree = ""; }; 0484CECD2F44B7BB009AB2CB /* AdminDeleteRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteRecord.swift; sourceTree = ""; }; 0484CECF2F44BCFB009AB2CB /* AdminDeleteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteManager.swift; sourceTree = ""; }; @@ -13890,8 +13886,6 @@ children = ( D90AA32E2CC9616A00021CB0 /* Signal-Message-Backup-Tests */, D90AA6182CC961ED00021CB0 /* BackupArchiveIntegrationTests.swift */, - 041A5F082E05D3AC00FAED05 /* BackupEnablementReminderMegaphoneTests.swift */, - 047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */, 04E66D432E00AB3A0059DBAC /* BackupSettingsStoreTests.swift */, D9A36B922C7FEDA100CEC0E7 /* LineByLineStringDiff.swift */, ); @@ -20073,8 +20067,6 @@ D90AA6192CC961ED00021CB0 /* BackupArchiveIntegrationTests.swift in Sources */, 66681CDF2C58174F00E50136 /* BackupAttachmentDownloadStoreTests.swift in Sources */, 66C795302C9B83A200C13937 /* BackupAttachmentUploadStoreTests.swift in Sources */, - 04AA85BC2E0EFC5C0051C0A7 /* BackupEnablementReminderMegaphoneTests.swift in Sources */, - 047A6DD02E00B5720048EDF4 /* BackupKeyReminderMegaphoneTests.swift in Sources */, 66A1F4EB2E07CEA50095DE4B /* BackupListMediaManagerTests.swift in Sources */, 04E66D452E00AB6A0059DBAC /* BackupSettingsStoreTests.swift in Sources */, F9426283289B1B5600460798 /* BlockingManagerTests.swift in Sources */, diff --git a/Signal/Megaphones/ExperienceUpgradeManager.swift b/Signal/Megaphones/ExperienceUpgradeManager.swift index 837c4de135..2cda5fc4e9 100644 --- a/Signal/Megaphones/ExperienceUpgradeManager.swift +++ b/Signal/Megaphones/ExperienceUpgradeManager.swift @@ -3,37 +3,61 @@ // SPDX-License-Identifier: AGPL-3.0-only // +import Contacts import SignalServiceKit import SignalUI class ExperienceUpgradeManager { private enum StoreKeys { - static let lastExperienceUpgradeDismissDate = "lastExperienceUpgradeDismissDate" + static let lastMegaphoneDismissDate = "lastExperienceUpgradeDismissDate" } - // The Megaphone is retained for the lifetime of the MegaphoneView. - private weak static var lastPresentedMegaphone: Megaphone? - private weak static var lastPresentedMegaphoneView: MegaphoneView? + 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 lastExperienceUpgradeDismissDate: Date - let nextExperienceUpgrade: ExperienceUpgrade? + let lastMegaphoneDismissDate: Date + let nextMegaphone: Megaphone? ( - lastExperienceUpgradeDismissDate, - nextExperienceUpgrade, + lastMegaphoneDismissDate, + nextMegaphone, ) = db.read { tx in guard let registeredState = try? tsAccountManager.registeredState(tx: tx), @@ -42,97 +66,149 @@ class ExperienceUpgradeManager { return (.distantPast, nil) } - let lastExperienceUpgradeDismissDate = keyValueStore.fetchValue( + let lastMegaphoneDismissDate = keyValueStore.fetchValue( Date.self, - forKey: StoreKeys.lastExperienceUpgradeDismissDate, + forKey: StoreKeys.lastMegaphoneDismissDate, tx: tx, ) ?? .distantPast - let nextExperienceUpgrade = allKnownExperienceUpgrades(transaction: tx) - .first { upgrade in - 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 { - return false - } - - switch upgrade.manifest { - case .introducingPins: - return ExperienceUpgradeManifest - .checkPreconditionsForIntroducingPins(transaction: tx) - case .notificationPermissionReminder: - return ExperienceUpgradeManifest - .checkPreconditionsForNotificationsPermissionsReminder() - case .newLinkedDeviceNotification: - let result = ExperienceUpgradeManifest - .checkPreconditionsForNewLinkedDeviceNotification(tx: tx) - switch result { - case .display: - return true - case .skip: - return false - case .clearNotification: - shouldClearNewDeviceNotification = true - return false - } - case .createUsernameReminder: - return ExperienceUpgradeManifest - .checkPreconditionsForCreateUsernameReminder(transaction: tx) - case .remoteMegaphone(let megaphone): - return ExperienceUpgradeManifest - .checkPreconditionsForRemoteMegaphone(megaphone, tx: tx) - case .inactiveLinkedDeviceReminder: - return ExperienceUpgradeManifest - .checkPreconditionsForInactiveLinkedDeviceReminder(tx: tx) - case .inactivePrimaryDeviceReminder: - return ExperienceUpgradeManifest - .checkPreconditionsForInactivePrimaryDeviceReminder(tx: tx) - case .pinReminder: - return ExperienceUpgradeManifest - .checkPreconditionsForPinReminder(transaction: tx) - case .contactPermissionReminder: - return ExperienceUpgradeManifest - .checkPreconditionsForContactsPermissionReminder() - case .backupKeyReminder: - return ExperienceUpgradeManifest - .checkPreconditionsForRecoveryKeyReminder( - backupSettingsStore: backupSettingsStore, - tsAccountManager: tsAccountManager, - transaction: tx, - ) - case .enableBackupsReminder: - return ExperienceUpgradeManifest - .checkPreconditionsForBackupEnablementReminder( - backupSettingsStore: backupSettingsStore, - remoteConfigProvider: remoteConfigManager, - tsAccountManager: tsAccountManager, - transaction: tx, - ) - case .haveEnabledBackupsNotification: - let result = ExperienceUpgradeManifest - .checkPreconditionsForEnabledBackupsNotification(tx: tx) - switch result { - case .display: - return true - case .skip: - return false - case .clearStoredDetails: - shouldClearBackupsEnabledDetails = true - return false - } - case .unrecognized: - return false - } + 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 ( - lastExperienceUpgradeDismissDate, - nextExperienceUpgrade, + lastMegaphoneDismissDate, + nextMegaphone, ) } @@ -148,14 +224,14 @@ class ExperienceUpgradeManager { } } - guard let nextExperienceUpgrade else { + guard let nextMegaphone else { _ = dismissLastPresented(now: now) return } if let lastPresentedMegaphone, - lastPresentedMegaphone.experienceUpgrade.manifest == nextExperienceUpgrade.manifest + lastPresentedMegaphone.experienceUpgrade.manifest == nextMegaphone.experienceUpgrade.manifest { return } @@ -163,25 +239,18 @@ class ExperienceUpgradeManager { // If we're dismissing a megaphone, don't immediately present another. if dismissLastPresented(now: now) { return - } - - if - now.timeIntervalSince(lastExperienceUpgradeDismissDate) > .day, - let megaphone = self.megaphone( - forExperienceUpgrade: nextExperienceUpgrade, - fromViewController: fromViewController, - ) + } else if + now.timeIntervalSince(lastMegaphoneDismissDate) > .day { - let megaphoneView = megaphone.buildView() - ObjectRetainer.retainObject(megaphone, forLifetimeOf: megaphoneView) - + let megaphoneView = nextMegaphone.buildView() megaphoneView.present(fromViewController: fromViewController) - lastPresentedMegaphone = megaphone + + lastPresentedMegaphone = nextMegaphone lastPresentedMegaphoneView = megaphoneView db.write { tx in experienceUpgradeStore.markAsViewed( - experienceUpgrade: nextExperienceUpgrade, + experienceUpgrade: nextMegaphone.experienceUpgrade, tx: tx, ) } @@ -192,7 +261,7 @@ class ExperienceUpgradeManager { /// persisted record if one exists and is applicable, and an in-memory /// model otherwise. private static func allKnownExperienceUpgrades( - transaction tx: DBReadTransaction, + tx: DBReadTransaction, ) -> [ExperienceUpgrade] { var experienceUpgrades = [ExperienceUpgrade]() var localManifestsWithoutRecords = ExperienceUpgradeManifest.wellKnownLocalUpgradeManifests @@ -232,157 +301,368 @@ class ExperienceUpgradeManager { db.write { tx in keyValueStore.writeValue( now, - forKey: StoreKeys.lastExperienceUpgradeDismissDate, + forKey: StoreKeys.lastMegaphoneDismissDate, tx: tx, ) } - lastPresentedMegaphoneView.dismiss(animated: false, completion: nil) + lastPresentedMegaphoneView.dismiss() self.lastPresentedMegaphone = nil self.lastPresentedMegaphoneView = nil return true } - // MARK: - + // MARK: - Megaphone Preconditions - private static func hasMegaphone(forExperienceUpgrade experienceUpgrade: ExperienceUpgrade) -> Bool { - switch experienceUpgrade.manifest { - case - .introducingPins, - .pinReminder, - .notificationPermissionReminder, - .newLinkedDeviceNotification, - .createUsernameReminder, - .inactiveLinkedDeviceReminder, - .inactivePrimaryDeviceReminder, - .contactPermissionReminder, - .backupKeyReminder, - .enableBackupsReminder, - .haveEnabledBackupsNotification: + 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 - case .remoteMegaphone: - // Remote megaphones are always presentable. We filter out any with - // unpresentable fields (e.g., unrecognized actions) before we get - // out of the `ExperienceUpgradeFinder`. - return true - case .unrecognized: + } + + return false + } + + private static func checkPreconditionsForNotificationsPermissionsReminder() -> Bool { + let (promise, future) = Promise.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 static func megaphone(forExperienceUpgrade experienceUpgrade: ExperienceUpgrade, fromViewController: UIViewController) -> Megaphone? { - let db = DependenciesBridge.shared.db - let deviceStore = DependenciesBridge.shared.deviceStore - let localUsernameManager = DependenciesBridge.shared.localUsernameManager - let inactiveLinkedDeviceFinder = DependenciesBridge.shared.inactiveLinkedDeviceFinder + private enum NewLinkedDeviceNotificationResult { + case display(MostRecentlyLinkedDeviceDetails) + case skip + case clearNotification + } - switch experienceUpgrade.manifest { - case .introducingPins: - return IntroducingPinsMegaphone(experienceUpgrade: experienceUpgrade, fromViewController: fromViewController) - case .pinReminder: - return PinReminderMegaphone(experienceUpgrade: experienceUpgrade, fromViewController: fromViewController) - case .notificationPermissionReminder: - return NotificationPermissionReminderMegaphone(experienceUpgrade: experienceUpgrade, fromViewController: fromViewController) - case .newLinkedDeviceNotification: - let mostRecentlyLinkedDeviceDetails = db.read { tx in - deviceStore.mostRecentlyLinkedDeviceDetails(tx: tx) - } + private static func checkPreconditionsForNewLinkedDeviceNotification( + tx: DBReadTransaction, + ) -> NewLinkedDeviceNotificationResult { + guard + let mostRecentlyLinkedDeviceDetails = deviceStore.mostRecentlyLinkedDeviceDetails(tx: tx) + else { + return .skip + } - guard let mostRecentlyLinkedDeviceDetails else { - owsFailDebug("Missing mostRecentlyLinkedDeviceDetails") - return nil - } - - return NewLinkedDeviceNotificationMegaphone( - db: DependenciesBridge.shared.db, - deviceStore: DependenciesBridge.shared.deviceStore, - experienceUpgrade: experienceUpgrade, - mostRecentlyLinkedDeviceDetails: mostRecentlyLinkedDeviceDetails, - ) - case .createUsernameReminder: - let usernameIsUnset: Bool = db.read { tx in - return localUsernameManager.usernameState(tx: tx).isExplicitlyUnset - } - - guard usernameIsUnset else { - owsFailDebug("Should never try and show this megaphone if a username is set!") - return nil - } - - return CreateUsernameMegaphone( - usernameSelectionCoordinator: .init( - currentUsername: nil, - context: .init( - databaseStorage: SSKEnvironment.shared.databaseStorageRef, - networkManager: SSKEnvironment.shared.networkManagerRef, - storageServiceManager: SSKEnvironment.shared.storageServiceManagerRef, - usernameEducationManager: DependenciesBridge.shared.usernameEducationManager, - localUsernameManager: DependenciesBridge.shared.localUsernameManager, - ), - ), - experienceUpgrade: experienceUpgrade, - fromViewController: fromViewController, - ) - case .inactiveLinkedDeviceReminder: - let inactiveLinkedDevice: InactiveLinkedDevice? = db.read { tx in - return inactiveLinkedDeviceFinder.findLeastActiveLinkedDevice(tx: tx) - } - - guard let inactiveLinkedDevice else { - owsFailDebug("Trying to show inactive linked device megaphone, but have no device!") - return nil - } - - return InactiveLinkedDeviceReminderMegaphone( - inactiveLinkedDevice: inactiveLinkedDevice, - fromViewController: fromViewController, - experienceUpgrade: experienceUpgrade, - ) - case .inactivePrimaryDeviceReminder: - let isPrimaryDevice = db.read { tx in - // If isPrimaryDevice is nil, it means we aren't registered yet, and shouldn't show the megaphone. - return DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx).isPrimaryDevice ?? true - } - - guard !isPrimaryDevice else { - owsFailDebug("Trying to show inactive primary device megaphone, but this is the primary device or an unregistered device") - return nil - } - - return InactivePrimaryDeviceReminderMegaphone(fromViewController: fromViewController, experienceUpgrade: experienceUpgrade) - case .contactPermissionReminder: - return ContactPermissionReminderMegaphone(experienceUpgrade: experienceUpgrade, fromViewController: fromViewController) - case .remoteMegaphone(let megaphone): - return RemoteMegaphone( - experienceUpgrade: experienceUpgrade, - remoteMegaphoneModel: megaphone, - fromViewController: fromViewController, - ) - case .backupKeyReminder: - return RecoveryKeyReminderMegaphone(experienceUpgrade: experienceUpgrade, fromViewController: fromViewController) - case .enableBackupsReminder: - return BackupEnablementMegaphone( - experienceUpgrade: experienceUpgrade, - fromViewController: fromViewController, - ) - case .haveEnabledBackupsNotification: - let lastBackupsEnabledDetails = db.read { tx in - backupSettingsStore.lastBackupEnabledDetails(tx: tx) - } - - guard let lastBackupsEnabledDetails else { - owsFailDebug("Missing lastBackupsEnabledDetails") - return nil - } - - return BackupsEnabledNotificationMegaphone( - experienceUpgrade: experienceUpgrade, - fromViewController: fromViewController, - backupsEnabledTime: lastBackupsEnabledDetails.enabledTime, - db: db, - ) - case .unrecognized: - return nil + // 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 + } } diff --git a/Signal/Megaphones/UserInterface/MegaphoneView.swift b/Signal/Megaphones/UserInterface/MegaphoneView.swift index 2fb65e14b6..80236c5bbf 100644 --- a/Signal/Megaphones/UserInterface/MegaphoneView.swift +++ b/Signal/Megaphones/UserInterface/MegaphoneView.swift @@ -136,13 +136,9 @@ class MegaphoneView: UIView { // MARK: - - private var hasPresented = false - func present(fromViewController: UIViewController) { AssertIsOnMainThread() - guard !hasPresented else { return owsFailDebug("can only present once") } - let labelStack = createLabelStack() let topStackSubviews: [UIView] @@ -170,17 +166,10 @@ class MegaphoneView: UIView { UIView.animate(withDuration: 0.2) { self.alpha = 1 } - - hasPresented = true } - func dismiss(animated: Bool = true, completion: (() -> Void)? = nil) { - UIView.animate(withDuration: animated ? 0.2 : 0, animations: { - self.alpha = 0 - }) { _ in - self.removeFromSuperview() - completion?() - } + func dismiss() { + removeFromSuperview() } // MARK: - diff --git a/Signal/Usernames/Selection/UsernameSelectionCoordinator.swift b/Signal/Usernames/Selection/UsernameSelectionCoordinator.swift index a3ba348eed..0bce09d273 100644 --- a/Signal/Usernames/Selection/UsernameSelectionCoordinator.swift +++ b/Signal/Usernames/Selection/UsernameSelectionCoordinator.swift @@ -8,7 +8,7 @@ import SignalUI class UsernameSelectionCoordinator { struct Context { - let databaseStorage: SDSDatabaseStorage + let databaseStorage: DB let networkManager: NetworkManager let storageServiceManager: StorageServiceManager let usernameEducationManager: UsernameEducationManager diff --git a/Signal/Usernames/Selection/UsernameSelectionViewController.swift b/Signal/Usernames/Selection/UsernameSelectionViewController.swift index 9f2d4f11ad..ea1f315560 100644 --- a/Signal/Usernames/Selection/UsernameSelectionViewController.swift +++ b/Signal/Usernames/Selection/UsernameSelectionViewController.swift @@ -23,7 +23,7 @@ class UsernameSelectionViewController: OWSViewController, OWSNavigationChildCont /// A wrapper for injected dependencies. struct Context { let networkManager: NetworkManager - let databaseStorage: SDSDatabaseStorage + let databaseStorage: DB let localUsernameManager: LocalUsernameManager let storageServiceManager: StorageServiceManager } diff --git a/SignalServiceKit/Devices/InactiveLinkedDeviceFinder.swift b/SignalServiceKit/Devices/InactiveLinkedDeviceFinder.swift index 0976ffa411..8186b9432a 100644 --- a/SignalServiceKit/Devices/InactiveLinkedDeviceFinder.swift +++ b/SignalServiceKit/Devices/InactiveLinkedDeviceFinder.swift @@ -43,13 +43,6 @@ public protocol InactiveLinkedDeviceFinder { #endif } -public extension InactiveLinkedDeviceFinder { - /// Whether the user has an "inactive" linked device. - func hasInactiveLinkedDevice(tx: DBReadTransaction) -> Bool { - return findLeastActiveLinkedDevice(tx: tx) != nil - } -} - class InactiveLinkedDeviceFinderImpl: InactiveLinkedDeviceFinder { private enum Constants { /// How long we should wait between device state refreshes. diff --git a/SignalServiceKit/Environment/RemoteConfigManager.swift b/SignalServiceKit/Environment/RemoteConfigManager.swift index 1ec523f19f..484373148d 100644 --- a/SignalServiceKit/Environment/RemoteConfigManager.swift +++ b/SignalServiceKit/Environment/RemoteConfigManager.swift @@ -539,7 +539,7 @@ public class RemoteConfig { /// /// - Parameter csvString: a CSV containing `:` pairs /// - Parameter key: a key to use as part of bucketing - static func isCountryCodeBucketEnabled(csvString: String, key: String, localIdentifiers: LocalIdentifiers) -> Bool { + public static func isCountryCodeBucketEnabled(csvString: String, key: String, localIdentifiers: LocalIdentifiers) -> Bool { guard let countryCodeValue = countryCodeBucketValue( csvString: csvString, diff --git a/SignalServiceKit/Megaphones/ExperienceUpgradeManifest.swift b/SignalServiceKit/Megaphones/ExperienceUpgradeManifest.swift index 0ff2768da1..1be2c3bb43 100644 --- a/SignalServiceKit/Megaphones/ExperienceUpgradeManifest.swift +++ b/SignalServiceKit/Megaphones/ExperienceUpgradeManifest.swift @@ -3,7 +3,6 @@ // SPDX-License-Identifier: AGPL-3.0-only // -import Contacts import Foundation import UIKit @@ -502,345 +501,4 @@ public enum ExperienceUpgradeManifest: Codable, Equatable, Hashable { return true } } - - // MARK: Local megaphone preconditions - - public static func checkPreconditionsForIntroducingPins(transaction: DBReadTransaction) -> Bool { - // The PIN setup flow requires an internet connection and you to not already have a PIN - let accountKeyStore = DependenciesBridge.shared.accountKeyStore - let tsAccountManager = DependenciesBridge.shared.tsAccountManager - let reachabilityManager = SSKEnvironment.shared.reachabilityManagerRef - if - reachabilityManager.isReachable, - tsAccountManager.registrationState(tx: transaction).isRegisteredPrimaryDevice, - accountKeyStore.getMasterKey(tx: transaction) == nil - { - return true - } - - return false - } - - public static func checkPreconditionsForNotificationsPermissionsReminder() -> Bool { - let (promise, future) = Promise.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 - } - } - - public enum NewLinkedDeviceNotificationResult { - case display - case skip - case clearNotification - } - - public static func checkPreconditionsForNewLinkedDeviceNotification( - tx: DBReadTransaction, - ) -> NewLinkedDeviceNotificationResult { - let deviceStore = DependenciesBridge.shared.deviceStore - 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 - } else { - .skip - } - } - - public enum BackupsEnabledNotificationResult { - case display - case skip - case clearStoredDetails - } - - public static func checkPreconditionsForEnabledBackupsNotification(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 - } - - return Date() > lastBackupEnabledDetails.shouldRemindUserAfter ? .display : .skip - } - - public static func checkPreconditionsForCreateUsernameReminder(transaction: DBReadTransaction) -> Bool { - guard - DependenciesBridge.shared.localUsernameManager.usernameState( - tx: transaction, - ).isExplicitlyUnset - else { - // If we have a username, do not show the reminder. - return false - } - let tsAccountManager = DependenciesBridge.shared.tsAccountManager - if tsAccountManager.phoneNumberDiscoverability(tx: transaction).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 = DependenciesBridge.shared.tsAccountManager - .lastSetIsDiscoverableByPhoneNumber(tx: transaction) - .timeIntervalSinceNow * -1 - - let requiredDelayAfterDisablingDiscovery: TimeInterval = 3 * .day - - return timeIntervalSinceDisabledDiscovery > requiredDelayAfterDisablingDiscovery - } - - public static func checkPreconditionsForInactiveLinkedDeviceReminder(tx: DBReadTransaction) -> Bool { - return DependenciesBridge.shared.inactiveLinkedDeviceFinder.hasInactiveLinkedDevice(tx: tx) - } - - public static func checkPreconditionsForInactivePrimaryDeviceReminder(tx: DBReadTransaction) -> Bool { - return DependenciesBridge.shared.inactivePrimaryDeviceStore.valueForInactivePrimaryDeviceAlert(transaction: tx) - } - - public static func checkPreconditionsForPinReminder(transaction: DBReadTransaction) -> Bool { - return SSKEnvironment.shared.ows2FAManagerRef.isDueForV2Reminder(transaction: transaction) - } - - public 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 - } - } - - public static func checkPreconditionsForRecoveryKeyReminder( - backupSettingsStore: BackupSettingsStore, - tsAccountManager: TSAccountManager, - transaction 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) - } - - public static func checkPreconditionsForBackupEnablementReminder( - backupSettingsStore: BackupSettingsStore, - remoteConfigProvider: RemoteConfigProvider, - tsAccountManager: TSAccountManager, - transaction: DBReadTransaction, - ) -> Bool { - guard - remoteConfigProvider.currentConfig().backupsMegaphone, - tsAccountManager.registrationState(tx: transaction).isRegisteredPrimaryDevice - else { - return false - } - - guard !backupSettingsStore.haveBackupsEverBeenEnabled(tx: transaction) else { - return false - } - - return InteractionFinder.outgoingAndIncomingMessageCount(transaction: transaction, limit: 1) >= 1 - } - - // MARK: Remote megaphone preconditions - - public static func checkPreconditionsForRemoteMegaphone(_ megaphone: RemoteMegaphoneModel, tx: DBReadTransaction) -> Bool { - let minimumVersion = AppVersionNumber(megaphone.manifest.minAppVersion) - let currentVersion = AppVersionNumber(AppVersionImpl.shared.currentAppVersion) - guard currentVersion >= minimumVersion else { - return false - } - - guard Date().timeIntervalSince1970 > TimeInterval(megaphone.manifest.dontShowBefore) else { - return false - } - - let tsAccountManager = DependenciesBridge.shared.tsAccountManager - guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx) else { - return false - } - - guard - RemoteConfig.isCountryCodeBucketEnabled( - csvString: megaphone.manifest.countries, - key: megaphone.manifest.id, - localIdentifiers: localIdentifiers, - ) - else { - return false - } - - guard - validateRemoteMegaphone( - conditionalCheck: megaphone.manifest.conditionalCheck, - tx: tx, - ) - else { - return false - } - - guard - validateRemoteMegaphone( - action: megaphone.manifest.primaryAction, - withText: megaphone.translation.primaryActionText, - ) - else { - return false - } - - guard - validateRemoteMegaphone( - action: megaphone.manifest.secondaryAction, - withText: megaphone.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 SSKEnvironment.shared.profileManagerRef.localUserProfile(tx: tx)?.hasBadge == true { - // Fail the check if we currently have a badge. - return false - } else if - DependenciesBridge.shared.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 - } } diff --git a/SignalServiceKit/Megaphones/RemoteMegaphoneModel.swift b/SignalServiceKit/Megaphones/RemoteMegaphoneModel.swift index 2b810b7f47..8d678204da 100644 --- a/SignalServiceKit/Megaphones/RemoteMegaphoneModel.swift +++ b/SignalServiceKit/Megaphones/RemoteMegaphoneModel.swift @@ -87,11 +87,11 @@ extension RemoteMegaphoneModel { /// Priority of this megaphone relative to other remote megaphones. /// Higher numbers indicate greater priority. - fileprivate(set) var priority: Int + public fileprivate(set) var priority: Int /// Version string representing the minimum app version for which this /// upgrade should be shown. - let minAppVersion: String + public let minAppVersion: String /// A CSV string of `:` pairs /// representing the fraction of users to which this megaphone should @@ -99,21 +99,21 @@ extension RemoteMegaphoneModel { /// /// This is the same format used in remote-config country-code /// restrictions. - fileprivate(set) var countries: String + public fileprivate(set) var countries: String /// Epoch time before which this megaphone should not be shown. - let dontShowBefore: EpochSeconds + public let dontShowBefore: EpochSeconds /// Epoch time after which this megaphone should not be shown. - let dontShowAfter: EpochSeconds + public let dontShowAfter: EpochSeconds /// Number of days after this megaphone is first presented that it /// should continue to be shown, if the user does not interact with it. - let showForNumberOfDays: Int + public let showForNumberOfDays: Int /// Represents a condition that must be satisfied in order for this /// megaphone to be presented. - fileprivate(set) var conditionalCheck: ConditionalCheck? + public fileprivate(set) var conditionalCheck: ConditionalCheck? /// Represents an action to be performed in response to user selection /// of the "primary" call-to-action in the presented megaphone. @@ -121,7 +121,7 @@ extension RemoteMegaphoneModel { /// Represents data associated with the performance of the primary /// action. - fileprivate(set) var primaryActionData: ActionData? + public fileprivate(set) var primaryActionData: ActionData? /// Represents an action to be performed in response to user selection /// of the "secondary" call-to-action in the presented megaphone. @@ -129,7 +129,7 @@ extension RemoteMegaphoneModel { /// Represents data associated with the performance of the seocndary /// action. - fileprivate(set) var secondaryActionData: ActionData? + public fileprivate(set) var secondaryActionData: ActionData? public init( id: String, @@ -293,7 +293,7 @@ extension RemoteMegaphoneModel.Manifest { case donateFriend case unrecognized(actionId: String) - var actionId: String { + public var actionId: String { switch self { case .finish: return "finish" diff --git a/SignalServiceKit/tests/Devices/InactiveLinkedDeviceFinderTest.swift b/SignalServiceKit/tests/Devices/InactiveLinkedDeviceFinderTest.swift index fe235e6ea9..0d5aab9fa7 100644 --- a/SignalServiceKit/tests/Devices/InactiveLinkedDeviceFinderTest.swift +++ b/SignalServiceKit/tests/Devices/InactiveLinkedDeviceFinderTest.swift @@ -129,14 +129,14 @@ final class InactiveLinkedDeviceFinderTest: XCTestCase { mockDB.write { inactiveLinkedDeviceFinder.permanentlyDisableFinders(tx: $0) } try await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary() XCTAssertEqual(mockDevicesService.refreshCount, 0) - XCTAssertFalse(mockDB.read { inactiveLinkedDeviceFinder.hasInactiveLinkedDevice(tx: $0) }) + XCTAssertFalse(mockDB.read { inactiveLinkedDeviceFinder.findLeastActiveLinkedDevice(tx: $0) != nil }) // Re-enable (only available in tests) and run more tests, to prove the // disabling is why the first battery passed. mockDB.write { inactiveLinkedDeviceFinder.reenablePermanentlyDisabledFinders(tx: $0) } try await inactiveLinkedDeviceFinder.refreshLinkedDeviceStateIfNecessary() XCTAssertEqual(mockDevicesService.refreshCount, 1) - XCTAssertTrue(mockDB.read { inactiveLinkedDeviceFinder.hasInactiveLinkedDevice(tx: $0) }) + XCTAssertTrue(mockDB.read { inactiveLinkedDeviceFinder.findLeastActiveLinkedDevice(tx: $0) != nil }) } private func setMockDevices(_ devices: [OWSDevice]) { diff --git a/SignalServiceKit/tests/MessageBackup/BackupEnablementReminderMegaphoneTests.swift b/SignalServiceKit/tests/MessageBackup/BackupEnablementReminderMegaphoneTests.swift deleted file mode 100644 index 377cd844cb..0000000000 --- a/SignalServiceKit/tests/MessageBackup/BackupEnablementReminderMegaphoneTests.swift +++ /dev/null @@ -1,173 +0,0 @@ -// -// Copyright 2025 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only -// - -import GRDB -import LibSignalClient -import Testing - -@testable import SignalServiceKit - -struct BackupEnablementReminderMegaphoneTests { - private let backupSettingsStore = BackupSettingsStore() - private let tsAccountManager = MockTSAccountManager() - - private let remoteConfigProvider: MockRemoteConfigProvider = { - let provider = MockRemoteConfigProvider() - provider._currentConfig = RemoteConfig(clockSkew: 0, valueFlags: ["ios.backupsMegaphone2": "true"]) - return provider - }() - - private let contactThread = TSContactThread(contactAddress: SignalServiceAddress( - serviceId: Pni.randomForTesting(), - phoneNumber: "+16505550101", - cache: SignalServiceAddressCache(), - )) - private let experienceUpgrade = ExperienceUpgrade.makeNew(withManifest: ExperienceUpgradeManifest.enableBackupsReminder) - - private func insertInteraction(thread: TSThread, db: Database) { - let interaction = TSInteraction(timestamp: 0, receivedAtTimestamp: 0, thread: thread) - try! interaction.asRecord().insert(db) - } - - private func checkPreconditions(tx: DBReadTransaction) -> Bool { - return ExperienceUpgradeManifest.checkPreconditionsForBackupEnablementReminder( - backupSettingsStore: backupSettingsStore, - remoteConfigProvider: remoteConfigProvider, - tsAccountManager: tsAccountManager, - transaction: tx, - ) - } - - // MARK: - - - @Test - func testBackupsEnabled() { - let db = InMemoryDB() - - db.write { tx in - backupSettingsStore.setBackupPlan(.free, tx: tx) - } - - db.read { tx in - let shouldShowBackupEnablementReminder = checkPreconditions(tx: tx) - #expect(!shouldShowBackupEnablementReminder, "Don't show reminder if backups is enabled") - } - } - - @Test - func testRemoteConfigDisabled() { - let db = InMemoryDB() - - for i in 0..<2000 { - let outgoingMessage = TSOutgoingMessage(in: contactThread, messageBody: "good heavens + \(i)") - db.write { tx in - let db = tx.database - try! outgoingMessage.asRecord().insert(db) - } - } - - db.read { tx in - #expect(checkPreconditions(tx: tx), "Megaphone should be allowed!") - } - - remoteConfigProvider._currentConfig = RemoteConfig(clockSkew: 0, valueFlags: [:]) - - db.read { tx in - #expect(!checkPreconditions(tx: tx), "Megaphone should be disallowed by remote config.") - } - } - - @Test - func testLessThanRequiredMessages() { - let db = InMemoryDB() - - db.write { tx in - backupSettingsStore.setBackupPlan(.disabled, tx: tx) - } - - db.read { tx in - #expect(!checkPreconditions(tx: tx), "Don't show reminder if user doesn't have enough messages") - } - } - - func testHasRequiredMessages() { - let db = InMemoryDB() - - db.write { tx in - let db = tx.database - try! contactThread.insert(db) - } - - for i in 0..<2000 { - let outgoingMessage = TSOutgoingMessage(in: contactThread, messageBody: "good heavens + \(i)") - db.write { tx in - let db = tx.database - try! outgoingMessage.asRecord().insert(db) - } - } - - db.read { tx in - let shouldShowBackupEnablementReminder = checkPreconditions(tx: tx) - #expect(shouldShowBackupEnablementReminder, "Should show reminder if user has enough messages") - } - } - - func testBackupsHasPreviouslyBeenEnabled() { - let db = InMemoryDB() - - // Enable then disable backups - db.write { tx in backupSettingsStore.setBackupPlan(.free, tx: tx) } - db.write { tx in backupSettingsStore.setBackupPlan(.disabled, tx: tx) } - - db.write { tx in - let db = tx.database - try! contactThread.insert(db) - } - - for i in 0..<2000 { - let outgoingMessage = TSOutgoingMessage(in: contactThread, messageBody: "good heavens + \(i)") - db.write { tx in - let db = tx.database - try! outgoingMessage.asRecord().insert(db) - } - } - - db.read { tx in - let shouldShowBackupEnablementReminder = checkPreconditions(tx: tx) - #expect(!shouldShowBackupEnablementReminder, "Should not show reminder if user has enabled then disabled backups, even if they have enough messages") - } - } - - func testSnoozed() throws { - let now = Date() - - experienceUpgrade.snoozeCount = 1 - - experienceUpgrade.lastSnoozedTimestamp = Date().addingTimeInterval(-25 * TimeInterval.day).timeIntervalSince1970 - #expect(experienceUpgrade.isSnoozed(now: now), "should still be snoozed if last snooze was recent") - - experienceUpgrade.lastSnoozedTimestamp = Date().addingTimeInterval(-31 * TimeInterval.day).timeIntervalSince1970 - #expect(!experienceUpgrade.isSnoozed(now: now), "should not be snoozed if last snooze was long enough ago") - - experienceUpgrade.snoozeCount = 2 - experienceUpgrade.lastSnoozedTimestamp = Date().addingTimeInterval(-31 * TimeInterval.day).timeIntervalSince1970 - #expect(experienceUpgrade.isSnoozed(now: now), "should still be snoozed if last snooze was recent") - - experienceUpgrade.lastSnoozedTimestamp = Date().addingTimeInterval(-91 * TimeInterval.day).timeIntervalSince1970 - #expect(!experienceUpgrade.isSnoozed(now: now), "should not still be snoozed if last snooze was long enough ago") - } -} - -// MARK: - - -private extension TSOutgoingMessage { - convenience init(in thread: TSThread, messageBody: String) { - let builder: TSOutgoingMessageBuilder = .withDefaultValues( - thread: thread, - messageBody: AttachmentContentValidatorMock.mockValidatedBody(messageBody), - ) - self.init(outgoingMessageWith: builder, recipientAddressStates: [:]) - } -} diff --git a/SignalServiceKit/tests/MessageBackup/BackupKeyReminderMegaphoneTests.swift b/SignalServiceKit/tests/MessageBackup/BackupKeyReminderMegaphoneTests.swift deleted file mode 100644 index 8c6f87f47f..0000000000 --- a/SignalServiceKit/tests/MessageBackup/BackupKeyReminderMegaphoneTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// Copyright 2025 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only -// - -import LibSignalClient -import XCTest - -@testable import SignalServiceKit - -class RecoveryKeyReminderMegaphoneTests: XCTestCase { - private let backupSettingsStore: BackupSettingsStore = BackupSettingsStore() - private let db: DB = InMemoryDB() - private let tsAccountManager: TSAccountManager = MockTSAccountManager() - - private func checkPreconditions(tx: DBReadTransaction) -> Bool { - return ExperienceUpgradeManifest.checkPreconditionsForRecoveryKeyReminder( - backupSettingsStore: backupSettingsStore, - tsAccountManager: tsAccountManager, - transaction: tx, - ) - } - - func testPreconditionsForRecoveryKeyMegaphone_backupsDisabled() throws { - db.write { tx in - backupSettingsStore.setBackupPlan(.disabled, tx: tx) - } - - let shouldShowRecoveryKeyReminder = db.read { tx in - checkPreconditions(tx: tx) - } - XCTAssertFalse(shouldShowRecoveryKeyReminder, "Don't show reminder if backups is not enabled") - } - - func testPreconditionsForRecoveryKeyMegaphone_neverDoneBackup() throws { - db.write { tx in - backupSettingsStore.setBackupPlan(.free, tx: tx) - } - - let shouldShowRecoveryKeyReminder = db.read { tx in - checkPreconditions(tx: tx) - } - XCTAssertFalse(shouldShowRecoveryKeyReminder, "Don't show reminder if user has never done a backup") - } - - func testPreconditionsForRecoveryKeyMegaphone_backupsTooNew() throws { - db.write { tx in - backupSettingsStore.setBackupPlan(.free, tx: tx) - } - - db.write { tx in - backupSettingsStore.setLastBackupDate(Date(), tx: tx) - } - - let shouldShowRecoveryKeyReminder = db.read { tx in - checkPreconditions(tx: tx) - } - XCTAssertFalse(shouldShowRecoveryKeyReminder, "Don't show reminder if user just registered for backups") - } - - func testPreconditionsForRecoveryKeyMegaphone_firstReminder() throws { - db.write { tx in - backupSettingsStore.setBackupPlan(.free, tx: tx) - } - - let fifteenDaysAgo = Date().addingTimeInterval(-15 * 24 * 60 * 60) - db.write { tx in - backupSettingsStore.setLastBackupDate(fifteenDaysAgo, tx: tx) - } - - let shouldShowRecoveryKeyReminder = db.read { tx in - checkPreconditions(tx: tx) - } - XCTAssertTrue(shouldShowRecoveryKeyReminder, "Should show reminder if user registered long enough ago and hasn't seen a recovery key reminder yet") - } - - func testPreconditionsForRecoveryKeyMegaphone_alreadySeenFirstReminder() throws { - db.write { tx in - backupSettingsStore.setBackupPlan(.free, tx: tx) - } - - let fifteenDaysAgo = Date().addingTimeInterval(-15 * 24 * 60 * 60) - db.write { tx in - backupSettingsStore.setLastBackupDate(fifteenDaysAgo, tx: tx) - } - - db.write { tx in - backupSettingsStore.setLastRecoveryKeyReminderDate(Date(), tx: tx) - } - - let shouldShowRecoveryKeyReminder = db.read { tx in - checkPreconditions(tx: tx) - } - XCTAssertFalse(shouldShowRecoveryKeyReminder, "Don't show reminder if user has seen a recovery key reminder recently") - } - - func testPreconditionsForRecoveryKeyMegaphone_longEnoughAfterFirstReminder() throws { - db.write { tx in - backupSettingsStore.setBackupPlan(.free, tx: tx) - } - - let moreThanSixMonthsAgo = Date().addingTimeInterval(-190 * 24 * 60 * 60) - db.write { tx in - // This will also set first backup date if its nil - backupSettingsStore.setLastBackupDate(moreThanSixMonthsAgo, tx: tx) - } - - db.write { tx in - backupSettingsStore.setLastRecoveryKeyReminderDate(moreThanSixMonthsAgo, tx: tx) - } - - let shouldShowRecoveryKeyReminder = db.read { tx in - checkPreconditions(tx: tx) - } - XCTAssertTrue(shouldShowRecoveryKeyReminder, "Should show reminder if user registered long enough ago and hasn't seen a reminder in awhile") - } -} - -// MARK: - - -private extension BackupSettingsStore { - func lastBackupDate(tx: DBReadTransaction) -> Date? { - return lastBackupDetails(tx: tx)?.date - } - - func setLastBackupDate(_ date: Date, tx: DBWriteTransaction) { - setLastBackupDetails(date: date, backupFileSizeBytes: 1, backupMediaSizeBytes: 1, tx: tx) - } -}