Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Environment/RemoteConfigManager.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import CryptoKit
import Foundation
import LibSignalClient

public class RemoteConfig {

    public static var current: RemoteConfig {
        return SSKEnvironment.shared.remoteConfigManagerRef.currentConfig()
    }

    /// Difference between the last time the server says it is and the time our
    /// local device says it is. Add this to the local device time to get the
    /// "real" time according to the server.
    ///
    /// This will always be noisy; for one the server response takes variable
    /// time to get to us, so really this represents the time on the server when
    /// it crafted its response, not when we got it. And of course the local
    /// clock can change.
    let lastKnownClockSkew: TimeInterval

    fileprivate let valueFlags: [String: String]

    public let paymentsDisabledRegions: PhoneNumberRegions
    public let applePayDisabledRegions: PhoneNumberRegions
    public let creditAndDebitCardDisabledRegions: PhoneNumberRegions
    public let paypalDisabledRegions: PhoneNumberRegions
    public let sepaEnabledRegions: PhoneNumberRegions
    public let idealEnabledRegions: PhoneNumberRegions

    init(
        clockSkew: TimeInterval,
        valueFlags: [String: String],
    ) {
        self.lastKnownClockSkew = clockSkew
        self.valueFlags = valueFlags
        self.paymentsDisabledRegions = Self.parsePhoneNumberRegions(valueFlags: valueFlags, flag: .paymentsDisabledRegions)
        self.applePayDisabledRegions = Self.parsePhoneNumberRegions(valueFlags: valueFlags, flag: .applePayDisabledRegions)
        self.creditAndDebitCardDisabledRegions = Self.parsePhoneNumberRegions(valueFlags: valueFlags, flag: .creditAndDebitCardDisabledRegions)
        self.paypalDisabledRegions = Self.parsePhoneNumberRegions(valueFlags: valueFlags, flag: .paypalDisabledRegions)
        self.sepaEnabledRegions = Self.parsePhoneNumberRegions(valueFlags: valueFlags, flag: .sepaEnabledRegions)
        self.idealEnabledRegions = Self.parsePhoneNumberRegions(valueFlags: valueFlags, flag: .idealEnabledRegions)
    }

    fileprivate static var emptyConfig: RemoteConfig {
        RemoteConfig(clockSkew: 0, valueFlags: [:])
    }

    /// Merges new values into an existing config.
    ///
    /// - Parameter newValueFlags: If nil, `valueFlags` aren't changed (e.g.,
    /// the server gave us an HTTP 304 and we're reusing the existing config).
    /// If nonnil, non-hot-swappable flags are taken from `self.valueFlags` and
    /// all others are taken from `newValueFlags`.
    ///
    /// - Parameter newClockSkew: The new clock skew; always used. Even when
    /// `newValueFlags` is nil, the HTTP 304 response has a new clock skew.
    func merging(newValueFlags: [String: String]?, newClockSkew: TimeInterval) -> RemoteConfig {
        if var newValueFlags {
            for flag in IsEnabledFlag.allCases {
                if flag.isHotSwappable { continue }
                newValueFlags[flag.rawValue] = self.valueFlags[flag.rawValue]
            }
            for flag in ValueFlag.allCases {
                if flag.isHotSwappable { continue }
                newValueFlags[flag.rawValue] = self.valueFlags[flag.rawValue]
            }
            for flag in TimeGatedFlag.allCases {
                if flag.isHotSwappable { continue }
                newValueFlags[flag.rawValue] = self.valueFlags[flag.rawValue]
            }
            return RemoteConfig(clockSkew: newClockSkew, valueFlags: newValueFlags)
        } else {
            return RemoteConfig(clockSkew: newClockSkew, valueFlags: self.valueFlags)
        }
    }

    public func netConfig() -> [String: String] {
        return Dictionary(
            uniqueKeysWithValues: self.valueFlags
                .lazy
                .compactMap { (key: String, value: String) -> (key: String, value: String)? in
                    // Omit values that are false.
                    // TODO: Remove this once v2/config omits these by default.
                    if value == "false" {
                        return nil
                    }
                    guard let range = key.range(of: "ios.libsignal.", options: [.anchored]) else {
                        return nil
                    }
                    return (String(key[range.upperBound...]), value)
                },
        )
    }

    public var maxGroupSizeRecommended: UInt {
        getUIntValue(forFlag: .maxGroupSizeRecommended, defaultValue: 151)
    }

    public var maxGroupSizeHardLimit: UInt {
        getUIntValue(forFlag: .maxGroupSizeHardLimit, defaultValue: 1001)
    }

    public var maxGroupSizeBannedMembers: UInt {
        maxGroupSizeHardLimit
    }

    public var cdsSyncInterval: TimeInterval {
        interval(.cdsSyncInterval, defaultInterval: .day * 2)
    }

    public var automaticSessionResetKillSwitch: Bool {
        return isEnabled(.automaticSessionResetKillSwitch)
    }

    public var automaticSessionResetAttemptInterval: TimeInterval {
        interval(.automaticSessionResetAttemptInterval, defaultInterval: .hour)
    }

    public var reactiveProfileKeyAttemptInterval: TimeInterval {
        interval(.reactiveProfileKeyAttemptInterval, defaultInterval: .hour)
    }

    public var paymentsResetKillSwitch: Bool {
        isEnabled(.paymentsResetKillSwitch)
    }

    public var canDonateOneTimeWithApplePay: Bool {
        !isEnabled(.applePayOneTimeDonationKillSwitch)
    }

    public var canDonateGiftWithApplePay: Bool {
        !isEnabled(.applePayGiftDonationKillSwitch)
    }

    public var canDonateMonthlyWithApplePay: Bool {
        !isEnabled(.applePayMonthlyDonationKillSwitch)
    }

    public var canDonateOneTimeWithCreditOrDebitCard: Bool {
        !isEnabled(.cardOneTimeDonationKillSwitch)
    }

