Skip to content

Instantly share code, notes, and snippets.

@eoghain
Created April 7, 2022 00:02
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save eoghain/f93aad351017a06e3fe1d6c453221cc3 to your computer and use it in GitHub Desktop.
Save eoghain/f93aad351017a06e3fe1d6c453221cc3 to your computer and use it in GitHub Desktop.
Feature Flags
import UIKit
/// Struct identifying a Feature Flag that is used to enable/disable access to a feature
///
/// example:
/// ```
/// // Only allow this feature on iPhones in the US if the flag is enabled
/// let featureFlag = FeatureFlag(name: "EnableMyFeature", localeRestrictions: ["en_US"], deviceTypes: [.phone])
/// guard featureFlag.isEnabled else { return }
/// ```
struct FeatureFlag {
// MARK: - Properties
// MARK: Public
/// Flag name - matches the Key in UserDefaults & config.json file
var name: String
/// Default value to use when we don't have a value in UserDefaults (default: true)
var defaultValue: Bool
/// Human readable name used in Settings for overwriting (default: name value)
var displayName: String?
/// Optionally restrict to certain locales (leave empty for worldwide)
var localeRestrictions: [String]
/// Optionally restrict to certain languages (leave empty for all languages)
var languageRestrictions: [String]
/// Optionally restrict to a range of iOS versions
var minimumOSVersion: String?
var maximumOSVersion: String?
/// Optionally restrict to certain device types (leave empty for all languages)
var deviceRestrictions: [UIUserInterfaceIdiom]
/// Requires the user to have an account
var isAccountRequired: Bool
/// Show in the debug UI? (default: true)
var showInSettings: Bool
/// Ask to restart when changed in DebugSettings (default: false)
var needsRelaunch: Bool
// MARK: Computed
/// Is the current device valid for this FeatureFlag
var supportsCurrentDevice: Bool {
return isSupportedDeviceType()
}
/// Is the current OSVersion valid for this FeatureFlag
var supportsCurrentOSVersion: Bool {
return isSupportedOSVersion()
}
/// Verifies the flag is valid for the locale, language, device, os, and user and finally returns the set value
var isEnabled: Bool {
guard isSupportedLocale() else { return false }
guard isSupportedLanguage() else { return false }
guard isSupportedOSVersion() else { return false }
guard isSupportedDeviceType() else { return false }
guard isSupportedAccount() else { return false }
let flag = userDefaults.bool(forKey: self.name)
return isKillSwitch ? !flag : flag
}
// MARK: Private
/// Use this FeatureFlag with an inverted value (default: false)
///
/// If this is set the value used for determining the isEnabled state will be inverted!!!!
///
/// * If the UserDefaults value is **true** then this FeatureFlag will be disabled
/// * If the UserDefaults value is **false** then this FeatureFlag will be enabled
private var isKillSwitch: Bool
// Dependency injection
private var userDefaults: UserDefaults
private var uiDevice: UIDevice
// MARK: - Initialization
/**
Init with defaults to allow caller to only supply what is necessary.
- note: Name must be *Unique*, it will be stored in UserDefaults
- important: If **isKillSwitch** is true the value used for determining the isEnabled state will be inverted!!!!
* If the UserDefaults value is **true** then this FeatureFlag will be disabled
* If the UserDefaults value is **false** then this FeatureFlag will be enabled
- parameter name: The name of the JSON Key used in config.json for this feature flag, must be *Unique* (i.e. "EnableInviteFriendToFollowTopic")
- parameter isKillSwitch: Inverts the value from UserDefaults
- parameter defaultValue: The value to use if we don't find anything in UserDefaults
- parameter displayName: The human readable name for display in settings (i.e. "Invite Flow")
- parameter localeRestrictions: Array of locales where this feature can be enabled [] = all (i.e. ["en_US", "en_CA"])
- parameter languageRestrictions: Array of languages for this feature [] = all (i.e. ["en", "de"])
- parameter minimumOSVersion: String (i.e. "1.2.3")
- parameter maximumOSVersion: String (i.e. "1.4.5")
- parameter deviceRestrictions: Array of devices for this feature [] = all (i.e. [.phone, .pad])
- parameter isAccountRequired: Should the user have an account to use this feature
- parameter showInSettings: Allow the value to be overridden in the settings tool?
- parameter needsRelaunch: Used when changing the value in the *Debug Settings Tool*
- parameter mockUserDefaults: Used to inject a mock version of UserDefaults to be used for *Testing*
- parameter mockUIDevice: Used to inject a mock version of UIDevice to be used for *Testing*
*/
init(
name: String,
isKillSwitch: Bool = false,
defaultValue: Bool = true,
displayName: String? = nil,
localeRestrictions: [String] = [],
languageRestrictions: [String] = [],
minimumOSVersion: String? = nil,
maximumOSVersion: String? = nil,
deviceRestrictions: [UIUserInterfaceIdiom] = [],
isAccountRequired: Bool = false,
showInSettings: Bool = true,
needsRelaunch: Bool = false,
mockUserDefaults: UserDefaults = UserDefaults.standard,
mockUIDevice: UIDevice = UIDevice.current
) {
self.name = name
self.isKillSwitch = isKillSwitch
self.defaultValue = defaultValue
self.displayName = displayName ?? name // default to name if not supplied
self.localeRestrictions = localeRestrictions
self.languageRestrictions = languageRestrictions
self.minimumOSVersion = minimumOSVersion
self.maximumOSVersion = maximumOSVersion
self.deviceRestrictions = deviceRestrictions
self.isAccountRequired = isAccountRequired
self.showInSettings = showInSettings
self.needsRelaunch = needsRelaunch
self.userDefaults = mockUserDefaults
self.uiDevice = mockUIDevice
// Store default value in UserDefaults Registration Domin (last domain checked so it will be overridden by stored values)
// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/UserDefaults/AboutPreferenceDomains/AboutPreferenceDomains.html#//apple_ref/doc/uid/10000059i-CH2-SW1
userDefaults.register(defaults: [name: defaultValue])
}
// MARK: - Public Methods
/// Sets the value into UserDefaults for this FeatureFlag
/// - Parameter newValue: value used to determine if FeatureFlag isEnabled
func setValue(_ newValue: Bool) {
userDefaults.set(newValue, forKey: name)
}
var notEnabledReason: String {
var messages = [String]()
if !isSupportedLocale() {
messages.append("Disabled because \(currentLocale()) not in \(localeRestrictions)")
}
if !isSupportedLanguage() {
messages.append("Disabled because \(preferredLanguage()) not in \(languageRestrictions)")
}
if !isSupportedOSVersion() {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let osVersionString = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
messages.append("Disabled because OS Version \(osVersionString) not between \(String(describing: minimumOSVersion)) and \(String(describing: maximumOSVersion))")
}
if !isSupportedDeviceType() {
messages.append("Disabled because \(uiDevice.userInterfaceIdiom) not in \(deviceRestrictions)")
}
if !isSupportedAccount() {
messages.append("Disabled because account required")
}
if userDefaults.bool(forKey: self.name) == false {
messages.append("Disabled because UserDefaults value is false")
}
return messages.joined(separator: "\n")
}
// MARK: - Internal Checks
private func isSupportedLocale() -> Bool {
// No restrictions so always true
guard localeRestrictions.isEmpty == false else { return true }
// If we don't find our current locale in restriction list then it's not supported
guard localeRestrictions.contains(currentLocale()) else { return false }
return true
}
private func isSupportedLanguage() -> Bool {
// No restrictions so always true
guard languageRestrictions.isEmpty == false else { return true }
// If we don't find our current language in restriction list then it's not supported
guard languageRestrictions.contains(preferredLanguage()) else { return false }
return true
}
private func isSupportedOSVersion() -> Bool {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let osVersionString = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let minimumOSVersion = self.minimumOSVersion ?? osVersionString
let maximumOSVersion = self.maximumOSVersion ?? osVersionString
guard minimumOSVersion.versionCompare(osVersionString) != .orderedDescending,
maximumOSVersion.versionCompare(osVersionString) != .orderedAscending else { return false }
return true
}
private func isSupportedDeviceType() -> Bool {
// No restrictions so always true
guard deviceRestrictions.isEmpty == false else { return true }
// If we don't find our current userInterfaceIdiom in deviceRestrictions then it's not supported
guard deviceRestrictions.contains(uiDevice.userInterfaceIdiom) else { return false }
return true
}
// MARK: Helper Methods
private func currentLocale() -> String {
return NSLocale.current.identifier
}
private func preferredLanguage() -> String {
return NSLocale.preferredLanguages.first ?? ""
}
}
// MARK: - Objective-C Shim
// Obj-c Shim concept taken from this blog post
// https://www.steveonstuff.com/2022/01/13/migrating-from-objc-to-swift
/// FeatureFlag_ObjC is a shim for using the FeatureFlag struct in Objective-C.
@objc
class FeatureFlag_ObjC: NSObject {
let featureFlag: FeatureFlag
init(featureFlag: FeatureFlag) {
self.featureFlag = featureFlag
super.init()
}
/// Flag name - matches the Key in UserDefaults & config.json file
@objc var name: String {
return featureFlag.name
}
/// Default value to use when we don't have a value in UserDefaults (default: true)
@objc var defaultValue: Bool {
return featureFlag.defaultValue
}
/// Verifies the flag is valid for the locale, language, device, os, and user and finally returns the set value
@objc var isEnabled: Bool {
return featureFlag.isEnabled
}
/// Sets the value into UserDefaults for this FeatureFlag
/// - Parameter newValue: value used to determine if FeatureFlag isEnabled
@objc func setValue(_ newValue: Bool) {
self.featureFlag.setValue(newValue)
}
}
import Foundation
/// Feature Flags
///
/// How to create a Feature Flag:
/// 1. Create a static let Feature Flag in the **Feature Flags** section
/// 2. Add the static name to the allFlags array
/// 3. If you need Objective-C access to this FeatureFlag add a line to the FeatureFlags_ObjC class below in the **ObjC Feature Flags** section
struct FeatureFlags {
// Array of all flags, add your flag here as well as creating the static version below
static let allFlags: [FeatureFlag] = [
inviteFlow,
passwordStrengthMeter
]
// MARK: Feature Flags
static let sample = FeatureFlag(
name: "SampleFeatureFlag",
displayName: "Sample Feature Flag",
localeRestrictions: ["en_US", "en_CA"]
)
// DefaultValue should be false for KillSwitches to let the server supplied value overwrite if it exists.
static let sampleKillSwitch = FeatureFlag(
name: "SampleKillSwitch",
isKillSwitch: true,
defaultValue: false,
displayName: "Sample Kill Switch",
languageRestrictions: ["en"])
}
// MARK: - Objective-C Shim
// Obj-c Shim concept taken from this blog post
// https://www.steveonstuff.com/2022/01/13/migrating-from-objc-to-swift
/// FeatureFlags_ObjC is a shim for using the FeatureFlags struct in Objective-C.
@objc
class FeatureFlags_ObjC: NSObject {
@objc static var allFlags: [FeatureFlag_ObjC] {
// Return shim'd flags
return FeatureFlags.allFlags.map { FeatureFlag_ObjC(featureFlag: $0) }
}
// MARK: ObjC Feature Flags
@objc static let inviteFlow = FeatureFlag_ObjC(featureFlag: FeatureFlags.inviteFlow)
@objc static let personalizeForYou = FeatureFlag_ObjC(featureFlag: FeatureFlags.personalizeForYou)
@objc static let historyEnabled = FeatureFlag_ObjC(featureFlag: FeatureFlags.historyEnabled)
@objc static let passwordStrengthMeter = FeatureFlag_ObjC(featureFlag: FeatureFlags.passwordStrengthMeter)
}
import XCTest
class FeatureFlagTests: XCTestCase {
private var userDefaults: UserDefaults!
override func setUpWithError() throws {
try? super.setUpWithError()
// Create local injectable UserDefaults object
userDefaults = UserDefaults(suiteName: #file)
userDefaults.removePersistentDomain(forName: #file)
}
func testDefaultDisplayName() {
let flagName = "ThisIsATest"
let featureFlag = FeatureFlag(name: flagName, mockUserDefaults: userDefaults)
XCTAssertTrue(featureFlag.displayName == flagName, "Display name not defaulting when left empty")
}
func testDisplayName() {
let flagName = "ThisIsATest"
let displayName = "This is a test feature flag"
let featureFlag = FeatureFlag(name: flagName, displayName: displayName, mockUserDefaults: userDefaults)
XCTAssertTrue(featureFlag.displayName == displayName, "Display name getting overwritten even when supplied")
}
func testEnabled() {
let flagName = "ThisIsATest"
userDefaults.set(true, forKey: flagName)
let featureFlag = FeatureFlag(name: flagName, mockUserDefaults: userDefaults)
XCTAssertTrue(featureFlag.isEnabled, featureFlag.disabledReason)
}
func testValueFromUserDefaults() {
let flagName = "ThisIsATest"
let flagName2 = "ThisIsAnotherTest"
userDefaults.set(false, forKey: flagName)
userDefaults.set(true, forKey: flagName2)
let defaultEnabled = FeatureFlag(name: flagName, mockUserDefaults: userDefaults)
let defaultDisabled = FeatureFlag(name: flagName2, defaultValue: false, mockUserDefaults: userDefaults)
XCTAssertFalse(defaultEnabled.isEnabled, defaultEnabled.disabledReason)
XCTAssertTrue(defaultDisabled.isEnabled, defaultDisabled.disabledReason)
}
func testSupportedLocale() {
let flagName = "ThisIsATest"
userDefaults.set(true, forKey: flagName)
MockLocale.swizzleCurrentLocale()
MockLocale.mockLocaleIdentifier = "en_US"
let usFlag = FeatureFlag(name: flagName, localeRestrictions: ["en_US"], mockUserDefaults: userDefaults)
let caFlag = FeatureFlag(name: flagName, localeRestrictions: ["en_CA"], mockUserDefaults: userDefaults)
XCTAssertTrue(usFlag.isEnabled, usFlag.disabledReason)
XCTAssertFalse(caFlag.isEnabled, "Locale restricted FeatureFlag is enabled when it shouldn't be")
MockLocale.swizzleCurrentLocale()
MockLocale.mockLocaleIdentifier = nil
}
func testSupportedLanguage() {
let flagName = "ThisIsATest"
userDefaults.set(true, forKey: flagName)
let enFlag = FeatureFlag(name: flagName, languageRestrictions: ["en"], mockUserDefaults: userDefaults)
let deFlag = FeatureFlag(name: flagName, languageRestrictions: ["de"], mockUserDefaults: userDefaults)
// preferred language when running tests should always be "en"
XCTAssertTrue(enFlag.isEnabled, enFlag.disabledReason)
XCTAssertFalse(deFlag.isEnabled, "Language restricted FeatureFlag is enabled when it shouldn't be")
}
func testSupportedOS() {
let flagName = "ThisIsATest"
userDefaults.set(true, forKey: flagName)
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let osVersionString = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let minOSString = "\(osVersion.majorVersion - 1).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let maxOSString = "\(osVersion.majorVersion + 1).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let supported = FeatureFlag(name: flagName, minimumOSVersion: osVersionString, maximumOSVersion: osVersionString, mockUserDefaults: userDefaults)
let belowMinimum = FeatureFlag(name: flagName, minimumOSVersion: maxOSString, mockUserDefaults: userDefaults)
let overMaximum = FeatureFlag(name: flagName, maximumOSVersion: minOSString, mockUserDefaults: userDefaults)
XCTAssertTrue(supported.isEnabled, supported.disabledReason)
XCTAssertFalse(belowMinimum.isEnabled, "Below minimum OS Version FeatureFlag is enabled when it shouldn't be")
XCTAssertFalse(overMaximum.isEnabled, "Over maximum OS Version FeatureFlag is enabled when it shouldn't be")
XCTAssertTrue(supported.supportsCurrentOSVersion, supported.disabledReason)
XCTAssertFalse(overMaximum.supportsCurrentOSVersion, overMaximum.disabledReason)
}
func testSupportedDeviceType() {
let flagName = "ThisIsATest"
userDefaults.set(true, forKey: flagName)
let mockDevice = MockDevice()
mockDevice.mockUserInterfaceIdiom = .pad
let phoneFlag = FeatureFlag(name: flagName, deviceRestrictions: [.phone], mockUserDefaults: userDefaults, mockUIDevice: mockDevice)
let padFlag = FeatureFlag(name: flagName, deviceRestrictions: [.pad], mockUserDefaults: userDefaults, mockUIDevice: mockDevice)
let multiFlag = FeatureFlag(name: flagName, deviceRestrictions: [.phone, .pad], mockUserDefaults: userDefaults, mockUIDevice: mockDevice)
XCTAssertFalse(phoneFlag.isEnabled, "Device restricted FeatureFlag is enabled when it shouldn't be")
XCTAssertTrue(padFlag.isEnabled, padFlag.disabledReason)
XCTAssertTrue(multiFlag.isEnabled, multiFlag.disabledReason)
XCTAssertTrue(padFlag.supportsCurrentDevice, padFlag.disabledReason)
XCTAssertFalse(phoneFlag.supportsCurrentDevice, "Device restricted FeatureFlag is enabled when it shouldn't be")
}
}
class MockDevice: UIDevice {
var mockUserInterfaceIdiom: UIUserInterfaceIdiom = .phone
override var userInterfaceIdiom: UIUserInterfaceIdiom {
return mockUserInterfaceIdiom
}
}
import Foundation
class MockLocale {
static var mockLocaleIdentifier: String?
// MARK: -
// Swizzle current locale to be able to test it with different values
static func swizzleCurrentLocale() {
let originalMethod = class_getClassMethod(NSLocale.self, #selector(getter: NSLocale.current))!
let swizzledMethod = class_getClassMethod(Self.self, #selector(Self.mockCurrentLocale))!
method_exchangeImplementations(originalMethod, swizzledMethod)
}
@objc
private static func mockCurrentLocale() -> NSLocale {
guard let mockLocaleIdentifier = mockLocaleIdentifier, !mockLocaleIdentifier.isEmpty else {
fatalError("mockLocaleIdentifier required to be set to a non-nil, non-empty value.")
}
return NSLocale(localeIdentifier: mockLocaleIdentifier)
}
}
// MARK: - String Extension for semantic version comparison
extension String {
func versionCompare(_ otherVersion: String) -> ComparisonResult {
let versionDelimiter = "."
var version = self
var otherVersion = otherVersion
// Separate strings by "." (i.e. "1.0.0" -> ["1", "0", "0"])
var versionComponents = self.components(separatedBy: versionDelimiter)
var otherVersionComponents = otherVersion.components(separatedBy: versionDelimiter)
let sectionDiffCount = versionComponents.count - otherVersionComponents.count
// The 2 strings have different numbers of sections (i.e. "1.0" and "1.0.0")
if sectionDiffCount != 0 {
// Create additional Zeros to be added to the shorter string
let additionalZeros = Array(repeating: "0", count: abs(sectionDiffCount))
if sectionDiffCount > 0 {
otherVersionComponents.append(contentsOf: additionalZeros)
} else {
versionComponents.append(contentsOf: additionalZeros)
}
// Convert arrays back into strings (i.e. ["1","0","0"] -> "1.0.0"
version = versionComponents.joined(separator: versionDelimiter)
otherVersion = otherVersionComponents.joined(separator: versionDelimiter)
}
// Do numeric string comparison
return version.compare(otherVersion, options: .numeric)
}
}
import XCTest
class StringVersionComparisonTest: XCTestCase {
func testMatching() {
XCTAssertTrue("1.0.0".versionCompare("1.0.0") == .orderedSame, "Exact same version numbers don't match")
XCTAssertTrue("0.1.0".versionCompare("0.1.0") == .orderedSame, "Exact same version numbers don't match")
XCTAssertTrue("1".versionCompare("1") == .orderedSame, "Exact same version numbers don't match")
XCTAssertTrue("1.0".versionCompare("1.0") == .orderedSame, "Exact same version numbers don't match")
XCTAssertTrue("1.0.1".versionCompare("1.0.1") == .orderedSame, "Exact same version numbers don't match")
}
func testNewer() {
XCTAssertTrue("1.0.0".versionCompare("2.0.0") == .orderedAscending, "Versions should be Ascending but aren't")
XCTAssertTrue("1.0.0".versionCompare("1.0.1") == .orderedAscending, "Versions should be Ascending but aren't")
XCTAssertTrue("1.0.0".versionCompare("1.1.0") == .orderedAscending, "Versions should be Ascending but aren't")
XCTAssertTrue("14.6.80".versionCompare("14.7.80") == .orderedAscending, "Versions should be Ascending but aren't")
}
func testOlder() {
XCTAssertTrue("2.0.0".versionCompare("1.0.0") == .orderedDescending, "Versions should be Descending but aren't")
XCTAssertTrue("2.2.2".versionCompare("2.2.1") == .orderedDescending, "Versions should be Descending but aren't")
XCTAssertTrue("2.2.2".versionCompare("2.1.2") == .orderedDescending, "Versions should be Descending but aren't")
XCTAssertTrue("14.7.80".versionCompare("14.6.80") == .orderedDescending, "Versions should be Descending but aren't")
}
func testMismatched() {
XCTAssertTrue("1.0.0".versionCompare("1") == .orderedSame, "Exact same version numbers don't match")
XCTAssertTrue("1.0.0".versionCompare("1.0") == .orderedSame, "Exact same version numbers don't match")
XCTAssertTrue("14.6.80".versionCompare("14.7") == .orderedAscending, "Versions should be Ascending but aren't")
XCTAssertTrue("14.6.80".versionCompare("15") == .orderedAscending, "Versions should be Ascending but aren't")
XCTAssertTrue("14.7".versionCompare("14.6.80") == .orderedDescending, "Versions should be Descending but aren't")
XCTAssertTrue("15".versionCompare("14.6.80") == .orderedDescending, "Versions should be Descending but aren't")
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment