TM-SGNL-iOS/SignalServiceKit/Megaphones/ExperienceUpgradeManifest.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

714 lines
25 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import Contacts
public enum ExperienceUpgradeManifest {
/// Prompts the user to create a PIN, if they did not create one during
/// registration.
///
/// Skipping a PIN is not user-selectable during registration, but is
/// possible if KBS returned errors.
case introducingPins
/// Prompts the user to enable notifications permissions.
case notificationPermissionReminder
/// Prompts the user to create a username.
case createUsernameReminder
/// Prompts the user according to the contained ``RemoteMegaphoneModel``.
///
/// Remote megaphones are fetched from the service, and expected to change
/// over time.
case remoteMegaphone(megaphone: RemoteMegaphoneModel)
/// Prompts the user about any "inactive" linked devices that will expire
/// soon.
case inactiveLinkedDeviceReminder
/// Prompts the user to enter their PIN, to help ensure they remember it.
///
/// Note that this upgrade stores state in external components, rather than
/// in an ``ExperienceUpgrade``.
case pinReminder
/// Prompts the user to enable contacts permissions.
case contactPermissionReminder
/// An unrecognized upgrade, which should generally be ignored/discarded.
///
/// This may represent a persisted ``ExperienceUpgrade`` record which refers
/// to an upgrade that has since been removed.
case unrecognized(uniqueId: String)
}
// MARK: - Codable
extension ExperienceUpgradeManifest: Codable {
public enum CodingKeys: String, CodingKey {
/// Keys to the unique ID identifying a manifest.
case uniqueId
/// Keys to the remote megaphone for a manifest. Only present if the
/// manifest represents a remote megaphone.
case remoteMegaphone
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let persistedUniqueId = try container.decode(String.self, forKey: .uniqueId)
let persistedRemoteMegaphone = try container.decodeIfPresent(RemoteMegaphoneModel.self, forKey: .remoteMegaphone)
self.init(uniqueId: persistedUniqueId, remoteMegaphone: persistedRemoteMegaphone)
owsAssertDebug(uniqueId == persistedUniqueId, "Persisted unique ID does not match deserialized model!")
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(uniqueId, forKey: .uniqueId)
if case .remoteMegaphone(let megaphone) = self {
try container.encode(megaphone, forKey: .remoteMegaphone)
}
}
}
// MARK: - From persisted unique IDs
extension ExperienceUpgradeManifest {
/// Instantiate an ``ExperienceUpgradeManifest`` from the unique ID of a
/// persisted ``ExperienceUpgrade`` which does not have a manifest.
///
/// This is only relevant for ``ExperienceUpgrade``s that were persisted
/// before the ``ExperienceUpgradeManifest`` was added.
static func makeLegacy(fromPersistedExperienceUpgradeUniqueId uniqueId: String) -> ExperienceUpgradeManifest {
ExperienceUpgradeManifest(uniqueId: uniqueId, remoteMegaphone: nil)
}
private init(uniqueId: String, remoteMegaphone: RemoteMegaphoneModel?) {
self = {
switch uniqueId {
case Self.introducingPins.uniqueId:
return .introducingPins
case Self.notificationPermissionReminder.uniqueId:
return .notificationPermissionReminder
case Self.createUsernameReminder.uniqueId:
return .createUsernameReminder
case Self.inactiveLinkedDeviceReminder.uniqueId:
return .inactiveLinkedDeviceReminder
case Self.pinReminder.uniqueId:
return .pinReminder
case Self.contactPermissionReminder.uniqueId:
return .contactPermissionReminder
default:
break
}
if let megaphone = remoteMegaphone {
return .remoteMegaphone(megaphone: megaphone)
}
return .unrecognized(uniqueId: uniqueId)
}()
}
}
// MARK: - Well-known, local manifests
extension ExperienceUpgradeManifest {
/// Contains upgrade manifests that are well-known to the app.
///
/// Examples of manifests _not_ listed here include upgrades that were once
/// well-known, but have since been removed.
static let wellKnownLocalUpgradeManifests: Set<ExperienceUpgradeManifest> = [
.introducingPins,
.notificationPermissionReminder,
.createUsernameReminder,
.inactiveLinkedDeviceReminder,
.pinReminder,
.contactPermissionReminder
]
}
// MARK: - Unique IDs
extension ExperienceUpgradeManifest {
/// The "unique ID" of this upgrade. Stable, and may be used for persistence.
var uniqueId: String {
switch self {
case .introducingPins:
// For historical compatibility, this experience has a unique ID
// that does not match the enum case.
return "009"
case .notificationPermissionReminder:
return "notificationPermissionReminder"
case .createUsernameReminder:
return "createUsernameReminder"
case .remoteMegaphone(let megaphone):
return megaphone.id
case .inactiveLinkedDeviceReminder:
return "inactiveLinkedDeviceReminder"
case .pinReminder:
return "pinReminder"
case .contactPermissionReminder:
return "contactPermissionReminder"
case .unrecognized(let uniqueId):
return uniqueId
}
}
}
extension ExperienceUpgradeManifest: Equatable {
public static func == (lhs: ExperienceUpgradeManifest, rhs: ExperienceUpgradeManifest) -> Bool {
lhs.uniqueId == rhs.uniqueId
}
}
extension ExperienceUpgradeManifest: Hashable {
public func hash(into hasher: inout Hasher) {
uniqueId.hash(into: &hasher)
}
}
// MARK: - Importance order
protocol ExperienceUpgradeSortable {
/// The relative "importance" of this upgrade - i.e., whether this or
/// another which of multiple upgrade should be preferred for presentation.
///
/// Lower values indicate higher importance. When comparing, ties in the
/// primary index are broken by the secondary index. Equal primary and
/// secondary indicies indicates equal importance.
///
/// These values are not expected to remain stable.
var importanceIndex: (primary: Int, secondary: Int) { get }
}
extension Sequence where Element: ExperienceUpgradeSortable {
/// Returns the elements sorted by importance order - i.e., each
/// element in the returned array should be preferred for presention over
/// its subsequent elements.
func sortedByImportance() -> [Element] {
sorted { lhs, rhs in
if lhs.importanceIndex.primary == rhs.importanceIndex.primary {
return lhs.importanceIndex.secondary < rhs.importanceIndex.secondary
}
return lhs.importanceIndex.primary < rhs.importanceIndex.primary
}
}
}
extension ExperienceUpgradeManifest: ExperienceUpgradeSortable {
var importanceIndex: (primary: Int, secondary: Int) {
switch self {
case .introducingPins:
return (0, 0)
case .notificationPermissionReminder:
return (1, 0)
case .createUsernameReminder:
return (2, 0)
case .remoteMegaphone(let megaphone):
// Remote megaphone manifests use higher numbers to indicate higher
// priority, so we should invert their priority here.
return (3, -1 * megaphone.manifest.priority)
case .inactiveLinkedDeviceReminder:
return (4, 0)
case .pinReminder:
return (5, 0)
case .contactPermissionReminder:
return (6, 0)
case .unrecognized:
return (Int.max, Int.max)
}
}
}
extension ExperienceUpgrade: ExperienceUpgradeSortable {
var importanceIndex: (primary: Int, secondary: Int) {
manifest.importanceIndex
}
}
// MARK: - Metadata
extension ExperienceUpgradeManifest {
/// Whether this upgrade should not be shown to brand-new users.
var skipForNewUsers: Bool {
switch self {
case
.introducingPins,
.createUsernameReminder,
.remoteMegaphone,
.inactiveLinkedDeviceReminder:
return false
case
.notificationPermissionReminder,
.pinReminder,
.contactPermissionReminder,
.unrecognized:
return true
}
}
/// Whether we should save state for this upgrade in an ``ExperienceUpgrade``
/// record. If we track state for this upgrade using other components, we
/// may not need to persist ``ExperienceUpgrade`` state.
var shouldSave: Bool {
switch self {
case
.introducingPins,
.pinReminder,
.unrecognized:
return false
case
.notificationPermissionReminder,
.createUsernameReminder,
.inactiveLinkedDeviceReminder,
.remoteMegaphone,
.contactPermissionReminder:
return true
}
}
/// Whether we should mark this upgrade's corresponding ``ExperienceUpgrade``
/// record as complete, if it exists. If we track state for this upgrade
/// using other components, we may not need to mark the ``ExperienceUpgrade``
/// as complete.
var shouldComplete: Bool {
switch self {
case
.introducingPins,
.notificationPermissionReminder,
.createUsernameReminder,
.inactiveLinkedDeviceReminder,
.pinReminder,
.contactPermissionReminder,
.unrecognized:
return false
case .remoteMegaphone:
return true
}
}
/// The interval after snoozing during which we should not show the upgrade.
func snoozeDuration(forSnoozeCount snoozeCount: UInt) -> TimeInterval {
guard snoozeCount > 0 else {
owsFailDebug("Asking for snooze duration, but snooze count is zero!")
return 0
}
switch self {
case
.introducingPins,
.pinReminder:
return 2 * kDayInterval
case
.notificationPermissionReminder,
.inactiveLinkedDeviceReminder:
return 3 * kDayInterval
case .createUsernameReminder:
// On snooze, never show again.
return .infinity
case .remoteMegaphone(let megaphone):
let daysToSnooze: UInt = {
// If we have snooze duration days as action data, get the
// appropriate number of days from there based on our snooze
// count. Otherwise, return a default value.
let snoozeDurationDays: [UInt]? = {
if
let primaryActionData = megaphone.manifest.primaryActionData,
case .snoozeDurationDays(let days) = primaryActionData
{
return days
} else if
let secondaryActionData = megaphone.manifest.secondaryActionData,
case .snoozeDurationDays(let days) = secondaryActionData
{
return days
}
return nil
}()
if
let snoozeDurationDays = snoozeDurationDays,
let lastDurationDays = snoozeDurationDays.last
{
// Safe to subtract from `snoozeCount`, since we checked for 0 above.
let snoozeDurationDaysIndex = snoozeCount - 1
return snoozeDurationDays[safe: Int(snoozeDurationDaysIndex)] ?? lastDurationDays
}
return 3
}()
return Double(daysToSnooze) * kDayInterval
case .contactPermissionReminder:
return 30 * kDayInterval
case .unrecognized:
return .infinity
}
}
/// The number of days this upgrade should be shown, starting from the
/// first time it is shown.
var numberOfDaysToShowFor: Int {
switch self {
case
.introducingPins,
.notificationPermissionReminder,
.createUsernameReminder,
.inactiveLinkedDeviceReminder,
.pinReminder,
.contactPermissionReminder:
return Int.max
case .remoteMegaphone(let megaphone):
return megaphone.manifest.showForNumberOfDays
case .unrecognized:
return 0
}
}
/// The interval immediately after registration during which we should not
/// show the upgrade.
private var delayAfterRegistration: TimeInterval {
switch self {
case
.notificationPermissionReminder,
.createUsernameReminder,
.inactiveLinkedDeviceReminder,
.contactPermissionReminder:
return kDayInterval
case .introducingPins:
return 2 * kHourInterval
case .remoteMegaphone(let megaphone):
guard let conditionalCheck = megaphone.manifest.conditionalCheck else {
return 0
}
switch conditionalCheck {
case .standardDonate:
return 7 * kDayInterval
case .internalUser:
return 0
case .unrecognized:
return .infinity
}
case .pinReminder:
return 8 * kHourInterval
case .unrecognized:
return .infinity
}
}
/// The date after which the upgrade should no longer be shown.
private var expirationDate: Date {
switch self {
case
.introducingPins,
.notificationPermissionReminder,
.createUsernameReminder,
.inactiveLinkedDeviceReminder,
.pinReminder,
.contactPermissionReminder:
return Date.distantFuture
case .remoteMegaphone(let megaphone):
return Date(timeIntervalSince1970: TimeInterval(megaphone.manifest.dontShowAfter))
case .unrecognized:
return Date.distantPast
}
}
/// Whether we should show this upgrade on linked devices.
private var showOnLinkedDevices: Bool {
switch self {
case
.introducingPins,
.pinReminder,
.inactiveLinkedDeviceReminder,
.contactPermissionReminder,
.unrecognized:
return false
case
.notificationPermissionReminder,
.createUsernameReminder:
return true
case
.remoteMegaphone:
// Controlled by conditional check
return true
}
}
}
// MARK: - Should we show this upgrade
extension ExperienceUpgradeManifest {
func shouldBeShown(transaction: SDSAnyReadTransaction) -> Bool {
if
let registrationDate = DependenciesBridge.shared.tsAccountManager.registrationDate(tx: transaction.asV2Read),
Date().timeIntervalSince(registrationDate) < delayAfterRegistration
{
// We have not waited long enough after registration to show this
// upgrade.
return false
}
guard Date() < expirationDate else {
// We should not show an expired upgrade.
return false
}
guard showOnLinkedDevices || DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction.asV2Read).isRegisteredPrimaryDevice else {
// We are a linked device, which should not show this upgrade.
return false
}
return Self.checkPreconditions(specificTo: self, transaction: transaction)
}
private static func checkPreconditions(specificTo enumCase: ExperienceUpgradeManifest, transaction: SDSAnyReadTransaction) -> Bool {
switch enumCase {
case .introducingPins:
return checkPreconditionsForIntroducingPins(transaction: transaction)
case .notificationPermissionReminder:
return checkPreconditionsForNotificationsPermissionsReminder()
case .createUsernameReminder:
return checkPreconditionsForCreateUsernameReminder(transaction: transaction)
case .remoteMegaphone(let megaphone):
return checkPreconditionsForRemoteMegaphone(megaphone, tx: transaction)
case .inactiveLinkedDeviceReminder:
return checkPreconditionsForInactiveLinkedDeviceReminder(tx: transaction)
case .pinReminder:
return checkPreconditionsForPinReminder(transaction: transaction)
case .contactPermissionReminder:
return checkPreconditionsForContactsPermissionReminder()
case .unrecognized:
return false
}
}
// MARK: Local megaphone preconditions
private static func checkPreconditionsForIntroducingPins(transaction: SDSAnyReadTransaction) -> Bool {
// The PIN setup flow requires an internet connection and you to not already have a PIN
if
SSKEnvironment.shared.reachabilityManagerRef.isReachable,
DependenciesBridge.shared.tsAccountManager.registrationState(tx: transaction.asV2Read).isRegisteredPrimaryDevice,
!DependenciesBridge.shared.svr.hasMasterKey(transaction: transaction.asV2Read)
{
return true
}
return false
}
private static func checkPreconditionsForNotificationsPermissionsReminder() -> Bool {
let (promise, future) = Promise<Bool>.pending()
DispatchQueue.global(qos: .userInitiated).async {
UNUserNotificationCenter.current().getNotificationSettings { settings in
future.resolve(settings.authorizationStatus == .authorized)
}
}
DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
guard promise.result == nil else { return }
future.reject(OWSGenericError("timeout fetching notification permissions"))
}
do {
return !(try promise.wait())
} catch {
Logger.warn("failed to query notification permission")
return false
}
}
private static func checkPreconditionsForCreateUsernameReminder(transaction: SDSAnyReadTransaction) -> Bool {
guard
DependenciesBridge.shared.localUsernameManager.usernameState(
tx: transaction.asV2Read
).isExplicitlyUnset
else {
// If we have a username, do not show the reminder.
return false
}
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
if tsAccountManager.phoneNumberDiscoverability(tx: transaction.asV2Read).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.asV2Read)
.timeIntervalSinceNow * -1
let requiredDelayAfterDisablingDiscovery: TimeInterval = 3 * kDayInterval
return timeIntervalSinceDisabledDiscovery > requiredDelayAfterDisablingDiscovery
}
private static func checkPreconditionsForInactiveLinkedDeviceReminder(tx: SDSAnyReadTransaction) -> Bool {
return DependenciesBridge.shared.inactiveLinkedDeviceFinder.hasInactiveLinkedDevice(tx: tx.asV2Read)
}
private static func checkPreconditionsForPinReminder(transaction: SDSAnyReadTransaction) -> Bool {
return SSKEnvironment.shared.ows2FAManagerRef.isDueForV2Reminder(transaction: transaction)
}
private static func checkPreconditionsForContactsPermissionReminder() -> Bool {
// FIXME: [Contacts, iOS 18] Do we really want to nag users who've granted limited access?
return CNContactStore.authorizationStatus(for: .contacts) != .authorized
}
// MARK: Remote megaphone preconditions
private static func checkPreconditionsForRemoteMegaphone(_ megaphone: RemoteMegaphoneModel, tx: SDSAnyReadTransaction) -> 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.asV2Read) else {
return false
}
guard RemoteConfig.isCountryCodeBucketEnabled(
csvString: megaphone.manifest.countries,
key: megaphone.manifest.id,
csvDescription: "remoteMegaphoneCountries_\(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: SDSAnyReadTransaction
) -> Bool {
guard let conditionalCheck else {
// Having no conditional check is valid.
return true
}
switch conditionalCheck {
case .standardDonate:
if
let localProfileBadgeInfo = SSKEnvironment.shared.profileManagerRef.localProfileBadgeInfo,
!localProfileBadgeInfo.isEmpty
{
// Fail the check if we currently have a badge.
return false
} else if
DependenciesBridge.shared.donationReceiptCredentialResultStore
.hasAnyPaymentsStillProcessing(tx: tx.asV2Read)
{
// 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 = 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
}
}
private extension RemoteMegaphoneModel.Manifest.Action {
var isRecognized: Bool {
if case .unrecognized = self {
return false
}
return true
}
}
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
}
}