    public var canDonateGiftWithCreditOrDebitCard: Bool {
        !isEnabled(.cardGiftDonationKillSwitch)
    }

    public var canDonateMonthlyWithCreditOrDebitCard: Bool {
        !isEnabled(.cardMonthlyDonationKillSwitch)
    }

    public var canDonateOneTimeWithPaypal: Bool {
        !isEnabled(.paypalOneTimeDonationKillSwitch)
    }

    public var canDonateGiftWithPayPal: Bool {
        !isEnabled(.paypalGiftDonationKillSwitch)
    }

    public var canDonateMonthlyWithPaypal: Bool {
        !isEnabled(.paypalMonthlyDonationKillSwitch)
    }

    public func standardMediaQualityLevel(callingCode: Int?) -> ImageQualityLevel? {
        guard
            let csvString = self.value(.standardMediaQualityLevel),
            let stringValue = Self.countryCodeValue(csvString: csvString, callingCode: callingCode),
            let uintValue = UInt(stringValue),
            let defaultMediaQuality = ImageQualityLevel(rawValue: uintValue)
        else {
            return nil
        }
        return defaultMediaQuality
    }

    fileprivate static func parsePhoneNumberRegions(
        valueFlags: [String: String],
        flag: ValueFlag,
    ) -> PhoneNumberRegions {
        guard let valueList = valueFlags[flag.rawValue] else { return [] }
        return PhoneNumberRegions(fromRemoteConfig: valueList)
    }

    public var messageResendKillSwitch: Bool {
        isEnabled(.messageResendKillSwitch)
    }

    public var replaceableInteractionExpiration: TimeInterval {
        interval(.replaceableInteractionExpiration, defaultInterval: .hour)
    }

    public var messageSendLogEntryLifetime: TimeInterval {
        interval(.messageSendLogEntryLifetime, defaultInterval: 2 * .week)
    }

    public var maxSenderKeyAge: TimeInterval {
        return Double(getStringConvertibleValue(forFlag: .maxSenderKeyAge, defaultValue: 2 * UInt64.weekInMs)) / 1000
    }

    public var maxGroupCallRingSize: UInt {
        getUIntValue(forFlag: .maxGroupCallRingSize, defaultValue: 16)
    }

    public var enableAutoAPNSRotation: Bool {
        return isEnabled(.enableAutoAPNSRotation, defaultValue: false)
    }

    /// The minimum length for a valid nickname, in Unicode codepoints.
    public var minNicknameLength: UInt32 {
        getUInt32Value(forFlag: .minNicknameLength, defaultValue: 3)
    }

    /// The maximum length for a valid nickname, in Unicode codepoints.
    public var maxNicknameLength: UInt32 {
        getUInt32Value(forFlag: .maxNicknameLength, defaultValue: 32)
    }

    /// Most of our code uses UInt32; add a large bound smaller than that.
    private static let attachmentHardLimit: UInt64 = 1_610_612_736

    public var attachmentMaxEncryptedBytes: UInt64 {
        return min(Self.attachmentHardLimit, getUInt64Value(
            forFlag: .attachmentMaxEncryptedBytes,
            defaultValue: 100 * 1024 * 1024,
        ))
    }

    public var attachmentMaxEncryptedReceiveBytes: UInt64 {
        let maxEncryptedBytes = self.attachmentMaxEncryptedBytes
        return min(Self.attachmentHardLimit, getUInt64Value(
            forFlag: .attachmentMaxEncryptedReceiveBytes,
            defaultValue: maxEncryptedBytes + maxEncryptedBytes / 4,
        ))
    }

    public var videoAttachmentMaxEncryptedBytes: UInt64 {
        return min(Self.attachmentHardLimit, getUInt64Value(
            forFlag: .videoAttachmentMaxEncryptedBytes,
            defaultValue: self.attachmentMaxEncryptedBytes,
        ))
    }

    public var backupAttachmentMaxEncryptedBytes: UInt64 {
        return min(Self.attachmentHardLimit, getUInt64Value(
            forFlag: .backupAttachmentMaxEncryptedBytes,
            defaultValue: self.attachmentMaxEncryptedBytes,
        ))
    }

    public var backupMaxThumbnailFileSize: UInt32 {
        return getUInt32Value(
            forFlag: .maxThumbnailFileSizeBytes,
            defaultValue: AttachmentThumbnailQuality.backupThumbnailMaxSizeBytes,
        )
    }

    public var backupListMediaDefaultRefreshInterval: TimeInterval {
        let defaultValue: UInt64
        if BuildFlags.Backups.useLowerDefaultListMediaRefreshInterval {
            defaultValue = .dayInMs
        } else {
            defaultValue = .weekInMs
        }

        let intervalMs = getUInt64Value(forFlag: .backupListMediaDefaultRefreshIntervalMs, defaultValue: defaultValue)
        return TimeInterval(intervalMs) / 1000
    }

    public var backupListMediaOutOfQuotaRefreshInterval: TimeInterval {
        let intervalMs = getUInt64Value(forFlag: .backupListMediaOutOfQuotaRefreshIntervalMs, defaultValue: .dayInMs)
        return TimeInterval(intervalMs) / 1000
    }

    /// How many successful calls per million should show a call quality survey for the user's region
    public func callQualitySurveyPPM(localIdentifiers: LocalIdentifiers) -> UInt64 {
        let defaultValue: UInt64 = 10_000
        let string = Self.countryCodeBucketValue(
            csvString: getStringConvertibleValue(
                forFlag: .callQualitySurveyPPM,
                defaultValue: "*:\(defaultValue)",
            ),
            localIdentifiers: localIdentifiers,
        )
        guard let string else { return defaultValue }
        return UInt64(string) ?? defaultValue
    }

    public var mediaTierFallbackCdnNumber: UInt32 {
        getUInt32Value(forFlag: .mediaTierFallbackCdnNumber, defaultValue: 3)
    }

