Signal-iOS/SignalServiceKit/src/Util/RemoteConfigManager.swift

993 lines
36 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalCoreKit
@objc
public class RemoteConfig: BaseFlags {
// rather than interact with `config` directly, prefer encoding any string constants
// into a getter below...
fileprivate let isEnabledFlags: [String: Bool]
fileprivate let valueFlags: [String: AnyObject]
private let standardMediaQualityLevel: ImageQualityLevel?
private let paymentsDisabledRegions: PhoneNumberRegions
private let applePayDisabledRegions: PhoneNumberRegions
private let creditAndDebitCardDisabledRegions: PhoneNumberRegions
private let paypalDisabledRegions: PhoneNumberRegions
init(isEnabledFlags: [String: Bool],
valueFlags: [String: AnyObject]) {
self.isEnabledFlags = isEnabledFlags
self.valueFlags = valueFlags
self.standardMediaQualityLevel = Self.determineStandardMediaQualityLevel(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)
}
@objc
public static var kbs: Bool {
return isEnabled(.kbs)
}
private static let forceDisableUuidSafetyNumbers = true
@objc
public static var uuidSafetyNumbers: Bool {
guard !forceDisableUuidSafetyNumbers else { return false }
return isEnabled(.uuidSafetyNumbers)
}
@objc
public static var groupsV2MaxGroupSizeRecommended: UInt {
getUIntValue(forFlag: .groupsV2MaxGroupSizeRecommended, defaultValue: 151)
}
@objc
public static var groupsV2MaxGroupSizeHardLimit: UInt {
getUIntValue(forFlag: .groupsV2MaxGroupSizeHardLimit, defaultValue: 1001)
}
public static var groupsV2MaxBannedMembers: UInt {
groupsV2MaxGroupSizeHardLimit
}
@objc
public static var groupCalling: Bool {
return DebugFlags.forceGroupCalling || !isEnabled(.groupCallingKillSwitch)
}
@objc
public static var cdsSyncInterval: TimeInterval {
interval(.cdsSyncInterval, defaultInterval: kDayInterval * 2)
}
@objc
public static var automaticSessionResetKillSwitch: Bool {
return isEnabled(.automaticSessionResetKillSwitch)
}
@objc
public static var automaticSessionResetAttemptInterval: TimeInterval {
interval(.automaticSessionResetAttemptInterval, defaultInterval: kHourInterval)
}
@objc
public static var reactiveProfileKeyAttemptInterval: TimeInterval {
interval(.reactiveProfileKeyAttemptInterval, defaultInterval: kHourInterval)
}
@objc
public static var paymentsResetKillSwitch: Bool {
isEnabled(.paymentsResetKillSwitch)
}
public static var standardMediaQualityLevel: ImageQualityLevel? {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else { return nil }
return remoteConfig.standardMediaQualityLevel
}
public static var paymentsDisabledRegions: PhoneNumberRegions {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else { return [] }
return remoteConfig.paymentsDisabledRegions
}
public static var applePayDisabledRegions: PhoneNumberRegions {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else { return [] }
return remoteConfig.applePayDisabledRegions
}
public static var creditAndDebitCardDisabledRegions: PhoneNumberRegions {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else { return [] }
return remoteConfig.creditAndDebitCardDisabledRegions
}
public static var canDonateOneTimeWithApplePay: Bool {
!isEnabled(.applePayOneTimeDonationKillSwitch)
}
public static var canDonateGiftWithApplePay: Bool {
!isEnabled(.applePayGiftDonationKillSwitch)
}
public static var canDonateMonthlyWithApplePay: Bool {
!isEnabled(.applePayMonthlyDonationKillSwitch)
}
public static var canDonateOneTimeWithCreditOrDebitCard: Bool {
!isEnabled(.cardOneTimeDonationKillSwitch)
}
public static var canDonateGiftWithCreditOrDebitCard: Bool {
!isEnabled(.cardGiftDonationKillSwitch)
}
public static var canDonateMonthlyWithCreditOrDebitCard: Bool {
!isEnabled(.cardMonthlyDonationKillSwitch)
}
public static var canDonateOneTimeWithPaypal: Bool {
!isEnabled(.paypalOneTimeDonationKillSwitch)
}
public static var canDonateGiftWithPayPal: Bool {
!isEnabled(.paypalGiftDonationKillSwitch)
}
public static var canDonateMonthlyWithPaypal: Bool {
!isEnabled(.paypalMonthlyDonationKillSwitch)
}
public static var paypalDisabledRegions: PhoneNumberRegions {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else { return [] }
return remoteConfig.paypalDisabledRegions
}
private static func determineStandardMediaQualityLevel(valueFlags: [String: AnyObject]) -> ImageQualityLevel? {
let rawFlag: String = Flags.SupportedValuesFlags.standardMediaQualityLevel.rawFlag
guard
let csvString = valueFlags[rawFlag] as? String,
let stringValue = Self.countryCodeValue(csvString: csvString, csvDescription: rawFlag),
let uintValue = UInt(stringValue),
let defaultMediaQuality = ImageQualityLevel(rawValue: uintValue)
else {
return nil
}
return defaultMediaQuality
}
fileprivate static func parsePhoneNumberRegions(
valueFlags: [String: AnyObject],
flag: Flags.SupportedValuesFlags
) -> PhoneNumberRegions {
guard let valueList = valueFlags[flag.rawFlag] as? String else { return [] }
return PhoneNumberRegions(fromRemoteConfig: valueList)
}
@objc
public static var senderKeyKillSwitch: Bool {
isEnabled(.senderKeyKillSwitch)
}
@objc
public static var messageResendKillSwitch: Bool {
isEnabled(.messageResendKillSwitch)
}
@objc
public static var replaceableInteractionExpiration: TimeInterval {
interval(.replaceableInteractionExpiration, defaultInterval: kHourInterval)
}
@objc
public static var messageSendLogEntryLifetime: TimeInterval {
interval(.messageSendLogEntryLifetime, defaultInterval: 2 * kWeekInterval)
}
@objc
public static var donorBadgeDisplay: Bool {
DebugFlags.forceDonorBadgeDisplay || !isEnabled(.donorBadgeDisplayKillSwitch)
}
@objc
public static var stories: Bool {
if DebugFlags.forceStories {
return true
}
if isEnabled(.storiesKillSwitch) {
return false
}
return true
}
public static var inboundGroupRings: Bool {
DebugFlags.internalSettings || !isEnabled(.inboundGroupRingsKillSwitch)
}
public static var outboundGroupRings: Bool {
DebugFlags.internalSettings || isEnabled(.groupRings2)
}
public static var maxGroupCallRingSize: UInt {
getUIntValue(forFlag: .maxGroupCallRingSize, defaultValue: 16)
}
public static var enableAutoAPNSRotation: Bool {
return isEnabled(.enableAutoAPNSRotation, defaultValue: false)
}
/// The minimum length for a valid nickname, in Unicode codepoints.
public static var minNicknameLength: UInt32 {
getUInt32Value(forFlag: .minNicknameLength, defaultValue: 3)
}
/// The maximum length for a valid nickname, in Unicode codepoints.
public static var maxNicknameLength: UInt32 {
getUInt32Value(forFlag: .maxNicknameLength, defaultValue: 32)
}
// MARK: UInt values
private static func getUIntValue(
forFlag flag: Flags.SupportedValuesFlags,
defaultValue: UInt
) -> UInt {
getStringConvertibleValue(
forFlag: flag,
defaultValue: defaultValue
)
}
private static func getUInt32Value(
forFlag flag: Flags.SupportedValuesFlags,
defaultValue: UInt32
) -> UInt32 {
getStringConvertibleValue(
forFlag: flag,
defaultValue: defaultValue
)
}
private static func getStringConvertibleValue<V>(
forFlag flag: Flags.SupportedValuesFlags,
defaultValue: V
) -> V where V: LosslessStringConvertible {
guard AppReadiness.isAppReady else {
owsFailDebug("Storage is not yet ready.")
return defaultValue
}
guard let rawValue: AnyObject = value(flag) else {
return defaultValue
}
guard let stringValue = rawValue as? String else {
owsFailDebug("Unexpected value.")
return defaultValue
}
guard let value = V(stringValue) else {
owsFailDebug("Invalid value.")
return defaultValue
}
return value
}
// MARK: - Country code buckets
/// 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, csvDescription: String) -> Bool {
guard
let countryCodeValue = countryCodeValue(csvString: csvString, csvDescription: csvDescription),
let countEnabled = UInt64(countryCodeValue)
else {
return false
}
return isBucketEnabled(key: key, countEnabled: countEnabled, bucketSize: 1_000_000)
}
private static func isCountryCodeBucketEnabled(flag: Flags.SupportedValuesFlags, valueFlags: [String: AnyObject]) -> Bool {
let rawFlag = flag.rawFlag
guard let csvString = valueFlags[rawFlag] as? String else { return false }
return isCountryCodeBucketEnabled(csvString: csvString, key: rawFlag, csvDescription: rawFlag)
}
/// Given a CSV of `<country-code>:<value>` pairs, extract the `<value>`
/// corresponding to the current user's country.
private static func countryCodeValue(csvString: String, csvDescription: String) -> String? {
guard !csvString.isEmpty else { return nil }
// The value should always be a comma-separated list of country codes colon-separated
// from a value. There all may be an optional be a wildcard "*" country code that any
// unspecified country codes should use. If neither the local country code or the wildcard
// is specified, we assume the value is not set.
let countryCodeToValueMap = csvString
.components(separatedBy: ",")
.reduce(into: [String: String]()) { result, value in
let components = value.components(separatedBy: ":")
guard components.count == 2 else { return owsFailDebug("Invalid \(csvDescription) value \(value)") }
let countryCode = components[0]
let countryValue = components[1]
result[countryCode] = countryValue
}
guard !countryCodeToValueMap.isEmpty else { return nil }
guard let localE164 = TSAccountManager.shared.localNumber,
let localCountryCode = PhoneNumber(fromE164: localE164)?.getCountryCode()?.stringValue else {
owsFailDebug("Missing local number")
return nil
}
return countryCodeToValueMap[localCountryCode] ?? countryCodeToValueMap["*"]
}
private static func isBucketEnabled(key: String, countEnabled: UInt64, bucketSize: UInt64) -> Bool {
guard let uuid = TSAccountManager.shared.localUuid else {
owsFailDebug("Missing local UUID")
return false
}
return countEnabled > bucket(key: key, uuid: uuid, bucketSize: bucketSize)
}
static func bucket(key: String, uuid: UUID, bucketSize: UInt64) -> UInt64 {
guard var data = (key + ".").data(using: .utf8) else {
owsFailDebug("Failed to get data from key")
return 0
}
data.append(uuid.data)
guard let hash = Cryptography.computeSHA256Digest(data) else {
owsFailDebug("Failed to calculate hash")
return 0
}
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 static func interval(
_ flag: Flags.SupportedValuesFlags,
defaultInterval: TimeInterval
) -> TimeInterval {
guard let intervalString: String = value(flag),
let interval = TimeInterval(intervalString) else {
return defaultInterval
}
return interval
}
private static func isEnabled(_ flag: Flags.SupportedIsEnabledFlags, defaultValue: Bool = false) -> Bool {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else {
return defaultValue
}
return remoteConfig.isEnabledFlags[flag.rawFlag] ?? defaultValue
}
private static func value<T>(_ flag: Flags.SupportedValuesFlags) -> T? {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else {
return nil
}
guard let remoteObject = remoteConfig.valueFlags[flag.rawFlag] else {
return nil
}
guard let remoteValue = remoteObject as? T else {
owsFailDebug("Remote value has unexpected type: \(remoteObject)")
return nil
}
return remoteValue
}
@objc
public static func logFlags() {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else {
Logger.info("No cached config.")
return
}
let logFlag = { (prefix: String, key: String, value: Any?) in
if let value = value {
Logger.info("\(prefix): \(key) = \(value)", function: "")
} else {
Logger.info("\(prefix): \(key) = nil", function: "")
}
}
for flag in Flags.SupportedIsEnabledFlags.allCases {
let value = remoteConfig.isEnabledFlags[flag.rawFlag]
logFlag("Config.SupportedIsEnabled", flag.rawFlag, value)
}
for flag in Flags.StickyIsEnabledFlags.allCases {
let value = remoteConfig.isEnabledFlags[flag.rawFlag]
logFlag("Config.StickyIsEnabled", flag.rawFlag, value)
}
for flag in Flags.SupportedValuesFlags.allCases {
let value = remoteConfig.valueFlags[flag.rawFlag]
logFlag("Config.SupportedValues", flag.rawFlag, value)
}
for flag in Flags.StickyValuesFlags.allCases {
let value = remoteConfig.valueFlags[flag.rawFlag]
logFlag("Config.StickyValues", flag.rawFlag, value)
}
let flagMap = allFlags()
for key in flagMap.keys.sorted() {
let value = flagMap[key]
logFlag("Flag", key, value)
}
}
}
// MARK: -
private struct Flags {
static let prefix = "ios."
// Values defined in this array remain forever true once they are
// marked true regardless of the remote state.
enum StickyIsEnabledFlags: String, FlagType {
case uuidSafetyNumbers
}
// 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.
enum HotSwappableIsEnabledFlags: String, FlagType {
// This can't be empty, so we define a bogus case. Remove this if you add a flag here.
case __noHotSwappableIsEnabledFlags
}
// We filter the received config down to just the supported flags.
// This ensures if we have a sticky flag it doesn't get inadvertently
// set because we cached a value before it went public. e.g. if we set
// a sticky flag to 100% in beta then turn it back to 0% before going
// to production.
enum SupportedIsEnabledFlags: String, FlagType {
case kbs
case uuidSafetyNumbers
case groupCallingKillSwitch
case automaticSessionResetKillSwitch
case paymentsResetKillSwitch
case senderKeyKillSwitch
case messageResendKillSwitch
case donorBadgeDisplayKillSwitch
case groupRings2
case inboundGroupRingsKillSwitch
case storiesKillSwitch
case applePayOneTimeDonationKillSwitch
case applePayGiftDonationKillSwitch
case applePayMonthlyDonationKillSwitch
case cardOneTimeDonationKillSwitch
case cardGiftDonationKillSwitch
case cardMonthlyDonationKillSwitch
case paypalOneTimeDonationKillSwitch
case paypalGiftDonationKillSwitch
case paypalMonthlyDonationKillSwitch
case enableAutoAPNSRotation
case ringrtcNwPathMonitorTrialKillSwitch
}
// Values defined in this array remain set once they are
// set regardless of the remote state.
enum StickyValuesFlags: String, FlagType {
case groupsV2MaxGroupSizeRecommended
case groupsV2MaxGroupSizeHardLimit
}
// 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.
enum HotSwappableValuesFlags: String, FlagType {
case groupsV2MaxGroupSizeRecommended
case groupsV2MaxGroupSizeHardLimit
case automaticSessionResetAttemptInterval
case reactiveProfileKeyAttemptInterval
case paymentsDisabledRegions
case applePayDisabledRegions
case creditAndDebitCardDisabledRegions
case paypalDisabledRegions
case maxGroupCallRingSize
}
// We filter the received config down to just the supported values.
// This ensures if we have a sticky value it doesn't get inadvertently
// set because we cached a value before it went public. e.g. if we set
// a sticky value to X in beta then remove it before going to production.
enum SupportedValuesFlags: String, FlagType {
case groupsV2MaxGroupSizeRecommended
case groupsV2MaxGroupSizeHardLimit
case clientExpiration
case cdsSyncInterval
case automaticSessionResetAttemptInterval
case reactiveProfileKeyAttemptInterval
case standardMediaQualityLevel
case replaceableInteractionExpiration
case messageSendLogEntryLifetime
case paymentsDisabledRegions
case applePayDisabledRegions
case creditAndDebitCardDisabledRegions
case paypalDisabledRegions
case maxGroupCallRingSize
case minNicknameLength
case maxNicknameLength
}
}
// MARK: -
private protocol FlagType: CaseIterable {
var rawValue: String { get }
var rawFlag: String { get }
static var allRawFlags: [String] { get }
}
// MARK: -
private extension FlagType {
var rawFlag: String {
switch rawValue {
case "groupsV2MaxGroupSizeRecommended": return "global.groupsv2.maxGroupSize"
case "groupsV2MaxGroupSizeHardLimit": return "global.groupsv2.groupSizeHardLimit"
case "cdsSyncInterval": return "cds.syncInterval.seconds"
case "paymentsDisabledRegions": return "global.payments.disabledRegions"
case "applePayDisabledRegions": return "global.donations.apayDisabledRegions"
case "creditAndDebitCardDisabledRegions": return "global.donations.ccDisabledRegions"
case "paypalDisabledRegions": return "global.donations.paypalDisabledRegions"
case "maxGroupCallRingSize": return "global.calling.maxGroupCallRingSize"
case "minNicknameLength": return "global.nicknames.min"
case "maxNicknameLength": return "global.nicknames.max"
default: return Flags.prefix + rawValue
}
}
static var allRawFlags: [String] { allCases.map { $0.rawFlag } }
}
// MARK: -
@objc
public protocol RemoteConfigManager: AnyObject {
var cachedConfig: RemoteConfig? { get }
func warmCaches()
}
// MARK: -
@objc
public class StubbableRemoteConfigManager: NSObject, RemoteConfigManager {
public var cachedConfig: RemoteConfig?
public func warmCaches() {}
}
// MARK: -
@objc
public class ServiceRemoteConfigManager: NSObject, RemoteConfigManager {
let keyValueStore: SDSKeyValueStore = SDSKeyValueStore(collection: "RemoteConfigManager")
// MARK: -
private let hasWarmedCache = AtomicBool(false)
private var _cachedConfig = AtomicOptional<RemoteConfig>(nil)
@objc
public private(set) var cachedConfig: RemoteConfig? {
get {
if !hasWarmedCache.get() {
owsFailDebug("CachedConfig not yet set.")
}
return _cachedConfig.get()
}
set { _cachedConfig.set(newValue) }
}
@objc
public override init() {
super.init()
// The fetched config won't take effect until the *next* launch.
// That's not ideal, but we can't risk changing configs in the middle
// of an app lifetime.
AppReadiness.runNowOrWhenMainAppDidBecomeReadyAsync {
guard self.tsAccountManager.isRegistered else {
return
}
self.scheduleNextRefresh()
}
// Listen for registration state changes so we can fetch the config
// when the user registers. This will still not take effect until
// the *next* launch, but we'll have it ready to apply at that point.
NotificationCenter.default.addObserver(
self,
selector: #selector(registrationStateDidChange),
name: .registrationStateDidChange,
object: nil
)
}
// MARK: -
@objc
func registrationStateDidChange() {
AssertIsOnMainThread()
guard tsAccountManager.isRegistered else { return }
Logger.info("Refreshing and immediately applying new flags due to new registration.")
refresh().done(on: DispatchQueue.global()) {
self.cacheCurrent()
}.catch { error in
Logger.error("Failed to update remote config after registration change \(error)")
}
}
public func warmCaches() {
owsAssertDebug(GRDBSchemaMigrator.areMigrationsComplete)
cacheCurrent()
AppReadiness.runNowOrWhenAppWillBecomeReady {
RemoteConfig.logFlags()
}
}
private func cacheCurrent() {
var isEnabledFlags = [String: Bool]()
var valueFlags = [String: AnyObject]()
self.databaseStorage.read { transaction in
isEnabledFlags = self.keyValueStore.getRemoteConfigIsEnabledFlags(transaction: transaction) ?? [:]
valueFlags = self.keyValueStore.getRemoteConfigValueFlags(transaction: transaction) ?? [:]
}
if !isEnabledFlags.isEmpty || !valueFlags.isEmpty {
Logger.info("Loaded stored config. isEnabledFlags: \(isEnabledFlags), valueFlags: \(valueFlags)")
self.cachedConfig = RemoteConfig(isEnabledFlags: isEnabledFlags, valueFlags: valueFlags)
} else {
Logger.info("no stored remote config")
}
checkClientExpiration(valueFlags: valueFlags)
hasWarmedCache.set(true)
}
private static let refreshInterval = 2 * kHourInterval
private var refreshTimer: Timer?
private var lastAttempt: Date = .distantPast
private var consecutiveFailures: UInt = 0
private var nextPermittedAttempt: Date {
AssertIsOnMainThread()
let backoffDelay = OWSOperation.retryIntervalForExponentialBackoff(failureCount: consecutiveFailures)
let earliestPermittedAttempt = lastAttempt.addingTimeInterval(backoffDelay)
let lastSuccess = databaseStorage.read { keyValueStore.getLastFetched(transaction: $0) }
let nextScheduledRefresh = (lastSuccess ?? .distantPast).addingTimeInterval(Self.refreshInterval)
return max(earliestPermittedAttempt, nextScheduledRefresh)
}
private func scheduleNextRefresh() {
AssertIsOnMainThread()
refreshTimer?.invalidate()
refreshTimer = nil
let nextAttempt = nextPermittedAttempt
if nextAttempt.isBeforeNow {
refresh()
} else {
Logger.info("Scheduling remote config refresh for \(nextAttempt).")
refreshTimer = Timer.scheduledTimer(
withTimeInterval: nextAttempt.timeIntervalSinceNow,
repeats: false
) { [weak self] timer in
timer.invalidate()
self?.refresh()
}
}
}
private static func isValidValue(_ value: AnyObject) -> Bool {
// Discard Data for now; ParamParser can't auto-decode them.
if value as? String != nil {
return true
} else {
owsFailDebug("Unexpected value: \(type(of: value))")
return false
}
}
@discardableResult
private func refresh() -> Promise<Void> {
AssertIsOnMainThread()
Logger.info("Refreshing remote config.")
lastAttempt = Date()
return firstly(on: DispatchQueue.global()) {
self.serviceClient.getRemoteConfig()
}.done(on: DispatchQueue.global()) { (fetchedConfig: [String: RemoteConfigItem]) in
// Extract the _supported_ flags from the fetched config.
var isEnabledFlags = [String: Bool]()
var valueFlags = [String: AnyObject]()
fetchedConfig.forEach { (key: String, item: RemoteConfigItem) in
switch item {
case .isEnabled(let isEnabled):
if Flags.SupportedIsEnabledFlags.allRawFlags.contains(key) {
isEnabledFlags[key] = isEnabled
}
case .value(let value):
if Flags.SupportedValuesFlags.allRawFlags.contains(key) {
if Self.isValidValue(value) {
valueFlags[key] = value
} else {
owsFailDebug("Invalid value: \(value) \(type(of: value))")
}
}
}
}
// Hotswap any hotswappable flags.
var cachedIsEnabledFlags = self.cachedConfig?.isEnabledFlags ?? [:]
var cachedValueFlags = self.cachedConfig?.valueFlags ?? [:]
for flag in Flags.HotSwappableIsEnabledFlags.allRawFlags {
cachedIsEnabledFlags[flag] = isEnabledFlags[flag]
}
for flag in Flags.HotSwappableValuesFlags.allRawFlags {
cachedValueFlags[flag] = valueFlags[flag]
}
self.cachedConfig = RemoteConfig(isEnabledFlags: cachedIsEnabledFlags, valueFlags: cachedValueFlags)
Logger.info("Hotswapped new remoteConfig. isEnabledFlags: \(cachedIsEnabledFlags), valueFlags: \(cachedValueFlags)")
// Persist all flags in the database to be applied on next launch.
self.databaseStorage.write { transaction in
// Preserve any sticky flags.
if let existingConfig = self.keyValueStore.getRemoteConfigIsEnabledFlags(transaction: transaction) {
existingConfig.forEach { (key: String, value: Bool) in
// Preserve "is enabled" flags if they are sticky and already set.
if Flags.StickyIsEnabledFlags.allRawFlags.contains(key),
value == true {
isEnabledFlags[key] = value
}
}
}
if let existingConfig = self.keyValueStore.getRemoteConfigValueFlags(transaction: transaction) {
existingConfig.forEach { (key: String, value: AnyObject) in
// Preserve "value" flags if they are sticky and already set and missing from the fetched config.
if Flags.StickyValuesFlags.allRawFlags.contains(key),
valueFlags[key] == nil {
valueFlags[key] = value
}
}
}
self.keyValueStore.setRemoteConfigIsEnabledFlags(isEnabledFlags, transaction: transaction)
self.keyValueStore.setRemoteConfigValueFlags(valueFlags, transaction: transaction)
self.keyValueStore.setLastFetched(Date(), transaction: transaction)
self.checkClientExpiration(valueFlags: valueFlags)
}
// As a special case, persist RingRTC field trials. See comments in
// ``RingrtcFieldTrials`` for details.
RingrtcFieldTrials.saveNwPathMonitorTrialState(
isEnabled: {
let flag = Flags.SupportedIsEnabledFlags.ringrtcNwPathMonitorTrialKillSwitch
let isKilled = isEnabledFlags[flag.rawValue] ?? false
return !isKilled
}(),
in: CurrentAppContext().appUserDefaults()
)
self.consecutiveFailures = 0
Logger.info("Stored new remoteConfig. isEnabledFlags: \(isEnabledFlags), valueFlags: \(valueFlags)")
}.catch(on: DispatchQueue.main) { error in
Logger.error("error: \(error)")
self.consecutiveFailures += 1
}.ensure(on: DispatchQueue.main) {
self.scheduleNextRefresh()
}
}
}
// MARK: - Client Expiration
private extension ServiceRemoteConfigManager {
private struct DecodedMinimumVersion: Codable {
let string: String?
let enforcementDate: Date?
enum CodingKeys: String, CodingKey {
case string = "minVersion"
case enforcementDate = "iso8601"
}
}
private struct MinimumVersion: Equatable, CustomStringConvertible {
let string: String
let enforcementDate: Date
var description: String {
// We filter things like look like an ip address, but we don't
// want to filter the version string so we replace the dots
// before logging.
return "<MinimumVersion: \(string.replacingOccurrences(of: ".", with: "_")), \(enforcementDate)>"
}
}
func checkClientExpiration(valueFlags: [String: AnyObject]) {
var minimumVersions: [MinimumVersion]?
defer {
if let minimumVersions = minimumVersions {
Logger.info("Minimum client versions: \(minimumVersions)")
if let remoteExpirationDate = remoteExpirationDate(minimumVersions: minimumVersions) {
Logger.info("Setting client expiration date: \(remoteExpirationDate)")
AppExpiry.shared.setExpirationDateForCurrentVersion(remoteExpirationDate)
} else {
Logger.info("Clearing client expiration date")
AppExpiry.shared.setExpirationDateForCurrentVersion(nil)
}
}
}
guard let jsonString = valueFlags[Flags.SupportedValuesFlags.clientExpiration.rawFlag] as? String else {
Logger.info("Received empty clientExpiration, clearing cached value.")
minimumVersions = []
return
}
guard let valueData = jsonString.data(using: .utf8) else {
owsFailDebug("Failed to convert client expiration string to data, ignoring.")
return
}
let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = .iso8601
do {
let decodedValues = try jsonDecoder.decode([DecodedMinimumVersion].self, from: valueData)
minimumVersions = decodedValues.compactMap { decodedValue in
// If the string or enforcement date are nil, the JSON provided in the
// remote config is in some way invalid. Probably, someone typoed a key.
// We don't want to ignore all client expiration because one value was
// wrong, so we just throw away that specific minimum version.
guard let string = decodedValue.string, let enforcementDate = decodedValue.enforcementDate else {
owsFailDebug("Received improperly formatted clientExpiration: \(jsonString)")
return nil
}
// The version should always be a complete long version, like: 3.16.0.1
// If it's not, we throw it away but still make sure to maintain all the
// valid minimum versions we received.
guard string.components(separatedBy: ".").count == 4 else {
owsFailDebug("Received invalid version string for clientExpiration: \(string)")
return nil
}
return MinimumVersion(string: string, enforcementDate: enforcementDate)
}
} catch {
owsFailDebug("Failed to decode client expiration (\(jsonString), \(error)), ignoring.")
}
}
private func remoteExpirationDate(minimumVersions: [MinimumVersion]) -> Date? {
var oldestEnforcementDate: Date?
let currentVersion4 = AppVersion.shared.currentAppVersion4
for minimumVersion in minimumVersions {
// We only are interested in minimum versions greater than our current version.
// Note: This method of comparison will only work as long as we always use
// *long* version strings (x.x.x.x). We enforce that `MinimumVersion` only
// uses long versions while decoding.
guard minimumVersion.string.compare(
currentVersion4,
options: .numeric
) == .orderedDescending else { continue }
if let enforcementDate = oldestEnforcementDate {
oldestEnforcementDate = min(enforcementDate, minimumVersion.enforcementDate)
} else {
oldestEnforcementDate = minimumVersion.enforcementDate
}
}
return oldestEnforcementDate
}
}
// MARK: -
private extension SDSKeyValueStore {
// MARK: - Remote Config Enabled Flags
private static var remoteConfigIsEnabledFlagsKey: String { "remoteConfigKey" }
func getRemoteConfigIsEnabledFlags(transaction: SDSAnyReadTransaction) -> [String: Bool]? {
guard let object = getObject(forKey: Self.remoteConfigIsEnabledFlagsKey,
transaction: transaction) else {
return nil
}
guard let remoteConfig = object as? [String: Bool] else {
owsFailDebug("unexpected object: \(object)")
return nil
}
return remoteConfig
}
func setRemoteConfigIsEnabledFlags(_ newValue: [String: Bool], transaction: SDSAnyWriteTransaction) {
return setObject(newValue,
key: Self.remoteConfigIsEnabledFlagsKey,
transaction: transaction)
}
// MARK: - Remote Config Value Flags
private static var remoteConfigValueFlagsKey: String { "remoteConfigValueFlags" }
func getRemoteConfigValueFlags(transaction: SDSAnyReadTransaction) -> [String: AnyObject]? {
guard let object = getObject(forKey: Self.remoteConfigValueFlagsKey, transaction: transaction) else {
return nil
}
guard let remoteConfig = object as? [String: AnyObject] else {
owsFailDebug("unexpected object: \(object)")
return nil
}
return remoteConfig
}
func setRemoteConfigValueFlags(_ newValue: [String: AnyObject], transaction: SDSAnyWriteTransaction) {
return setObject(newValue, key: Self.remoteConfigValueFlagsKey, transaction: transaction)
}
// MARK: - Last Fetched
var lastFetchedKey: String { "lastFetchedKey" }
func getLastFetched(transaction: SDSAnyReadTransaction) -> Date? {
return getDate(lastFetchedKey, transaction: transaction)
}
func setLastFetched(_ newValue: Date, transaction: SDSAnyWriteTransaction) {
return setDate(newValue, key: lastFetchedKey, transaction: transaction)
}
}