1308 lines
47 KiB
Swift
1308 lines
47 KiB
Swift
//
|
|
// 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 var isOptimizeStorageEnabled: Bool {
|
|
isEnabled(
|
|
.optimizeStorageEnabled,
|
|
defaultValue: BuildFlags.Backups.showOptimizeMedia,
|
|
)
|
|
}
|
|
|
|
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 requirePqRatio: Double {
|
|
getDoubleValue(forFlag: .requirePqRatio, defaultValue: 0.0)
|
|
}
|
|
|
|
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 ringrtcDredDuration: UInt8 {
|
|
getUInt8Value(forFlag: .ringrtcDredDuration, defaultValue: 0)
|
|
}
|
|
|
|
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 enableReflectorsTest: Bool {
|
|
guard BuildFlags.reflectorProxyTest else {
|
|
return false
|
|
}
|
|
return isEnabled(.enableReflectorsTest)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
public var isRemoteMuteSendEnabled: Bool {
|
|
return !isEnabled(.remoteMuteKillSwitch)
|
|
}
|
|
|
|
public var postRegistrationChangeNumberWaitingPeriodInSeconds: TimeInterval {
|
|
return TimeInterval(getUInt64Value(
|
|
forFlag: .postRegistrationWaitingPeriodSeconds,
|
|
defaultValue: UInt64.hourInMs / UInt64(MSEC_PER_SEC),
|
|
))
|
|
}
|
|
|
|
// 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 getUInt8Value(
|
|
forFlag flag: ValueFlag,
|
|
defaultValue: UInt8,
|
|
) -> UInt8 {
|
|
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,
|
|
)
|
|
}
|
|
|
|
// MARK: - Float values
|
|
|
|
private func getDoubleValue(
|
|
forFlag flag: ValueFlag,
|
|
defaultValue: Double,
|
|
) -> Double {
|
|
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
|
|
public 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 enableReflectorsTest = "ios.enableReflectorsTest"
|
|
case groupTerminateReceiveKillSwitch = "ios.groupTerminateReceiveKillSwitch"
|
|
case messageResendKillSwitch = "ios.messageResendKillSwitch"
|
|
case optimizeStorageEnabled = "ios.optimizeStorageEnabled"
|
|
case paymentsResetKillSwitch = "ios.paymentsResetKillSwitch"
|
|
case paypalGiftDonationKillSwitch = "ios.paypalGiftDonationKillSwitch"
|
|
case paypalMonthlyDonationKillSwitch = "ios.paypalMonthlyDonationKillSwitch"
|
|
case paypalOneTimeDonationKillSwitch = "ios.paypalOneTimeDonationKillSwitch"
|
|
case remoteMuteKillSwitch = "ios.remoteMuteKillSwitch"
|
|
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 .enableReflectorsTest: true
|
|
case .groupTerminateReceiveKillSwitch: true
|
|
case .messageResendKillSwitch: false
|
|
case .optimizeStorageEnabled: true
|
|
case .paymentsResetKillSwitch: false
|
|
case .paypalGiftDonationKillSwitch: false
|
|
case .paypalMonthlyDonationKillSwitch: false
|
|
case .paypalOneTimeDonationKillSwitch: false
|
|
case .remoteMuteKillSwitch: true
|
|
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 postRegistrationWaitingPeriodSeconds = "global.changeNumber.postRegistrationWaitingPeriodSeconds"
|
|
case reactiveProfileKeyAttemptInterval = "ios.reactiveProfileKeyAttemptInterval"
|
|
case replaceableInteractionExpiration = "ios.replaceableInteractionExpiration"
|
|
case requirePqRatio = "ios.requirePqRatio"
|
|
case ringrtcDredDuration = "ios.ringrtcDredDuration"
|
|
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 .postRegistrationWaitingPeriodSeconds: true
|
|
case .reactiveProfileKeyAttemptInterval: true
|
|
case .replaceableInteractionExpiration: false
|
|
case .requirePqRatio: true
|
|
case .ringrtcDredDuration: true
|
|
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
|
|
|
|
/// Forcibly refresh the remote config.
|
|
func forceRefresh() async throws
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
#if TESTABLE_BUILD
|
|
|
|
public class StubbableRemoteConfigManager: MockRemoteConfigProvider, RemoteConfigManager {
|
|
public func refreshIfNeeded() async throws {}
|
|
public func forceRefresh() 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 forceRefresh() async throws {
|
|
try await refreshTaskQueue.run {
|
|
do {
|
|
try await self._refresh()
|
|
} catch {
|
|
Logger.warn("\(error)")
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|