    public var enableGifSearch: Bool {
        return isEnabled(.enableGifSearch, defaultValue: true)
    }

    public var shouldCheckForServiceExtensionFailures: Bool {
        return !isEnabled(.serviceExtensionFailureKillSwitch)
    }

    public var groupTerminateReceiveEnabled: Bool {
        guard BuildFlags.GroupTerminate.receive else {
            return false
        }
        return !isEnabled(.groupTerminateReceiveKillSwitch, defaultValue: false)
    }

    public var backgroundRefreshInterval: TimeInterval {
        return TimeInterval(getUIntValue(
            forFlag: .backgroundRefreshInterval,
            defaultValue: UInt(TimeInterval.day),
        ))
    }

    public var messageQueueTime: TimeInterval {
        return interval(.messageQueueTimeInSeconds, defaultInterval: 45 * .day)
    }

    public var messageQueueTimeMs: UInt64 {
        return UInt64(messageQueueTime * Double(MSEC_PER_SEC))
    }

    public var backupsMegaphone: Bool {
        if BuildFlags.Backups.showMegaphones, !CurrentAppContext().isRunningTests {
            return true
        }

        return isEnabled(.backupsMegaphone)
    }

    public var pinnedThreadLimit: UInt {
        return getUIntValue(
            forFlag: .pinnedThreadLimit,
            defaultValue: 4,
        )
    }

    public var pinnedMessageLimit: UInt {
        return getUIntValue(
            forFlag: .pinnedMessageLimit,
            defaultValue: UInt(3),
        )
    }

    public var normalDeleteMaxAgeInSeconds: TimeInterval {
        return TimeInterval(getUInt64Value(
            forFlag: .normalDeleteMaxAgeInSeconds,
            defaultValue: UInt64.dayInMs / UInt64(MSEC_PER_SEC),
        ))
    }

    public var adminDeleteMaxAgeInSeconds: TimeInterval {
        return TimeInterval(getUInt64Value(
            forFlag: .adminDeleteMaxAgeInSeconds,
            defaultValue: UInt64.dayInMs / UInt64(MSEC_PER_SEC),
        ))
    }

    public var shouldUseDynamicSendMessageTimeout: Bool {
        return !isEnabled(.dynamicSendMessageTimeoutKillSwitch)
    }

    // MARK: - RingRTC

    public var ringrtcNwPathMonitorTrial: Bool {
        return !isEnabled(.ringrtcNwPathMonitorTrialKillSwitch, defaultValue: false)
    }

    public var ringrtcVp9Enabled: Bool {
        return isEnabled(.ringrtcVp9Enabled, defaultValue: false)
    }

    /// List of "device models" hardware identifiers allow-listed for which
    /// RingRTC should always offer encoding VP9. (overriden by the deny list)
    ///
    /// Compare entries to the value of `String(sysctlKey: "hw.machine")`.
    public var ringrtcVp9DeviceModelEnablelist: [String] {
        guard let valueFlag = valueFlags[ValueFlag.ringrtcVp9DeviceModelEnablelist.rawValue] else {
            return []
        }

        return valueFlag.split(separator: ".").map { String($0) }
    }

    /// List of "device models" hardware identifiers deny-listed for which
    /// RingRTC should avoid encoding VP9.
    ///
    /// Compare entries to the value of `String(sysctlKey: "hw.machine")`.
    public var ringrtcVp9DeviceModelDenylist: [String] {
        guard let valueFlag = valueFlags[ValueFlag.ringrtcVp9DeviceModelDenylist.rawValue] else {
            return []
        }

        return valueFlag.split(separator: ".").map { String($0) }
    }

    // MARK: -

#if TESTABLE_BUILD
    public var testHotSwappable: Bool? {
        if self.valueFlags[IsEnabledFlag.hotSwappable.rawValue] != nil {
            return isEnabled(.hotSwappable)
        }
        return nil
    }

    public var testNonSwappable: Bool? {
        if self.valueFlags[IsEnabledFlag.nonSwappable.rawValue] != nil {
            return isEnabled(.nonSwappable)
        }
        return nil
    }

    public var testHotSwappableValue: String? {
        return value(.hotSwappable)
    }

    public var testNonSwappableValue: String? {
        return value(.nonSwappable)
    }
#endif

    // MARK: UInt values

    private func getUIntValue(
        forFlag flag: ValueFlag,
        defaultValue: UInt,
    ) -> UInt {
        getStringConvertibleValue(
            forFlag: flag,
            defaultValue: defaultValue,
        )
    }

    private func getUInt32Value(
        forFlag flag: ValueFlag,
        defaultValue: UInt32,
    ) -> UInt32 {
        getStringConvertibleValue(
            forFlag: flag,
            defaultValue: defaultValue,
        )
    }

    private func getUInt64Value(
        forFlag flag: ValueFlag,
        defaultValue: UInt64,
    ) -> UInt64 {
        getStringConvertibleValue(
            forFlag: flag,
            defaultValue: defaultValue,
        )
    }

    private func getStringConvertibleValue<V>(
        forFlag flag: ValueFlag,
        defaultValue: V,
    ) -> V where V: LosslessStringConvertible {
        guard let stringValue: String = value(flag) else {
            return defaultValue
        }

        guard let value = V(stringValue) else {
            owsFailDebug("Invalid value.")
            return defaultValue
        }

        return value
    }

    // MARK: - Country code buckets

    private static func countryCodeBucketValue(csvString: String, localIdentifiers: LocalIdentifiers) -> String? {
        let phoneNumberUtil = SSKEnvironment.shared.phoneNumberUtilRef
        let callingCode = phoneNumberUtil.localCallingCode(localIdentifiers: localIdentifiers)
        return countryCodeValue(csvString: csvString, callingCode: callingCode)
    }

