Make Megaphone construction one step instead of two

This commit is contained in:
Sasha Weiss 2026-06-01 13:24:08 -07:00 committed by GitHub
parent 28e9247793
commit 08ae6b3e07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 539 additions and 929 deletions

View File

@ -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 = "<group>"; };
04127D902F23B3B000B4E95B /* CVCapsuleLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVCapsuleLabel.swift; sourceTree = "<group>"; };
041A5F062E05B3F000FAED05 /* BackupEnablementMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEnablementMegaphone.swift; sourceTree = "<group>"; };
041A5F082E05D3AC00FAED05 /* BackupEnablementReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEnablementReminderMegaphoneTests.swift; sourceTree = "<group>"; };
042223B92EDF30B300158556 /* OutgoingUnpinMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutgoingUnpinMessage.swift; sourceTree = "<group>"; };
0426758A2EC4D5BF00124C5F /* PinnedMessageIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessageIconView.swift; sourceTree = "<group>"; };
0426758F2EC529F500124C5F /* TSInfoMessage+PinnedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+PinnedMessage.swift"; sourceTree = "<group>"; };
@ -4224,7 +4221,6 @@
046092232FBCC7E700A8765F /* SafetyTipsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyTipsManager.swift; sourceTree = "<group>"; };
046926082E8EBAA800B1FC74 /* TSInfoMessage+Polls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+Polls.swift"; sourceTree = "<group>"; };
0477BE312FA4FC38002F9B47 /* TSReleaseNotesThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSReleaseNotesThread.swift; sourceTree = "<group>"; };
047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupKeyReminderMegaphoneTests.swift; sourceTree = "<group>"; };
0480EFFF2E57C513006CBB29 /* BackupsEnabledNotificationMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupsEnabledNotificationMegaphone.swift; sourceTree = "<group>"; };
0484CECD2F44B7BB009AB2CB /* AdminDeleteRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteRecord.swift; sourceTree = "<group>"; };
0484CECF2F44BCFB009AB2CB /* AdminDeleteManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdminDeleteManager.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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<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 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
}
}

View File

@ -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: -

View File

@ -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

View File

@ -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
}

View File

@ -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.

View File

@ -539,7 +539,7 @@ public class RemoteConfig {
///
/// - Parameter csvString: a CSV containing `<country-code>:<parts-per-million>` 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,

View File

@ -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<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
}
}
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
}
}

View File

@ -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 `<country-code>:<parts-per-million>` 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"

View File

@ -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]) {

View File

@ -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: [:])
}
}

View File

@ -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)
}
}