Signal-iOS/SignalServiceKit/src/Util/RemoteConfigManager.swift
Matthew Chen a66f2cfc56 Migrate groups from v1 to v2, part 2.
* Add legacy group upgrade action sheet.
* Add 'group update info messages' around migrations.
* Add new limits around group size.
* Rename migration modes to "manual", "auto-migration."
* Add legacy groups action sheets.
* Try to fill in missing UUIDs before migrating groups.
* Ignore "known unregistered" users during group migrations.
2020-10-30 09:05:13 -03:00

690 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright (c) 2020 Open Whisper Systems. All rights reserved.
//
import Foundation
import PromiseKit
@objc
public class RemoteConfig: BaseFlags {
// rather than interact with `config` directly, prefer encoding any string constants
// into a getter below...
private let isEnabledFlags: [String: Bool]
private let valueFlags: [String: AnyObject]
private let researchMegaphone: Bool
init(isEnabledFlags: [String: Bool],
valueFlags: [String: AnyObject]) {
self.isEnabledFlags = isEnabledFlags
self.valueFlags = valueFlags
self.researchMegaphone = Self.isResearchMegaphoneEnabled(valueFlags: valueFlags)
}
@objc
public static var kbs: Bool {
// This feature latches "on" once they have a master key in KBS,
// even if we turn it off on the server they will keep using KBS.
guard !KeyBackupService.hasMasterKey else { return true }
return isEnabled(.kbs)
}
@objc
public static var groupsV2InviteLinks: Bool {
true
}
private static let forceDisableUuidSafetyNumbers = true
@objc
public static var uuidSafetyNumbers: Bool {
guard !forceDisableUuidSafetyNumbers else { return false }
return isEnabled(.uuidSafetyNumbers)
}
@objc
public static var profilesForAll: Bool {
if DebugFlags.forceProfilesForAll { return true }
return isEnabled(.profilesForAll)
}
@objc
public static var groupsV2MaxGroupSizeRecommended: UInt {
let defaultValue: UInt = 151
guard AppReadiness.isAppReady else {
owsFailDebug("Storage is not yet ready.")
return defaultValue
}
guard let rawValue: AnyObject = value(.groupsV2MaxGroupSizeRecommended) else {
return defaultValue
}
guard let stringValue = rawValue as? String else {
owsFailDebug("Unexpected value.")
return defaultValue
}
guard let uintValue = UInt(stringValue) else {
owsFailDebug("Invalid value.")
return defaultValue
}
return uintValue
}
@objc
public static var groupsV2MaxGroupSizeHardLimit: UInt {
let defaultValue: UInt = 1001
guard AppReadiness.isAppReady else {
owsFailDebug("Storage is not yet ready.")
return defaultValue
}
guard let rawValue: AnyObject = value(.groupsV2MaxGroupSizeHardLimit) else {
return defaultValue
}
guard let stringValue = rawValue as? String else {
owsFailDebug("Unexpected value.")
return defaultValue
}
guard let uintValue = UInt(stringValue) else {
owsFailDebug("Invalid value.")
return defaultValue
}
return uintValue
}
@objc
public static var usernames: Bool {
FeatureFlags.usernamesSupported
}
@objc
public static var researchMegaphone: Bool {
guard let remoteConfig = SSKEnvironment.shared.remoteConfigManager.cachedConfig else { return false }
return remoteConfig.researchMegaphone
}
private static func isResearchMegaphoneEnabled(valueFlags: [String: AnyObject]) -> Bool {
let rawFlag = Flags.SupportedValuesFlags.researchMegaphone.rawFlag
guard let value = valueFlags[rawFlag] as? String else { return false }
guard !value.isEmpty else { return false }
// The value should always be a comma-separated list of country codes colon-separated
// from how many buckets out of 1 million should be enabled to see the research
// megaphone in that country code. There will also optional be a wildcard "*" bucket
// that any unspecified country codes should use. If neither the local country code
// or the wildcard is specified, we assume the megaphone is not enabled.
let countEnabledPerCountryCode = value
.components(separatedBy: ",")
.reduce(into: [String: UInt64]()) { result, value in
let components = value.components(separatedBy: ":")
guard components.count == 2 else { return owsFailDebug("Invalid research megaphone value \(value)")}
let countryCode = components[0]
let countEnabled = UInt64(components[1])
result[countryCode] = countEnabled
}
guard !countEnabledPerCountryCode.isEmpty else { return false }
guard let localE164 = TSAccountManager.shared().localNumber,
let localCountryCode = PhoneNumber(fromE164: localE164)?.getCountryCode()?.stringValue else {
owsFailDebug("Missing local number")
return false
}
let localCountEnabled = countEnabledPerCountryCode[localCountryCode]
let wildcardCountEnabled = countEnabledPerCountryCode["*"]
if let countEnabled = localCountEnabled ?? wildcardCountEnabled {
return isBucketEnabled(key: rawFlag, countEnabled: countEnabled, bucketSize: 1000000)
} else {
return false
}
}
// MARK: -
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
}
let uuidBytes = withUnsafePointer(to: uuid.uuid) {
Data(bytes: $0, count: MemoryLayout.size(ofValue: uuid.uuid))
}
data.append(uuidBytes)
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
let uuidBucket = hash[0..<8].withUnsafeBytes {
UInt64(bigEndian: $0.load(as: UInt64.self)) % bucketSize
}
return uuidBucket
}
private static func isEnabled(_ flag: Flags.SupportedIsEnabledFlags, defaultValue: Bool = false) -> Bool {
guard let remoteConfig = SSKEnvironment.shared.remoteConfigManager.cachedConfig else {
return defaultValue
}
return remoteConfig.isEnabledFlags[flag.rawFlag] ?? defaultValue
}
private static func value<T>(_ flag: Flags.SupportedValuesFlags) -> T? {
guard let remoteConfig = SSKEnvironment.shared.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 = SSKEnvironment.shared.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 = buildFlagMap()
for key in Array(flagMap.keys).sorted() {
let value = flagMap[key]
logFlag("Flag", key, value)
}
}
public static func buildFlagMap() -> [String: Any] {
BaseFlags.buildFlagMap(for: RemoteConfig.self) { (key: String) -> Any? in
RemoteConfig.value(forKey: key)
}
}
}
// 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
}
// 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 groupsV2InviteLinksV2
case profilesForAll
}
// Values defined in this array remain set once they are
// set regardless of the remote state.
enum StickyValuesFlags: String, FlagType {
case groupsV2MaxGroupSizeRecommended
case groupsV2MaxGroupSizeHardLimit
}
// 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 researchMegaphone
}
}
// 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 "researchMegaphone": return "research.megaphone.1"
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 {
// MARK: - Dependencies
private var databaseStorage: SDSDatabaseStorage {
return SDSDatabaseStorage.shared
}
private var tsAccountManager: TSAccountManager {
return SSKEnvironment.shared.tsAccountManager
}
private let serviceClient: SignalServiceClient = SignalServiceRestClient()
let keyValueStore: SDSKeyValueStore = SDSKeyValueStore(collection: "RemoteConfigManager")
// MARK: -
private let hasWarmedCache = AtomicBool(false)
private var _cachedConfig: RemoteConfig?
@objc
public private(set) var cachedConfig: RemoteConfig? {
get {
if !hasWarmedCache.get() {
owsFailDebug("CachedConfig not yet set.")
}
return _cachedConfig
}
set {
AssertIsOnMainThread()
assert(_cachedConfig == nil)
_cachedConfig = 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.runNowOrWhenAppDidBecomeReadyPolite {
guard self.tsAccountManager.isRegistered else {
return
}
self.refreshIfReady()
}
// 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() {
guard self.tsAccountManager.isRegistered else { return }
self.refreshIfReady()
}
public func warmCaches() {
cacheCurrent()
AppReadiness.runNowOrWhenAppWillBecomeReady {
RemoteConfig.logFlags()
}
}
private func cacheCurrent() {
AssertIsOnMainThread()
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 func refreshIfReady() {
guard let lastFetched = (databaseStorage.read { transaction in
self.keyValueStore.getLastFetched(transaction: transaction)
}) else {
refresh()
return
}
if abs(lastFetched.timeIntervalSinceNow) > 2 * kHourInterval {
refresh()
} else {
Logger.info("skipping due to recent fetch.")
}
}
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
}
}
private func refresh() {
firstly {
self.serviceClient.getRemoteConfig()
}.done(on: .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))")
}
}
}
}
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)
}
Logger.info("stored new remoteConfig. isEnabledFlags: \(isEnabledFlags), valueFlags: \(valueFlags)")
}.catch { error in
Logger.error("error: \(error)")
}
}
}
// 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 currentVersion = AppVersion.shared().currentAppVersionLong
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(
currentVersion,
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 let remoteConfigIsEnabledFlagsKey = "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 let remoteConfigValueFlagsKey = "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)
}
}