    /// Determine if a country-code-dependent flag is enabled for the current
    /// user, given a country-code CSV and key.
    ///
    /// - 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 {
        guard
            let countryCodeValue = countryCodeBucketValue(
                csvString: csvString,
                localIdentifiers: localIdentifiers,
            ),
            let countEnabled = UInt64(countryCodeValue)
        else {
            return false
        }

        return isBucketEnabled(key: key, countEnabled: countEnabled, bucketSize: 1_000_000, localAci: localIdentifiers.aci)
    }

    /// Given a CSV of `<country-code>:<value>` pairs, extract the `<value>`
    /// corresponding to the current user's country. The value should always be
    /// a comma-separated list of country codes colon-separated from a value.
    /// There may be an optional "*" wildcard country code that any unspecified
    /// country codes should use. If we can't parse the country code from our
    /// own phone number, we fall back to this wildcard value.
    private static func countryCodeValue(csvString: String, callingCode: Int?) -> String? {
        let callingCodeToValueMap = csvString
            .components(separatedBy: ",")
            .reduce(into: [String: String]()) { result, value in
                let components = value.components(separatedBy: ":")
                guard components.count == 2 else {
                    owsFailDebug("malformed country-code:value remote config value")
                    return
                }
                let callingCode = components[0]
                let countryValue = components[1]
                result[callingCode] = countryValue
            }

        return callingCode.flatMap({ callingCodeToValueMap[String($0)] }) ?? callingCodeToValueMap["*"]
    }

    private static func isBucketEnabled(key: String, countEnabled: UInt64, bucketSize: UInt64, localAci: Aci) -> Bool {
        return countEnabled > bucket(key: key, aci: localAci, bucketSize: bucketSize)
    }

    static func bucket(key: String, aci: Aci, bucketSize: UInt64) -> UInt64 {
        var data = Data((key + ".").utf8)

        data.append(aci.serviceIdBinary)

        let hash = Data(SHA256.hash(data: data))
        guard hash.count == 32 else {
            owsFailDebug("Hash has incorrect length \(hash.count)")
            return 0
        }

        // uuid_bucket = UINT64_FROM_FIRST_8_BYTES_BIG_ENDIAN(SHA256(rawFlag + "." + uuidBytes)) % bucketSize
        return UInt64(bigEndianData: hash.prefix(8))! % bucketSize
    }

    // MARK: -

    private func interval(_ flag: ValueFlag, defaultInterval: TimeInterval) -> TimeInterval {
        guard let intervalString: String = value(flag), let interval = TimeInterval(intervalString) else {
            return defaultInterval
        }
        return interval
    }

    private func isEnabled(_ flag: IsEnabledFlag, defaultValue: Bool = false) -> Bool {
        switch valueFlags[flag.rawValue] {
        case nil:
            return defaultValue
        case "1", "true", "TRUE":
            return true
        default:
            return false
        }
    }

    private func isEnabled(_ flag: TimeGatedFlag, defaultValue: Bool = false) -> Bool {
        guard let rawValue = valueFlags[flag.rawValue] else {
            return defaultValue
        }
        guard let epochValue = TimeInterval(rawValue) else {
            owsFailDebug("Invalid value: \(rawValue)")
            return defaultValue
        }
        let dateThreshold = Date(timeIntervalSince1970: epochValue)
        let correctedDate = Date().addingTimeInterval(self.lastKnownClockSkew)
        return correctedDate >= dateThreshold
    }

    fileprivate func value(_ flag: ValueFlag) -> String? {
        return valueFlags[flag.rawValue]
    }

    public func debugDescriptions() -> [String: String] {
        return self.valueFlags
    }

    public func logFlags() {
        for (key, value) in debugDescriptions().sorted(by: { $0.key < $1.key }) {
            Logger.info("RemoteConfig: \(key) = \(value)")
        }
    }
}

// MARK: - IsEnabledFlag

private enum IsEnabledFlag: String, FlagType {
    case applePayGiftDonationKillSwitch = "ios.applePayGiftDonationKillSwitch"
    case applePayMonthlyDonationKillSwitch = "ios.applePayMonthlyDonationKillSwitch"
    case applePayOneTimeDonationKillSwitch = "ios.applePayOneTimeDonationKillSwitch"
    case automaticSessionResetKillSwitch = "ios.automaticSessionResetKillSwitch"
    case backupsMegaphone = "ios.backupsMegaphone2"
    case cardGiftDonationKillSwitch = "ios.cardGiftDonationKillSwitch"
    case cardMonthlyDonationKillSwitch = "ios.cardMonthlyDonationKillSwitch"
    case cardOneTimeDonationKillSwitch = "ios.cardOneTimeDonationKillSwitch"
    case dynamicSendMessageTimeoutKillSwitch = "ios.dynamicSendMessageTimeoutKillSwitch"
    case enableAutoAPNSRotation = "ios.enableAutoAPNSRotation"
    case enableGifSearch = "global.gifSearch"
    case groupTerminateReceiveKillSwitch = "ios.groupTerminateReceiveKillSwitch"
    case messageResendKillSwitch = "ios.messageResendKillSwitch"
    case paymentsResetKillSwitch = "ios.paymentsResetKillSwitch"
    case paypalGiftDonationKillSwitch = "ios.paypalGiftDonationKillSwitch"
    case paypalMonthlyDonationKillSwitch = "ios.paypalMonthlyDonationKillSwitch"
    case paypalOneTimeDonationKillSwitch = "ios.paypalOneTimeDonationKillSwitch"
    case ringrtcNwPathMonitorTrialKillSwitch = "ios.ringrtcNwPathMonitorTrialKillSwitch"
    case ringrtcVp9Enabled = "ios.ringrtcVp9Enabled.2"
    case serviceExtensionFailureKillSwitch = "ios.serviceExtensionFailureKillSwitch"

#if TESTABLE_BUILD
    case hotSwappable = "test.hotSwappable.enabled"
    case nonSwappable = "test.nonSwappable.enabled"
#endif

