Signal-iOS/SignalServiceKit/Util/RemoteConfigManager.swift
Jordan Rose 54e4f752c9 Add OWSAuthConnectionUsingLibSignal
This is a *subclass* of OWSChatConnectionUsingLibSignal, mainly
because the authenticated/identified connection really does have
capabilities the unauth/unidentified one does not, and splitting those
out makes it easier to see which are which.

For now each websocket connection, identified and unidentified, has
its own libsignal ChatService instance, even though ChatService is
designed to manage both connections together. Later on in the
migration it'll make sense to consolidate them, but under the existing
structure of OWSChatConnection this is the easiest way to manage their
lifecycles.

Controlled by new RemoteConfig flag
"ios.experimentalTransportEnabled.libsignalAuth" (and for local
testing, UserDefaults flag UseLibsignalForIdentifiedWebsocket).
2024-06-20 15:42:00 -07:00

1191 lines
47 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import LibSignalClient
import SignalCoreKit
@objc
public class RemoteConfig: NSObject {
/// 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.
fileprivate let lastKnownClockSkew: TimeInterval
fileprivate let isEnabledFlags: [String: Bool]
fileprivate let valueFlags: [String: String]
fileprivate let timeGatedFlags: [String: Date]
private let paymentsDisabledRegions: PhoneNumberRegions
private let applePayDisabledRegions: PhoneNumberRegions
private let creditAndDebitCardDisabledRegions: PhoneNumberRegions
private let paypalDisabledRegions: PhoneNumberRegions
private let sepaEnabledRegions: PhoneNumberRegions
private let idealEnabledRegions: PhoneNumberRegions
init(
clockSkew: TimeInterval,
isEnabledFlags: [String: Bool],
valueFlags: [String: String],
timeGatedFlags: [String: Date]
) {
self.lastKnownClockSkew = clockSkew
self.isEnabledFlags = isEnabledFlags
self.valueFlags = valueFlags
self.timeGatedFlags = timeGatedFlags
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, isEnabledFlags: [:], valueFlags: [:], timeGatedFlags: [:])
}
fileprivate func mergingHotSwappableFlags(from newConfig: RemoteConfig) -> RemoteConfig {
var isEnabledFlags = self.isEnabledFlags
for flag in IsEnabledFlag.allCases {
guard flag.isHotSwappable else { continue }
isEnabledFlags[flag.rawValue] = newConfig.isEnabledFlags[flag.rawValue]
}
var valueFlags = self.valueFlags
for flag in ValueFlag.allCases {
guard flag.isHotSwappable else { continue }
valueFlags[flag.rawValue] = newConfig.valueFlags[flag.rawValue]
}
var timeGatedFlags = self.timeGatedFlags
for flag in TimeGatedFlag.allCases {
guard flag.isHotSwappable else { continue }
timeGatedFlags[flag.rawValue] = newConfig.timeGatedFlags[flag.rawValue]
}
return RemoteConfig(
clockSkew: newConfig.lastKnownClockSkew,
isEnabledFlags: isEnabledFlags,
valueFlags: valueFlags,
timeGatedFlags: timeGatedFlags
)
}
@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 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 func standardMediaQualityLevel(localPhoneNumber: String?) -> ImageQualityLevel? {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else { return nil }
return remoteConfig.standardMediaQualityLevel(localPhoneNumber: localPhoneNumber)
}
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 sepaEnabledRegions: PhoneNumberRegions {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else { return [] }
return remoteConfig.sepaEnabledRegions
}
public static var idealEnabledRegions: PhoneNumberRegions {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else { return [] }
return remoteConfig.idealEnabledRegions
}
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 func standardMediaQualityLevel(localPhoneNumber: String?) -> ImageQualityLevel? {
let rawValue: String = ValueFlag.standardMediaQualityLevel.rawValue
guard
let csvString = valueFlags[rawValue],
let stringValue = Self.countryCodeValue(csvString: csvString, csvDescription: rawValue, localPhoneNumber: localPhoneNumber),
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)
}
@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)
}
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)
}
public static var maxAttachmentDownloadSizeBytes: UInt {
return getUIntValue(forFlag: .maxAttachmentDownloadSizeBytes, defaultValue: 100 * 1024 * 1024)
}
public static var enableGifSearch: Bool {
return isEnabled(.enableGifSearch, defaultValue: true)
}
public static var shouldCheckForServiceExtensionFailures: Bool {
return !isEnabled(.serviceExtensionFailureKillSwitch)
}
public static var backgroundRefreshInterval: TimeInterval {
return TimeInterval(getUIntValue(
forFlag: .backgroundRefreshInterval,
defaultValue: UInt(kDayInterval)
))
}
@available(*, unavailable, message: "cached in UserDefaults by ChatConnectionManager")
public static var experimentalTransportUseLibsignal: Bool {
return false
}
public static var experimentalTransportShadowingHigh: Bool {
return isEnabled(.experimentalTransportShadowingHigh, defaultValue: DebugFlags.betaLogging)
}
@available(*, unavailable, message: "cached in UserDefaults by ChatConnectionManager")
public static var experimentalTransportShadowingEnabled: Bool {
return false
}
public static var cdsiLookupWithLibsignal: Bool {
return isEnabled(.cdsiLookupWithLibsignal, defaultValue: true)
}
/// The time a linked device may be offline before it expires and is
/// unlinked.
public static var linkedDeviceLifespan: TimeInterval {
return interval(
.linkedDeviceLifespanInterval,
defaultInterval: kMonthInterval
)
}
public static var callLinkJoin: Bool {
return (
FeatureBuild.current == .dev
|| FeatureBuild.current == .internal && isEnabled(.callLinkJoin)
)
}
// MARK: UInt values
private static func getUIntValue(
forFlag flag: ValueFlag,
defaultValue: UInt
) -> UInt {
getStringConvertibleValue(
forFlag: flag,
defaultValue: defaultValue
)
}
private static func getUInt32Value(
forFlag flag: ValueFlag,
defaultValue: UInt32
) -> UInt32 {
getStringConvertibleValue(
forFlag: flag,
defaultValue: defaultValue
)
}
private static func getStringConvertibleValue<V>(
forFlag flag: ValueFlag,
defaultValue: V
) -> V where V: LosslessStringConvertible {
guard AppReadiness.isAppReady else {
owsFailDebug("Storage is not yet ready.")
return defaultValue
}
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
/// 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, localIdentifiers: LocalIdentifiers) -> Bool {
guard
let countryCodeValue = countryCodeValue(csvString: csvString, csvDescription: csvDescription, localPhoneNumber: localIdentifiers.phoneNumber),
let countEnabled = UInt64(countryCodeValue)
else {
return false
}
return isBucketEnabled(key: key, countEnabled: countEnabled, bucketSize: 1_000_000, localAci: localIdentifiers.aci)
}
private static func isCountryCodeBucketEnabled(flag: ValueFlag, valueFlags: [String: String], localIdentifiers: LocalIdentifiers) -> Bool {
let rawValue = flag.rawValue
guard let csvString = valueFlags[rawValue] else { return false }
return isCountryCodeBucketEnabled(csvString: csvString, key: rawValue, csvDescription: rawValue, localIdentifiers: localIdentifiers)
}
/// 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, localPhoneNumber: 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 callingCodeToValueMap = 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 callingCode = components[0]
let countryValue = components[1]
result[callingCode] = countryValue
}
guard !callingCodeToValueMap.isEmpty else { return nil }
guard
let localPhoneNumber,
let localCallingCode = phoneNumberUtil.parseE164(localPhoneNumber)?.getCallingCode()?.stringValue
else {
owsFailDebug("Invalid local number")
return nil
}
return callingCodeToValueMap[localCallingCode] ?? 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 {
guard var data = (key + ".").data(using: .utf8) else {
owsFailDebug("Failed to get data from key")
return 0
}
data.append(Data(aci.serviceIdBinary))
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: ValueFlag, defaultInterval: TimeInterval) -> TimeInterval {
guard let intervalString: String = value(flag), let interval = TimeInterval(intervalString) else {
return defaultInterval
}
return interval
}
private static func isEnabled(_ flag: IsEnabledFlag, defaultValue: Bool = false) -> Bool {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else {
return defaultValue
}
return remoteConfig.isEnabled(flag, defaultValue: defaultValue)
}
private func isEnabled(_ flag: IsEnabledFlag, defaultValue: Bool = false) -> Bool {
return isEnabledFlags[flag.rawValue] ?? defaultValue
}
private static func isEnabled(_ flag: TimeGatedFlag, defaultValue: Bool = false) -> Bool {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else {
return defaultValue
}
return remoteConfig.isEnabled(flag, defaultValue: defaultValue)
}
private func isEnabled(_ flag: TimeGatedFlag, defaultValue: Bool = false) -> Bool {
guard let dateThreshold = timeGatedFlags[flag.rawValue] else {
return defaultValue
}
let correctedDate = Date().addingTimeInterval(self.lastKnownClockSkew)
return correctedDate >= dateThreshold
}
private static func value(_ flag: ValueFlag) -> String? {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else {
return nil
}
return remoteConfig.valueFlags[flag.rawValue]
}
public static func debugDescriptions() -> [String: String] {
guard let remoteConfig = Self.remoteConfigManager.cachedConfig else {
return [:]
}
var result = [String: String]()
for (key, value) in remoteConfig.isEnabledFlags {
result[key] = "\(value)"
}
for (key, value) in remoteConfig.valueFlags {
result[key] = "\(value)"
}
for (key, value) in remoteConfig.timeGatedFlags {
result[key] = "\(value)"
}
return result
}
public static func logFlags() {
for (key, value) in debugDescriptions() {
Logger.info("RemoteConfig: \(key) = \(value)")
}
}
}
// MARK: - IsEnabledFlag
private enum IsEnabledFlag: String, FlagType {
case automaticSessionResetKillSwitch = "ios.automaticSessionResetKillSwitch"
case paymentsResetKillSwitch = "ios.paymentsResetKillSwitch"
case messageResendKillSwitch = "ios.messageResendKillSwitch"
case applePayOneTimeDonationKillSwitch = "ios.applePayOneTimeDonationKillSwitch"
case applePayGiftDonationKillSwitch = "ios.applePayGiftDonationKillSwitch"
case applePayMonthlyDonationKillSwitch = "ios.applePayMonthlyDonationKillSwitch"
case cardOneTimeDonationKillSwitch = "ios.cardOneTimeDonationKillSwitch"
case cardGiftDonationKillSwitch = "ios.cardGiftDonationKillSwitch"
case cardMonthlyDonationKillSwitch = "ios.cardMonthlyDonationKillSwitch"
case paypalOneTimeDonationKillSwitch = "ios.paypalOneTimeDonationKillSwitch"
case paypalGiftDonationKillSwitch = "ios.paypalGiftDonationKillSwitch"
case paypalMonthlyDonationKillSwitch = "ios.paypalMonthlyDonationKillSwitch"
case enableAutoAPNSRotation = "ios.enableAutoAPNSRotation"
case ringrtcNwPathMonitorTrialKillSwitch = "ios.ringrtcNwPathMonitorTrialKillSwitch"
case enableGifSearch = "global.gifSearch"
case serviceExtensionFailureKillSwitch = "ios.serviceExtensionFailureKillSwitch"
case experimentalTransportUseLibsignal = "ios.experimentalTransportEnabled.libsignal"
case experimentalTransportUseLibsignalAuth = "ios.experimentalTransportEnabled.libsignalAuth"
case experimentalTransportShadowingHigh = "ios.experimentalTransportEnabled.shadowingHigh"
case experimentalTransportShadowingEnabled = "ios.experimentalTransportEnabled.shadowing"
case cdsiLookupWithLibsignal = "ios.cdsiLookup.libsignal"
case deleteForMeSyncMessageSending = "ios.deleteForMeSyncMessage.sending"
case callLinkJoin = "ios.callLink.join.v1"
var isSticky: Bool {
switch self {
case .automaticSessionResetKillSwitch: fallthrough
case .paymentsResetKillSwitch: fallthrough
case .messageResendKillSwitch: fallthrough
case .applePayOneTimeDonationKillSwitch: fallthrough
case .applePayGiftDonationKillSwitch: fallthrough
case .applePayMonthlyDonationKillSwitch: fallthrough
case .cardOneTimeDonationKillSwitch: fallthrough
case .cardGiftDonationKillSwitch: fallthrough
case .cardMonthlyDonationKillSwitch: fallthrough
case .paypalOneTimeDonationKillSwitch: fallthrough
case .paypalGiftDonationKillSwitch: fallthrough
case .paypalMonthlyDonationKillSwitch: fallthrough
case .enableAutoAPNSRotation: fallthrough
case .ringrtcNwPathMonitorTrialKillSwitch: fallthrough
case .enableGifSearch: fallthrough
case .serviceExtensionFailureKillSwitch: fallthrough
case .experimentalTransportUseLibsignal: fallthrough
case .experimentalTransportUseLibsignalAuth: fallthrough
case .experimentalTransportShadowingHigh: fallthrough
case .experimentalTransportShadowingEnabled: fallthrough
case .cdsiLookupWithLibsignal: fallthrough
case .deleteForMeSyncMessageSending:
return false
case .callLinkJoin:
return false
}
}
var isHotSwappable: Bool {
switch self {
case .cdsiLookupWithLibsignal: fallthrough
case .serviceExtensionFailureKillSwitch:
return true
case .automaticSessionResetKillSwitch: fallthrough
case .paymentsResetKillSwitch: fallthrough
case .messageResendKillSwitch: fallthrough
case .applePayOneTimeDonationKillSwitch: fallthrough
case .applePayGiftDonationKillSwitch: fallthrough
case .applePayMonthlyDonationKillSwitch: fallthrough
case .cardOneTimeDonationKillSwitch: fallthrough
case .cardGiftDonationKillSwitch: fallthrough
case .cardMonthlyDonationKillSwitch: fallthrough
case .paypalOneTimeDonationKillSwitch: fallthrough
case .paypalGiftDonationKillSwitch: fallthrough
case .paypalMonthlyDonationKillSwitch: fallthrough
case .enableAutoAPNSRotation: fallthrough
case .ringrtcNwPathMonitorTrialKillSwitch: fallthrough
case .enableGifSearch: fallthrough
case .experimentalTransportUseLibsignal: fallthrough
case .experimentalTransportUseLibsignalAuth: fallthrough
case .experimentalTransportShadowingHigh: fallthrough
case .experimentalTransportShadowingEnabled: fallthrough
case .deleteForMeSyncMessageSending:
return false
case .callLinkJoin:
return true
}
}
}
private enum ValueFlag: String, FlagType {
case groupsV2MaxGroupSizeRecommended = "global.groupsv2.maxGroupSize"
case groupsV2MaxGroupSizeHardLimit = "global.groupsv2.groupSizeHardLimit"
case clientExpiration = "ios.clientExpiration"
case cdsSyncInterval = "cds.syncInterval.seconds"
case automaticSessionResetAttemptInterval = "ios.automaticSessionResetAttemptInterval"
case reactiveProfileKeyAttemptInterval = "ios.reactiveProfileKeyAttemptInterval"
case standardMediaQualityLevel = "ios.standardMediaQualityLevel"
case replaceableInteractionExpiration = "ios.replaceableInteractionExpiration"
case messageSendLogEntryLifetime = "ios.messageSendLogEntryLifetime"
case paymentsDisabledRegions = "global.payments.disabledRegions"
case applePayDisabledRegions = "global.donations.apayDisabledRegions"
case creditAndDebitCardDisabledRegions = "global.donations.ccDisabledRegions"
case paypalDisabledRegions = "global.donations.paypalDisabledRegions"
case sepaEnabledRegions = "global.donations.sepaEnabledRegions"
case idealEnabledRegions = "global.donations.idealEnabledRegions"
case maxGroupCallRingSize = "global.calling.maxGroupCallRingSize"
case minNicknameLength = "global.nicknames.min"
case maxNicknameLength = "global.nicknames.max"
case maxAttachmentDownloadSizeBytes = "global.attachments.maxBytes"
case backgroundRefreshInterval = "ios.backgroundRefreshInterval"
case linkedDeviceLifespanInterval = "ios.linkedDeviceLifespanInterval"
var isSticky: Bool {
switch self {
case .groupsV2MaxGroupSizeRecommended: fallthrough
case .groupsV2MaxGroupSizeHardLimit:
return true
case .clientExpiration: fallthrough
case .cdsSyncInterval: fallthrough
case .automaticSessionResetAttemptInterval: fallthrough
case .reactiveProfileKeyAttemptInterval: fallthrough
case .standardMediaQualityLevel: fallthrough
case .replaceableInteractionExpiration: fallthrough
case .messageSendLogEntryLifetime: fallthrough
case .paymentsDisabledRegions: fallthrough
case .applePayDisabledRegions: fallthrough
case .creditAndDebitCardDisabledRegions: fallthrough
case .paypalDisabledRegions: fallthrough
case .sepaEnabledRegions: fallthrough
case .idealEnabledRegions: fallthrough
case .maxGroupCallRingSize: fallthrough
case .minNicknameLength: fallthrough
case .maxNicknameLength: fallthrough
case .maxAttachmentDownloadSizeBytes: fallthrough
case .backgroundRefreshInterval: fallthrough
case .linkedDeviceLifespanInterval:
return false
}
}
var isHotSwappable: Bool {
switch self {
case .groupsV2MaxGroupSizeRecommended: fallthrough
case .groupsV2MaxGroupSizeHardLimit: fallthrough
case .automaticSessionResetAttemptInterval: fallthrough
case .reactiveProfileKeyAttemptInterval: fallthrough
case .paymentsDisabledRegions: fallthrough
case .applePayDisabledRegions: fallthrough
case .creditAndDebitCardDisabledRegions: fallthrough
case .paypalDisabledRegions: fallthrough
case .sepaEnabledRegions: fallthrough
case .idealEnabledRegions: fallthrough
case .maxGroupCallRingSize: fallthrough
case .backgroundRefreshInterval: fallthrough
case .linkedDeviceLifespanInterval:
return true
case .clientExpiration: fallthrough
case .cdsSyncInterval: fallthrough
case .standardMediaQualityLevel: fallthrough
case .replaceableInteractionExpiration: fallthrough
case .messageSendLogEntryLifetime: fallthrough
case .minNicknameLength: fallthrough
case .maxNicknameLength: fallthrough
case .maxAttachmentDownloadSizeBytes:
return false
}
}
}
private enum TimeGatedFlag: String, FlagType {
case __none
var isSticky: Bool {
switch self {
case .__none:
return false
}
}
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 remain set once they are set regardless of
// the remote state.
var isSticky: Bool { get }
// 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 RemoteConfigManager {
func warmCaches()
var cachedConfig: RemoteConfig? { get }
func refresh(account: AuthedAccount) -> Promise<RemoteConfig>
}
// MARK: -
#if TESTABLE_BUILD
public class StubbableRemoteConfigManager: RemoteConfigManager {
public var cachedConfig: RemoteConfig?
public func warmCaches() {}
public func refresh(account: AuthedAccount) -> Promise<RemoteConfig> {
return .value(cachedConfig!)
}
}
#endif
// MARK: -
public class RemoteConfigManagerImpl: RemoteConfigManager {
private let appExpiry: AppExpiry
private let db: DB
private let keyValueStore: KeyValueStore
private let tsAccountManager: TSAccountManager
private let serviceClient: SignalServiceClient
// MARK: -
private let _cachedConfig = AtomicValue<RemoteConfig?>(nil, lock: .init())
public var cachedConfig: RemoteConfig? {
let result = _cachedConfig.get()
owsAssertDebug(result != nil, "cachedConfig not yet set.")
return result
}
@discardableResult
private func updateCachedConfig(_ updateBlock: (RemoteConfig?) -> RemoteConfig) -> RemoteConfig {
return _cachedConfig.update { mutableValue in
let newValue = updateBlock(mutableValue)
mutableValue = newValue
return newValue
}
}
public init(
appExpiry: AppExpiry,
db: DB,
keyValueStoreFactory: KeyValueStoreFactory,
tsAccountManager: TSAccountManager,
serviceClient: SignalServiceClient
) {
self.appExpiry = appExpiry
self.db = db
self.keyValueStore = keyValueStoreFactory.keyValueStore(collection: "RemoteConfigManager")
self.tsAccountManager = tsAccountManager
self.serviceClient = serviceClient
AppReadiness.runNowOrWhenMainAppDidBecomeReadyAsync {
guard self.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
return
}
self.scheduleNextRefresh()
}
NotificationCenter.default.addObserver(
self,
selector: #selector(registrationStateDidChange),
name: .registrationStateDidChange,
object: nil
)
}
// MARK: -
@objc
func registrationStateDidChange() {
AssertIsOnMainThread()
guard tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else { return }
Logger.info("Refreshing and immediately applying new flags due to new registration.")
refresh().catch { error in
Logger.error("Failed to update remote config after registration change \(error)")
}
}
public func warmCaches() {
owsAssertDebug(GRDBSchemaMigrator.areMigrationsComplete)
// swiftlint:disable large_tuple
let (
lastKnownClockSkew,
isEnabledFlags,
valueFlags,
timeGatedFlags,
registrationState
): (TimeInterval, [String: Bool]?, [String: String]?, [String: Date]?, TSRegistrationState) = db.read { tx in
return (
self.keyValueStore.getLastKnownClockSkew(transaction: tx),
self.keyValueStore.getRemoteConfigIsEnabledFlags(transaction: tx),
self.keyValueStore.getRemoteConfigValueFlags(transaction: tx),
self.keyValueStore.getRemoteConfigTimeGatedFlags(transaction: tx),
DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx)
)
}
// swiftlint:enable large_tuple
let remoteConfig: RemoteConfig
if registrationState.isRegistered, (isEnabledFlags != nil || valueFlags != nil || timeGatedFlags != nil) {
remoteConfig = RemoteConfig(
clockSkew: lastKnownClockSkew,
isEnabledFlags: isEnabledFlags ?? [:],
valueFlags: valueFlags ?? [:],
timeGatedFlags: timeGatedFlags ?? [:]
)
} else {
// If we're not registered or haven't saved one, use an empty one.
remoteConfig = .emptyConfig
}
updateCachedConfig { _ in remoteConfig }
warmSecondaryCaches(valueFlags: valueFlags ?? [:])
AppReadiness.runNowOrWhenAppWillBecomeReady {
RemoteConfig.logFlags()
}
}
fileprivate func warmSecondaryCaches(valueFlags: [String: String]) {
checkClientExpiration(valueFlags: valueFlags)
}
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 = db.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()
}
}
}
@discardableResult
public func refresh(account: AuthedAccount = .implicit()) -> Promise<RemoteConfig> {
AssertIsOnMainThread()
lastAttempt = Date()
let promise = firstly(on: DispatchQueue.global()) {
self.serviceClient.getRemoteConfig(auth: account.chatServiceAuth)
}.map(on: DispatchQueue.global()) { (fetchedConfig: RemoteConfigResponse) in
let clockSkew: TimeInterval
if let serverEpochTimeSeconds = fetchedConfig.serverEpochTimeSeconds {
let dateAccordingToServer = Date(timeIntervalSince1970: TimeInterval(serverEpochTimeSeconds))
clockSkew = dateAccordingToServer.timeIntervalSince(Date())
} else {
clockSkew = 0
}
// 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.
var isEnabledFlags = [String: Bool]()
var valueFlags = [String: String]()
var timeGatedFlags = [String: Date]()
fetchedConfig.items.forEach { (key: String, item: RemoteConfigItem) in
switch item {
case .isEnabled(let isEnabled):
if IsEnabledFlag(rawValue: key) != nil {
isEnabledFlags[key] = isEnabled
}
case .value(let value):
if ValueFlag(rawValue: key) != nil {
valueFlags[key] = value
} else if TimeGatedFlag(rawValue: key) != nil {
if let secondsSinceEpoch = TimeInterval(value) {
timeGatedFlags[key] = Date(timeIntervalSince1970: secondsSinceEpoch)
} else {
owsFailDebug("Invalid value: \(value) \(type(of: value))")
}
}
}
}
// Persist all flags in the database to be applied on next launch.
self.db.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 let flag = IsEnabledFlag(rawValue: key), flag.isSticky, value == true {
isEnabledFlags[key] = value
}
}
}
if let existingConfig = self.keyValueStore.getRemoteConfigValueFlags(transaction: transaction) {
existingConfig.forEach { (key: String, value: String) in
// Preserve "value" flags if they are sticky and already set and missing
// from the fetched config.
if let flag = ValueFlag(rawValue: key), flag.isSticky, valueFlags[key] == nil {
valueFlags[key] = value
}
}
}
if let existingConfig = self.keyValueStore.getRemoteConfigTimeGatedFlags(transaction: transaction) {
existingConfig.forEach { (key: String, value: Date) in
// Preserve "time gated" flags if they are sticky and already set and
// missing from the fetched config.
if let flag = TimeGatedFlag(rawValue: key), flag.isSticky, timeGatedFlags[key] == nil {
timeGatedFlags[key] = value
}
}
}
self.keyValueStore.setClockSkew(clockSkew, transaction: transaction)
self.keyValueStore.setRemoteConfigIsEnabledFlags(isEnabledFlags, transaction: transaction)
self.keyValueStore.setRemoteConfigValueFlags(valueFlags, transaction: transaction)
self.keyValueStore.setRemoteConfigTimeGatedFlags(timeGatedFlags, 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 = IsEnabledFlag.ringrtcNwPathMonitorTrialKillSwitch
let isKilled = isEnabledFlags[flag.rawValue] ?? false
return !isKilled
}(),
in: CurrentAppContext().appUserDefaults()
)
// Similarly, persist the choice of libsignal for the chat websockets.
let shouldUseLibsignalForIdentifiedWebsocket = isEnabledFlags[IsEnabledFlag.experimentalTransportUseLibsignalAuth.rawValue] ?? false
ChatConnectionManagerImpl.saveShouldUseLibsignalForIdentifiedWebsocket(
shouldUseLibsignalForIdentifiedWebsocket,
in: CurrentAppContext().appUserDefaults()
)
let shouldUseLibsignalForUnidentifiedWebsocket = isEnabledFlags[IsEnabledFlag.experimentalTransportUseLibsignal.rawValue] ?? false
ChatConnectionManagerImpl.saveShouldUseLibsignalForUnidentifiedWebsocket(
shouldUseLibsignalForUnidentifiedWebsocket,
in: CurrentAppContext().appUserDefaults()
)
let enableShadowingForUnidentifiedWebsocket = isEnabledFlags[IsEnabledFlag.experimentalTransportShadowingEnabled.rawValue] ?? true
ChatConnectionManagerImpl.saveEnableShadowingForUnidentifiedWebsocket(
enableShadowingForUnidentifiedWebsocket,
in: CurrentAppContext().appUserDefaults()
)
self.consecutiveFailures = 0
// This has *all* the new values, even those that can't be hot-swapped.
let newConfig = RemoteConfig(
clockSkew: clockSkew,
isEnabledFlags: isEnabledFlags,
valueFlags: valueFlags,
timeGatedFlags: timeGatedFlags
)
// This has hot-swappable new values and non-hot-swappable old values.
let mergedConfig = self.updateCachedConfig { oldConfig in
return (oldConfig ?? .emptyConfig).mergingHotSwappableFlags(from: newConfig)
}
self.warmSecondaryCaches(valueFlags: mergedConfig.valueFlags)
// We always return `newConfig` because callers may want to see the
// newly-fetched, non-hot-swappable values for themselves.
return newConfig
}
promise.catch(on: DispatchQueue.main) { error in
Logger.error("error: \(error)")
self.consecutiveFailures += 1
}.ensure(on: DispatchQueue.main) {
self.scheduleNextRefresh()
}.cauterize()
return promise
}
// MARK: - Client Expiration
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)>"
}
}
private func checkClientExpiration(valueFlags: [String: String]) {
var minimumVersions: [MinimumVersion]?
defer {
if let minimumVersions {
appExpiry.setExpirationDateForCurrentVersion(remoteExpirationDate(minimumVersions: minimumVersions), db: db)
}
}
guard let jsonString = valueFlags[ValueFlag.clientExpiration.rawValue] 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 (eg 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 currentVersion = AppVersionNumber(AppVersionImpl.shared.currentAppVersion)
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.
let minimumVersionNumber = AppVersionNumber(minimumVersion.string)
guard minimumVersionNumber > currentVersion else {
continue
}
if let enforcementDate = oldestEnforcementDate {
oldestEnforcementDate = min(enforcementDate, minimumVersion.enforcementDate)
} else {
oldestEnforcementDate = minimumVersion.enforcementDate
}
}
return oldestEnforcementDate
}
}
// MARK: -
private extension KeyValueStore {
// MARK: - Remote Config Enabled Flags
private static var remoteConfigIsEnabledFlagsKey: String { "remoteConfigKey" }
func getRemoteConfigIsEnabledFlags(transaction: DBReadTransaction) -> [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: DBWriteTransaction) {
return setObject(newValue,
key: Self.remoteConfigIsEnabledFlagsKey,
transaction: transaction)
}
// MARK: - Remote Config Value Flags
private static var remoteConfigValueFlagsKey: String { "remoteConfigValueFlags" }
func getRemoteConfigValueFlags(transaction: DBReadTransaction) -> [String: String]? {
guard let object = getObject(forKey: Self.remoteConfigValueFlagsKey, transaction: transaction) else {
return nil
}
guard let remoteConfig = object as? [String: String] else {
owsFailDebug("unexpected object: \(object)")
return nil
}
return remoteConfig
}
func setRemoteConfigValueFlags(_ newValue: [String: String], transaction: DBWriteTransaction) {
return setObject(newValue, key: Self.remoteConfigValueFlagsKey, transaction: transaction)
}
// MARK: - Remote Config Time Gated Flags
private static var remoteConfigTimeGatedFlagsKey: String { "remoteConfigTimeGatedFlags" }
func getRemoteConfigTimeGatedFlags(transaction: DBReadTransaction) -> [String: Date]? {
guard let object = getObject(forKey: Self.remoteConfigTimeGatedFlagsKey, transaction: transaction) else {
return nil
}
guard let remoteConfig = object as? [String: Date] else {
owsFailDebug("unexpected object: \(object)")
return nil
}
return remoteConfig
}
func setRemoteConfigTimeGatedFlags(_ newValue: [String: Date], transaction: DBWriteTransaction) {
return setObject(newValue, key: Self.remoteConfigTimeGatedFlagsKey, transaction: transaction)
}
// 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)
}
}