Make Megaphone construction one step instead of two
This commit is contained in:
parent
28e9247793
commit
08ae6b3e07
@ -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 */,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: -
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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]) {
|
||||
|
||||
@ -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: [:])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user