    var isHotSwappable: Bool {
        switch self {
        case .applePayGiftDonationKillSwitch: false
        case .applePayMonthlyDonationKillSwitch: false
        case .applePayOneTimeDonationKillSwitch: false
        case .automaticSessionResetKillSwitch: false
        case .backupsMegaphone: true
        case .cardGiftDonationKillSwitch: false
        case .cardMonthlyDonationKillSwitch: false
        case .cardOneTimeDonationKillSwitch: false
        case .dynamicSendMessageTimeoutKillSwitch: true
        case .enableAutoAPNSRotation: false
        case .enableGifSearch: false
        case .groupTerminateReceiveKillSwitch: true
        case .messageResendKillSwitch: false
        case .paymentsResetKillSwitch: false
        case .paypalGiftDonationKillSwitch: false
        case .paypalMonthlyDonationKillSwitch: false
        case .paypalOneTimeDonationKillSwitch: false
        case .ringrtcNwPathMonitorTrialKillSwitch: true // cached during launch, so not hot-swapped in practice
        case .ringrtcVp9Enabled: true
        case .serviceExtensionFailureKillSwitch: true
#if TESTABLE_BUILD
        case .hotSwappable: true
        case .nonSwappable: false
#endif
        }
    }
}

private enum ValueFlag: String, FlagType {
    case adminDeleteMaxAgeInSeconds = "global.adminDeleteMaxAgeInSeconds"
    case applePayDisabledRegions = "global.donations.apayDisabledRegions"
    case attachmentMaxEncryptedBytes = "global.attachments.maxBytes"
    case attachmentMaxEncryptedReceiveBytes = "global.attachments.maxReceiveBytes"
    case automaticSessionResetAttemptInterval = "ios.automaticSessionResetAttemptInterval"
    case backgroundRefreshInterval = "ios.backgroundRefreshInterval"
    case backupAttachmentMaxEncryptedBytes = "ios.backupAttachments.maxBytes"
    case backupListMediaDefaultRefreshIntervalMs = "ios.backupListMediaDefaultRefreshIntervalMs"
    case backupListMediaOutOfQuotaRefreshIntervalMs = "ios.backupListMediaOutOfQuotaRefreshIntervalMs"
    case callQualitySurveyPPM = "ios.callQualitySurveyPPM"
    case cdsSyncInterval = "cds.syncInterval.seconds"
    case clientExpiration = "ios.clientExpiration"
    case creditAndDebitCardDisabledRegions = "global.donations.ccDisabledRegions"
    case idealEnabledRegions = "global.donations.idealEnabledRegions"
    case maxGroupCallRingSize = "global.calling.maxGroupCallRingSize"
    case maxGroupSizeHardLimit = "global.groupsv2.groupSizeHardLimit"
    case maxGroupSizeRecommended = "global.groupsv2.maxGroupSize"
    case maxNicknameLength = "global.nicknames.max"
    case maxSenderKeyAge = "ios.maxSenderKeyAge"
    case maxThumbnailFileSizeBytes = "global.backups.maxThumbnailFileSizeBytes"
    case mediaTierFallbackCdnNumber = "global.backups.mediaTierFallbackCdnNumber"
    case messageQueueTimeInSeconds = "global.messageQueueTimeInSeconds"
    case messageSendLogEntryLifetime = "ios.messageSendLogEntryLifetime"
    case minNicknameLength = "global.nicknames.min"
    case normalDeleteMaxAgeInSeconds = "global.normalDeleteMaxAgeInSeconds"
    case paymentsDisabledRegions = "global.payments.disabledRegions"
    case paypalDisabledRegions = "global.donations.paypalDisabledRegions"
    case pinnedMessageLimit = "global.pinned_message_limit"
    case pinnedThreadLimit = "global.pinned_chat_limit"
    case reactiveProfileKeyAttemptInterval = "ios.reactiveProfileKeyAttemptInterval"
    case replaceableInteractionExpiration = "ios.replaceableInteractionExpiration"
    case ringrtcVp9DeviceModelDenylist = "ios.ringrtcVp9DeviceModelDenylist"
    case ringrtcVp9DeviceModelEnablelist = "ios.ringrtcVp9DeviceModelEnablelist"
    case sepaEnabledRegions = "global.donations.sepaEnabledRegions"
    case standardMediaQualityLevel = "ios.standardMediaQualityLevel"
    case videoAttachmentMaxEncryptedBytes = "ios.videoAttachments.maxBytes"

#if TESTABLE_BUILD
    case hotSwappable = "test.hotSwappable.value"
    case nonSwappable = "test.nonSwappable.value"
#endif

    var isHotSwappable: Bool {
        switch self {
        case .adminDeleteMaxAgeInSeconds: true
        case .applePayDisabledRegions: true
        case .attachmentMaxEncryptedBytes: true
        case .attachmentMaxEncryptedReceiveBytes: true
        case .automaticSessionResetAttemptInterval: true
        case .backgroundRefreshInterval: true
        case .backupAttachmentMaxEncryptedBytes: true
        case .backupListMediaDefaultRefreshIntervalMs: true
        case .backupListMediaOutOfQuotaRefreshIntervalMs: true
        case .callQualitySurveyPPM: true
        case .cdsSyncInterval: false
        case .clientExpiration: true
        case .creditAndDebitCardDisabledRegions: true
        case .idealEnabledRegions: true
        case .maxGroupCallRingSize: true
        case .maxGroupSizeHardLimit: true
        case .maxGroupSizeRecommended: true
        case .maxNicknameLength: false
        case .maxSenderKeyAge: true
        case .maxThumbnailFileSizeBytes: true
        case .mediaTierFallbackCdnNumber: true
        case .messageQueueTimeInSeconds: false
        case .messageSendLogEntryLifetime: false
        case .minNicknameLength: false
        case .normalDeleteMaxAgeInSeconds: true
        case .paymentsDisabledRegions: true
        case .paypalDisabledRegions: true
        case .pinnedMessageLimit: true
        case .pinnedThreadLimit: true
        case .reactiveProfileKeyAttemptInterval: true
        case .replaceableInteractionExpiration: false
        case .ringrtcVp9DeviceModelDenylist: true
        case .ringrtcVp9DeviceModelEnablelist: true
        case .sepaEnabledRegions: true
        case .standardMediaQualityLevel: true
        case .videoAttachmentMaxEncryptedBytes: true
#if TESTABLE_BUILD
        case .hotSwappable: true
        case .nonSwappable: false
#endif
        }
    }
}

