Signal-iOS/SignalServiceKit/src/Util/FeatureFlags.swift
2022-04-12 23:04:27 -07:00

660 lines
26 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
private enum FeatureBuild: Int {
case dev
case internalPreview
case qa
case openPreview
case beta
case production
}
extension FeatureBuild {
func includes(_ level: FeatureBuild) -> Bool {
return self.rawValue <= level.rawValue
}
}
private let build: FeatureBuild = OWSIsDebugBuild() ? .dev : .qa
// MARK: -
@objc
public enum StorageMode: Int {
// Use GRDB.
case grdb
// These modes can be used while running tests.
// They are more permissive than the release modes.
//
// The build shepherd should be running the test
// suites in .grdbTests mode before each release.
case grdbTests
}
// MARK: -
extension StorageMode: CustomStringConvertible {
public var description: String {
switch self {
case .grdb:
return ".grdb"
case .grdbTests:
return ".grdbTests"
}
}
}
// MARK: -
/// By centralizing feature flags here and documenting their rollout plan, it's easier to review
/// which feature flags are in play.
@objc(SSKFeatureFlags)
public class FeatureFlags: BaseFlags {
@objc
public static let phoneNumberSharing = build.includes(.qa)
@objc
public static let phoneNumberDiscoverability = build.includes(.qa)
@objc
public static let usernames = build.includes(.qa)
@objc
public static let linkedPhones = build.includes(.qa)
// We keep this feature flag around as we may want to
// ship a build that disables the dependency on KBS
// during registration. Features cannot be toggled
// remotely during registration.
@objc
public static let pinsForNewUsers = true
@objc
public static let supportAnimatedStickers_Lottie = false
@objc
public static let paymentsRequests = false
@objc
public static let paymentsScrubDetails = false
@objc
public static let deprecateREST = false
@objc
public static let groupRings = false
@objc
public static let stories = build.includes(.qa)
@objc
public static let storiesMigration2 = build.includes(.qa)
@objc
public static let storiesMigration3 = build.includes(.qa)
@objc
public static let storiesMigration4 = build.includes(.qa)
@objc
public static let canUseNativeWebsocket = build.includes(.beta)
@objc
public static let groupAbuse = false
@objc
public static var notificationServiceExtension: Bool {
// The CallKit APIs for the NSE are only available from iOS 14.5 and on,
// however there is a significant bug in iOS 14 where the NSE will not
// launch properly after a crash so we only support it in iOS 15.
if #available(iOS 15, *) { return true }
return false
}
public static func buildFlagMap() -> [String: Any] {
BaseFlags.buildFlagMap(for: FeatureFlags.self) { (key: String) -> Any? in
FeatureFlags.value(forKey: key)
}
}
public static var allTestableFlags: [TestableFlag] {
BaseFlags.findTestableFlags(for: FeatureFlags.self) { (key: String) -> Any? in
FeatureFlags.value(forKey: key)
}
}
@objc
public static func logFlags() {
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: "")
}
}
let flagMap = buildFlagMap()
for key in Array(flagMap.keys).sorted() {
let value = flagMap[key]
logFlag("FeatureFlag", key, value)
}
}
}
// MARK: -
extension FeatureFlags {
public static var buildVariantString: String? {
// Leaving this internal only for now. If we ever move this to
// HelpSettings we need to localize these strings
guard DebugFlags.internalSettings else {
owsFailDebug("Incomplete implementation. Needs localization")
return nil
}
let featureFlagString: String?
switch build {
case .dev:
featureFlagString = LocalizationNotNeeded("Development")
case .internalPreview:
featureFlagString = LocalizationNotNeeded("Internal Preview")
case .qa:
featureFlagString = LocalizationNotNeeded("Internal")
case .openPreview:
featureFlagString = LocalizationNotNeeded("Open Preview")
case .beta:
featureFlagString = LocalizationNotNeeded("Beta")
case .production:
// Production can be inferred from the lack of flag
featureFlagString = nil
}
let configuration: String? = {
#if DEBUG
LocalizationNotNeeded("Debug")
#elseif TESTABLE_BUILD
LocalizationNotNeeded("Testable build")
#elseif RELEASE
// RELEASE can be inferred from the lack of configuration. This will only be hit if the outer #if is removed.
nil
#else
owsFailDebug("Invalid configuration")
return "*"
#endif
}()
// If we're Production+Release, this will return nil and won't show up in Help Settings
return [featureFlagString, configuration]
.compactMap { $0 }
.joined(separator: "")
.nilIfEmpty
}
@objc
public static var storageMode: StorageMode {
if CurrentAppContext().isRunningTests {
return .grdbTests
} else {
return .grdb
}
}
@objc
public static var storageModeDescription: String {
return "\(storageMode)"
}
}
// MARK: -
/// Flags that we'll leave in the code base indefinitely that are helpful for
/// development should go here, rather than cluttering up FeatureFlags.
@objc(SSKDebugFlags)
public class DebugFlags: BaseFlags {
@objc
public static let internalLogging = build.includes(.openPreview)
@objc
public static let betaLogging = build.includes(.beta)
// DEBUG builds won't receive push notifications, which prevents receiving messages
// while the app is backgrounded or the system call screen is active.
//
// Set this flag to true to be able to download messages even when the app is in the background.
@objc
public static let keepWebSocketOpenInBackground = false
@objc
public static let testPopulationErrorAlerts = build.includes(.beta)
@objc
public static let audibleErrorLogging = build.includes(.qa)
@objc
public static let internalSettings = build.includes(.qa)
// This can be used to shut down various background operations.
@objc
public static let suppressBackgroundActivity = false
@objc
public static let reduceLogChatter: Bool = {
if CurrentAppContext().isRunningTests {
return true
}
return false && build.includes(.dev)
}()
@objc
public static let logSQLQueries = build.includes(.dev) && !reduceLogChatter
// We can use this to test recovery from "missed updates".
@objc
public static let groupsV2dontSendUpdates = TestableFlag(false,
title: LocalizationNotNeeded("Groups v2: Don't Send Updates"),
details: LocalizationNotNeeded("The app will not send 'group update' messages for v2 groups. " +
"Other group members will only learn of group changes from normal group messages."))
// If set, v2 groups will be created and updated with invalid avatars
// so that we can test clients' robustness to this case.
@objc
public static let groupsV2corruptAvatarUrlPaths = TestableFlag(false,
title: LocalizationNotNeeded("Groups v2: Corrupt avatar URL paths"),
details: LocalizationNotNeeded("Client will update group state with corrupt avatar URL paths."))
// If set, v2 groups will be created and updated with
// corrupt avatars, group names, and/or dm state
// so that we can test clients' robustness to this case.
@objc
public static let groupsV2corruptBlobEncryption = TestableFlag(false,
title: LocalizationNotNeeded("Groups v2: Corrupt blobs"),
details: LocalizationNotNeeded("Client will update group state with corrupt blobs."))
// If set, client will invite instead of adding other users.
@objc
public static let groupsV2forceInvites = TestableFlag(false,
title: LocalizationNotNeeded("Groups v2: Always Invite"),
details: LocalizationNotNeeded("Members added to a v2 group will always be invited instead of added."))
// If set, client will always send corrupt invites.
@objc
public static let groupsV2corruptInvites = TestableFlag(false,
title: LocalizationNotNeeded("Groups v2: Corrupt Invites"),
details: LocalizationNotNeeded("Client will only emit corrupt invites to v2 groups."))
@objc
public static let groupsV2onlyCreateV1Groups = TestableFlag(false,
title: LocalizationNotNeeded("Groups v2: Only create v1 groups"),
details: LocalizationNotNeeded("Client will not try to create v2 groups."))
@objc
public static let groupsV2migrationsDropOtherMembers = TestableFlag(false,
title: LocalizationNotNeeded("Groups v2: Migrations drop others"),
details: LocalizationNotNeeded("Group migrations will drop other members."))
@objc
public static let groupsV2migrationsInviteOtherMembers = TestableFlag(false,
title: LocalizationNotNeeded("Groups v2: Migrations invite others"),
details: LocalizationNotNeeded("Group migrations will invite other members."))
@objc
public static let groupsV2migrationsIgnoreMigrationCapability = false
@objc
public static let aggressiveProfileFetching = TestableFlag(false,
title: LocalizationNotNeeded("Aggressive profile fetching"),
details: LocalizationNotNeeded("Client will update profiles aggressively."))
@objc
public static let groupsV2ignoreCorruptInvites = false
@objc
public static let groupsV2memberStatusIndicators = build.includes(.qa)
@objc
public static let isMessageProcessingVerbose = false
// Currently this flag is only honored by NetworkManager,
// but we could eventually honor in other places as well:
//
// * The socket manager.
// * Places we make requests using tasks.
@objc
public static let logCurlOnSuccess = false
// Our "group update" info messages should be robust to
// various situations that shouldn't occur in production,
// bug we want to be able to test them using the debug UI.
@objc
public static let permissiveGroupUpdateInfoMessages = build.includes(.dev)
@objc
public static let showContextMenuDebugRects = false
@objc
public static let verboseNotificationLogging = build.includes(.qa)
@objc
public static let verboseSignalRecipientLogging = build.includes(.qa)
@objc
public static let shouldMergeUserProfiles = build.includes(.qa)
@objc
public static let deviceTransferVerboseProgressLogging = build.includes(.qa)
@objc
public static let reactWithThumbsUpFromLockscreen = build.includes(.qa)
@objc
public static let messageDetailsExtraInfo = build.includes(.qa)
@objc
public static let exposeCensorshipCircumvention = build.includes(.qa)
@objc
public static let allowV1GroupsUpdates = build.includes(.qa)
@objc
public static let forceGroupCalling = build.includes(.beta)
@objc
public static let disableMessageProcessing = TestableFlag(false,
title: LocalizationNotNeeded("Disable message processing"),
details: LocalizationNotNeeded("Client will store but not process incoming messages."))
@objc
public static let dontSendContactOrGroupSyncMessages = TestableFlag(false,
title: LocalizationNotNeeded("Don't send contact or group sync messages"),
details: LocalizationNotNeeded("Client will not send contact or group info to linked devices."))
@objc
public static let forceAttachmentDownloadFailures = TestableFlag(false,
title: LocalizationNotNeeded("Force attachment download failures."),
details: LocalizationNotNeeded("All attachment downloads will fail."))
@objc
public static let forceAttachmentDownloadPendingMessageRequest = TestableFlag(false,
title: LocalizationNotNeeded("Attachment download vs. message request."),
details: LocalizationNotNeeded("Attachment downloads will be blocked by pending message request."))
@objc
public static let forceAttachmentDownloadPendingManualDownload = TestableFlag(false,
title: LocalizationNotNeeded("Attachment download vs. manual download."),
details: LocalizationNotNeeded("Attachment downloads will be blocked by manual download."))
@objc
public static let fastPerfTests = false
@objc
public static let forceDonorBadgeDisplay = build.includes(.qa)
@objc
public static let forceDonorBadgeAcquisition = build.includes(.qa)
@objc
public static let forceSubscriptionMegaphone = build.includes(.qa)
@objc
public static let extraDebugLogs = build.includes(.openPreview)
@objc
public static let fakeLinkedDevices = false
@objc
public static let paymentsOnlyInContactThreads = true
@objc
public static let paymentsIgnoreBlockTimestamps = TestableFlag(false,
title: LocalizationNotNeeded("Payments: Ignore ledger block timestamps"),
details: LocalizationNotNeeded("Payments will not fill in missing ledger block timestamps"))
@objc
public static let paymentsIgnoreCurrencyConversions = TestableFlag(false,
title: LocalizationNotNeeded("Payments: Ignore currency conversions"),
details: LocalizationNotNeeded("App will behave as though currency conversions are unavailable"))
@objc
public static let paymentsHaltProcessing = TestableFlag(false,
title: LocalizationNotNeeded("Payments: Halt Processing"),
details: LocalizationNotNeeded("Processing of payments will pause"))
@objc
public static let paymentsIgnoreBadData = TestableFlag(false,
title: LocalizationNotNeeded("Payments: Ignore bad data"),
details: LocalizationNotNeeded("App will skip asserts for invalid data"))
@objc
public static let paymentsFailOutgoingSubmission = TestableFlag(false,
title: LocalizationNotNeeded("Payments: Fail outgoing submission"),
details: LocalizationNotNeeded("Submission of outgoing transactions will always fail"))
@objc
public static let paymentsFailOutgoingVerification = TestableFlag(false,
title: LocalizationNotNeeded("Payments: Fail outgoing verification"),
details: LocalizationNotNeeded("Verification of outgoing transactions will always fail"))
@objc
public static let paymentsFailIncomingVerification = TestableFlag(false,
title: LocalizationNotNeeded("Payments: Fail incoming verification"),
details: LocalizationNotNeeded("Verification of incoming receipts will always fail"))
@objc
public static let paymentsDoubleNotify = TestableFlag(false,
title: LocalizationNotNeeded("Payments: Double notify"),
details: LocalizationNotNeeded("App will send two payment notifications and sync messages for each outgoing payment"))
@objc
public static let paymentsNoRequestsComplete = TestableFlag(false,
title: LocalizationNotNeeded("Payments: No requests complete"),
details: LocalizationNotNeeded("MC SDK network activity never completes"))
@objc
public static let paymentsMalformedMessages = TestableFlag(false,
title: LocalizationNotNeeded("Payments: Malformed messages"),
details: LocalizationNotNeeded("Payment notifications and sync messages are malformed."))
@objc
public static let paymentsSkipSubmissionAndOutgoingVerification = TestableFlag(false,
title: LocalizationNotNeeded("Payments: Skip Submission And Verification"),
details: LocalizationNotNeeded("Outgoing payments won't be submitted or verified."))
@objc
public static let messageSendsFail = TestableFlag(false,
title: LocalizationNotNeeded("Message Sends Fail"),
details: LocalizationNotNeeded("All outgoing message sends will fail."))
@objc
public static let disableUD = TestableFlag(false,
title: LocalizationNotNeeded("Disable sealed sender"),
details: LocalizationNotNeeded("Sealed sender will be disabled for all messages."))
@objc
public static let callingUseTestSFU = TestableFlag(false,
title: LocalizationNotNeeded("Calling: Use Test SFU"),
details: LocalizationNotNeeded("Group calls will connect to sfu.test.voip.signal.org."))
@objc
public static let delayedMessageResend = TestableFlag(false,
title: LocalizationNotNeeded("Sender Key: Delayed message resend"),
details: LocalizationNotNeeded("Waits 10s before responding to a resend request."))
@objc
public static let showFailedDecryptionPlaceholders = TestableFlag(false,
title: LocalizationNotNeeded("Sender Key: Show failed decryption placeholders"),
details: LocalizationNotNeeded("Shows placeholder interactions in the conversation list."))
@objc
public static let fastPlaceholderExpiration = TestableFlag(false,
title: LocalizationNotNeeded("Sender Key: Early placeholder expiration"),
details: LocalizationNotNeeded("Shortens the valid window for message resend+recovery."),
toggleHandler: { _ in
databaseStorage.read { messageDecrypter.schedulePlaceholderCleanup(transaction: $0)}
})
@objc
public static let forceChangePhoneNumberUI = TestableFlag(build.includes(.beta),
title: LocalizationNotNeeded("Force 'change phone number' UI."),
details: LocalizationNotNeeded("The UI will appear in settings."))
@objc
public static let deviceTransferPreserveOldDevice = false
@objc
public static let deviceTransferThrowAway = false
public static func buildFlagMap() -> [String: Any] {
BaseFlags.buildFlagMap(for: DebugFlags.self) { (key: String) -> Any? in
DebugFlags.value(forKey: key)
}
}
public static var allTestableFlags: [TestableFlag] {
BaseFlags.findTestableFlags(for: DebugFlags.self) { (key: String) -> Any? in
DebugFlags.value(forKey: key)
}
}
@objc
public static func logFlags() {
let logFlag = { (prefix: String, key: String, value: Any?) in
if let flag = value as? TestableFlag {
Logger.info("\(prefix): \(key) = \(flag.get())", function: "")
} else if let value = value {
Logger.info("\(prefix): \(key) = \(value)", function: "")
} else {
Logger.info("\(prefix): \(key) = nil", function: "")
}
}
let flagMap = buildFlagMap()
for key in Array(flagMap.keys).sorted() {
let value = flagMap[key]
logFlag("DebugFlag", key, value)
}
}
}
// MARK: -
@objc
public class BaseFlags: NSObject {
static func buildFlagMap(for flagsClass: Any, flagFunc: (String) -> Any?) -> [String: Any] {
var result = [String: Any]()
var count: CUnsignedInt = 0
let methods = class_copyPropertyList(object_getClass(flagsClass), &count)!
for i in 0 ..< count {
let selector = property_getName(methods.advanced(by: Int(i)).pointee)
if let key = String(cString: selector, encoding: .utf8) {
guard !key.hasPrefix("_") else {
continue
}
if let value = flagFunc(key) {
result[key] = value
}
}
}
return result
}
static func findTestableFlags(for flagsClass: Any, flagFunc: (String) -> Any?) -> [TestableFlag] {
var result = [TestableFlag]()
var count: CUnsignedInt = 0
let methods = class_copyPropertyList(object_getClass(flagsClass), &count)!
for i in 0 ..< count {
let selector = property_getName(methods.advanced(by: Int(i)).pointee)
if let key = String(cString: selector, encoding: .utf8) {
guard !key.hasPrefix("_") else {
continue
}
if let value = flagFunc(key) as? TestableFlag {
result.append(value)
}
}
}
return result
}
}
// MARK: -
@objc
public class TestableFlag: NSObject {
private let defaultValue: Bool
private let affectsCapabilities: Bool
private let flag: AtomicBool
public let title: String
public let details: String
public let toggleHandler: ((Bool) -> Void)?
fileprivate init(_ defaultValue: Bool,
title: String,
details: String,
affectsCapabilities: Bool = false,
toggleHandler: ((Bool) -> Void)? = nil) {
self.defaultValue = defaultValue
self.title = title
self.details = details
self.affectsCapabilities = affectsCapabilities
self.flag = AtomicBool(defaultValue)
self.toggleHandler = toggleHandler
super.init()
NotificationCenter.default.addObserver(forName: Self.ResetAllTestableFlagsNotification,
object: nil, queue: nil) { [weak self] _ in
guard let self = self else { return }
self.set(self.defaultValue)
}
}
@objc
@available(swift, obsoleted: 1.0)
public var value: Bool {
self.get()
}
@objc
public func get() -> Bool {
guard build.includes(.qa) else {
return defaultValue
}
return flag.get()
}
public func set(_ value: Bool) {
flag.set(value)
if affectsCapabilities {
updateCapabilities()
}
}
@objc
public func switchDidChange(_ sender: UISwitch) {
set(sender.isOn)
}
@objc
public var switchSelector: Selector {
#selector(switchDidChange(_:))
}
@objc
public static let ResetAllTestableFlagsNotification = NSNotification.Name("ResetAllTestableFlags")
private func updateCapabilities() {
firstly(on: .global()) { () -> Promise<Void> in
TSAccountManager.shared.updateAccountAttributes().asVoid()
}.done {
Logger.info("")
}.catch { error in
owsFailDebug("Error: \(error)")
}
}
}