private enum TimeGatedFlag: String, FlagType {
    case __none

    var isHotSwappable: Bool {
        // These flags are time-gated. This means they are hot-swappable by
        // default. Even if we don't fetch a fresh remote config, we may cross the
        // time threshold while the app is in memory, updating the value from false
        // to true. As such we'll also hot swap every time gated flag.
        return true
    }
}

// MARK: -

private protocol FlagType: CaseIterable {
    // Values defined in this array will update while the app is running, as
    // soon as we fetch an update to the remote config. They will not wait for
    // an app restart.
    var isHotSwappable: Bool { get }
}

// MARK: -

public protocol RemoteConfigProvider {
    func currentConfig() -> RemoteConfig
    func warmCaches(tx: DBReadTransaction) -> RemoteConfig
}

// MARK: -

class RemoteConfigProviderImpl: RemoteConfigProvider {
    private let tsAccountManager: any TSAccountManager

    fileprivate let keyValueStore: KeyValueStore

    init(tsAccountManager: any TSAccountManager) {
        self.tsAccountManager = tsAccountManager

        self.keyValueStore = KeyValueStore(collection: "RemoteConfigManager")
    }

    private let _cachedConfig = AtomicValue<RemoteConfig?>(nil, lock: .init())
    private var cachedConfig: RemoteConfig? {
        let result = _cachedConfig.get()
        owsAssertDebug(result != nil, "cachedConfig not yet set.")
        return result
    }

    func currentConfig() -> RemoteConfig {
        return cachedConfig ?? .emptyConfig
    }

    fileprivate func updateCachedConfig(_ updateBlock: (RemoteConfig?) -> RemoteConfig) -> RemoteConfig {
        return _cachedConfig.update { mutableValue in
            let newValue = updateBlock(mutableValue)
            mutableValue = newValue
            return newValue
        }
    }

    func warmCaches(tx: DBReadTransaction) -> RemoteConfig {
        let (clockSkew, valueFlags) = { () -> (TimeInterval?, [String: String]?) in
            guard self.tsAccountManager.registrationState(tx: tx).isRegistered else {
                return (nil, nil)
            }
            let valueFlags = RemoteConfigStore(keyValueStore: self.keyValueStore).loadValueFlags(tx: tx)
            guard let valueFlags else {
                return (nil, nil)
            }
            let clockSkew = self.keyValueStore.getLastKnownClockSkew(transaction: tx)
            return (clockSkew, valueFlags)
        }()

        return updateCachedConfig { oldConfig in
            if let oldConfig {
                // If we're calling warmCaches for the second or later time, we can only
                // update the flags that are hot-swappable.
                return oldConfig.merging(newValueFlags: valueFlags ?? [:], newClockSkew: clockSkew ?? 0)
            } else {
                // If we're calling warmCaches for first time, we can set hot swappable and
                // non-hot swappable flags.
                return RemoteConfig(clockSkew: clockSkew ?? 0, valueFlags: valueFlags ?? [:])
            }
        }
    }
}

// MARK: -

#if TESTABLE_BUILD

public class MockRemoteConfigProvider: RemoteConfigProvider {
    public func warmCaches(tx: DBReadTransaction) -> RemoteConfig { _currentConfig }
    public var _currentConfig: RemoteConfig = .emptyConfig
    public func currentConfig() -> RemoteConfig { _currentConfig }
}

#endif

// MARK: -

public protocol RemoteConfigManager: RemoteConfigProvider {
    /// Refresh the remote config from the server if it's been too long since we
    /// last fetched it.
    func refreshIfNeeded() async throws
}

// MARK: -

#if TESTABLE_BUILD

public class StubbableRemoteConfigManager: MockRemoteConfigProvider, RemoteConfigManager {
    public func refreshIfNeeded() async throws {}
}

#endif

// MARK: -

public class RemoteConfigManagerImpl: RemoteConfigManager {
    private let appExpiry: AppExpiry
    private let appReadiness: AppReadiness
    private let dateProvider: DateProvider
    private let db: any DB
    private let keyValueStore: KeyValueStore
    private let net: Net
    private let networkManager: NetworkManager
    private let remoteConfigProvider: RemoteConfigProviderImpl
    private let tsAccountManager: TSAccountManager

    // MARK: -

    init(
        appExpiry: AppExpiry,
        appReadiness: AppReadiness,
        dateProvider: @escaping DateProvider,
        db: any DB,
        net: Net,
        networkManager: NetworkManager,
        remoteConfigProvider: RemoteConfigProviderImpl,
        tsAccountManager: TSAccountManager,
    ) {
        self.appExpiry = appExpiry
        self.appReadiness = appReadiness
        self.dateProvider = dateProvider
        self.db = db
        self.keyValueStore = remoteConfigProvider.keyValueStore
        self.net = net
        self.networkManager = networkManager
        self.remoteConfigProvider = remoteConfigProvider
        self.tsAccountManager = tsAccountManager

        appReadiness.runNowOrWhenMainAppDidBecomeReadyAsync {
            self.refreshRepeatedlyIfNeeded(forceInitialRefreshImmediately: false)

            NotificationCenter.default.addObserver(
                self,
                selector: #selector(self.registrationStateDidChange),
                name: .registrationStateDidChange,
                object: nil,
            )
        }
    }

    public func warmCaches(tx: DBReadTransaction) -> RemoteConfig {
        return remoteConfigProvider.warmCaches(tx: tx)
    }

    public func currentConfig() -> RemoteConfig {
        return remoteConfigProvider.currentConfig()
    }

    // MARK: -

    @objc
    @MainActor
    private func registrationStateDidChange() {
        AssertIsOnMainThread()

        Logger.info("Forcing a refresh because the registration state changed.")
        self.refreshRepeatedlyIfNeeded(forceInitialRefreshImmediately: true)
    }

    private static let refreshInterval: TimeInterval = 2 * .hour
    private let refreshTaskQueue = ConcurrentTaskQueue(concurrentLimit: 1)

    @MainActor
    private var refreshTask: Task<Void, any Error>?

    @MainActor
    private func refreshRepeatedlyIfNeeded(forceInitialRefreshImmediately: Bool) {
        self.refreshTask?.cancel()
        guard self.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
            return
        }
        self.refreshTask = Task {
            try await self.refreshRepeatedly(forceInitialRefreshImmediately: forceInitialRefreshImmediately)
        }
    }

    private func refreshRepeatedly(forceInitialRefreshImmediately: Bool) async throws {
        var refreshImmediately = forceInitialRefreshImmediately
        while true {
            try Task.checkCancellation()

            let nextFetchDate = self.fetchNextFetchDate()
            let fetchDelay = nextFetchDate.timeIntervalSince(self.dateProvider())
            if !refreshImmediately, fetchDelay > 0 {
                try await Task.sleep(nanoseconds: fetchDelay.clampedNanoseconds)
            }
            refreshImmediately = false

            try await Retry.performWithBackoff(
                maxAttempts: Int.max,
                maxAverageBackoff: 14.1 * .minute,
            ) {
                do {
                    try await self.refreshIfNeeded()
                } catch {
                    // Treat all failures as retryable. They all *should* be retryable.
                    throw OWSRetryableError()
                }
            }
        }
    }

    private func fetchNextFetchDate() -> Date {
        let lastFetchDate = self.db.read { self.keyValueStore.getLastFetched(transaction: $0) }
        return (lastFetchDate ?? .distantPast).addingTimeInterval(Self.refreshInterval)
    }

    public func refreshIfNeeded() async throws {
        try await refreshTaskQueue.run {
            let nextFetchDate = self.fetchNextFetchDate()
            guard self.dateProvider() > nextFetchDate else {
                return
            }

            do {
                try await self._refresh()
                // We expect `_refresh` to update `keyValueStore.lastFetched`, so add a
                // check to ensure that it does.
                owsPrecondition(self.fetchNextFetchDate() != nextFetchDate)
            } catch {
                Logger.warn("\(error)")
                throw error
            }
        }
    }

    /// should only be called within `refreshTaskQueue`
    private func _refresh() async throws {
        let (valueFlags, headers) = try await fetchRemoteConfig()

        if valueFlags == nil {
            Logger.info("Fetched a new remote config but the values haven't changed.")
        }

        let serverEpochTimeMs = headers["x-signal-timestamp"].flatMap(UInt64.init(_:))
        owsAssertDebug(serverEpochTimeMs != nil, "Must have X-Signal-Timestamp.")

        let clockSkew: TimeInterval
        if let serverEpochTimeMs {
            let dateAccordingToServer = Date(timeIntervalSince1970: TimeInterval(serverEpochTimeMs) / 1000)
            clockSkew = dateAccordingToServer.timeIntervalSince(Date())
        } else {
            clockSkew = 0
        }

        // Persist all flags in the database to be applied on next launch.

        await self.db.awaitableWrite { transaction in
            self.keyValueStore.setClockSkew(clockSkew, transaction: transaction)
            if let valueFlags {
                self.keyValueStore.removeRemoteConfigIsEnabledFlags(tx: transaction)
                self.keyValueStore.setRemoteConfigValueFlags(valueFlags, transaction: transaction)
                self.keyValueStore.removeRemoteConfigTimeGatedFlags(tx: transaction)
                self.keyValueStore.setETag(headers["etag"], tx: transaction)
            }
            self.keyValueStore.setLastFetched(Date(), transaction: transaction)
        }

        // This has hot-swappable new values and non-hot-swappable old values.
        let mergedConfig = remoteConfigProvider.updateCachedConfig { oldConfig in
            return (oldConfig ?? .emptyConfig).merging(newValueFlags: valueFlags, newClockSkew: clockSkew)
        }

        await checkClientExpiration(valueFlag: mergedConfig.value(.clientExpiration))

        net.setRemoteConfig(mergedConfig.netConfig(), buildVariant: BuildFlags.netBuildVariant)

        mergedConfig.logFlags()
    }

    // MARK: -

    private struct RemoteConfigurationResponse: Decodable {
        var config: [String: String]
    }

    private func fetchRemoteConfig() async throws -> ([String: String]?, HttpHeaders) {
        let oldETag = self.db.read { tx in self.keyValueStore.getETag(tx: tx) }

        let request = OWSRequestFactory.getRemoteConfigRequest(eTag: oldETag)
        do {
            let response = try await networkManager.asyncRequest(request)

            let result = try JSONDecoder().decode(RemoteConfigurationResponse.self, from: response.responseBodyData ?? Data())

            return (result.config, response.headers)
        } catch OWSHTTPError.serviceResponse(let serviceResponse) where serviceResponse.responseStatus == 304 {
            return (nil, serviceResponse.responseHeaders)
        }
    }

    // MARK: - Client Expiration

    private struct MinimumVersion: Decodable, CustomDebugStringConvertible {
        let mustBeAtLeastVersion: AppVersionNumber4
        let enforcementDate: Date

        enum CodingKeys: String, CodingKey {
            case mustBeAtLeastVersion = "minVersion"
            case enforcementDate = "iso8601"
        }

        var debugDescription: String {
            return "<MinimumVersion \(mustBeAtLeastVersion) @ \(enforcementDate)>"
        }
    }

    private func checkClientExpiration(valueFlag: String?) async {
        if let minimumVersions = parseClientExpiration(valueFlag: valueFlag) {
            await appExpiry.setExpirationDateForCurrentVersion(remoteExpirationDate(from: minimumVersions), now: dateProvider(), db: db)
        } else {
            // If it's not valid, there's a typo in the config, err on the safe side
            // and leave it alone.
        }
    }

    private func parseClientExpiration(valueFlag: String?) -> [MinimumVersion]? {
        guard let valueFlag = valueFlag?.nilIfEmpty else {
            return []
        }

        do {
            let jsonDecoder = JSONDecoder()
            jsonDecoder.dateDecodingStrategy = .iso8601
            return try jsonDecoder.decode([MinimumVersion].self, from: Data(valueFlag.utf8))
        } catch {
            owsFailDebug("Failed to decode client expiration (\(valueFlag), \(error)), ignoring.")
            return nil
        }
    }

    private func remoteExpirationDate(from minimumVersions: [MinimumVersion]) -> Date? {
        let currentVersion = AppVersionImpl.shared.currentAppVersion4
        // We only consider the requirements we don't already satisfy.
        return minimumVersions.lazy
            .filter { currentVersion < $0.mustBeAtLeastVersion }.map { $0.enforcementDate }.min()
    }
}

// MARK: -

struct RemoteConfigStore {
    private let keyValueStore: KeyValueStore

    init(keyValueStore: KeyValueStore) {
        self.keyValueStore = keyValueStore
    }

    func loadValueFlags(tx: DBReadTransaction) -> [String: String]? {
        var result = self.keyValueStore.getRemoteConfigValueFlags(transaction: tx)

        // TODO: Remove these IsEnabled/TimeGated fallbacks after a while.
        // (Doing so will reset "IsEnabled" flags for long-inactive users, but
        // that's fine because they should fetch new ones immediately.)
        if let isEnabledFlags = self.keyValueStore.getRemoteConfigIsEnabledFlags(transaction: tx) {
            result = result ?? [:]
            isEnabledFlags.forEach { result?[$0] = $1 ? "true" : "false" }
        }
        if let timeGatedFlags = self.keyValueStore.getRemoteConfigTimeGatedFlags(transaction: tx) {
            result = result ?? [:]
            timeGatedFlags.forEach { result?[$0] = String($1.timeIntervalSince1970) }
        }

        return result
    }
}

// MARK: -

private extension KeyValueStore {

    func removeRemoteConfigIsEnabledFlags(tx: DBWriteTransaction) {
        removeValue(forKey: Self.remoteConfigIsEnabledFlagsKey, transaction: tx)
    }

    func removeRemoteConfigTimeGatedFlags(tx: DBWriteTransaction) {
        removeValue(forKey: Self.remoteConfigTimeGatedFlagsKey, transaction: tx)
    }

    // MARK: - Remote Config Enabled Flags

    private static var remoteConfigIsEnabledFlagsKey: String { "remoteConfigKey" }

    func getRemoteConfigIsEnabledFlags(transaction: DBReadTransaction) -> [String: Bool]? {
        let decodedValue = getDictionary(
            Self.remoteConfigIsEnabledFlagsKey,
            keyClass: NSString.self,
            objectClass: NSNumber.self,
            transaction: transaction,
        ) as [String: NSNumber]?
        return decodedValue?.mapValues { $0.boolValue }
    }

    // MARK: - Remote Config Value Flags

    private static var remoteConfigValueFlagsKey: String { "remoteConfigValueFlags" }

    func getRemoteConfigValueFlags(transaction: DBReadTransaction) -> [String: String]? {
        return getDictionary(
            Self.remoteConfigValueFlagsKey,
            keyClass: NSString.self,
            objectClass: NSString.self,
            transaction: transaction,
        ) as [String: String]?
    }

    func setRemoteConfigValueFlags(_ newValue: [String: String], transaction: DBWriteTransaction) {
        return setObject(
            newValue as [NSString: NSString] as NSDictionary,
            key: Self.remoteConfigValueFlagsKey,
            transaction: transaction,
        )
    }

    // MARK: - Remote Config Time Gated Flags

    private static var remoteConfigTimeGatedFlagsKey: String { "remoteConfigTimeGatedFlags" }

    func getRemoteConfigTimeGatedFlags(transaction: DBReadTransaction) -> [String: Date]? {
        return getDictionary(
            Self.remoteConfigTimeGatedFlagsKey,
            keyClass: NSString.self,
            objectClass: NSDate.self,
            transaction: transaction,
        ) as [String: Date]?
    }

    // MARK: - Last Fetched

    var lastFetchedKey: String { "lastFetchedKey" }

    func getLastFetched(transaction: DBReadTransaction) -> Date? {
        return getDate(lastFetchedKey, transaction: transaction)
    }

    func setLastFetched(_ newValue: Date, transaction: DBWriteTransaction) {
        return setDate(newValue, key: lastFetchedKey, transaction: transaction)
    }

    // MARK: - Clock Skew

    var clockSkewKey: String { "clockSkewKey" }

    func getLastKnownClockSkew(transaction: DBReadTransaction) -> TimeInterval {
        return getDouble(clockSkewKey, defaultValue: 0, transaction: transaction)
    }

    func setClockSkew(_ newValue: TimeInterval, transaction: DBWriteTransaction) {
        return setDouble(newValue, key: clockSkewKey, transaction: transaction)
    }

    // MARK: - ETag

    private var eTagKey: String { "eTag" }

    func getETag(tx: DBReadTransaction) -> String? {
        return getString(eTagKey, transaction: tx)
    }

    func setETag(_ newValue: String?, tx: DBWriteTransaction) {
        setString(newValue, key: eTagKey, transaction: tx)
    }
}