├── .xcode-version ├── .ruby-version ├── TweakAccessorGenerator ├── img └── just_tweak_banner.png ├── Example ├── JustTweak │ ├── CodeGeneration │ │ └── config.json │ ├── Accessors │ │ ├── GeneratedConfigurationAccessor+ExampleProtocol.swift │ │ ├── GeneratedTweakAccessor+Constants.swift │ │ ├── GeneratedTweakAccessor.swift │ │ └── TweakAccessor.swift │ ├── Protocols │ │ └── ExampleProtocol.swift │ ├── Assets │ │ ├── Images.xcassets │ │ │ └── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ ├── GoogleService-Info.plist │ │ └── ExampleOptimizelyDatafile.json │ ├── TweakProviders │ │ ├── LocalTweaks_TopPriority_example.json │ │ ├── LocalTweaks_example.json │ │ ├── FirebaseTweakProvider.swift │ │ └── OptimizelyTweakProvider.swift │ ├── Info.plist │ ├── UI │ │ └── Base.lproj │ │ │ └── LaunchScreen.xib │ └── Code │ │ ├── AppDelegate.swift │ │ └── ViewController.swift ├── JustTweak.xcodeproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── xcshareddata │ │ └── xcschemes │ │ └── JustTweak-Example.xcscheme ├── JustTweak.xcworkspace │ ├── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ │ └── Package.resolved │ └── contents.xcworkspacedata ├── Podfile.lock ├── Tests │ ├── UI │ │ ├── BooleanTweakTableViewCellTests.swift │ │ ├── TextTweakTableViewCellTests.swift │ │ └── TweakViewControllerTests.swift │ ├── LocalTweaks_test_override.json │ ├── TestHelpers │ │ ├── TestHelpers.swift │ │ └── CustomOperators.swift │ ├── Info.plist │ ├── UnitTests.xctestplan │ ├── Core │ │ ├── EphemeralTweakProviderTests.swift │ │ ├── TweaksUtilitiesTests.swift │ │ ├── TweakExtensionsTests.swift │ │ ├── LocalTweakProviderTests.swift │ │ ├── UserDefaultsTweakProviderTests.swift │ │ ├── TweakManagerCacheTests.swift │ │ ├── TweakManager+PresentationTests.swift │ │ ├── TweakTests.swift │ │ ├── PropertyWrappersTests.swift │ │ └── TweakManagerTests.swift │ └── LocalTweaks_test.json ├── Podfile └── Shared │ └── Symbols.swift ├── Gemfile ├── cli-tools └── TweakAccessorGenerator │ ├── TweakAccessorGenerator │ ├── Assets │ │ ├── TestConfiguration1 │ │ │ └── config.json │ │ └── TestConfiguration2 │ │ │ └── config.json │ ├── Core │ │ ├── Types.swift │ │ ├── TweakValue.swift │ │ ├── Models.swift │ │ ├── TweakLoader.swift │ │ └── TweakAccessorCodeGenerator.swift │ ├── Extensions │ │ ├── Array+Duplicates.swift │ │ ├── NSNumber+ValueTypes.swift │ │ └── String+Casing.swift │ └── main.swift │ ├── TweakAccessorGenerator.xcodeproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ ├── TweakAccessorGenerator.xcscheme │ │ └── TweakAccessorGenerator-Release.xcscheme │ └── TweakAccessorGeneratorTests │ ├── Assets │ ├── InvalidTweaks_InvalidJSON.json │ ├── InvalidTweaks_MissingValues.json │ ├── InvalidTweaks_DuplicateGeneratedPropertyName.json │ ├── GeneratedTweakAccessor+ConstantsContent │ ├── ValidTweaks_TopPriority.json │ ├── ValidTweaks_LowPriority.json │ ├── Tweaks.json │ └── GeneratedTweakAccessorContent │ ├── Suites │ ├── Array+DuplicatesTests.swift │ ├── NSNumber+ValueTypesTests.swift │ ├── String+CasingTests.swift │ ├── TweakAccessorCodeGeneratorTests.swift │ └── TweakLoaderTests.swift │ ├── Info.plist │ └── UnitTests.xctestplan ├── Framework └── Sources │ ├── Resources │ └── Localizations.bundle │ │ └── en.lproj │ │ └── Localizable.strings │ ├── Extensions │ ├── Bundle+JustTweak.swift │ └── TweakExtensions.swift │ ├── UI │ ├── Cells │ │ ├── NumericTweakTableViewCell.swift │ │ ├── BooleanTweakTableViewCell.swift │ │ └── TextTweakTableViewCell.swift │ └── TweakManager+Presentation.swift │ ├── Errors │ └── TweakError.swift │ ├── Protocols │ └── TweakProvider.swift │ ├── DTOs │ ├── TweakValue.swift │ └── Tweak.swift │ ├── TweakProviders │ ├── EphemeralTweakProvider.swift │ ├── UserDefaultsTweakProvider.swift │ └── LocalTweakProvider.swift │ ├── Utilities │ └── PropertyWrappers.swift │ └── TweakManager.swift ├── Package.swift ├── .github ├── workflows │ ├── publish-to-trunk-workflow.yml │ └── pull-request-workflow.yml └── PULL_REQUEST_TEMPLATE.md ├── fastlane ├── README.md └── Fastfile ├── .gitignore ├── JustTweak.podspec └── Gemfile.lock /.xcode-version: -------------------------------------------------------------------------------- 1 | 10.2 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.0.2 2 | 3 | -------------------------------------------------------------------------------- /TweakAccessorGenerator: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justeat/JustTweak/HEAD/TweakAccessorGenerator -------------------------------------------------------------------------------- /img/just_tweak_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/justeat/JustTweak/HEAD/img/just_tweak_banner.png -------------------------------------------------------------------------------- /Example/JustTweak/CodeGeneration/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessorName": "GeneratedTweakAccessor" 3 | } 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | ruby '3.0.2' 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'cocoapods', '~> 1.10.2' 6 | gem 'fastlane', '~> 2.191.0' 7 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator/Assets/TestConfiguration1/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessorName": "GeneratedTweakAccessor" 3 | } 4 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator/Assets/TestConfiguration2/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessorName": "GeneratedTweakAccessor" 3 | } 4 | -------------------------------------------------------------------------------- /Example/JustTweak.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Framework/Sources/Resources/Localizations.bundle/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | "just_tweak_configurations_vc_title" = "Edit Configuration"; 2 | "just_tweak_unnamed_tweaks_group_title" = "Other"; 3 | "just_tweak_search_bar_placeholder_text" = "Search Tweaks"; 4 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/JustTweak/Accessors/GeneratedConfigurationAccessor+ExampleProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneratedConfigurationAccessor+ExampleProtocol.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | extension GeneratedTweakAccessor: ExampleProtocol { } 9 | -------------------------------------------------------------------------------- /Example/JustTweak.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/JustTweak.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Framework/Sources/Extensions/Bundle+JustTweak.swift: -------------------------------------------------------------------------------- 1 | // Bundle+JustTweak.swift 2 | 3 | import Foundation 4 | 5 | public extension Bundle { 6 | class var justTweak: Bundle { 7 | #if SWIFT_PACKAGE 8 | Bundle.module 9 | #else 10 | Bundle(identifier: "org.cocoapods.JustTweak")! 11 | #endif 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Example/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - JustTweak (10.0.3) 3 | 4 | DEPENDENCIES: 5 | - JustTweak (from `../`) 6 | 7 | EXTERNAL SOURCES: 8 | JustTweak: 9 | :path: "../" 10 | 11 | SPEC CHECKSUMS: 12 | JustTweak: 57e7c41add4d1ec30d3b18d50be2403125f47e29 13 | 14 | PODFILE CHECKSUM: 60571fcd23f115b554ccbb8c325e342cef52d624 15 | 16 | COCOAPODS: 1.10.2 17 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Framework/Sources/UI/Cells/NumericTweakTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NumericTweakTableViewCell 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | 8 | class NumericTweakTableViewCell: TextTweakTableViewCell { 9 | 10 | override var keyboardType: UIKeyboardType { 11 | get { 12 | return .numberPad 13 | } 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Assets/InvalidTweaks_InvalidJSON.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": { 3 | "answer_to_the_universe": { 4 | "Title": "Definitive answer" 5 | "Description": "Answer to the Ultimate Question of Life, the Universe, and Everything" 6 | "Group": "General" 7 | "Value": 42 8 | "GeneratedPropertyName": "definitiveAnswer" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator/Core/Types.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Types.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | typealias TweaksFormat = [FeatureKey: FeatureFormat] 9 | typealias FeatureFormat = [VariableKey: TweakFormat] 10 | typealias TweakFormat = [String: Any] 11 | 12 | typealias FeatureKey = String 13 | typealias VariableKey = String 14 | -------------------------------------------------------------------------------- /Example/JustTweak.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator/Core/TweakValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakValue.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public protocol TweakValue: CustomStringConvertible {} 9 | 10 | extension Bool: TweakValue {} 11 | extension Int: TweakValue {} 12 | extension Float: TweakValue {} 13 | extension Double: TweakValue {} 14 | extension String: TweakValue {} 15 | -------------------------------------------------------------------------------- /Framework/Sources/Errors/TweakError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakError.swift 3 | // Copyright (c) 2022 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public enum TweakError: String, Error { 9 | case notFound = "Feature or variable is not found" 10 | case notSupported = "Variable type is not supported" 11 | case decryptionClosureNotProvided = "Value is encrypted but there's no decryption closure provided" 12 | } 13 | -------------------------------------------------------------------------------- /Example/JustTweak.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-argument-parser", 6 | "repositoryURL": "https://github.com/apple/swift-argument-parser.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "6b2aa2748a7881eebb9f84fb10c01293e15b52ca", 10 | "version": "0.5.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "swift-argument-parser", 6 | "repositoryURL": "https://github.com/apple/swift-argument-parser.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "6b2aa2748a7881eebb9f84fb10c01293e15b52ca", 10 | "version": "0.5.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator/Core/Models.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Models.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | struct Tweak: Equatable { 9 | let feature: String 10 | let variable: String 11 | let title: String 12 | let description: String? 13 | let group: String 14 | let valueType: String 15 | let propertyName: String? 16 | } 17 | 18 | struct Configuration: Decodable { 19 | let accessorName: String 20 | } 21 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator/Extensions/Array+Duplicates.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+Duplicates.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | extension Array where Element: Hashable { 9 | 10 | var duplicates: Array { 11 | let groups = Dictionary(grouping: self, by: { $0 }) 12 | let duplicateGroups = groups.filter { $1.count > 1 } 13 | let duplicates = Array(duplicateGroups.keys) 14 | return duplicates 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "JustTweak", 7 | defaultLocalization: "en", 8 | platforms: [ 9 | .iOS(.v11) 10 | ], 11 | products: [ 12 | .library( 13 | name: "JustTweak", 14 | targets: ["JustTweak"]), 15 | ], 16 | targets: [ 17 | .target( 18 | name: "JustTweak", 19 | path: "Framework/Sources", 20 | resources: [.process("Resources")] 21 | ) 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /Example/JustTweak/Protocols/ExampleProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneratedConfigurationAccessor+ExampleProtocol.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | protocol ExampleProtocol { 9 | var meaningOfLife: Int { get } 10 | var shouldShowAlert: Bool { get } 11 | var isTapGestureToChangeColorEnabled: Bool { get } 12 | var canShowGreenView: Bool { get } 13 | var canShowRedView: Bool { get } 14 | var canShowYellowView: Bool { get } 15 | var labelText: String { get } 16 | var redViewAlpha: Double { get } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-trunk-workflow.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Trunk 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | build: 8 | runs-on: macOS-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - name: Install Cocoapods 12 | run: gem install cocoapods 13 | - name: Deploy to Cocoapods 14 | run: | 15 | set -eo pipefail 16 | export LIB_VERSION=$(git describe --tags `git rev-list --tags --max-count=1`) 17 | pod lib lint --allow-warnings 18 | pod trunk push --allow-warnings 19 | env: 20 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} 21 | -------------------------------------------------------------------------------- /Example/Tests/UI/BooleanTweakTableViewCellTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BooleanTweakTableViewCellTests.swift 3 | // Copyright (c) 2019 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | @testable import JustTweak 8 | 9 | class BooleanTweakTableViewCellTests: XCTestCase { 10 | 11 | func testInformsDelegateOfValueChanges() { 12 | let mockDelegate = MockTweakCellDelegate() 13 | let cell = BooleanTweakTableViewCell() 14 | cell.delegate = mockDelegate 15 | cell.switchControl.sendActions(for: .valueChanged) 16 | XCTAssertTrue(mockDelegate.didCallDelegate) 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Example/Podfile: -------------------------------------------------------------------------------- 1 | source 'https://cdn.cocoapods.org/' 2 | 3 | platform :ios, '11.0' 4 | use_frameworks! 5 | 6 | target 'JustTweak_Example' do 7 | pod 'JustTweak', :path => '../' 8 | script_phase :name => 'TweakAccessorGenerator', 9 | :script => '$SRCROOT/../TweakAccessorGenerator \ 10 | -l $SRCROOT/JustTweak/TweakProviders/LocalTweaks_example.json \ 11 | -o $SRCROOT/JustTweak/Accessors/ \ 12 | -c $SRCROOT/JustTweak/CodeGeneration/', 13 | :execution_position => :before_compile 14 | 15 | # pod 'Firebase/RemoteConfig' 16 | # pod 'OptimizelySDKiOS' 17 | 18 | target 'JustTweak_Tests' do 19 | inherit! :search_paths 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /Example/Tests/LocalTweaks_test_override.json: -------------------------------------------------------------------------------- 1 | { 2 | "ui_customization": { 3 | "display_blue_view": { 4 | "Title": "Display Blue View", 5 | "Group": "UI Customization", 6 | "Value": true 7 | }, 8 | "display_yellow_view": { 9 | "Title": "Display Yellow View", 10 | "Group": "UI Customization", 11 | "Value": true 12 | }, 13 | "label_text": { 14 | "Title": "Label Text", 15 | "Group": "UI Customization", 16 | "Value": "Overridden value" 17 | } 18 | }, 19 | "general": { 20 | "tap_to_change_color_enabled": { 21 | "Title": "Tap to change views color", 22 | "Group": "General", 23 | "Value": false 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator/Extensions/NSNumber+ValueTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSNumber+ValueTypes.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public extension NSNumber { 9 | 10 | var tweakType: String { 11 | let encoding = String(cString: self.objCType) 12 | switch encoding { 13 | case "d": 14 | return "Double" 15 | 16 | case "q": 17 | return "Int" 18 | 19 | case "c": 20 | return "Bool" 21 | 22 | default: 23 | assert(false, "Unsupported objCType for NSNumber \(self)") 24 | return "" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Example/Tests/TestHelpers/TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestHelpers.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | @testable import JustTweak 8 | 9 | class MockTweakCellDelegate: TweakViewControllerCellDelegate { 10 | 11 | private(set) var didCallDelegate: Bool = false 12 | 13 | func tweakConfigurationCellDidChangeValue(_ cell: TweakViewControllerCell) { 14 | didCallDelegate = true 15 | } 16 | 17 | } 18 | 19 | class MockPresentingViewController: UIViewController { 20 | 21 | private(set) var didCallDismissal: Bool = false 22 | override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { 23 | didCallDismissal = true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Example/JustTweak/Assets/Images.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "29x29", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "29x29", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "40x40", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "40x40", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "60x60", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "60x60", 31 | "scale" : "3x" 32 | } 33 | ], 34 | "info" : { 35 | "version" : 1, 36 | "author" : "xcode" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator/Extensions/String+Casing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Casing.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | extension String { 9 | 10 | func camelCased(with separator: Character = "_") -> String { 11 | self 12 | .split(separator: separator) 13 | .enumerated() 14 | .map { $0.offset == 0 ? String($0.element).lowercasedFirstChar() : String($0.element).capitalisedFirstChar() } 15 | .joined() 16 | } 17 | 18 | func lowercasedFirstChar() -> String { 19 | prefix(1).lowercased() + self.dropFirst() 20 | } 21 | 22 | func capitalisedFirstChar() -> String { 23 | prefix(1).uppercased() + self.dropFirst() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Example/Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Suites/Array+DuplicatesTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+DuplicatesTests.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | 8 | class Array_DuplicatesTests: XCTestCase { 9 | 10 | func test_noDuplicatesFound() { 11 | let arrayWithNoDuplicates = ["some", "array", "with", "no", "duplicates"] 12 | let expectedValue: [String] = [] 13 | XCTAssertEqual(arrayWithNoDuplicates.duplicates, expectedValue) 14 | } 15 | 16 | func test_duplicatesFound() { 17 | let arrayWithDuplicates = ["some", "array", "with", "some", "duplicates", "here", "here", "and", "here"] 18 | let expectedValue = ["here", "some"] 19 | XCTAssertEqual(arrayWithDuplicates.duplicates.sorted(), expectedValue) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Example/Shared/Symbols.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Symbols.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | import JustTweak 8 | 9 | struct Features { 10 | static let uiCustomization = "ui_customization" 11 | static let general = "general" 12 | } 13 | 14 | struct Variables { 15 | static let redViewAlpha = "red_view_alpha_component" 16 | static let displayRedView = "display_red_view" 17 | static let displayGreenView = "display_green_view" 18 | static let displayYellowView = "display_yellow_view" 19 | static let tapToChangeViewColor = "tap_to_change_color_enabled" 20 | static let labelText = "label_text" 21 | static let greetOnAppDidBecomeActive = "greet_on_app_did_become_active" 22 | static let answerToTheUniverse = "answer_to_the_universe" 23 | static let meaningOfLife = "meaning_of_life" 24 | } 25 | -------------------------------------------------------------------------------- /Example/Tests/UnitTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "7CD69B1F-AAC5-4B28-B048-0799F574A991", 5 | "name" : "Default", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "environmentVariableEntries" : [ 13 | { 14 | "key" : "OS_ACTIVITY_MODE", 15 | "value" : "disable" 16 | } 17 | ], 18 | "targetForVariableExpansion" : { 19 | "containerPath" : "container:JustTweak.xcodeproj", 20 | "identifier" : "607FACCF1AFB9204008FA782", 21 | "name" : "JustTweak_Example" 22 | } 23 | }, 24 | "testTargets" : [ 25 | { 26 | "target" : { 27 | "containerPath" : "container:JustTweak.xcodeproj", 28 | "identifier" : "607FACE41AFB9204008FA782", 29 | "name" : "JustTweak_Tests" 30 | } 31 | } 32 | ], 33 | "version" : 1 34 | } 35 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Assets/InvalidTweaks_MissingValues.json: -------------------------------------------------------------------------------- 1 | { 2 | "ui_customization": { 3 | "display_red_view": { 4 | "Description": "shows a red view in the main view controller", 5 | "Group": "UI Customization", 6 | "Value": false 7 | }, 8 | "display_yellow_view": { 9 | "Title": "Display Yellow View", 10 | "Group": "UI Customization", 11 | "Value": false 12 | }, 13 | "display_green_view": { 14 | "Title": "Display Green View", 15 | "Description": "shows a green view in the main view controller", 16 | "Value": true 17 | } 18 | }, 19 | "general": { 20 | "answer_to_the_universe": { 21 | "Title": "Definitive answer", 22 | "Description": "Answer to the Ultimate Question of Life, the Universe, and Everything", 23 | "Group": "General" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Example/Tests/TestHelpers/CustomOperators.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweaksUtilities.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | precedencegroup ComparisonPrecedence { 9 | associativity: left 10 | higherThan: LogicalConjunctionPrecedence 11 | } 12 | 13 | infix operator ?!: ComparisonPrecedence 14 | public func ?!(lhs: T?, rhs: T?) -> T? { 15 | return lhs == nil ? rhs : lhs 16 | } 17 | public func ?!(lhs: T?, rhs: T) -> T { 18 | return lhs == nil ? rhs : lhs! 19 | } 20 | public func ?!(lhs: Bool?, rhs: Bool?) -> Bool { 21 | return (lhs == nil ? rhs : lhs) ?? false 22 | } 23 | 24 | infix operator |||: ComparisonPrecedence 25 | public func |||(lhs: Bool?, rhs: Bool?) -> Bool { 26 | if let lhs = lhs, let rhs = rhs { 27 | return lhs || rhs 28 | } 29 | else if let lhs = lhs { 30 | return lhs 31 | } 32 | return rhs ?? false 33 | } 34 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ================ 3 | # Installation 4 | 5 | Make sure you have the latest version of the Xcode command line tools installed: 6 | 7 | ``` 8 | xcode-select --install 9 | ``` 10 | 11 | Install _fastlane_ using 12 | ``` 13 | [sudo] gem install fastlane -NV 14 | ``` 15 | or alternatively using `brew install fastlane` 16 | 17 | # Available Actions 18 | ## iOS 19 | ### ios unit_tests_just_tweak 20 | ``` 21 | fastlane ios unit_tests_just_tweak 22 | ``` 23 | 24 | ### ios unit_tests_tweak_accessor_generator 25 | ``` 26 | fastlane ios unit_tests_tweak_accessor_generator 27 | ``` 28 | 29 | 30 | ---- 31 | 32 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 33 | More information about fastlane can be found on [fastlane.tools](https://fastlane.tools). 34 | The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 35 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Assets/InvalidTweaks_DuplicateGeneratedPropertyName.json: -------------------------------------------------------------------------------- 1 | { 2 | "ui_customization": { 3 | "label_text": { 4 | "Title": "Label Text", 5 | "Description": "the title of the main label", 6 | "Group": "UI Customization", 7 | "Value": "Test value", 8 | "GeneratedPropertyName": "definitiveAnswer" 9 | } 10 | }, 11 | "general": { 12 | "answer_to_the_universe": { 13 | "Title": "Definitive answer", 14 | "Description": "Answer to the Ultimate Question of Life, the Universe, and Everything", 15 | "Group": "General", 16 | "Value": 42, 17 | "GeneratedPropertyName": "definitiveAnswer" 18 | }, 19 | "tap_to_change_color_enabled": { 20 | "Title": "Tap to change views color", 21 | "Description": "change the colour of the main view when receiving a tap", 22 | "Group": "General", 23 | "Value": true 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Example/Tests/Core/EphemeralTweakProviderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EphemeralTweakProviderTests.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | @testable import JustTweak 8 | 9 | final class EphemeralTweakProviderTests: XCTestCase { 10 | private var ephemeralTweakProvider: NSDictionary! 11 | 12 | override func setUpWithError() throws { 13 | try super.setUpWithError() 14 | ephemeralTweakProvider = NSDictionary() 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | ephemeralTweakProvider = nil 19 | try super.tearDownWithError() 20 | } 21 | 22 | func testDefaultDecryptionClosure() { 23 | XCTAssertNil(ephemeralTweakProvider.decryptionClosure) 24 | 25 | ephemeralTweakProvider.decryptionClosure = { tweak in 26 | tweak.value 27 | } 28 | 29 | XCTAssertNil(ephemeralTweakProvider.decryptionClosure) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store 3 | 4 | # Xcode 5 | build/ 6 | *.pbxuser 7 | !default.pbxuser 8 | *.mode1v3 9 | !default.mode1v3 10 | *.mode2v3 11 | !default.mode2v3 12 | *.perspectivev3 13 | !default.perspectivev3 14 | xcuserdata/ 15 | *.xccheckout 16 | profile 17 | *.moved-aside 18 | DerivedData 19 | *.hmap 20 | *.ipa 21 | *.orig 22 | 23 | # Bundler 24 | .bundle 25 | 26 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 27 | # Carthage/Checkouts 28 | 29 | Carthage/Build 30 | 31 | # We recommend against adding the Pods directory to your .gitignore. However 32 | # you should judge for yourself, the pros and cons are mentioned at: 33 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control 34 | # 35 | # Note: if you ignore the Pods directory, make sure to uncomment 36 | # `pod install` in .travis.yml 37 | # 38 | Pods/ 39 | default.profraw 40 | fastlane/test_output/ 41 | fastlane/report.xml 42 | derived_data/ 43 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Assets/GeneratedTweakAccessor+ConstantsContent: -------------------------------------------------------------------------------- 1 | // GeneratedTweakAccessorContent+Constants.swift 2 | 3 | /// Generated by TweakAccessorGenerator 4 | 5 | import Foundation 6 | 7 | extension GeneratedTweakAccessorContent { 8 | 9 | struct Features { 10 | static let general = "general" 11 | static let uiCustomization = "ui_customization" 12 | } 13 | 14 | struct Variables { 15 | static let answerToTheUniverse = "answer_to_the_universe" 16 | static let displayGreenView = "display_green_view" 17 | static let displayRedView = "display_red_view" 18 | static let displayYellowView = "display_yellow_view" 19 | static let greetOnAppDidBecomeActive = "greet_on_app_did_become_active" 20 | static let labelText = "label_text" 21 | static let redViewAlphaComponent = "red_view_alpha_component" 22 | static let tapToChangeColorEnabled = "tap_to_change_color_enabled" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Example/JustTweak/Accessors/GeneratedTweakAccessor+Constants.swift: -------------------------------------------------------------------------------- 1 | // GeneratedTweakAccessor+Constants.swift 2 | 3 | /// Generated by TweakAccessorGenerator 4 | 5 | import Foundation 6 | 7 | extension GeneratedTweakAccessor { 8 | 9 | struct Features { 10 | static let general = "general" 11 | static let uiCustomization = "ui_customization" 12 | } 13 | 14 | struct Variables { 15 | static let answerToTheUniverse = "answer_to_the_universe" 16 | static let displayGreenView = "display_green_view" 17 | static let displayRedView = "display_red_view" 18 | static let displayYellowView = "display_yellow_view" 19 | static let encryptedAnswerToTheUniverse = "encrypted_answer_to_the_universe" 20 | static let greetOnAppDidBecomeActive = "greet_on_app_did_become_active" 21 | static let labelText = "label_text" 22 | static let redViewAlphaComponent = "red_view_alpha_component" 23 | static let tapToChangeColorEnabled = "tap_to_change_color_enabled" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Framework/Sources/Protocols/TweakProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakProvider.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public enum LogLevel: Int { 9 | case error, debug, verbose 10 | } 11 | 12 | public typealias LogClosure = (String, LogLevel) -> Void 13 | 14 | public protocol TweakProvider { 15 | var logClosure: LogClosure? { set get } 16 | func isFeatureEnabled(_ feature: String) -> Bool 17 | func tweakWith(feature: String, variable: String) throws -> Tweak 18 | 19 | var decryptionClosure: ((Tweak) -> TweakValue)? { get set } 20 | } 21 | 22 | public protocol MutableTweakProvider: TweakProvider { 23 | func set(_ value: TweakValue, feature: String, variable: String) 24 | func deleteValue(feature: String, variable: String) 25 | } 26 | 27 | public let TweakProviderDidChangeNotification = Notification.Name("TweakProviderDidChangeNotification") 28 | public let TweakProviderDidChangeNotificationTweakKey = "TweakProviderDidChangeNotificationTweakKey" 29 | -------------------------------------------------------------------------------- /JustTweak.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'JustTweak' 3 | s.version = ENV['LIB_VERSION'] 4 | s.summary = 'A framework for feature flagging, locally and remotely configure and A/B test iOS apps.' 5 | s.description = <<-DESC 6 | JustTweak is a framework for feature flagging, locally and remotely configure and A/B test iOS apps. 7 | DESC 8 | 9 | s.homepage = 'https://github.com/justeat/JustTweak' 10 | s.license = { :type => 'Apache 2.0', :file => 'LICENSE' } 11 | s.author = 'Just Eat Takeaway iOS Team' 12 | s.source = { :git => 'https://github.com/justeat/JustTweak.git', :tag => s.version.to_s } 13 | 14 | s.ios.deployment_target = '11.0' 15 | s.swift_version = '5.1' 16 | 17 | s.source_files = 'Framework/Sources/**/*.swift' 18 | s.resources = 'Framework/Sources/Resources/**/*' 19 | 20 | s.preserve_paths = [ 21 | 'TweakAccessorGenerator', 22 | ] 23 | 24 | end 25 | -------------------------------------------------------------------------------- /Example/JustTweak/TweakProviders/LocalTweaks_TopPriority_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "ui_customization": { 3 | "display_red_view": { 4 | "Title": "Display Red View", 5 | "Description": "shows a red view in the main view controller", 6 | "Group": "UI Customization", 7 | "Value": true 8 | }, 9 | "display_yellow_view": { 10 | "Title": "Display Yellow View", 11 | "Description": "shows a yellow view in the main view controller", 12 | "Group": "UI Customization", 13 | "Value": true 14 | }, 15 | "display_green_view": { 16 | "Title": "Display Green View", 17 | "Description": "shows a green view in the main view controller", 18 | "Group": "UI Customization", 19 | "Value": false 20 | } 21 | }, 22 | "general": { 23 | "answer_to_the_universe": { 24 | "Title": "Definitive answer", 25 | "Description": "Answer to the Ultimate Question of Life, the Universe, and Everything", 26 | "Group": "General", 27 | "Value": 42, 28 | "GeneratedPropertyName": "definitiveAnswer" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Assets/ValidTweaks_TopPriority.json: -------------------------------------------------------------------------------- 1 | { 2 | "ui_customization": { 3 | "display_red_view": { 4 | "Title": "Display Red View", 5 | "Description": "shows a red view in the main view controller", 6 | "Group": "UI Customization", 7 | "Value": true 8 | }, 9 | "display_yellow_view": { 10 | "Title": "Display Yellow View", 11 | "Description": "shows a yellow view in the main view controller", 12 | "Group": "UI Customization", 13 | "Value": true 14 | }, 15 | "display_green_view": { 16 | "Title": "Display Green View", 17 | "Description": "shows a green view in the main view controller", 18 | "Group": "UI Customization", 19 | "Value": false 20 | } 21 | }, 22 | "general": { 23 | "answer_to_the_universe": { 24 | "Title": "Definitive answer", 25 | "Description": "Answer to the Ultimate Question of Life, the Universe, and Everything", 26 | "Group": "General", 27 | "Value": 42, 28 | "GeneratedPropertyName": "definitiveAnswer" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Example/Tests/LocalTweaks_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "ui_customization": { 3 | "display_red_view": { 4 | "Title": "Display Red View", 5 | "Group": "UI Customization", 6 | "Value": true 7 | }, 8 | "display_yellow_view": { 9 | "Title": "Display Yellow View", 10 | "Group": "UI Customization", 11 | "Value": false 12 | }, 13 | "display_green_view": { 14 | "Title": "Display Green View", 15 | "Group": "UI Customization", 16 | "Value": true 17 | }, 18 | "red_view_alpha_component": { 19 | "Title": "Red View Alpha Component", 20 | "Group": "UI Customization", 21 | "Value": 1.0 22 | }, 23 | "label_text": { 24 | "Title": "Label Text", 25 | "Group": "UI Customization", 26 | "Value": "Test value" 27 | } 28 | }, 29 | "general": { 30 | "greet_on_app_did_become_active": { 31 | "Title": "Greet on app launch", 32 | "Group": "General", 33 | "Value": false 34 | }, 35 | "tap_to_change_color_enabled": { 36 | "Title": "Tap to change views color", 37 | "Group": "General", 38 | "Value": true 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Framework/Sources/DTOs/TweakValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakValue.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public protocol TweakValue: CustomStringConvertible {} 9 | 10 | extension Bool: TweakValue {} 11 | extension Int: TweakValue {} 12 | extension Float: TweakValue {} 13 | extension Double: TweakValue {} 14 | extension String: TweakValue {} 15 | 16 | public extension TweakValue { 17 | 18 | var intValue: Int { 19 | return Int(doubleValue) 20 | } 21 | 22 | var floatValue: Float { 23 | return Float(doubleValue) 24 | } 25 | 26 | var doubleValue: Double { 27 | return Double(description) ?? 0.0 28 | } 29 | 30 | var boolValue: Bool { 31 | return self as? Bool ?? false 32 | } 33 | 34 | var stringValue: String? { 35 | return self as? String 36 | } 37 | } 38 | 39 | public func ==(lhs: TweakValue, rhs: TweakValue) -> Bool { 40 | if let lhs = lhs as? String, let rhs = rhs as? String { 41 | return lhs == rhs 42 | } 43 | return NSNumber(tweakValue: lhs) == NSNumber(tweakValue: rhs) 44 | } 45 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Suites/NSNumber+ValueTypesTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSNumber+ValueTypesTests.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import XCTest 8 | 9 | class NSNumber_ValueTypesTests: XCTestCase { 10 | 11 | func test_tweakType_for_NSNumber_with_Int_value() { 12 | let sut = NSNumber(value: 42) 13 | XCTAssertEqual(sut.tweakType, "Int") 14 | } 15 | 16 | func test_tweakType_for_NSNumber_with_IntegerLiteral_value() { 17 | let sut2 = NSNumber(integerLiteral: 42) 18 | XCTAssertEqual(sut2.tweakType, "Int") 19 | } 20 | 21 | func test_tweakType_for_NSNumber_with_Double_value() { 22 | let sut = NSNumber(value: 3.14) 23 | XCTAssertEqual(sut.tweakType, "Double") 24 | } 25 | 26 | func test_tweakType_for_NSNumber_with_FloatLiteral_value() { 27 | let sut = NSNumber(floatLiteral: 3.14) 28 | XCTAssertEqual(sut.tweakType, "Double") 29 | } 30 | 31 | func test_tweakType_for_NSNumber_with_Bool_value() { 32 | let sut = NSNumber(value: true) 33 | XCTAssertEqual(sut.tweakType, "Bool") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/UnitTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "5ACF2D18-3EDB-4507-A1E4-C3ECF1D3D55F", 5 | "name" : "Default", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "commandLineArgumentEntries" : [ 13 | { 14 | "argument" : "-l $SRCROOT\/..\/Example\/JustTweak\/TweakProviders\/LocalTweaks_example.json" 15 | }, 16 | { 17 | "argument" : "-o $SRCROOT\/..\/Example\/JustTweak\/Accessors\/" 18 | }, 19 | { 20 | "argument" : "-c $SRCROOT\/TweakAccessorGenerator\/Assets\/TestConfiguration1\/" 21 | }, 22 | { 23 | "argument" : "--help", 24 | "enabled" : false 25 | } 26 | ], 27 | "targetForVariableExpansion" : { 28 | "containerPath" : "container:TweakAccessorGenerator.xcodeproj", 29 | "identifier" : "12273D162625D6BE00732559", 30 | "name" : "TweakAccessorGenerator" 31 | } 32 | }, 33 | "testTargets" : [ 34 | { 35 | "target" : { 36 | "containerPath" : "container:TweakAccessorGenerator.xcodeproj", 37 | "identifier" : "12BE96492626FA5000C1B6C3", 38 | "name" : "TweakAccessorGeneratorTests" 39 | } 40 | } 41 | ], 42 | "version" : 1 43 | } 44 | -------------------------------------------------------------------------------- /Example/JustTweak/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UIRequiredDeviceCapabilities 30 | 31 | armv7 32 | 33 | UISupportedInterfaceOrientations 34 | 35 | UIInterfaceOrientationPortrait 36 | UIInterfaceOrientationLandscapeLeft 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## Screenshots (if appropriate): 16 | 17 | ## Types of changes 18 | 19 | - [ ] Bug fix (non-breaking change which fixes an issue) 20 | - [ ] New feature (non-breaking change which adds functionality) 21 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 22 | 23 | ## Checklist: 24 | 25 | 26 | - [ ] My code follows the code style of this project. 27 | - [ ] My change requires a change to the documentation. 28 | - [ ] I have updated the documentation accordingly. 29 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | default_platform :ios 2 | 3 | platform :ios do 4 | 5 | before_all do 6 | ensure_bundle_exec 7 | $derived_data_folder = "./derived_data" 8 | $workspace_filename = "Example/JustTweak.xcworkspace" 9 | $common_test_xcargs = "COMPILER_INDEX_STORE_ENABLE=NO" 10 | end 11 | 12 | lane :unit_tests_just_tweak do |parameters| 13 | UI.user_error! "Missing parameter 'device'" unless parameters.has_key?(:device) 14 | device = parameters[:device] 15 | run_tests( 16 | workspace: $workspace_filename, 17 | scheme: "JustTweak-Example", 18 | testplan: "UnitTests", 19 | device: device, 20 | code_coverage: true, 21 | result_bundle: true, 22 | concurrent_workers: 1, 23 | xcargs: $common_test_xcargs, 24 | derived_data_path: $derived_data_folder 25 | ) 26 | end 27 | 28 | lane :unit_tests_tweak_accessor_generator do |parameters| 29 | run_tests( 30 | workspace: $workspace_filename, 31 | scheme: "TweakAccessorGenerator", 32 | testplan: "UnitTests", 33 | code_coverage: true, 34 | result_bundle: true, 35 | concurrent_workers: 1, 36 | xcargs: $common_test_xcargs, 37 | derived_data_path: $derived_data_folder 38 | ) 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Suites/String+CasingTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+CasingTests.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | 8 | class String_CasingTests: XCTestCase { 9 | 10 | func test_camelCased_with_default_separator_1() { 11 | let sut = "Some-Property_Key_some-Value_some" 12 | let expectedValue = "some-PropertyKeySome-ValueSome" 13 | XCTAssertEqual(expectedValue, sut.camelCased()) 14 | } 15 | 16 | func test_camelCased_with_default_separator_2() { 17 | let sut = "Some_PropertyKey" 18 | let expectedValue = "somePropertyKey" 19 | XCTAssertEqual(expectedValue, sut.camelCased()) 20 | } 21 | 22 | func test_camelCased_with_custom_separator() { 23 | let sut = "Some-Property:Key:some-Value:Some" 24 | let expectedValue = "some-PropertyKeySome-ValueSome" 25 | XCTAssertEqual(expectedValue, sut.camelCased(with: ":")) 26 | } 27 | 28 | func test_lowercasedFirstChar() { 29 | let sut = "SomeName" 30 | let expectedValue = "someName" 31 | XCTAssertEqual(expectedValue, sut.lowercasedFirstChar()) 32 | } 33 | 34 | func test_capitalisedFirstChar() { 35 | let sut = "someName" 36 | let expectedValue = "SomeName" 37 | XCTAssertEqual(expectedValue, sut.capitalisedFirstChar()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-workflow.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Workflow 2 | on: [pull_request] 3 | jobs: 4 | run-tests: 5 | runs-on: macOS-latest 6 | timeout-minutes: 15 7 | steps: 8 | - name: Cancel previous jobs 9 | uses: styfle/cancel-workflow-action@0.6.0 10 | with: 11 | access_token: ${{ github.token }} 12 | - name: Git checkout 13 | uses: actions/checkout@v2.3.4 14 | with: 15 | fetch-depth: 0 16 | ref: ${{ github.ref }} 17 | - name: Setup Xcode 18 | uses: maxim-lobanov/setup-xcode@v1 19 | with: 20 | xcode-version: latest-stable 21 | - name: Setup ruby and bundler dependencies 22 | uses: ruby/setup-ruby@v1.81.0 23 | with: 24 | bundler-cache: true 25 | - name: Run pod install 26 | run: | 27 | set -eo pipefail 28 | export LIB_VERSION=$(git describe --tags `git rev-list --tags --max-count=1`) 29 | bundle exec pod install --project-directory=Example 30 | - name: Run tests (JustTweak) 31 | run: bundle exec fastlane unit_tests_just_tweak device:'iPhone 11' 32 | - name: Run tests (TweakAccessorGenerator) 33 | run: bundle exec fastlane unit_tests_tweak_accessor_generator 34 | - name: Validate lib 35 | run: | 36 | set -eo pipefail 37 | export LIB_VERSION=$(git describe --tags `git rev-list --tags --max-count=1`) 38 | bundle exec pod lib lint --allow-warnings 39 | -------------------------------------------------------------------------------- /Framework/Sources/TweakProviders/EphemeralTweakProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EphemeralTweakProvider.swift 3 | // Copyright (c) 2018 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | extension NSDictionary: TweakProvider { 9 | 10 | public var logClosure: LogClosure? { 11 | get { return nil } 12 | set { } 13 | } 14 | 15 | public var decryptionClosure: ((Tweak) -> TweakValue)? { 16 | get { 17 | nil 18 | } 19 | set {} 20 | } 21 | 22 | public func isFeatureEnabled(_ feature: String) -> Bool { 23 | self[feature] as? Bool ?? false 24 | } 25 | 26 | public func tweakWith(feature: String, variable: String) throws -> Tweak { 27 | guard let storedValue = self[variable] else { throw TweakError.notFound } 28 | var value: TweakValue? = nil 29 | if let theValue = storedValue as? String { 30 | value = theValue 31 | } 32 | else if let theValue = storedValue as? NSNumber { 33 | value = theValue.tweakValue 34 | } 35 | guard let finalValue = value else { throw TweakError.notSupported } 36 | return Tweak(feature: feature, variable: variable, value: finalValue) 37 | } 38 | } 39 | 40 | extension NSMutableDictionary: MutableTweakProvider { 41 | 42 | public func set(_ value: TweakValue, feature: String, variable: String) { 43 | self[variable] = value 44 | } 45 | 46 | public func deleteValue(feature: String, variable: String) { 47 | removeObject(forKey: variable) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Framework/Sources/Extensions/TweakExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakExtensions.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public extension String { 9 | 10 | var tweakValue: TweakValue { 11 | if let bool = Bool(self.lowercased()) { 12 | return bool 13 | } 14 | else if let doubleValue = Double(self) { 15 | return doubleValue 16 | } 17 | return self 18 | } 19 | } 20 | 21 | public extension NSNumber { 22 | 23 | var tweakValue: TweakValue { 24 | let encoding = String(cString: self.objCType) 25 | switch encoding { 26 | case "d": 27 | return self.doubleValue 28 | 29 | case "f": 30 | return self.floatValue 31 | 32 | case "c": 33 | return self.boolValue 34 | 35 | default: 36 | return self.intValue 37 | } 38 | } 39 | 40 | convenience init?(tweakValue: TweakValue) { 41 | if let tweakValue = tweakValue as? Bool { 42 | self.init(value: tweakValue as Bool) 43 | } 44 | else if let tweakValue = tweakValue as? Int { 45 | self.init(value: tweakValue as Int) 46 | } 47 | else if let tweakValue = tweakValue as? Float { 48 | self.init(value: tweakValue as Float) 49 | } 50 | else if let tweakValue = tweakValue as? Double { 51 | self.init(value: tweakValue as Double) 52 | } 53 | else { 54 | return nil 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Framework/Sources/UI/TweakManager+Presentation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakManager+Presentation.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | extension TweakManager { 9 | 10 | var displayableTweaks: [Tweak] { 11 | var tweaks = [String : Tweak]() 12 | for localTweakProvider in self.localTweakProviders.reversed() { 13 | for (feature, variables) in localTweakProvider.features { 14 | for variable in variables { 15 | if let tweak = try? tweakWith(feature: feature, variable: variable), 16 | let jsonTweak = try? localTweakProvider.tweakWith(feature: feature, variable: variable) { 17 | let aggregatedTweak = Tweak(feature: feature, 18 | variable: variable, 19 | value: tweak.value, 20 | title: jsonTweak.title, 21 | description: jsonTweak.desc, 22 | group: jsonTweak.group) 23 | let key = "\(feature)-\(variable)" 24 | tweaks[key] = aggregatedTweak 25 | } 26 | } 27 | } 28 | } 29 | return tweaks.values.sorted(by: { $0.displayTitle < $1.displayTitle }) 30 | } 31 | 32 | private var localTweakProviders: [LocalTweakProvider] { 33 | return tweakProviders.filter { $0 is LocalTweakProvider } as! [LocalTweakProvider] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Example/JustTweak/Assets/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AD_UNIT_ID_FOR_BANNER_TEST 6 | <DOWNLOAD_GoogleService.plist_FROM_YOUR_APP_IN_FIREBASE_CONSOLE> 7 | AD_UNIT_ID_FOR_INTERSTITIAL_TEST 8 | <DOWNLOAD_GoogleService.plist_FROM_YOUR_APP_IN_FIREBASE_CONSOLE> 9 | CLIENT_ID 10 | <DOWNLOAD_GoogleService.plist_FROM_YOUR_APP_IN_FIREBASE_CONSOLE> 11 | REVERSED_CLIENT_ID 12 | <DOWNLOAD_GoogleService.plist_FROM_YOUR_APP_IN_FIREBASE_CONSOLE> 13 | API_KEY 14 | <DOWNLOAD_GoogleService.plist_FROM_YOUR_APP_IN_FIREBASE_CONSOLE> 15 | GCM_SENDER_ID 16 | <DOWNLOAD_GoogleService.plist_FROM_YOUR_APP_IN_FIREBASE_CONSOLE> 17 | PLIST_VERSION 18 | 1 19 | BUNDLE_ID 20 | com.justeat.JustTweak.Example 21 | PROJECT_ID 22 | just-tweak-example 23 | STORAGE_BUCKET 24 | just-tweak-example.appspot.com 25 | IS_ADS_ENABLED 26 | 27 | IS_ANALYTICS_ENABLED 28 | 29 | IS_APPINVITE_ENABLED 30 | 31 | IS_GCM_ENABLED 32 | 33 | IS_SIGNIN_ENABLED 34 | 35 | GOOGLE_APP_ID 36 | <DOWNLOAD_GoogleService.plist_FROM_YOUR_APP_IN_FIREBASE_CONSOLE> 37 | DATABASE_URL 38 | <DOWNLOAD_GoogleService.plist_FROM_YOUR_APP_IN_FIREBASE_CONSOLE> 39 | 40 | 41 | -------------------------------------------------------------------------------- /Framework/Sources/UI/Cells/BooleanTweakTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BooleanTweakTableViewCell 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | 8 | internal class BooleanTweakTableViewCell: UITableViewCell, TweakViewControllerCell { 9 | 10 | var feature: String? 11 | var variable: String? 12 | 13 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 14 | super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) 15 | } 16 | 17 | required init?(coder aDecoder: NSCoder) { 18 | fatalError("init(coder:) has not been implemented") 19 | } 20 | 21 | var title: String? { 22 | get { 23 | return textLabel?.text 24 | } 25 | set { 26 | textLabel?.text = newValue 27 | } 28 | } 29 | 30 | var desc: String? { 31 | get { 32 | return detailTextLabel?.text 33 | } 34 | set { 35 | detailTextLabel?.text = newValue 36 | } 37 | } 38 | 39 | var value: TweakValue { 40 | get { 41 | return switchControl.isOn 42 | } 43 | set { 44 | switchControl.isOn = newValue.boolValue 45 | } 46 | } 47 | weak var delegate: TweakViewControllerCellDelegate? 48 | 49 | lazy var switchControl: UISwitch! = { 50 | let switchControl = UISwitch() 51 | switchControl.addTarget(self, action: #selector(didChangeTweakValue), for: .valueChanged) 52 | self.accessoryView = switchControl 53 | self.selectionStyle = .none 54 | return switchControl 55 | }() 56 | 57 | @objc func didChangeTweakValue() { 58 | delegate?.tweakConfigurationCellDidChangeValue(self) 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /Example/Tests/UI/TextTweakTableViewCellTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextTweakTableViewCellTests.swift 3 | // Copyright (c) 2019 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | @testable import JustTweak 8 | 9 | class TextTweakTableViewCellTests: XCTestCase { 10 | 11 | func testInformsDelegateOfValueChanges() { 12 | let mockDelegate = MockTweakCellDelegate() 13 | let cell = TextTweakTableViewCell() 14 | cell.delegate = mockDelegate 15 | cell.textField.sendActions(for: .editingDidEnd) 16 | XCTAssertTrue(mockDelegate.didCallDelegate) 17 | } 18 | 19 | func testUpdatesTweakValueWhenTextChanges() { 20 | let cell = TextTweakTableViewCell() 21 | cell.value = "Old Value" 22 | cell.textField.text = "Some new value" 23 | cell.textField.sendActions(for: .editingChanged) 24 | XCTAssertTrue(cell.value == "Some new value") 25 | } 26 | 27 | func testUpdatesTweakValueWhenTextChangesToNil() { 28 | let cell = TextTweakTableViewCell() 29 | cell.value = "Old Value" 30 | cell.textField.text = nil 31 | cell.textField.sendActions(for: .editingChanged) 32 | XCTAssertTrue(cell.value == "") 33 | } 34 | 35 | func testTextFieldNeverGrowsMoreThanHalfTheSizeOfTheCell() { 36 | let cell = TextTweakTableViewCell(frame: CGRect(x: 0, y: 0, width: 320, height: 44)) 37 | cell.value = "Some extremely long string that wouldn't fit in 320 points" 38 | XCTAssertTrue(cell.textField.bounds.width <= 160) 39 | } 40 | 41 | func testTextFieldResignsFirstResponderOnReturn() { 42 | let cell = TextTweakTableViewCell(frame: CGRect(x: 0, y: 0, width: 320, height: 44)) 43 | // HACK to get the text field to become first responder 44 | UIApplication.shared.delegate?.window??.addSubview(cell) 45 | cell.textField.becomeFirstResponder() 46 | RunLoop.main.run(until: Date(timeIntervalSinceNow: 0)) 47 | // HACKEND 48 | XCTAssertTrue(cell.textField.isFirstResponder) 49 | let _ = cell.textField.delegate?.textFieldShouldReturn?(cell.textField) 50 | XCTAssertFalse(cell.textField.isFirstResponder) 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Assets/ValidTweaks_LowPriority.json: -------------------------------------------------------------------------------- 1 | { 2 | "ui_customization": { 3 | "display_red_view": { 4 | "Title": "Display Red View", 5 | "Description": "shows a red view in the main view controller", 6 | "Group": "UI Customization", 7 | "Value": false, 8 | "GeneratedPropertyName": "canShowRedView" 9 | }, 10 | "display_yellow_view": { 11 | "Title": "Display Yellow View", 12 | "Description": "shows a yellow view in the main view controller", 13 | "Group": "UI Customization", 14 | "Value": false, 15 | "GeneratedPropertyName": "canShowYellowView" 16 | }, 17 | "display_green_view": { 18 | "Title": "Display Green View", 19 | "Description": "shows a green view in the main view controller", 20 | "Group": "UI Customization", 21 | "Value": true, 22 | "GeneratedPropertyName": "canShowGreenView" 23 | }, 24 | "red_view_alpha_component": { 25 | "Title": "Red View Alpha Component", 26 | "Description": "defines the alpha level of the red view", 27 | "Group": "UI Customization", 28 | "Value": 1.0, 29 | "GeneratedPropertyName": "redViewAlpha" 30 | }, 31 | "label_text": { 32 | "Title": "Label Text", 33 | "Description": "the title of the main label", 34 | "Group": "UI Customization", 35 | "Value": "Test value" 36 | } 37 | }, 38 | "general": { 39 | "answer_to_the_universe": { 40 | "Title": "Definitive answer", 41 | "Description": "Answer to the Ultimate Question of Life, the Universe, and Everything", 42 | "Group": "General", 43 | "Value": 42, 44 | "GeneratedPropertyName": "meaningOfLife" 45 | }, 46 | "greet_on_app_did_become_active": { 47 | "Title": "Greet on app launch", 48 | "Description": "shows an alert on applicationDidBecomeActive", 49 | "Group": "General", 50 | "Value": false, 51 | "GeneratedPropertyName": "shouldShowAlert" 52 | }, 53 | "tap_to_change_color_enabled": { 54 | "Title": "Tap to change views color", 55 | "Description": "change the colour of the main view when receiving a tap", 56 | "Group": "General", 57 | "Value": true, 58 | "GeneratedPropertyName": "isTapGestureToChangeColorEnabled" 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Assets/Tweaks.json: -------------------------------------------------------------------------------- 1 | { 2 | "ui_customization": { 3 | "display_red_view": { 4 | "Title": "Display Red View", 5 | "Description": "shows a red view in the main view controller", 6 | "Group": "UI Customization", 7 | "Value": false 8 | }, 9 | "display_yellow_view": { 10 | "Title": "Display Yellow View", 11 | "Description": "shows a yellow view in the main view controller", 12 | "Group": "UI Customization", 13 | "Value": false 14 | }, 15 | "display_green_view": { 16 | "Title": "Display Green View", 17 | "Description": "shows a green view in the main view controller", 18 | "Group": "UI Customization", 19 | "Value": true 20 | }, 21 | "red_view_alpha_component": { 22 | "Title": "Red View Alpha Component", 23 | "Description": "defines the alpha level of the red view", 24 | "Group": "UI Customization", 25 | "Value": 1.0 26 | }, 27 | "label_text": { 28 | "Title": "Label Text", 29 | "Description": "the title of the main label", 30 | "Group": "UI Customization", 31 | "Value": "Test value" 32 | } 33 | }, 34 | "general": { 35 | "encrypted_answer_to_the_universe": { 36 | "Title": "Encrypted definitive answer", 37 | "Description": "Encrypted answer to the Ultimate Question of Life, the Universe, and Everything", 38 | "Group": "General", 39 | "Value": "24 ton yletinifeD", 40 | "GeneratedPropertyName": "definitiveAnswerEncrypted", 41 | "Encrypted": true 42 | }, 43 | "answer_to_the_universe": { 44 | "Title": "Definitive answer", 45 | "Description": "Answer to the Ultimate Question of Life, the Universe, and Everything", 46 | "Group": "General", 47 | "Value": 42, 48 | "GeneratedPropertyName": "definitiveAnswer" 49 | }, 50 | "greet_on_app_did_become_active": { 51 | "Title": "Greet on app launch", 52 | "Description": "shows an alert on applicationDidBecomeActive", 53 | "Group": "General", 54 | "Value": false 55 | }, 56 | "tap_to_change_color_enabled": { 57 | "Title": "Tap to change views color", 58 | "Description": "change the colour of the main view when receiving a tap", 59 | "Group": "General", 60 | "Value": true 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Example/Tests/Core/TweaksUtilitiesTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweaksUtilitiesTests.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | import JustTweak 8 | 9 | class TweaksUtilitiesTests: XCTestCase { 10 | 11 | func testExclusiveBinaryOperator_ReturnsFirstArgument_WhenFirstArgumentIsNotNil() { 12 | XCTAssertEqual("a" ?! "b", "a") 13 | } 14 | 15 | func testExclusiveBinaryOperator_ReturnsSecondArgument_WhenFirstArgumentIsNil() { 16 | XCTAssertEqual(nil ?! "b", "b") 17 | } 18 | 19 | func testExclusiveBinaryOperator_ReturnsTrue_WhenFirstArgumentIsNilAndSecondIsTrue() { 20 | XCTAssertTrue(nil ?! true) 21 | } 22 | 23 | func testExclusiveBinaryOperator_ReturnsFalse_WhenFirstArgumentIsFalseAndSecondIsTrue() { 24 | XCTAssertFalse(false ?! true) 25 | } 26 | 27 | func testExclusiveBinaryOperator_ReturnsNil_WhenBothArgumentsAreNil() { 28 | let a: String? = nil 29 | let b: String? = nil 30 | XCTAssertNil(a ?! b) 31 | } 32 | 33 | func testInclusingOrForBooleansOperator_ReturnsFalse_WhenFirstArgumentIsFalseAndSecondIsFalse() { 34 | XCTAssertFalse(false ||| false) 35 | } 36 | 37 | func testInclusingOrForBooleansOperator_ReturnsTrue_WhenFirstArgumentIsTrueAndSecondIsTrue() { 38 | XCTAssertTrue(true ||| true) 39 | } 40 | 41 | func testInclusingOrForBooleansOperator_ReturnsTrue_WhenFirstArgumentIsFalseAndSecondIsTrue() { 42 | XCTAssertTrue(false ||| true) 43 | } 44 | 45 | func testInclusingOrForBooleansOperator_ReturnsTrue_WhenFirstArgumentIsTrueAndSecondIsFalse() { 46 | XCTAssertTrue(true ||| false) 47 | } 48 | 49 | func testInclusingOrForBooleansOperator_ReturnsTrue_WhenFirstArgumentIsNilAndSecondIsTrue() { 50 | XCTAssertTrue(nil ||| true) 51 | } 52 | 53 | func testInclusingOrForBooleansOperator_ReturnsTrue_WhenFirstArgumentIsTrueAndSecondIsNil() { 54 | XCTAssertTrue(true ||| nil) 55 | } 56 | 57 | func testInclusingOrForBooleansOperator_ReturnsFalse_WhenFirstArgumentIsNilAndSecondIsFalse() { 58 | XCTAssertFalse(nil ||| false) 59 | } 60 | 61 | func testInclusingOrForBooleansOperator_ReturnsFalse_WhenFirstArgumentIsFalseAndSecondIsNil() { 62 | XCTAssertFalse(false ||| nil) 63 | } 64 | 65 | func testInclusingOrForBooleansOperator_ReturnsFalse_WhenBothArgumentsAreNil() { 66 | XCTAssertFalse(nil ||| nil) 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /Example/JustTweak/UI/Base.lproj/LaunchScreen.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /Example/JustTweak/TweakProviders/LocalTweaks_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "ui_customization": { 3 | "display_red_view": { 4 | "Title": "Display Red View", 5 | "Description": "shows a red view in the main view controller", 6 | "Group": "UI Customization", 7 | "Value": false, 8 | "GeneratedPropertyName": "canShowRedView" 9 | }, 10 | "display_yellow_view": { 11 | "Title": "Display Yellow View", 12 | "Description": "shows a yellow view in the main view controller", 13 | "Group": "UI Customization", 14 | "Value": false, 15 | "GeneratedPropertyName": "canShowYellowView" 16 | }, 17 | "display_green_view": { 18 | "Title": "Display Green View", 19 | "Description": "shows a green view in the main view controller", 20 | "Group": "UI Customization", 21 | "Value": true, 22 | "GeneratedPropertyName": "canShowGreenView" 23 | }, 24 | "red_view_alpha_component": { 25 | "Title": "Red View Alpha Component", 26 | "Description": "defines the alpha level of the red view", 27 | "Group": "UI Customization", 28 | "Value": 1.0, 29 | "GeneratedPropertyName": "redViewAlpha" 30 | }, 31 | "label_text": { 32 | "Title": "Label Text", 33 | "Description": "the title of the main label", 34 | "Group": "UI Customization", 35 | "Value": "Test value" 36 | } 37 | }, 38 | "general": { 39 | "encrypted_answer_to_the_universe": { 40 | "Title": "Encrypted definitive answer", 41 | "Description": "Encrypted answer to the Ultimate Question of Life, the Universe, and Everything", 42 | "Group": "General", 43 | "Value": "24 ton yletinifeD", 44 | "GeneratedPropertyName": "definitiveAnswerEncrypted", 45 | "Encrypted": true 46 | }, 47 | "answer_to_the_universe": { 48 | "Title": "Definitive answer", 49 | "Description": "Answer to the Ultimate Question of Life, the Universe, and Everything", 50 | "Group": "General", 51 | "Value": 42, 52 | "GeneratedPropertyName": "meaningOfLife" 53 | }, 54 | "greet_on_app_did_become_active": { 55 | "Title": "Greet on app launch", 56 | "Description": "shows an alert on applicationDidBecomeActive", 57 | "Group": "General", 58 | "Value": false, 59 | "GeneratedPropertyName": "shouldShowAlert" 60 | }, 61 | "tap_to_change_color_enabled": { 62 | "Title": "Tap to change views color", 63 | "Description": "change the colour of the main view when receiving a tap", 64 | "Group": "General", 65 | "Value": true, 66 | "GeneratedPropertyName": "isTapGestureToChangeColorEnabled" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Framework/Sources/Utilities/PropertyWrappers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PropertyWrappers.swift 3 | // Copyright (c) 2019 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | @propertyWrapper 9 | public struct TweakProperty { 10 | let feature: String 11 | let variable: String 12 | let tweakManager: TweakManager 13 | 14 | public init(feature: String, variable: String, tweakManager: TweakManager) { 15 | self.feature = feature 16 | self.variable = variable 17 | self.tweakManager = tweakManager 18 | } 19 | 20 | public var wrappedValue: T { 21 | get { 22 | let tweak = try? tweakManager.tweakWith(feature: feature, variable: variable) 23 | return tweak?.value as! T 24 | } 25 | set { 26 | tweakManager.set(newValue, feature: feature, variable: variable) 27 | } 28 | } 29 | } 30 | 31 | @propertyWrapper 32 | public struct FallbackTweakProperty { 33 | let fallbackValue: T 34 | let feature: String 35 | let variable: String 36 | let tweakManager: TweakManager 37 | 38 | public init(fallbackValue: T, feature: String, variable: String, tweakManager: TweakManager) { 39 | self.fallbackValue = fallbackValue 40 | self.feature = feature 41 | self.variable = variable 42 | self.tweakManager = tweakManager 43 | } 44 | 45 | public var wrappedValue: T { 46 | get { 47 | let tweak = try? tweakManager.tweakWith(feature: feature, variable: variable) 48 | return (tweak?.value as? T) ?? fallbackValue 49 | } 50 | set { 51 | tweakManager.set(newValue, feature: feature, variable: variable) 52 | } 53 | } 54 | } 55 | 56 | @propertyWrapper 57 | public struct OptionalTweakProperty { 58 | let fallbackValue: T? 59 | let feature: String 60 | let variable: String 61 | let tweakManager: TweakManager 62 | 63 | public init(fallbackValue: T?, feature: String, variable: String, tweakManager: TweakManager) { 64 | self.fallbackValue = fallbackValue 65 | self.feature = feature 66 | self.variable = variable 67 | self.tweakManager = tweakManager 68 | } 69 | 70 | public var wrappedValue: T? { 71 | get { 72 | let tweak = try? tweakManager.tweakWith(feature: feature, variable: variable) 73 | return (tweak?.value as? T) ?? fallbackValue 74 | } 75 | set { 76 | if let newValue = newValue { 77 | tweakManager.set(newValue, feature: feature, variable: variable) 78 | } 79 | else { 80 | tweakManager.deleteValue(feature: feature, variable: variable) 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Example/Tests/Core/TweakExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakExtensionsTests.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | import JustTweak 8 | 9 | class TweakExtensionsTests: XCTestCase { 10 | 11 | func testString_RepresentingBool_CanBeConvertedToTweakValue_True() { 12 | XCTAssertTrue("true".tweakValue.boolValue) 13 | } 14 | 15 | func testString_RepresentingBool_CanBeConvertedToTweakValue_False() { 16 | XCTAssertFalse("false".tweakValue.boolValue) 17 | } 18 | 19 | func testString_RepresentingBool_CanBeConvertedToTweakValue_MixedCaseTrue() { 20 | XCTAssertTrue("TrUe".tweakValue.boolValue) 21 | } 22 | 23 | func testString_RepresentingBool_CanBeConvertedToTweakValue_MixedCaseFalse() { 24 | XCTAssertFalse("fAlSe".tweakValue.boolValue) 25 | } 26 | 27 | func testString_RepresentingNumber_CanBeConvertedToTweakValue_Double() { 28 | XCTAssertTrue("1.0".tweakValue == 1.0) 29 | } 30 | 31 | func testString_RepresentingNumber_CanBeConvertedToTweakValue_Int() { 32 | XCTAssertTrue("1".tweakValue == 1) 33 | } 34 | 35 | func testString_ReturnsSelfAsTweakValue_IfDoesNotRepresentAnyOtherTypeOfTweakValue() { 36 | XCTAssertTrue("hello".tweakValue == "hello") 37 | } 38 | 39 | func testIntValueCanBeConvertedIntoNumber() { 40 | let value: Int = 1 41 | let tweakValue: TweakValue = value 42 | XCTAssertEqual(NSNumber(value: value), NSNumber(tweakValue: tweakValue)) 43 | } 44 | 45 | func testFloatValueCanBeConvertedIntoNumber() { 46 | let value: Float = 1 47 | let tweakValue: TweakValue = value 48 | XCTAssertEqual(NSNumber(value: value), NSNumber(tweakValue: tweakValue)) 49 | } 50 | 51 | func testDoubleValueCanBeConvertedIntoNumber() { 52 | let value: Double = 1 53 | let tweakValue: TweakValue = value 54 | XCTAssertEqual(NSNumber(value: value), NSNumber(tweakValue: tweakValue)) 55 | } 56 | 57 | func testBoolValueCanBeConvertedIntoNumber() { 58 | let value = true 59 | let tweakValue: TweakValue = value 60 | XCTAssertEqual(NSNumber(value: value), NSNumber(tweakValue: tweakValue)) 61 | } 62 | 63 | func testStringValueCannotBeConvertedIntoNumber() { 64 | let tweakValue: TweakValue = "Hello" 65 | XCTAssertNil(NSNumber(tweakValue: tweakValue)) 66 | } 67 | 68 | func testNumberCanBeConvertedIntoBoolTweak() { 69 | let tweakValue: TweakValue = true 70 | XCTAssertTrue(NSNumber(value: true).tweakValue == tweakValue) 71 | XCTAssertTrue(NSNumber(value: false).tweakValue == false) 72 | } 73 | 74 | func testNumberCanBeConvertedIntoIntTweak() { 75 | XCTAssertTrue(NSNumber(value: 1).tweakValue == 1) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator/Core/TweakLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakLoader.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | extension String: Error {} 9 | 10 | class TweakLoader { 11 | 12 | func load(_ filePath: String) throws -> [Tweak] { 13 | let url = URL(fileURLWithPath: filePath) 14 | let data = try Data(contentsOf: url) 15 | guard let content = try JSONSerialization.jsonObject(with: data) as? TweaksFormat else { 16 | throw "Invalid JSON format for file \(filePath)" 17 | } 18 | 19 | let tweaks = try content.map { (featureKey: String, tweaks: [String: [String: Any]]) throws -> [Tweak] in 20 | try tweaks.map { (variableKey: String, value: [String: Any]) throws -> Tweak in 21 | try tweak(for: value, feature: featureKey, variable: variableKey) 22 | } 23 | .sorted { $0.variable < $1.variable } 24 | } 25 | .flatMap { $0 } 26 | .sorted { $0.feature < $1.feature } 27 | 28 | try validate(tweaks) 29 | 30 | return tweaks 31 | } 32 | 33 | func validate(_ tweaks: [Tweak]) throws { 34 | let propertyNames = tweaks.map { $0.propertyName }.compactMap { $0 } 35 | let duplicates = propertyNames.duplicates 36 | if duplicates.count > 0 { 37 | throw "Found duplicate 'GeneratedPropertyName': \(duplicates)" 38 | } 39 | } 40 | 41 | func type(for value: Any) throws -> String { 42 | switch value { 43 | case _ as String: return "String" 44 | case let numberValue as NSNumber: return numberValue.tweakType 45 | case _ as Bool: return "Bool" 46 | case _ as Double: return "Double" 47 | default: 48 | throw "Unsupported value type \(Swift.type(of: value))" 49 | } 50 | } 51 | 52 | func tweak(for dictionary: [String: Any], feature: FeatureKey, variable: VariableKey) throws -> Tweak { 53 | guard let title = dictionary["Title"] as? String else { 54 | throw "Missing 'Title' value in dictionary \(dictionary)" 55 | } 56 | guard let group = dictionary["Group"] as? String else { 57 | throw "Missing 'Group' value in dictionary \(dictionary)" 58 | } 59 | guard let value = dictionary["Value"] else { 60 | throw "Missing 'Value' value in dictionary \(dictionary)" 61 | } 62 | return Tweak(feature: feature, 63 | variable: variable, 64 | title: title, 65 | description: dictionary["Description"] as? String, 66 | group: group, 67 | valueType: try type(for: value), 68 | propertyName: dictionary["GeneratedPropertyName"] as? String) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Assets/GeneratedTweakAccessorContent: -------------------------------------------------------------------------------- 1 | // GeneratedTweakAccessorContent.swift 2 | 3 | /// Generated by TweakAccessorGenerator 4 | 5 | import Foundation 6 | import JustTweak 7 | 8 | class GeneratedTweakAccessorContent { 9 | 10 | private(set) var tweakManager: TweakManager 11 | 12 | init(with tweakManager: TweakManager) { 13 | self.tweakManager = tweakManager 14 | } 15 | 16 | var canShowGreenView: Bool { 17 | get { (try? tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.displayGreenView))?.boolValue ?? false } 18 | set { tweakManager.set(newValue, feature: Features.uiCustomization, variable: Variables.displayGreenView) } 19 | } 20 | 21 | var canShowRedView: Bool { 22 | get { (try? tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.displayRedView))?.boolValue ?? false } 23 | set { tweakManager.set(newValue, feature: Features.uiCustomization, variable: Variables.displayRedView) } 24 | } 25 | 26 | var canShowYellowView: Bool { 27 | get { (try? tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.displayYellowView))?.boolValue ?? false } 28 | set { tweakManager.set(newValue, feature: Features.uiCustomization, variable: Variables.displayYellowView) } 29 | } 30 | 31 | var isTapGestureToChangeColorEnabled: Bool { 32 | get { (try? tweakManager.tweakWith(feature: Features.general, variable: Variables.tapToChangeColorEnabled))?.boolValue ?? false } 33 | set { tweakManager.set(newValue, feature: Features.general, variable: Variables.tapToChangeColorEnabled) } 34 | } 35 | 36 | var labelText: String { 37 | get { (try? tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.labelText))?.stringValue ?? "" } 38 | set { tweakManager.set(newValue, feature: Features.uiCustomization, variable: Variables.labelText) } 39 | } 40 | 41 | var meaningOfLife: Int { 42 | get { (try? tweakManager.tweakWith(feature: Features.general, variable: Variables.answerToTheUniverse))?.intValue ?? 0 } 43 | set { tweakManager.set(newValue, feature: Features.general, variable: Variables.answerToTheUniverse) } 44 | } 45 | 46 | var redViewAlpha: Double { 47 | get { (try? tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.redViewAlphaComponent))?.doubleValue ?? 0.0 } 48 | set { tweakManager.set(newValue, feature: Features.uiCustomization, variable: Variables.redViewAlphaComponent) } 49 | } 50 | 51 | var shouldShowAlert: Bool { 52 | get { (try? tweakManager.tweakWith(feature: Features.general, variable: Variables.greetOnAppDidBecomeActive))?.boolValue ?? false } 53 | set { tweakManager.set(newValue, feature: Features.general, variable: Variables.greetOnAppDidBecomeActive) } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Suites/TweakAccessorCodeGeneratorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakAccessorCodeGeneratorTests.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | 8 | class TweakAccessorCodeGeneratorTests: XCTestCase { 9 | 10 | private var bundle: Bundle! 11 | private let tweaksFilename = "ValidTweaks_LowPriority" 12 | private var tweaksFilePath: String! 13 | private let generatedClassName = "GeneratedTweakAccessor" 14 | private var codeGenerator: TweakAccessorCodeGenerator! 15 | private var tweakLoader: TweakLoader! 16 | private var tweaks: [Tweak]! 17 | 18 | override func setUpWithError() throws { 19 | try super.setUpWithError() 20 | bundle = Bundle(for: type(of: self)) 21 | tweaksFilePath = try XCTUnwrap(bundle.path(forResource: tweaksFilename, ofType: "json")) 22 | codeGenerator = TweakAccessorCodeGenerator() 23 | tweakLoader = TweakLoader() 24 | tweaks = try tweakLoader.load(tweaksFilePath) 25 | } 26 | 27 | override func tearDownWithError() throws { 28 | bundle = nil 29 | tweaksFilePath = nil 30 | codeGenerator = nil 31 | tweakLoader = nil 32 | tweaks = nil 33 | try super.tearDownWithError() 34 | } 35 | 36 | func test_generateConstants_output() throws { 37 | let configuration = Configuration(accessorName: "GeneratedTweakAccessorContent") 38 | let content = codeGenerator.generateConstantsFileContent(tweaks: tweaks, configuration: configuration) 39 | let testContentPath = try XCTUnwrap(bundle.path(forResource: "GeneratedTweakAccessor+ConstantsContent", ofType: "")) 40 | let testContent = try String(contentsOfFile: testContentPath, encoding: .utf8) 41 | XCTAssertEqual(content, testContent) 42 | } 43 | 44 | func test_generateAccessor_output() throws { 45 | let configuration = Configuration(accessorName: "GeneratedTweakAccessorContent") 46 | let content = codeGenerator.generateAccessorFileContent(tweaksFilename: tweaksFilename, 47 | tweaks: tweaks, 48 | configuration: configuration) 49 | let testContentPath = try XCTUnwrap(bundle.path(forResource: "GeneratedTweakAccessorContent", ofType: "")) 50 | let testContent = try String(contentsOfFile: testContentPath, encoding: .utf8) 51 | 52 | XCTAssertEqual(content, testContent) 53 | } 54 | 55 | private func codeBlock(for customTweakProviderFile: String) throws -> String { 56 | let testBundle = Bundle(for: TweakAccessorCodeGeneratorTests.self) 57 | let filePath = try XCTUnwrap(testBundle.path(forResource: customTweakProviderFile, ofType: "")) 58 | return try String(contentsOfFile: filePath) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Example/JustTweak/TweakProviders/FirebaseTweakProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FirebaseTweakProvider.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import JustTweak 7 | import FirebaseCore 8 | import FirebaseRemoteConfig 9 | 10 | public class FirebaseTweakProvider: TweakProvider { 11 | 12 | public init() { 13 | /* DOWNLOAD THE GoogleService.plist from the Firebase dashboard */ 14 | let googleServicePlistURL = Bundle.main.url(forResource: "GoogleService-Info", withExtension: "plist") 15 | if let _ = googleServicePlistURL { 16 | firebaseAppClass.configure() 17 | configured = true 18 | fetchTweaks() 19 | } 20 | else { 21 | logClosure?("\(self) couldn't find a GoogleService Plist. This is required for this configuration to function. No Tweak will be returned from queries.", .error) 22 | } 23 | } 24 | 25 | public var logClosure: LogClosure? 26 | 27 | // Google dependencies 28 | private var configured: Bool = false 29 | internal lazy var firebaseAppClass: FirebaseApp.Type = { 30 | return FirebaseApp.self 31 | }() 32 | internal lazy var remoteConfiguration: RemoteConfig = { 33 | return RemoteConfig.remoteConfig() 34 | }() 35 | 36 | private func fetchTweaks() { 37 | guard configured else { return } 38 | remoteConfiguration.configSettings = RemoteConfigSettings() 39 | remoteConfiguration.fetch { [weak self] (status, error) in 40 | guard let self = self else { return } 41 | if let error = error { 42 | self.logClosure?("Error while fetching Firebase configuration => \(error)", .error) 43 | } 44 | else { 45 | self.remoteConfiguration.activate(completion: nil) // You can pass a completion handler if you want the configuration to be applied immediately after being fetched; otherwise it will be applied on next launch 46 | let notificationCentre = NotificationCenter.default 47 | notificationCentre.post(name: TweakProviderDidChangeNotification, object: self) 48 | } 49 | } 50 | } 51 | 52 | public func isFeatureEnabled(_ feature: String) -> Bool { 53 | let configValue = remoteConfiguration.configValue(forKey: feature) 54 | guard configValue.source != .static else { return false } 55 | return configValue.boolValue 56 | } 57 | 58 | public func tweakWith(feature: String, variable: String) throws -> Tweak { 59 | guard configured else { throw TweakError.notFound } 60 | let configValue = remoteConfiguration.configValue(forKey: variable) 61 | guard configValue.source != .static else { return nil } 62 | guard let stringValue = configValue.stringValue else { return nil } 63 | return Tweak(feature: feature, 64 | variable: variable, 65 | value: stringValue.tweakValue, 66 | title: nil, 67 | group: nil) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Framework/Sources/UI/Cells/TextTweakTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextTweakTableViewCell 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | 8 | class TextTweakTableViewCell: UITableViewCell, TweakViewControllerCell, UITextFieldDelegate { 9 | 10 | var feature: String? 11 | var variable: String? 12 | 13 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 14 | super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) 15 | } 16 | 17 | required init?(coder aDecoder: NSCoder) { 18 | fatalError("init(coder:) has not been implemented") 19 | } 20 | 21 | var title: String? { 22 | get { 23 | return textLabel?.text 24 | } 25 | set { 26 | textLabel?.text = newValue 27 | } 28 | } 29 | 30 | var desc: String? { 31 | get { 32 | return detailTextLabel?.text 33 | } 34 | set { 35 | detailTextLabel?.text = newValue 36 | } 37 | } 38 | 39 | var value: TweakValue = "" { 40 | didSet { 41 | textField.text = value.description 42 | textField.sizeToFit() 43 | if textField.bounds.width > bounds.width / 2.0 { 44 | textField.bounds.size = CGSize(width: bounds.width / 2.0, 45 | height: textField.bounds.size.height) 46 | } 47 | } 48 | } 49 | weak var delegate: TweakViewControllerCellDelegate? 50 | 51 | var keyboardType: UIKeyboardType { 52 | get { 53 | return .default 54 | } 55 | } 56 | 57 | lazy var textField: UITextField! = { 58 | let textField = UITextField() 59 | textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged) 60 | textField.addTarget(self, action: #selector(textEditingDidEnd), for: .editingDidEnd) 61 | textField.textColor = UIColor.darkGray 62 | textField.keyboardType = self.keyboardType 63 | textField.returnKeyType = .done 64 | textField.textAlignment = .right 65 | textField.borderStyle = .none 66 | textField.delegate = self 67 | self.accessoryView = textField 68 | self.selectionStyle = .none 69 | return textField 70 | }() 71 | 72 | @objc func textDidChange() { 73 | guard let text = textField.text else { return } 74 | if let int = Int(text) { 75 | value = int 76 | } 77 | else if let double = Double(text) { 78 | value = double 79 | } 80 | else if let float = Float(text) { 81 | value = float 82 | } 83 | else { 84 | value = text 85 | } 86 | } 87 | 88 | @objc func textEditingDidEnd() { 89 | delegate?.tweakConfigurationCellDidChangeValue(self) 90 | } 91 | 92 | func textFieldShouldReturn(_ textField: UITextField) -> Bool { 93 | textField.endEditing(true) 94 | return true 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /Example/JustTweak/Code/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import JustTweak 7 | import UIKit 8 | 9 | @UIApplicationMain 10 | class AppDelegate: UIResponder, UIApplicationDelegate { 11 | 12 | var window: UIWindow? 13 | var tweakAccessor: GeneratedTweakAccessor! 14 | 15 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { 16 | let navigationController = window?.rootViewController as! UINavigationController 17 | let viewController = navigationController.topViewController as! ViewController 18 | tweakAccessor = GeneratedTweakAccessor(with: makeTweakManager()) 19 | viewController.tweakAccessor = tweakAccessor 20 | viewController.tweakManager = tweakAccessor.tweakManager 21 | return true 22 | } 23 | 24 | func applicationDidBecomeActive(_ application: UIApplication) { 25 | if tweakAccessor.shouldShowAlert { 26 | let alertController = UIAlertController(title: "Hello", 27 | message: "Welcome to this Demo app!", 28 | preferredStyle: .alert) 29 | alertController.addAction(UIAlertAction(title: "Continue", style: .default, handler: nil)) 30 | window?.rootViewController?.present(alertController, animated: true, completion: nil) 31 | } 32 | } 33 | 34 | func makeTweakManager() -> TweakManager { 35 | var tweakProviders: [TweakProvider] = [] 36 | 37 | // EphemeralTweakProvider 38 | #if DEBUG || CONFIGURATION_UI_TESTS 39 | let ephemeralTweakProvider_1 = NSMutableDictionary() 40 | tweakProviders.append(ephemeralTweakProvider_1) 41 | #endif 42 | 43 | // UserDefaultsTweakProvider 44 | #if DEBUG || CONFIGURATION_DEBUG 45 | let userDefaultsTweakProvider_1 = UserDefaultsTweakProvider(userDefaults: UserDefaults.standard) 46 | tweakProviders.append(userDefaultsTweakProvider_1) 47 | #endif 48 | 49 | // LocalTweakProvider 50 | #if DEBUG 51 | let jsonFileURL_1 = Bundle.main.url(forResource: "LocalTweaks_TopPriority_example", withExtension: "json")! 52 | let localTweakProvider_1 = LocalTweakProvider(jsonURL: jsonFileURL_1) 53 | tweakProviders.append(localTweakProvider_1) 54 | #endif 55 | 56 | // LocalTweakProvider 57 | let jsonFileURL_2 = Bundle.main.url(forResource: "LocalTweaks_example", withExtension: "json")! 58 | let localTweakProvider_2 = LocalTweakProvider(jsonURL: jsonFileURL_2) 59 | tweakProviders.append(localTweakProvider_2) 60 | 61 | let tweakManager = TweakManager(tweakProviders: tweakProviders) 62 | tweakManager.useCache = true 63 | 64 | tweakManager.decryptionClosure = { tweak in 65 | String((tweak.value.stringValue ?? "").reversed()) 66 | } 67 | 68 | return tweakManager 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Framework/Sources/TweakProviders/UserDefaultsTweakProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaultsTweakProvider.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | final public class UserDefaultsTweakProvider { 9 | 10 | private let userDefaults: UserDefaults 11 | 12 | private static let userDefaultsKeyPrefix = "lib.fragments.userDefaultsKey" 13 | 14 | public var logClosure: LogClosure? 15 | public var decryptionClosure: ((Tweak) -> TweakValue)? 16 | 17 | public init(userDefaults: UserDefaults) { 18 | self.userDefaults = userDefaults 19 | } 20 | } 21 | 22 | extension UserDefaultsTweakProvider: TweakProvider { 23 | 24 | public func isFeatureEnabled(_ feature: String) -> Bool { 25 | let userDefaultsKey = keyForTweakWithIdentifier(feature) 26 | return userDefaults.bool(forKey: userDefaultsKey) 27 | } 28 | 29 | public func tweakWith(feature: String, variable: String) throws -> Tweak { 30 | let userDefaultsKey = keyForTweakWithIdentifier(variable) 31 | let userDefaultsValue = userDefaults.object(forKey: userDefaultsKey) as AnyObject? 32 | guard let value = updateUserDefaults(userDefaultsValue) else { throw TweakError.notFound } 33 | 34 | return Tweak( 35 | feature: feature, 36 | variable: variable, 37 | value: value, 38 | title: nil, 39 | group: nil 40 | ) 41 | } 42 | } 43 | 44 | extension UserDefaultsTweakProvider: MutableTweakProvider { 45 | 46 | public func set(_ value: TweakValue, feature: String, variable: String) { 47 | updateUserDefaults(value: value, feature: feature, variable: variable) 48 | } 49 | 50 | public func deleteValue(feature: String, variable: String) { 51 | userDefaults.removeObject(forKey: keyForTweakWithIdentifier(variable)) 52 | } 53 | } 54 | 55 | extension UserDefaultsTweakProvider { 56 | 57 | private func keyForTweakWithIdentifier(_ identifier: String) -> String { 58 | return "\(UserDefaultsTweakProvider.userDefaultsKeyPrefix).\(identifier)" 59 | } 60 | 61 | private func updateUserDefaults(_ object: AnyObject?) -> TweakValue? { 62 | if let object = object as? String { 63 | return object 64 | } 65 | else if let object = object as? NSNumber { 66 | return object.tweakValue 67 | } 68 | return nil 69 | } 70 | 71 | private func updateUserDefaults(value: TweakValue, feature: String, variable: String) { 72 | userDefaults.set(value, forKey: keyForTweakWithIdentifier(variable)) 73 | DispatchQueue.main.async { 74 | let notificationCenter = NotificationCenter.default 75 | let tweak = Tweak(feature: feature, variable: variable, value: value) 76 | let userInfo = [TweakProviderDidChangeNotificationTweakKey: tweak] 77 | notificationCenter.post(name: TweakProviderDidChangeNotification, 78 | object: self, 79 | userInfo: userInfo) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Example/JustTweak/Accessors/GeneratedTweakAccessor.swift: -------------------------------------------------------------------------------- 1 | // GeneratedTweakAccessor.swift 2 | 3 | /// Generated by TweakAccessorGenerator 4 | 5 | import Foundation 6 | import JustTweak 7 | 8 | class GeneratedTweakAccessor { 9 | 10 | private(set) var tweakManager: TweakManager 11 | 12 | init(with tweakManager: TweakManager) { 13 | self.tweakManager = tweakManager 14 | } 15 | 16 | var canShowGreenView: Bool { 17 | get { (try? tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.displayGreenView))?.boolValue ?? false } 18 | set { tweakManager.set(newValue, feature: Features.uiCustomization, variable: Variables.displayGreenView) } 19 | } 20 | 21 | var canShowRedView: Bool { 22 | get { (try? tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.displayRedView))?.boolValue ?? false } 23 | set { tweakManager.set(newValue, feature: Features.uiCustomization, variable: Variables.displayRedView) } 24 | } 25 | 26 | var canShowYellowView: Bool { 27 | get { (try? tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.displayYellowView))?.boolValue ?? false } 28 | set { tweakManager.set(newValue, feature: Features.uiCustomization, variable: Variables.displayYellowView) } 29 | } 30 | 31 | var definitiveAnswerEncrypted: String { 32 | get { (try? tweakManager.tweakWith(feature: Features.general, variable: Variables.encryptedAnswerToTheUniverse))?.stringValue ?? "" } 33 | set { tweakManager.set(newValue, feature: Features.general, variable: Variables.encryptedAnswerToTheUniverse) } 34 | } 35 | 36 | var isTapGestureToChangeColorEnabled: Bool { 37 | get { (try? tweakManager.tweakWith(feature: Features.general, variable: Variables.tapToChangeColorEnabled))?.boolValue ?? false } 38 | set { tweakManager.set(newValue, feature: Features.general, variable: Variables.tapToChangeColorEnabled) } 39 | } 40 | 41 | var labelText: String { 42 | get { (try? tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.labelText))?.stringValue ?? "" } 43 | set { tweakManager.set(newValue, feature: Features.uiCustomization, variable: Variables.labelText) } 44 | } 45 | 46 | var meaningOfLife: Int { 47 | get { (try? tweakManager.tweakWith(feature: Features.general, variable: Variables.answerToTheUniverse))?.intValue ?? 0 } 48 | set { tweakManager.set(newValue, feature: Features.general, variable: Variables.answerToTheUniverse) } 49 | } 50 | 51 | var redViewAlpha: Double { 52 | get { (try? tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.redViewAlphaComponent))?.doubleValue ?? 0.0 } 53 | set { tweakManager.set(newValue, feature: Features.uiCustomization, variable: Variables.redViewAlphaComponent) } 54 | } 55 | 56 | var shouldShowAlert: Bool { 57 | get { (try? tweakManager.tweakWith(feature: Features.general, variable: Variables.greetOnAppDidBecomeActive))?.boolValue ?? false } 58 | set { tweakManager.set(newValue, feature: Features.general, variable: Variables.greetOnAppDidBecomeActive) } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Example/Tests/Core/LocalTweakProviderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalTweakProviderTests.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | import JustTweak 8 | 9 | class LocalTweakProviderTests: XCTestCase { 10 | 11 | private var tweakProvider: LocalTweakProvider! 12 | 13 | override func setUp() { 14 | super.setUp() 15 | tweakProvider = tweakProviderWithFileNamed("LocalTweaks_test")! 16 | } 17 | 18 | override func tearDown() { 19 | tweakProvider = nil 20 | super.tearDown() 21 | } 22 | 23 | private func tweakProviderWithFileNamed(_ fileName: String) -> LocalTweakProvider? { 24 | let bundle = Bundle(for: LocalTweakProviderTests.self) 25 | let jsonURL = bundle.url(forResource: fileName, withExtension: "json")! 26 | return LocalTweakProvider(jsonURL: jsonURL) 27 | } 28 | 29 | func testParsesBoolTweak() { 30 | let redViewTweak = Tweak(feature: Features.uiCustomization, 31 | variable: Variables.displayRedView, 32 | value: true, 33 | title: "Display Red View", 34 | group: "UI Customization") 35 | XCTAssertEqual(redViewTweak, try tweakProvider.tweakWith(feature: Features.uiCustomization, 36 | variable: Variables.displayRedView)) 37 | } 38 | 39 | func testParsesFloatTweak() { 40 | let redViewAlphaTweak = Tweak(feature: Features.uiCustomization, 41 | variable: Variables.redViewAlpha, 42 | value: 1.0, 43 | title: "Red View Alpha Component", 44 | group: "UI Customization") 45 | XCTAssertEqual(redViewAlphaTweak, try tweakProvider.tweakWith(feature: Features.uiCustomization, 46 | variable: Variables.redViewAlpha)) 47 | } 48 | 49 | func testParsesStringTweak() { 50 | let buttonLabelTweak = Tweak(feature: Features.uiCustomization, 51 | variable: Variables.labelText, 52 | value: "Test value", 53 | title: "Label Text", group: "UI Customization") 54 | XCTAssertEqual(buttonLabelTweak, try tweakProvider.tweakWith(feature: Features.uiCustomization, 55 | variable: Variables.labelText)) 56 | } 57 | 58 | func testDecryptionClosure() { 59 | XCTAssertNil(tweakProvider.decryptionClosure) 60 | 61 | tweakProvider.decryptionClosure = { tweak in 62 | (tweak.value.stringValue ?? "") + "Decrypted" 63 | } 64 | 65 | let tweak = Tweak(feature: "feature", variable: "variable", value: "topSecret") 66 | let decryptedTweak = tweakProvider.decryptionClosure?(tweak) 67 | 68 | XCTAssertEqual(decryptedTweak?.stringValue, "topSecretDecrypted") 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Framework/Sources/DTOs/Tweak.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tweak.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public struct Tweak { 9 | 10 | public let feature: String 11 | public let variable: String 12 | 13 | public let value: TweakValue 14 | 15 | public let title: String? 16 | public let desc: String? 17 | public let group: String? 18 | public let source: String? 19 | 20 | public var displayTitle: String { 21 | return title ?? "\(feature):\(variable)" 22 | } 23 | 24 | private var dictionaryValue: [String : Any?] { 25 | get { 26 | return ["feature": feature, 27 | "variable": variable, 28 | "value": value, 29 | "title": title, 30 | "description": desc, 31 | "group": group, 32 | "source": source 33 | ] 34 | } 35 | } 36 | 37 | public init(feature: String, 38 | variable: String, 39 | value: TweakValue, 40 | title: String? = nil, 41 | description: String? = nil, 42 | group: String? = nil, 43 | source: String? = nil) { 44 | self.feature = feature 45 | self.variable = variable 46 | self.value = value 47 | self.title = title 48 | self.desc = description 49 | self.group = group 50 | self.source = source 51 | } 52 | 53 | func mutatedCopy(feature: String? = nil, 54 | variable: String? = nil, 55 | value: TweakValue? = nil, 56 | title: String? = nil, 57 | description: String? = nil, 58 | group: String? = nil, 59 | source: String? = nil) -> Self { 60 | Self(feature: feature ?? self.feature, 61 | variable: variable ?? self.variable, 62 | value: value ?? self.value, 63 | title: title ?? self.title, 64 | description: description ?? self.desc, 65 | group: group ?? self.group, 66 | source: source ?? self.source) 67 | } 68 | } 69 | 70 | extension Tweak: CustomStringConvertible { 71 | 72 | public var description: String { 73 | get { 74 | return dictionaryValue.description 75 | } 76 | } 77 | } 78 | 79 | extension Tweak: Equatable { 80 | 81 | public static func ==(lhs: Self, rhs: Self) -> Bool { 82 | return lhs.feature == rhs.feature && 83 | lhs.variable == rhs.variable && 84 | lhs.value == rhs.value && 85 | lhs.title == rhs.title && 86 | lhs.desc == rhs.desc && 87 | lhs.group == rhs.group && 88 | lhs.source == rhs.source 89 | } 90 | } 91 | 92 | public extension Tweak { 93 | 94 | var intValue: Int { 95 | return value.intValue 96 | } 97 | 98 | var floatValue: Float { 99 | return value.floatValue 100 | } 101 | 102 | var doubleValue: Double { 103 | return value.doubleValue 104 | } 105 | 106 | var boolValue: Bool { 107 | return value.boolValue 108 | } 109 | 110 | var stringValue: String? { 111 | return value.stringValue 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Framework/Sources/TweakProviders/LocalTweakProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalTweakProvider.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | final public class LocalTweakProvider { 9 | 10 | private enum EncodingKeys : String { 11 | case Title, Description, Group, Value, Encrypted 12 | } 13 | 14 | private let configurationFile: [String : [String : [String : AnyObject]]] 15 | private let fileURL: URL 16 | 17 | public var logClosure: LogClosure? 18 | public var decryptionClosure: ((Tweak) -> TweakValue)? 19 | 20 | public var features: [String : [String]] { 21 | var storage: [String : [String]] = [:] 22 | for feature in Array(configurationFile.keys) { 23 | for variable in Array(configurationFile[feature]!.keys) { 24 | if let _ = storage[feature] { 25 | storage[feature]?.append(variable) 26 | } else { 27 | storage[feature] = [variable] 28 | } 29 | } 30 | } 31 | return storage 32 | } 33 | 34 | public init(jsonURL: URL) { 35 | let data = try! Data(contentsOf: jsonURL) 36 | let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) 37 | let configuration = json as! [String : [String : [String : AnyObject]]] 38 | configurationFile = configuration 39 | fileURL = jsonURL 40 | } 41 | 42 | private func tweakValueFromJSONObject(_ jsonObject: AnyObject?) -> TweakValue { 43 | let value: TweakValue 44 | if let numberValue = jsonObject as? NSNumber { 45 | value = numberValue.tweakValue 46 | } 47 | else if let stringValue = jsonObject as? String { 48 | value = stringValue 49 | } 50 | else { 51 | value = false 52 | } 53 | return value 54 | } 55 | } 56 | 57 | extension LocalTweakProvider: TweakProvider { 58 | 59 | public func isFeatureEnabled(_ feature: String) -> Bool { 60 | return configurationFile[feature] != nil 61 | } 62 | 63 | public func tweakWith(feature: String, variable: String) throws -> Tweak { 64 | guard let entry = configurationFile[feature]?[variable] else { throw TweakError.notFound } 65 | let title = entry[EncodingKeys.Title.rawValue] as? String 66 | let description = entry[EncodingKeys.Description.rawValue] as? String 67 | let group = entry[EncodingKeys.Group.rawValue] as? String 68 | let value = tweakValueFromJSONObject(entry[EncodingKeys.Value.rawValue]) 69 | let isEncrypted = (entry[EncodingKeys.Encrypted.rawValue] as? Bool) ?? false 70 | 71 | let tweak = Tweak(feature: feature, 72 | variable: variable, 73 | value: value, 74 | title: title, 75 | description: description, 76 | group: group) 77 | 78 | if isEncrypted { 79 | guard let decryptionClosure = decryptionClosure else { 80 | // The configuration is not correct, it's encrypted, but there's no way to decrypt 81 | throw TweakError.decryptionClosureNotProvided 82 | } 83 | 84 | return tweak.mutatedCopy(value: decryptionClosure(tweak)) 85 | } else { 86 | return tweak 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Example/JustTweak/TweakProviders/OptimizelyTweakProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OptimizelyTweakProvider.swift 3 | // Copyright (c) 2018 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import JustTweak 7 | import OptimizelySDKiOS 8 | 9 | public class OptimizelyTweakProvider: TweakProvider { 10 | 11 | private var optimizelyManager: OPTLYManager? 12 | private var optimizelyClient: OPTLYClient? 13 | 14 | public var logClosure: LogClosure? 15 | 16 | public var userId: String! 17 | public var attributes: [String : String]? 18 | 19 | public init() { 20 | /* DOWNLOAD THE Optimizely datafile from the Optimizely dashboard */ 21 | optimizelyManager = OPTLYManager(builder: OPTLYManagerBuilder(block: { builder in 22 | guard let builder = builder, 23 | let filePath = Bundle.main.path(forResource: "ExampleOptimizelyDatafile", ofType: "json"), 24 | let fileContents = try? String.init(contentsOfFile: filePath, encoding: .utf8), 25 | let jsonDatafile = fileContents.data(using: .utf8) else { return } 26 | builder.datafile = jsonDatafile 27 | builder.sdkKey = "SDK_KEY_HERE" 28 | })) 29 | optimizelyManager?.initialize() { [weak self] error, client in 30 | guard let strongSelf = self else { return } 31 | switch (error, client) { 32 | case (nil, let client): 33 | strongSelf.optimizelyClient = client 34 | let notificationCentre = NotificationCenter.default 35 | notificationCentre.post(name: TweakProviderDidChangeNotification, object: strongSelf) 36 | case (let error, _): 37 | if let error = error { 38 | strongSelf.logClosure?("Couldn't initialize Optimizely manager. \(error.localizedDescription)", .error) 39 | } else { 40 | strongSelf.logClosure?("Couldn't initialize Optimizely manager", .error) 41 | } 42 | } 43 | } 44 | } 45 | 46 | public func isFeatureEnabled(_ feature: String) -> Bool { 47 | return optimizelyClient?.isFeatureEnabled(feature, userId: userId, attributes: attributes) ?? false 48 | } 49 | 50 | public func tweakWith(feature: String, variable: String) throws -> Tweak { 51 | guard let optimizelyClient = optimizelyClient, 52 | optimizelyClient.isFeatureEnabled(feature, userId: userId, attributes: attributes) == true 53 | else { 54 | throw TweakError.notFound 55 | } 56 | 57 | let tweakValue: TweakValue? = { 58 | if let boolValue = optimizelyClient.getFeatureVariableBoolean(feature, variableKey: variable, userId: userId, attributes: attributes)?.boolValue { 59 | return boolValue 60 | } 61 | else if let doubleValue = optimizelyClient.getFeatureVariableDouble(feature, variableKey: variable, userId: userId, attributes: attributes)?.doubleValue { 62 | return doubleValue 63 | } 64 | else if let intValue = optimizelyClient.getFeatureVariableInteger(feature, variableKey: variable, userId: userId, attributes: attributes)?.intValue { 65 | return intValue 66 | } 67 | else if let stringValue = optimizelyClient.getFeatureVariableString(feature, variableKey: variable, userId: userId, attributes: attributes) { 68 | return stringValue 69 | } 70 | return nil 71 | }() 72 | 73 | guard let tweakValue = tweakValue else { 74 | throw TweakError.notFound 75 | } 76 | return Tweak(feature: feature, variable: variable, value: tweakValue, title: nil, group: nil) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import ArgumentParser 8 | 9 | struct TweakAccessorGenerator: ParsableCommand { 10 | 11 | @Option(name: .shortAndLong, help: "The local tweaks file path.") 12 | var localTweaksFilePath: String 13 | 14 | @Option(name: .shortAndLong, help: "The output folder.") 15 | var outputFolder: String 16 | 17 | @Option(name: .shortAndLong, help: "The configuration folder.") 18 | var configurationFolder: String 19 | 20 | private var tweaksFilename: String { 21 | let url = URL(fileURLWithPath: localTweaksFilePath) 22 | return String(url.lastPathComponent.split(separator: ".").first!) 23 | } 24 | 25 | private var configurationFolderURL: URL { 26 | URL(fileURLWithPath: configurationFolder) 27 | } 28 | 29 | private func loadConfigurationFromJson() -> Configuration { 30 | let configurationUrl = configurationFolderURL.appendingPathComponent("config.json") 31 | let jsonData = try! Data(contentsOf: configurationUrl) 32 | let decodedResult = try! JSONDecoder().decode(Configuration.self, from: jsonData) 33 | return decodedResult 34 | } 35 | 36 | func run() throws { 37 | let codeGenerator = TweakAccessorCodeGenerator() 38 | let tweakLoader = TweakLoader() 39 | let tweaks = try tweakLoader.load(localTweaksFilePath) 40 | let configuration = loadConfigurationFromJson() 41 | 42 | writeConstantsFile(codeGenerator: codeGenerator, 43 | tweaks: tweaks, 44 | outputFolder: outputFolder, 45 | configuration: configuration) 46 | 47 | writeAccessorFile(codeGenerator: codeGenerator, 48 | tweaks: tweaks, 49 | outputFolder: outputFolder, 50 | configuration: configuration) 51 | } 52 | } 53 | 54 | extension TweakAccessorGenerator { 55 | 56 | private func writeConstantsFile(codeGenerator: TweakAccessorCodeGenerator, 57 | tweaks: [Tweak], 58 | outputFolder: String, 59 | configuration: Configuration) { 60 | let fileName = "\(configuration.accessorName)+Constants.swift" 61 | let url: URL = URL(fileURLWithPath: outputFolder).appendingPathComponent(fileName) 62 | let constants = codeGenerator.generateConstantsFileContent(tweaks: tweaks, 63 | configuration: configuration) 64 | if let existingConstants = try? String(contentsOf: url, encoding: .utf8) { 65 | if existingConstants != constants { 66 | try! constants.write(to: url, atomically: true, encoding: .utf8) 67 | } 68 | } else { 69 | try! constants.write(to: url, atomically: true, encoding: .utf8) 70 | } 71 | } 72 | 73 | private func writeAccessorFile(codeGenerator: TweakAccessorCodeGenerator, 74 | tweaks: [Tweak], 75 | outputFolder: String, 76 | configuration: Configuration) { 77 | let fileName = "\(configuration.accessorName).swift" 78 | let url: URL = URL(fileURLWithPath: outputFolder).appendingPathComponent(fileName) 79 | let accessor = codeGenerator.generateAccessorFileContent(tweaksFilename: tweaksFilename, 80 | tweaks: tweaks, 81 | configuration: configuration) 82 | if let existingAccessor = try? String(contentsOf: url, encoding: .utf8) { 83 | if existingAccessor != accessor { 84 | try! accessor.write(to: url, atomically: true, encoding: .utf8) 85 | } 86 | } else { 87 | try! accessor.write(to: url, atomically: true, encoding: .utf8) 88 | } 89 | } 90 | } 91 | 92 | TweakAccessorGenerator.main() 93 | -------------------------------------------------------------------------------- /Example/JustTweak/Accessors/TweakAccessor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakAccessor.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import JustTweak 8 | 9 | class TweakAccessor { 10 | 11 | static let tweakManager: TweakManager = { 12 | var tweakProviders: [TweakProvider] = [] 13 | 14 | // UserDefaultsTweakProvider 15 | #if DEBUG || CONFIGURATION_DEBUG 16 | let userDefaultsTweakProvider_1 = UserDefaultsTweakProvider(userDefaults: UserDefaults.standard) 17 | tweakProviders.append(userDefaultsTweakProvider_1) 18 | #endif 19 | 20 | // OptimizelyTweakProvider 21 | // let optimizelyTweakProvider = OptimizelyTweakProvider() 22 | // optimizelyTweakProvider.userId = UUID().uuidString 23 | // tweakProviders.append(optimizelyTweakProvider) 24 | 25 | // FirebaseTweakProvider 26 | // let firebaseTweakProvider = FirebaseTweakProvider() 27 | // tweakProviders.append(firebaseTweakProvider) 28 | 29 | // LocalTweakProvider 30 | #if CONFIGURATION_DEBUG 31 | let jsonFileURL_1 = Bundle.main.url(forResource: "LocalTweaks_TopPriority_example", withExtension: "json")! 32 | let localTweakProvider_1 = LocalTweakProvider(jsonURL: jsonFileURL_1) 33 | tweakProviders.append(localTweakProvider_1) 34 | #endif 35 | 36 | // LocalTweakProvider 37 | let jsonFileURL_2 = Bundle.main.url(forResource: "LocalTweaks_example", withExtension: "json")! 38 | let localTweakProvider_2 = LocalTweakProvider(jsonURL: jsonFileURL_2) 39 | tweakProviders.append(localTweakProvider_2) 40 | 41 | return TweakManager(tweakProviders: tweakProviders) 42 | }() 43 | 44 | private var tweakManager: TweakManager { 45 | return Self.tweakManager 46 | } 47 | 48 | // MARK: - Via Property Wrappers 49 | 50 | @FallbackTweakProperty(fallbackValue: false, 51 | feature: Features.general, 52 | variable: Variables.greetOnAppDidBecomeActive, 53 | tweakManager: tweakManager) 54 | var shouldShowAlert: Bool 55 | 56 | @FallbackTweakProperty(fallbackValue: false, 57 | feature: Features.uiCustomization, 58 | variable: Variables.displayRedView, 59 | tweakManager: tweakManager) 60 | var canShowRedView: Bool 61 | 62 | @FallbackTweakProperty(fallbackValue: false, 63 | feature: Features.uiCustomization, 64 | variable: Variables.displayGreenView, 65 | tweakManager: tweakManager) 66 | var canShowGreenView: Bool 67 | 68 | @FallbackTweakProperty(fallbackValue: "", 69 | feature: Features.uiCustomization, 70 | variable: Variables.labelText, 71 | tweakManager: tweakManager) 72 | var labelText: String 73 | 74 | @FallbackTweakProperty(fallbackValue: 42, 75 | feature: Features.uiCustomization, 76 | variable: Variables.meaningOfLife, 77 | tweakManager: tweakManager) 78 | var meaningOfLife: Int 79 | 80 | @OptionalTweakProperty(fallbackValue: nil, 81 | feature: Features.uiCustomization, 82 | variable: Variables.answerToTheUniverse, 83 | tweakManager: tweakManager) 84 | var optionalMeaningOfLife: Int? 85 | 86 | 87 | // MARK: - Via TweakManager 88 | 89 | var canShowYellowView: Bool { 90 | return (try? tweakManager.tweakWith(feature: Features.uiCustomization, 91 | variable: Variables.displayYellowView))?.boolValue ?? false 92 | } 93 | 94 | var redViewAlpha: Float { 95 | return (try? tweakManager.tweakWith(feature: Features.uiCustomization, 96 | variable: Variables.redViewAlpha))?.floatValue ?? 0.0 97 | } 98 | 99 | var isTapGestureToChangeColorEnabled: Bool { 100 | return (try? tweakManager.tweakWith(feature: Features.general, 101 | variable: Variables.tapToChangeViewColor))?.boolValue ?? false 102 | } 103 | } 104 | 105 | -------------------------------------------------------------------------------- /Example/JustTweak/Code/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | import JustTweak 8 | 9 | class ViewController: UIViewController { 10 | 11 | @IBOutlet var redView: UIView! 12 | @IBOutlet var greenView: UIView! 13 | @IBOutlet var yellowView: UIView! 14 | @IBOutlet var mainLabel: UILabel! 15 | 16 | var tweakAccessor: GeneratedTweakAccessor! 17 | var tweakManager: TweakManager! 18 | 19 | private var tapGestureRecognizer: UITapGestureRecognizer! 20 | 21 | deinit { 22 | NotificationCenter.default.removeObserver(self) 23 | } 24 | 25 | override func viewDidLoad() { 26 | super.viewDidLoad() 27 | updateView() 28 | let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(setAndShowMeaningOfLife)) 29 | tapGestureRecognizer.numberOfTapsRequired = 2 30 | view.addGestureRecognizer(tapGestureRecognizer) 31 | tweakManager.registerForConfigurationsUpdates(self) { [weak self] tweak in 32 | print("Tweak changed: \(tweak)") 33 | self?.updateView() 34 | } 35 | addEncryptedMeaningOfLifeTapGesture() 36 | } 37 | 38 | private func addEncryptedMeaningOfLifeTapGesture() { 39 | let tapGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(showEncryptedMeaningOfLife)) 40 | view.addGestureRecognizer(tapGestureRecognizer) 41 | } 42 | 43 | internal func updateView() { 44 | setUpGestures() 45 | redView.isHidden = !tweakAccessor.canShowRedView 46 | greenView.isHidden = !tweakAccessor.canShowGreenView 47 | yellowView.isHidden = !tweakAccessor.canShowYellowView 48 | mainLabel.text = tweakAccessor.labelText 49 | redView.alpha = CGFloat(tweakAccessor.redViewAlpha) 50 | } 51 | 52 | internal func setUpGestures() { 53 | if tapGestureRecognizer == nil { 54 | tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(changeViewColor)) 55 | view.addGestureRecognizer(tapGestureRecognizer) 56 | } 57 | tapGestureRecognizer.isEnabled = tweakAccessor.isTapGestureToChangeColorEnabled 58 | } 59 | 60 | @objc internal func setAndShowMeaningOfLife() { 61 | tweakAccessor.meaningOfLife = Bool.random() ? 42 : 108 62 | let alertController = UIAlertController(title: "The Meaning of Life", 63 | message: String(describing: tweakAccessor.meaningOfLife), 64 | preferredStyle: .alert) 65 | alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) 66 | present(alertController, animated: true, completion: nil) 67 | } 68 | 69 | @objc func showEncryptedMeaningOfLife() { 70 | let alertController = UIAlertController(title: "Encrypted Meaning of Life", 71 | message: String(describing: tweakAccessor.definitiveAnswerEncrypted), 72 | preferredStyle: .alert) 73 | alertController.addAction(UIAlertAction(title: "OK", style: .default)) 74 | present(alertController, animated: true) 75 | } 76 | 77 | @objc internal func changeViewColor() { 78 | func randomColorValue() -> CGFloat { 79 | return CGFloat(arc4random() % 255) / 255.0 80 | } 81 | view.backgroundColor = UIColor(red: randomColorValue(), 82 | green: randomColorValue(), 83 | blue: randomColorValue(), 84 | alpha: 1.0) 85 | } 86 | 87 | private var tweakViewController: TweakViewController { 88 | return TweakViewController(style: .grouped, tweakManager: tweakManager) 89 | } 90 | 91 | @IBAction func presentTweakViewController() { 92 | let tweakNavigationController = UINavigationController(rootViewController: tweakViewController) 93 | tweakNavigationController.navigationBar.prefersLargeTitles = true 94 | present(tweakNavigationController, animated: true, completion: nil) 95 | } 96 | 97 | @IBAction func pushTweakViewController() { 98 | navigationController?.pushViewController(tweakViewController, animated: true) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Example/Tests/Core/UserDefaultsTweakProviderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaultsTweakProviderTests.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | import JustTweak 8 | 9 | class UserDefaultsTweakProviderTests: XCTestCase { 10 | 11 | private var userDefaultsTweakProvider: UserDefaultsTweakProvider! 12 | private let userDefaults = UserDefaults(suiteName: String(describing: UserDefaultsTweakProviderTests.self))! 13 | 14 | private let userDefaultsKeyPrefix = "lib.fragments.userDefaultsKey" 15 | 16 | override func setUp() { 17 | super.setUp() 18 | userDefaultsTweakProvider = UserDefaultsTweakProvider(userDefaults: userDefaults) 19 | } 20 | 21 | override func tearDown() { 22 | userDefaults.removeObject(forKey: "\(userDefaultsKeyPrefix).display_red_view") 23 | userDefaultsTweakProvider = nil 24 | super.tearDown() 25 | } 26 | 27 | func testReturnsCorrectTweaksIdentifiersWhenInitializedAndTweaksHaveBeenSet() throws { 28 | let anotherConfiguration = UserDefaultsTweakProvider(userDefaults: userDefaults) 29 | anotherConfiguration.set("hello", feature: "feature_1", variable: "variable_4") 30 | anotherConfiguration.set(true, feature: "feature_1", variable: "variable_3") 31 | anotherConfiguration.set(12.34, feature: "feature_1", variable: "variable_2") 32 | anotherConfiguration.set(42, feature: "feature_1", variable: "variable_1") 33 | 34 | XCTAssertTrue(try XCTUnwrap(anotherConfiguration.tweakWith(feature: "feature_1", variable: "variable_1")).value == 42) 35 | XCTAssertTrue(try XCTUnwrap(anotherConfiguration.tweakWith(feature: "feature_1", variable: "variable_2")).value == 12.34) 36 | XCTAssertTrue(try XCTUnwrap(anotherConfiguration.tweakWith(feature: "feature_1", variable: "variable_3")).value == true) 37 | XCTAssertTrue(try XCTUnwrap(anotherConfiguration.tweakWith(feature: "feature_1", variable: "variable_4")).value == "hello") 38 | } 39 | 40 | func testReturnsNilForTweaksThatHaveNoUserDefaultValue() { 41 | let tweak = try? userDefaultsTweakProvider.tweakWith(feature: Features.uiCustomization, variable: Variables.displayRedView) 42 | XCTAssertNil(tweak) 43 | } 44 | 45 | func testUpdatesValueForTweak_withBool() throws { 46 | userDefaultsTweakProvider.set(true, feature: "feature_1", variable: "variable_1") 47 | let tweak = try XCTUnwrap(userDefaultsTweakProvider.tweakWith(feature: "feature_1", variable: "variable_1")) 48 | XCTAssertTrue(tweak.value.boolValue) 49 | } 50 | 51 | func testUpdatesValueForTweak_withInteger() throws { 52 | userDefaultsTweakProvider.set(42, feature: "feature_1", variable: "variable_1") 53 | let tweak = try XCTUnwrap(userDefaultsTweakProvider.tweakWith(feature: "feature_1", variable: "variable_1")) 54 | XCTAssertTrue(tweak.value == 42) 55 | } 56 | 57 | func testUpdatesValueForTweak_withFloat() throws { 58 | userDefaultsTweakProvider.set(Float(12.34), feature: "feature_1", variable: "variable_1") 59 | let tweak = try XCTUnwrap(userDefaultsTweakProvider.tweakWith(feature: "feature_1", variable: "variable_1")) 60 | XCTAssertTrue(tweak.value == Float(12.34)) 61 | } 62 | 63 | func testUpdatesValueForTweak_withDouble() throws { 64 | userDefaultsTweakProvider.set(Double(23.45), feature: "feature_1", variable: "variable_1") 65 | let tweak = try XCTUnwrap(userDefaultsTweakProvider.tweakWith(feature: "feature_1", variable: "variable_1")) 66 | XCTAssertTrue(tweak.value == Double(23.45)) 67 | } 68 | 69 | func testUpdatesValueForTweak_withString() throws { 70 | userDefaultsTweakProvider.set("Hello", feature: "feature_1", variable: "variable_1") 71 | let tweak = try XCTUnwrap(userDefaultsTweakProvider.tweakWith(feature: "feature_1", variable: "variable_1")) 72 | XCTAssertTrue(tweak.value == "Hello") 73 | } 74 | 75 | func testDecryptionClosure() { 76 | XCTAssertNil(userDefaultsTweakProvider.decryptionClosure) 77 | 78 | userDefaultsTweakProvider.decryptionClosure = { tweak in 79 | (tweak.value.stringValue ?? "") + "Decrypted" 80 | } 81 | 82 | let tweak = Tweak(feature: "feature", variable: "variable", value: "topSecret") 83 | let decryptedTweak = userDefaultsTweakProvider.decryptionClosure?(tweak) 84 | 85 | XCTAssertEqual(decryptedTweak?.stringValue, "topSecretDecrypted") 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator.xcodeproj/xcshareddata/xcschemes/TweakAccessorGenerator.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 35 | 36 | 37 | 38 | 40 | 46 | 47 | 48 | 49 | 50 | 60 | 62 | 68 | 69 | 70 | 71 | 74 | 75 | 78 | 79 | 82 | 83 | 86 | 87 | 88 | 89 | 95 | 97 | 103 | 104 | 105 | 106 | 108 | 109 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator.xcodeproj/xcshareddata/xcschemes/TweakAccessorGenerator-Release.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 11 | 14 | 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 33 | 39 | 40 | 41 | 42 | 43 | 49 | 50 | 51 | 52 | 62 | 63 | 69 | 70 | 71 | 72 | 75 | 76 | 79 | 80 | 83 | 84 | 87 | 88 | 89 | 90 | 96 | 97 | 103 | 104 | 105 | 106 | 108 | 109 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /Example/JustTweak/Assets/ExampleOptimizelyDatafile.json: -------------------------------------------------------------------------------- 1 | { 2 | "accountId": "12345", 3 | "anonymizeIP": false, 4 | "botFiltering": false, 5 | "projectId": "23456", 6 | "revision": "6", 7 | "version": "4", 8 | "experiments": [ 9 | { 10 | "key": "my_experiment", 11 | "id": "45678", 12 | "layerId": "34567", 13 | "status": "Running", 14 | "variations": [ 15 | { 16 | "id": "56789", 17 | "key": "control", 18 | "variables": [ 19 | 20 | ] 21 | }, 22 | { 23 | "id": "67890", 24 | "key": "treatment", 25 | "variables": [ 26 | 27 | ] 28 | } 29 | ], 30 | "trafficAllocation": [ 31 | { 32 | "entityId": "56789", 33 | "endOfRange": 5000 34 | }, 35 | { 36 | "entityId": "67890", 37 | "endOfRange": 10000 38 | } 39 | ], 40 | "audienceIds": [ 41 | 42 | ], 43 | "forcedVariations": { 44 | 45 | } 46 | } 47 | ], 48 | "featureFlags": [ 49 | { 50 | "experimentIds": [ 51 | 52 | ], 53 | "id": "56789", 54 | "key": "price_filter", 55 | "rolloutId": "67890", 56 | "variables": [ 57 | { 58 | "defaultValue": "100", 59 | "id": "12345", 60 | "key": "min_price", 61 | "type": "integer" 62 | } 63 | ] 64 | } 65 | ], 66 | "events": [ 67 | { 68 | "experimentIds": [ 69 | "34567" 70 | ], 71 | "id": "56789", 72 | "key": "my_conversion" 73 | } 74 | ], 75 | "audiences": [ 76 | 77 | ], 78 | "attributes": [ 79 | 80 | ], 81 | "groups": [ 82 | 83 | ], 84 | "rollouts": [ 85 | { 86 | "experiments": [ 87 | { 88 | "audienceIds": [ 89 | 90 | ], 91 | "forcedVariations": { 92 | 93 | }, 94 | "id": "23456", 95 | "key": "23456", 96 | "layerId": "34567", 97 | "status": "Running", 98 | "trafficAllocation": [ 99 | { 100 | "endOfRange": 5000, 101 | "entityId": "45678" 102 | } 103 | ], 104 | "variations": [ 105 | { 106 | "featureEnabled": true, 107 | "id": "56789", 108 | "key": "56789", 109 | "variables": [ 110 | { 111 | "id": "67890", 112 | "value": "100" 113 | } 114 | ] 115 | } 116 | ] 117 | } 118 | ], 119 | "id": "78901" 120 | } 121 | ], 122 | "variables": [ 123 | 124 | ] 125 | } 126 | -------------------------------------------------------------------------------- /Example/JustTweak.xcodeproj/xcshareddata/xcschemes/JustTweak-Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 52 | 53 | 54 | 55 | 58 | 59 | 60 | 61 | 63 | 69 | 70 | 71 | 72 | 73 | 83 | 85 | 91 | 92 | 93 | 94 | 98 | 99 | 100 | 101 | 107 | 109 | 115 | 116 | 117 | 118 | 120 | 121 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /Example/Tests/Core/TweakManagerCacheTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakManagerCacheTests.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | @testable import JustTweak 8 | @testable import JustTweak_Example 9 | 10 | fileprivate struct Constants { 11 | static let featureActiveValue = true 12 | static let feature = "some_feature" 13 | static let variable = "some_variable" 14 | static let experiment = "some_experiment" 15 | } 16 | 17 | class TweakManagerCacheTests: XCTestCase { 18 | 19 | fileprivate var mockTweakProvider: MockTweakProvider! 20 | var tweakManager: TweakManager! 21 | 22 | override func setUp() { 23 | super.setUp() 24 | mockTweakProvider = MockTweakProvider() 25 | tweakManager = TweakManager(tweakProviders: [mockTweakProvider]) 26 | tweakManager.useCache = true 27 | } 28 | 29 | override func tearDown() { 30 | mockTweakProvider = nil 31 | tweakManager = nil 32 | super.tearDown() 33 | } 34 | 35 | // MARK: - isFeatureEnabled(feature:) 36 | 37 | func testFeatureEnabled_CacheDisabled() { 38 | testFeatureEnabled(useCache: false) 39 | } 40 | 41 | func testFeatureEnabled_CacheEnabled() { 42 | testFeatureEnabled(useCache: true) 43 | } 44 | 45 | private func testFeatureEnabled(useCache: Bool) { 46 | tweakManager.useCache = useCache 47 | XCTAssertEqual(mockTweakProvider.isFeatureEnabledCallsCounter, 0) 48 | XCTAssertEqual(tweakManager.isFeatureEnabled(Constants.feature), Constants.featureActiveValue) 49 | XCTAssertEqual(mockTweakProvider.isFeatureEnabledCallsCounter, 1) 50 | XCTAssertEqual(tweakManager.isFeatureEnabled(Constants.feature), Constants.featureActiveValue) 51 | XCTAssertEqual(mockTweakProvider.isFeatureEnabledCallsCounter, useCache ? 1 : 2) 52 | tweakManager.resetCache() 53 | XCTAssertEqual(tweakManager.isFeatureEnabled(Constants.feature), Constants.featureActiveValue) 54 | XCTAssertEqual(mockTweakProvider.isFeatureEnabledCallsCounter, useCache ? 2 : 3) 55 | } 56 | 57 | // MARK: - tweakWith(feature:variable:) 58 | 59 | func testTweakFetch_CacheDisabled() throws { 60 | try tweakFetch(useCache: false) 61 | } 62 | 63 | func testTweakFetch_CacheEnabled() throws { 64 | try tweakFetch(useCache: true) 65 | } 66 | 67 | private func tweakFetch(useCache: Bool) throws { 68 | tweakManager.useCache = useCache 69 | XCTAssertEqual(mockTweakProvider.tweakWithFeatureVariableCallsCounter, 0) 70 | let value = true 71 | tweakManager.set(value, feature: Constants.feature, variable: Constants.variable) 72 | XCTAssertEqual(try XCTUnwrap(tweakManager.tweakWith(feature: Constants.feature, variable: Constants.variable)).value as! Bool, value) 73 | XCTAssertEqual(mockTweakProvider.tweakWithFeatureVariableCallsCounter, 1) 74 | XCTAssertEqual(try XCTUnwrap(tweakManager.tweakWith(feature: Constants.feature, variable: Constants.variable)).value as! Bool, value) 75 | XCTAssertEqual(mockTweakProvider.tweakWithFeatureVariableCallsCounter, useCache ? 1 : 2) 76 | tweakManager.set(value, feature: Constants.feature, variable: Constants.variable) 77 | XCTAssertEqual(try XCTUnwrap(tweakManager.tweakWith(feature: Constants.feature, variable: Constants.variable)).value as! Bool, value) 78 | XCTAssertEqual(mockTweakProvider.tweakWithFeatureVariableCallsCounter, useCache ? 2 : 3) 79 | tweakManager.resetCache() 80 | XCTAssertEqual(try XCTUnwrap(tweakManager.tweakWith(feature: Constants.feature, variable: Constants.variable)).value as! Bool, value) 81 | XCTAssertEqual(mockTweakProvider.tweakWithFeatureVariableCallsCounter, useCache ? 3 : 4) 82 | } 83 | } 84 | 85 | fileprivate class MockTweakProvider: MutableTweakProvider { 86 | 87 | var decryptionClosure: ((Tweak) -> TweakValue)? 88 | var logClosure: LogClosure? 89 | 90 | var isFeatureEnabledCallsCounter: Int = 0 91 | var tweakWithFeatureVariableCallsCounter: Int = 0 92 | 93 | var featureBackingStore: [String : Bool] = [Constants.feature: Constants.featureActiveValue] 94 | var tweakBackingStore: [String : [String : Tweak]] = [:] 95 | 96 | func isFeatureEnabled(_ feature: String) -> Bool { 97 | isFeatureEnabledCallsCounter += 1 98 | return featureBackingStore[feature] ?? false 99 | } 100 | 101 | func tweakWith(feature: String, variable: String) throws -> Tweak { 102 | tweakWithFeatureVariableCallsCounter += 1 103 | guard let tweak = tweakBackingStore[feature]?[variable] else { 104 | throw TweakError.notFound 105 | } 106 | return tweak 107 | } 108 | 109 | func set(_ value: TweakValue, feature: String, variable: String) { 110 | let tweak = Tweak(feature: feature, variable: variable, value: value) 111 | if let _ = tweakBackingStore[feature] { 112 | tweakBackingStore[feature]?[variable] = tweak 113 | } 114 | else { 115 | tweakBackingStore[feature] = [variable : tweak] 116 | } 117 | } 118 | 119 | func deleteValue(feature: String, variable: String) { 120 | tweakBackingStore[feature]?[variable] = nil 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Example/Tests/Core/TweakManager+PresentationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakManager+PresentationTests.swift 3 | // JustTweak_Tests 4 | // 5 | // Created by Alberto De Bortoli on 02/02/2020. 6 | // Copyright © 2020 Just Eat. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import JustTweak 11 | 12 | class TweakManager_PresentationTests: XCTestCase { 13 | 14 | var tweakManager: TweakManager! 15 | let localTweakProviderLowPriority: LocalTweakProvider = { 16 | let bundle = Bundle(for: TweakManagerTests.self) 17 | let jsonConfigurationURL = bundle.url(forResource: "LocalTweaks_test", withExtension: "json")! 18 | return LocalTweakProvider(jsonURL: jsonConfigurationURL) 19 | }() 20 | let localTweakProviderHighPriority: LocalTweakProvider = { 21 | let bundle = Bundle(for: TweakManagerTests.self) 22 | let jsonConfigurationURL = bundle.url(forResource: "LocalTweaks_test_override", withExtension: "json")! 23 | return LocalTweakProvider(jsonURL: jsonConfigurationURL) 24 | }() 25 | 26 | func test_GivenOneLocalTweakProvider_WhenFetchedDisplayableTweaks_ThenAllTweaksSortedByTitleAreReturned() { 27 | let tweakProviders: [TweakProvider] = [localTweakProviderLowPriority] 28 | tweakManager = TweakManager(tweakProviders: tweakProviders) 29 | let displayableTweaks = tweakManager.displayableTweaks 30 | let targetTweaks = [ 31 | Tweak(feature: "ui_customization", 32 | variable: "display_green_view", 33 | value: true, 34 | title: "Display Green View", 35 | group: "UI Customization"), 36 | Tweak(feature: "ui_customization", 37 | variable: "display_red_view", 38 | value: true, 39 | title: "Display Red View", 40 | group: "UI Customization"), 41 | Tweak(feature: "ui_customization", 42 | variable: "display_yellow_view", 43 | value: false, 44 | title: "Display Yellow View", 45 | group: "UI Customization"), 46 | Tweak(feature: "general", 47 | variable: "greet_on_app_did_become_active", 48 | value: false, 49 | title: "Greet on app launch", 50 | group: "General"), 51 | Tweak(feature: "ui_customization", 52 | variable: "label_text", 53 | value: "Test value", 54 | title: "Label Text", 55 | group: "UI Customization"), 56 | Tweak(feature: "ui_customization", 57 | variable: "red_view_alpha_component", 58 | value: 1.0, 59 | title: "Red View Alpha Component", 60 | group: "UI Customization"), 61 | Tweak(feature: "general", 62 | variable: "tap_to_change_color_enabled", 63 | value: true, 64 | title: "Tap to change views color", 65 | group: "General") 66 | ] 67 | XCTAssertEqual(displayableTweaks, targetTweaks) 68 | } 69 | 70 | func test_GivenTwoLocalTweakProviders_WhenFetchedDisplayableTweaks_ThenTweaksFromBothConfigurationsSortedByTitleAreReturned() { 71 | let tweakProviders: [LocalTweakProvider] = [localTweakProviderHighPriority, localTweakProviderLowPriority] 72 | tweakManager = TweakManager(tweakProviders: tweakProviders) 73 | let displayableTweaks = tweakManager.displayableTweaks 74 | let targetTweaks = [ 75 | Tweak(feature: "ui_customization", 76 | variable: "display_blue_view", 77 | value: true, 78 | title: "Display Blue View", 79 | group: "UI Customization"), 80 | Tweak(feature: "ui_customization", 81 | variable: "display_green_view", 82 | value: true, 83 | title: "Display Green View", 84 | group: "UI Customization"), 85 | Tweak(feature: "ui_customization", 86 | variable: "display_red_view", 87 | value: true, 88 | title: "Display Red View", 89 | group: "UI Customization"), 90 | Tweak(feature: "ui_customization", 91 | variable: "display_yellow_view", 92 | value: true, 93 | title: "Display Yellow View", 94 | group: "UI Customization"), 95 | Tweak(feature: "general", 96 | variable: "greet_on_app_did_become_active", 97 | value: false, 98 | title: "Greet on app launch", 99 | group: "General"), 100 | Tweak(feature: "ui_customization", 101 | variable: "label_text", 102 | value: "Overridden value", 103 | title: "Label Text", 104 | group: "UI Customization"), 105 | Tweak(feature: "ui_customization", 106 | variable: "red_view_alpha_component", 107 | value: 1.0, 108 | title: "Red View Alpha Component", 109 | group: "UI Customization"), 110 | Tweak(feature: "general", 111 | variable: "tap_to_change_color_enabled", 112 | value: false, 113 | title: "Tap to change views color", 114 | group: "General") 115 | ] 116 | XCTAssertEqual(displayableTweaks, targetTweaks) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Example/Tests/Core/TweakTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakTests.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | import JustTweak 8 | 9 | class TweakTests: XCTestCase { 10 | 11 | func testTweakIsEqualToOtherTweakWithSameAttributes() { 12 | let tweakA = Tweak(feature: "some_feature", variable: "some_variable", value: false, title: nil, group: nil) 13 | let tweakB = Tweak(feature: "some_feature", variable: "some_variable", value: false, title: nil, group: nil) 14 | XCTAssertEqual(tweakA, tweakB) 15 | } 16 | 17 | func testTweakIsNotEqualToOtherTweakWithSameIdentifierButDifferentAttributes() { 18 | let tweakA = Tweak(feature: "some_feature", variable: "some_variable", value: false, title: nil, group: nil) 19 | let tweakB = Tweak(feature: "some_feature", variable: "some_variable", value: true, title: nil, group: nil) 20 | XCTAssertNotEqual(tweakA, tweakB) 21 | } 22 | 23 | func testStringTweakIsEqualToOtherTweakWithSameAttributes() { 24 | let tweakA = Tweak(feature: "some_feature", variable: "some_variable", value: "Test", title: nil, group: nil) 25 | let tweakB = Tweak(feature: "some_feature", variable: "some_variable", value: "Test", title: nil, group: nil) 26 | XCTAssertEqual(tweakA, tweakB) 27 | } 28 | 29 | func testIntTweakIsEqualToOtherTweakWithSameAttributes() { 30 | let tweakA = Tweak(feature: "some_feature", variable: "some_variable", value: 1, title: nil, group: nil) 31 | let tweakB = Tweak(feature: "some_feature", variable: "some_variable", value: 1, title: nil, group: nil) 32 | XCTAssertEqual(tweakA, tweakB) 33 | } 34 | 35 | func testFloatTweakIsEqualToOtherTweakWithSameAttributes() { 36 | let value: Float = 1.0 37 | let tweakA = Tweak(feature: "some_feature", variable: "some_variable", value: value, title: nil, group: nil) 38 | let tweakB = Tweak(feature: "some_feature", variable: "some_variable", value: value, title: nil, group: nil) 39 | XCTAssertEqual(tweakA, tweakB) 40 | } 41 | 42 | func testDoubleTweakIsEqualToOtherTweakWithSameAttributes() { 43 | let value: Double = 1.0 44 | let tweakA = Tweak(feature: "some_feature", variable: "some_variable", value: value, title: nil, group: nil) 45 | let tweakB = Tweak(feature: "some_feature", variable: "some_variable", value: value, title: nil, group: nil) 46 | XCTAssertEqual(tweakA, tweakB) 47 | } 48 | 49 | func testMethodsToBrigeValuesToObjectiveC_Bool() { 50 | let value: Double = 1.0 51 | let tweakA = Tweak(feature: "some_feature", variable: "some_variable", value: value, title: nil, group: nil) 52 | XCTAssertFalse(tweakA.boolValue) 53 | let tweakB = Tweak(feature: "some_feature", variable: "some_variable", value: true, title: nil, group: nil) 54 | XCTAssertTrue(tweakB.boolValue) 55 | } 56 | 57 | func testMethodsToBrigeValuesToObjectiveC_Int() { 58 | let value: Int = 1 59 | let tweak = Tweak(feature: "some_feature", variable: "some_variable", value: value, title: nil, group: nil) 60 | XCTAssertEqual(tweak.intValue, value) 61 | } 62 | 63 | func testMethodsToBrigeValuesToObjectiveC_Float() { 64 | let value: Float = 1.5 65 | let tweak = Tweak(feature: "some_feature", variable: "some_variable", value: value, title: nil, group: nil) 66 | XCTAssertEqual(tweak.floatValue, value) 67 | } 68 | 69 | func testMethodsToBrigeValuesToObjectiveC_Double() { 70 | let value: Double = 3.66 71 | let tweak = Tweak(feature: "some_feature", variable: "some_variable", value: value, title: nil, group: nil) 72 | XCTAssertEqual(tweak.doubleValue, value) 73 | } 74 | 75 | func testMethodsToBrigeValuesToObjectiveC_String() { 76 | let value: String = "3.66" 77 | let tweak = Tweak(feature: "some_feature", variable: "some_variable", value: value, title: nil, group: nil) 78 | XCTAssertEqual(tweak.stringValue, value) 79 | } 80 | 81 | func testMethodsToBridgeValuesToObjectiveC_ReturnCorrectValues_FromNumber() { 82 | let tweak = Tweak(feature: "some_feature", 83 | variable: "some_variable", 84 | value: 3.5, 85 | title: nil, 86 | group: nil) 87 | XCTAssertEqual(tweak.doubleValue, 3.5) 88 | XCTAssertEqual(tweak.floatValue, 3.5) 89 | XCTAssertEqual(tweak.intValue, 3) 90 | XCTAssertEqual(tweak.boolValue, false) 91 | XCTAssertNil(tweak.stringValue) 92 | } 93 | 94 | func testMethodsToBridgeValuesToObjectiveC_ReturnCorrectValues_FromBool() { 95 | let tweak = Tweak(feature: "some_feature", 96 | variable: "some_variable", 97 | value: true, 98 | title: nil, 99 | group: nil) 100 | XCTAssertEqual(tweak.doubleValue, 0.0) 101 | XCTAssertEqual(tweak.floatValue, 0.0) 102 | XCTAssertEqual(tweak.intValue, 0) 103 | XCTAssertEqual(tweak.boolValue, true) 104 | XCTAssertNil(tweak.stringValue) 105 | } 106 | 107 | func testMethodsToBridgeValuesToObjectiveC_ReturnCorrectValues_FromString() { 108 | let tweak = Tweak(feature: "some_feature", 109 | variable: "some_variable", 110 | value: "Hello", 111 | title: nil, 112 | group: nil) 113 | XCTAssertEqual(tweak.doubleValue, 0.0) 114 | XCTAssertEqual(tweak.floatValue, 0.0) 115 | XCTAssertEqual(tweak.intValue, 0) 116 | XCTAssertEqual(tweak.boolValue, false) 117 | XCTAssertEqual(tweak.stringValue, "Hello") 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /Example/Tests/Core/PropertyWrappersTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Accessor.swift 3 | // Copyright (c) 2019 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | import JustTweak 8 | 9 | class Accessor { 10 | 11 | static let tweakManager: TweakManager = { 12 | let ephemeralTweakProvider: MutableTweakProvider = NSMutableDictionary() 13 | return TweakManager(tweakProviders: [ephemeralTweakProvider]) 14 | }() 15 | 16 | @FallbackTweakProperty(fallbackValue: "default", 17 | feature: "stringValue_feature", 18 | variable: "stringValue_variable", 19 | tweakManager: tweakManager) 20 | var stringValue: String 21 | 22 | @FallbackTweakProperty(fallbackValue: false, 23 | feature: "boolValue_feature", 24 | variable: "boolValue_variable", 25 | tweakManager: tweakManager) 26 | var boolValue: Bool 27 | 28 | @FallbackTweakProperty(fallbackValue: 42, 29 | feature: "intValue_feature", 30 | variable: "intValue_variable", 31 | tweakManager: tweakManager) 32 | var intValue: Int 33 | 34 | @FallbackTweakProperty(fallbackValue: 3.14, 35 | feature: "doubleValue_feature", 36 | variable: "doubleValue_variable", 37 | tweakManager: tweakManager) 38 | var doubleValue: Double 39 | 40 | @FallbackTweakProperty(fallbackValue: 108.0, 41 | feature: "floatValue_feature", 42 | variable: "floatValue_variable", 43 | tweakManager: tweakManager) 44 | var floatValue: Float 45 | 46 | @OptionalTweakProperty(fallbackValue: nil, 47 | feature: "optional_stringValue_feature", 48 | variable: "optional_stringValue_variable", 49 | tweakManager: tweakManager) 50 | var optionalStringValue: String? 51 | 52 | @OptionalTweakProperty(fallbackValue: nil, 53 | feature: "optional_boolValue_feature", 54 | variable: "optional_boolValue_variable", 55 | tweakManager: tweakManager) 56 | var optionalBoolValue: Bool? 57 | 58 | @OptionalTweakProperty(fallbackValue: nil, 59 | feature: "optional_intValue_feature", 60 | variable: "optional_intValue_variable", 61 | tweakManager: tweakManager) 62 | var optionalIntValue: Int? 63 | 64 | @OptionalTweakProperty(fallbackValue: nil, 65 | feature: "optional_doubleValue_feature", 66 | variable: "optional_doubleValue_variable", 67 | tweakManager: tweakManager) 68 | var optionalDoubleValue: Double? 69 | 70 | @OptionalTweakProperty(fallbackValue: nil, 71 | feature: "optional_floatValue_feature", 72 | variable: "optional_floatValue_variable", 73 | tweakManager: tweakManager) 74 | var optionalFloatValue: Float? 75 | } 76 | 77 | class FeatureFlagPropertyWrapperTests: XCTestCase { 78 | 79 | var accessor: Accessor! 80 | 81 | override func setUp() { 82 | accessor = Accessor() 83 | } 84 | 85 | override func tearDown() { 86 | accessor = nil 87 | } 88 | 89 | func test_StringFeatureFlag() { 90 | XCTAssertEqual(accessor.stringValue, "default") 91 | let newValue = "other value" 92 | accessor.stringValue = newValue 93 | XCTAssertEqual(accessor.stringValue, newValue) 94 | } 95 | 96 | func test_BoolFeatureFlag() { 97 | XCTAssertFalse(accessor.boolValue) 98 | accessor.boolValue = true 99 | XCTAssertTrue(accessor.boolValue) 100 | } 101 | 102 | func test_IntFeatureFlag() { 103 | XCTAssertEqual(accessor.intValue, 42) 104 | let newValue = 100 105 | accessor.intValue = newValue 106 | XCTAssertEqual(accessor.intValue, newValue) 107 | } 108 | 109 | func test_DoubleFeatureFlag() { 110 | XCTAssertEqual(accessor.doubleValue, 3.14) 111 | let newValue = 100.200 112 | accessor.doubleValue = newValue 113 | XCTAssertEqual(accessor.doubleValue, newValue) 114 | } 115 | 116 | func test_FloatFeatureFlag() { 117 | XCTAssertEqual(accessor.floatValue, 108.0) 118 | let newValue: Float = 100.200 119 | accessor.floatValue = newValue 120 | XCTAssertEqual(accessor.floatValue, newValue) 121 | } 122 | 123 | func test_OptionalStringFeatureFlag() { 124 | XCTAssertEqual(accessor.optionalStringValue, nil) 125 | let newValue = "some value" 126 | accessor.optionalStringValue = newValue 127 | XCTAssertEqual(accessor.optionalStringValue, newValue) 128 | } 129 | 130 | func test_OptionalBoolFeatureFlag() { 131 | XCTAssertEqual(accessor.optionalBoolValue, nil) 132 | accessor.optionalBoolValue = true 133 | XCTAssertEqual(accessor.optionalBoolValue, true) 134 | } 135 | 136 | func test_OptionalIntFeatureFlag() { 137 | XCTAssertEqual(accessor.optionalIntValue, nil) 138 | let newValue = 100 139 | accessor.optionalIntValue = newValue 140 | XCTAssertEqual(accessor.optionalIntValue, newValue) 141 | } 142 | 143 | func test_OptionalDoubleFeatureFlag() { 144 | XCTAssertEqual(accessor.optionalDoubleValue, nil) 145 | let newValue = 3.14 146 | accessor.optionalDoubleValue = newValue 147 | XCTAssertEqual(accessor.optionalDoubleValue, newValue) 148 | } 149 | 150 | func test_OptionalFloatFeatureFlag() { 151 | XCTAssertEqual(accessor.optionalFloatValue, nil) 152 | let newValue: Float = 108.0 153 | accessor.optionalFloatValue = newValue 154 | XCTAssertEqual(accessor.optionalFloatValue, newValue) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Example/Tests/Core/TweakManagerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakManagerTests.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | @testable import JustTweak 8 | 9 | class TweakManagerTests: XCTestCase { 10 | 11 | var tweakManager: TweakManager! 12 | let localTweakProvider: TweakProvider = { 13 | let bundle = Bundle(for: TweakManagerTests.self) 14 | let jsonConfigurationURL = bundle.url(forResource: "LocalTweaks_test", withExtension: "json")! 15 | return LocalTweakProvider(jsonURL: jsonConfigurationURL) 16 | }() 17 | var userDefaultsTweakProvider: UserDefaultsTweakProvider! 18 | 19 | override func setUp() { 20 | super.setUp() 21 | let mockTweakProvider = MockTweakProvider() 22 | let testUserDefaults = UserDefaults(suiteName: "com.JustTweak.TweakManagerTests")! 23 | userDefaultsTweakProvider = UserDefaultsTweakProvider(userDefaults: testUserDefaults) 24 | let tweakProviders: [TweakProvider] = [userDefaultsTweakProvider, mockTweakProvider, localTweakProvider] 25 | tweakManager = TweakManager(tweakProviders: tweakProviders) 26 | } 27 | 28 | override func tearDown() { 29 | userDefaultsTweakProvider.deleteValue(feature: Features.uiCustomization, variable: Variables.greetOnAppDidBecomeActive) 30 | tweakManager = nil 31 | super.tearDown() 32 | } 33 | 34 | func testReturnsNoMutableTweakProvider_IfNoneHasBeenPassedToInitializer() { 35 | let tweakManager = TweakManager(tweakProviders: [localTweakProvider]) 36 | XCTAssertNil(tweakManager.mutableTweakProvider) 37 | } 38 | 39 | func testReturnsNil_ForUndefinedTweak() { 40 | XCTAssertNil(try? tweakManager.tweakWith(feature: Features.uiCustomization, variable: "some_undefined_tweak")) 41 | } 42 | 43 | func testReturnsRemoteConfigValue_ForDisplayRedViewTweak() throws { 44 | XCTAssertTrue(try XCTUnwrap(tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.displayRedView)).boolValue) 45 | } 46 | 47 | func testReturnsRemoteConfigValue_ForDisplayYellowViewTweak() throws { 48 | XCTAssertFalse(try XCTUnwrap(tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.displayYellowView)).boolValue) 49 | } 50 | 51 | func testReturnsRemoteConfigValue_ForDisplayGreenViewTweak() throws { 52 | XCTAssertFalse(try XCTUnwrap(tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.displayGreenView)).boolValue) 53 | } 54 | 55 | func testReturnsRemoteConfigValue_ForGreetOnAppDidBecomeActiveTweak() throws { 56 | XCTAssertTrue(try XCTUnwrap(tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.greetOnAppDidBecomeActive)).boolValue) 57 | } 58 | 59 | func testReturnsJSONConfigValue_ForTapToChangeViewColorTweak_AsYetUnkown() throws { 60 | XCTAssertTrue(try XCTUnwrap(tweakManager.tweakWith(feature: Features.general, variable: Variables.tapToChangeViewColor)).boolValue) 61 | } 62 | 63 | func testReturnsUserSetValue_ForGreetOnAppDidBecomeActiveTweak_AfterUpdatingUserDefaultsTweakProvider() throws { 64 | let mutableTweakProvider = tweakManager.mutableTweakProvider! 65 | mutableTweakProvider.set(false, feature: Features.uiCustomization, variable: Variables.greetOnAppDidBecomeActive) 66 | XCTAssertFalse(try XCTUnwrap(tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.greetOnAppDidBecomeActive)).boolValue) 67 | } 68 | 69 | func testCallsClosureForRegisteredObserverWhenAnyConfigurationChanges() { 70 | var didCallClosure = false 71 | tweakManager.registerForConfigurationsUpdates(self) { tweakIdentifier in 72 | didCallClosure = true 73 | } 74 | let tweak = Tweak(feature: "feature", variable: "variable", value: "value") 75 | let userInfo = [TweakProviderDidChangeNotificationTweakKey: tweak] 76 | NotificationCenter.default.post(name: TweakProviderDidChangeNotification, object: self, userInfo: userInfo) 77 | XCTAssertTrue(didCallClosure) 78 | } 79 | 80 | func testDoesNotCallClosureForDeregisteredObserverWhenAnyConfigurationChanges() { 81 | var didCallClosure = false 82 | tweakManager.registerForConfigurationsUpdates(self) { tweakIdentifier in 83 | didCallClosure = true 84 | } 85 | tweakManager.deregisterFromConfigurationsUpdates(self) 86 | let tweak = Tweak(feature: "feature", variable: "variable", value: "value") 87 | let userInfo = [TweakProviderDidChangeNotificationTweakKey: tweak] 88 | NotificationCenter.default.post(name: TweakProviderDidChangeNotification, object: self, userInfo: userInfo) 89 | XCTAssertFalse(didCallClosure) 90 | } 91 | 92 | func testTweakManagerDecryption() throws { 93 | let url = try XCTUnwrap(Bundle.main.url(forResource: "LocalTweaks_example", withExtension: "json")) 94 | 95 | tweakManager.tweakProviders.append(LocalTweakProvider(jsonURL: url)) 96 | 97 | tweakManager.decryptionClosure = { tweak in 98 | String((tweak.value.stringValue ?? "").reversed()) 99 | } 100 | 101 | let feature = "general" 102 | let variable = "encrypted_answer_to_the_universe" 103 | let tweak = try? tweakManager.tweakWith(feature: feature, variable: variable) 104 | 105 | XCTAssertEqual("Definitely not 42", tweak?.stringValue) 106 | } 107 | 108 | func testSetTweakManagerDecryptionClosureThenDecryptionClosureIsSetForProviders() throws { 109 | let mutableTweakProvider = try XCTUnwrap(tweakManager.mutableTweakProvider) 110 | 111 | XCTAssertNil(mutableTweakProvider.decryptionClosure) 112 | 113 | tweakManager.decryptionClosure = { tweak in 114 | tweak.value 115 | } 116 | 117 | XCTAssertNotNil(mutableTweakProvider.decryptionClosure) 118 | } 119 | } 120 | 121 | fileprivate class MockTweakProvider: TweakProvider { 122 | 123 | var logClosure: LogClosure? 124 | var decryptionClosure: ((Tweak) -> TweakValue)? 125 | let features: [String : [String]] = [:] 126 | let knownValues = [Variables.displayRedView: ["Value": true], 127 | Variables.displayYellowView: ["Value": false], 128 | Variables.displayGreenView: ["Value": false], 129 | Variables.greetOnAppDidBecomeActive: ["Value": true]] 130 | 131 | func isFeatureEnabled(_ feature: String) -> Bool { 132 | return false 133 | } 134 | 135 | func tweakWith(feature: String, variable: String) throws -> Tweak { 136 | guard let value = knownValues[variable] else { throw TweakError.notFound } 137 | return Tweak(feature: feature, variable: variable, value: value["Value"]!, title: nil, group: nil) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGenerator/Core/TweakAccessorCodeGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakAccessorCodeGenerator.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | class TweakAccessorCodeGenerator { 9 | 10 | private let featuresConst = "Features" 11 | private let variablesConst = "Variables" 12 | 13 | private let featureConstantsConst = "" 14 | private let variableConstantsConst = "" 15 | private let classContentConst = "" 16 | private let tweakManagerConst = "" 17 | } 18 | 19 | extension TweakAccessorCodeGenerator { 20 | 21 | func generateConstantsFileContent(tweaks: [Tweak], 22 | configuration: Configuration) -> String { 23 | let template = self.constantsTemplate(with: configuration.accessorName) 24 | let featureConstants = self.featureConstantsCodeBlock(with: tweaks) 25 | let variableConstants = self.variableConstantsCodeBlock(with: tweaks) 26 | 27 | let content = template 28 | .replacingOccurrences(of: featureConstantsConst, with: featureConstants) 29 | .replacingOccurrences(of: variableConstantsConst, with: variableConstants) 30 | .trimmingCharacters(in: .whitespacesAndNewlines) + "\n" 31 | return content 32 | } 33 | 34 | func generateAccessorFileContent(tweaksFilename: String, 35 | tweaks: [Tweak], 36 | configuration: Configuration) -> String { 37 | let template = self.accessorTemplate(with: configuration.accessorName) 38 | let tweakManager = self.tweakManagerCodeBlock() 39 | let classContent = self.classContent(with: tweaks, configuration: configuration) 40 | 41 | let content = template 42 | .replacingOccurrences(of: tweakManagerConst, with: tweakManager) 43 | .replacingOccurrences(of: classContentConst, with: classContent) 44 | .trimmingCharacters(in: .whitespacesAndNewlines) + "\n" 45 | return content 46 | } 47 | 48 | func constantsTemplate(with className: String) -> String { 49 | """ 50 | // \(className)+Constants.swift 51 | 52 | /// Generated by TweakAccessorGenerator 53 | 54 | import Foundation 55 | 56 | extension \(className) { 57 | 58 | \(featureConstantsConst) 59 | 60 | \(variableConstantsConst) 61 | } 62 | """ 63 | } 64 | 65 | private func accessorTemplate(with className: String) -> String { 66 | """ 67 | // \(className).swift 68 | 69 | /// Generated by TweakAccessorGenerator 70 | 71 | import Foundation 72 | import JustTweak 73 | 74 | class \(className) { 75 | 76 | \(tweakManagerConst) 77 | 78 | \(classContentConst) 79 | } 80 | """ 81 | } 82 | 83 | private func featureConstantsCodeBlock(with tweaks: [Tweak]) -> String { 84 | var features = Set() 85 | for tweak in tweaks { 86 | features.insert(tweak.feature) 87 | } 88 | let content: [String] = features.map { 89 | """ 90 | static let \($0.camelCased()) = "\($0)" 91 | """ 92 | } 93 | return """ 94 | struct \(featuresConst) { 95 | \(content.sorted().joined(separator: "\n")) 96 | } 97 | """ 98 | } 99 | 100 | private func variableConstantsCodeBlock(with tweaks: [Tweak]) -> String { 101 | var variables = Set() 102 | for tweak in tweaks { 103 | variables.insert(tweak.variable) 104 | } 105 | let content: [String] = variables.map { 106 | """ 107 | static let \($0.camelCased()) = "\($0)" 108 | """ 109 | } 110 | return """ 111 | struct \(variablesConst) { 112 | \(content.sorted().joined(separator: "\n")) 113 | } 114 | """ 115 | } 116 | 117 | private func tweakManagerCodeBlock() -> String { 118 | """ 119 | private(set) var tweakManager: TweakManager 120 | 121 | init(with tweakManager: TweakManager) { 122 | self.tweakManager = tweakManager 123 | } 124 | """ 125 | } 126 | 127 | private func classContent(with tweaks: [Tweak], configuration: Configuration) -> String { 128 | var content: Set = [] 129 | tweaks.forEach { 130 | content.insert(tweakComputedProperty(for: $0)) 131 | } 132 | return content.sorted().joined(separator: "\n\n") 133 | } 134 | 135 | private func tweakPropertyWrapper(for tweak: Tweak) -> String { 136 | let propertyName = tweak.propertyName ?? tweak.variable.camelCased() 137 | return """ 138 | @TweakProperty(feature: \(featuresConst).\(tweak.feature.camelCased()), 139 | variable: \(variablesConst).\(tweak.variable.camelCased()), 140 | tweakManager: tweakManager) 141 | var \(propertyName): \(tweak.valueType) 142 | """ 143 | } 144 | 145 | private func tweakComputedProperty(for tweak: Tweak) -> String { 146 | let propertyName = tweak.propertyName ?? tweak.variable.camelCased() 147 | let feature = "\(featuresConst).\(tweak.feature.camelCased())" 148 | let variable = "\(variablesConst).\(tweak.variable.camelCased())" 149 | let castProperty = try! self.castProperty(for: tweak.valueType) 150 | let defaultValue = try! self.defaultValue(for: tweak.valueType) 151 | return """ 152 | var \(propertyName): \(tweak.valueType) { 153 | get { (try? tweakManager.tweakWith(feature: \(feature), variable: \(variable)))?.\(castProperty) ?? \(defaultValue) } 154 | set { tweakManager.set(newValue, feature: \(feature), variable: \(variable)) } 155 | } 156 | """ 157 | } 158 | 159 | private func castProperty(for valueType: String) throws -> String { 160 | switch valueType { 161 | case "String": 162 | return "stringValue" 163 | case "Bool": 164 | return "boolValue" 165 | case "Double": 166 | return "doubleValue" 167 | case "Int": 168 | return "intValue" 169 | default: 170 | throw "Unsupported value type '\(valueType)'" 171 | } 172 | } 173 | 174 | private func defaultValue(for valueType: String) throws -> String { 175 | switch valueType { 176 | case "String": 177 | return "\"\"" 178 | case "Bool": 179 | return "false" 180 | case "Double": 181 | return "0.0" 182 | case "Int": 183 | return "0" 184 | default: 185 | throw "Unsupported value type '\(valueType)'" 186 | } 187 | } 188 | 189 | private func formatCustomTweakProviderSetupCode(_ setupCode: String) -> String { 190 | setupCode.split(separator: "\n") 191 | .map { " " + $0 } 192 | .joined(separator: "\n") 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /Framework/Sources/TweakManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakManager.swift 3 | // Copyright (c) 2016 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | final public class TweakManager { 9 | 10 | var tweakProviders: [TweakProvider] 11 | 12 | public var logClosure: LogClosure? { 13 | didSet { 14 | for (index, _) in tweakProviders.enumerated() { 15 | tweakProviders[index].logClosure = logClosure 16 | } 17 | } 18 | } 19 | 20 | public var decryptionClosure: ((Tweak) -> TweakValue)? { 21 | didSet { 22 | for (index, _) in tweakProviders.enumerated() { 23 | tweakProviders[index].decryptionClosure = decryptionClosure 24 | } 25 | } 26 | } 27 | 28 | public var useCache: Bool = false { 29 | didSet { 30 | if useCache != oldValue { 31 | resetCache() 32 | } 33 | } 34 | } 35 | 36 | private let queue = DispatchQueue(label: "com.justeat.tweakManager") 37 | 38 | private var featureCache = [String : Bool]() 39 | private var tweakCache = [String : [String : Tweak]]() 40 | private var experimentCache = [String : String]() 41 | private var observersMap = [NSObject : NSObjectProtocol]() 42 | 43 | var mutableTweakProvider: MutableTweakProvider? { 44 | return tweakProviders.first { $0 is MutableTweakProvider } as? MutableTweakProvider 45 | } 46 | 47 | public init(tweakProviders: [TweakProvider]) { 48 | self.tweakProviders = tweakProviders 49 | for (index, _) in self.tweakProviders.enumerated() { 50 | self.tweakProviders[index].logClosure = logClosure 51 | } 52 | let notificationCenter = NotificationCenter.default 53 | notificationCenter.addObserver(self, selector: #selector(configurationDidChange), name: TweakProviderDidChangeNotification, object: nil) 54 | } 55 | 56 | deinit { 57 | NotificationCenter.default.removeObserver(self) 58 | } 59 | } 60 | 61 | extension TweakManager: MutableTweakProvider { 62 | 63 | public func isFeatureEnabled(_ feature: String) -> Bool { 64 | queue.sync { 65 | if useCache, let cachedFeature = featureCache[feature] { 66 | logClosure?("Feature '\(cachedFeature)' found in cache.)", .verbose) 67 | return cachedFeature 68 | } 69 | 70 | var enabled = false 71 | for (_, configuration) in tweakProviders.enumerated() { 72 | if configuration.isFeatureEnabled(feature) { 73 | enabled = true 74 | break 75 | } 76 | } 77 | if useCache { 78 | featureCache[feature] = enabled 79 | } 80 | return enabled 81 | } 82 | } 83 | 84 | public func tweakWith(feature: String, variable: String) throws -> Tweak { 85 | try queue.sync { 86 | if useCache, let cachedTweaks = tweakCache[feature], let cachedTweak = cachedTweaks[variable] { 87 | logClosure?("Tweak '\(cachedTweak)' found in cache.)", .verbose) 88 | return cachedTweak 89 | } 90 | 91 | var result: Tweak? = nil 92 | for (_, tweakProvider) in tweakProviders.enumerated() { 93 | if let tweak = try? tweakProvider.tweakWith(feature: feature, variable: variable) { 94 | logClosure?("Tweak '\(tweak)' found in configuration \(tweakProvider))", .verbose) 95 | 96 | result = Tweak(feature: feature, 97 | variable: variable, 98 | value: tweak.value, 99 | title: tweak.title, 100 | group: tweak.group, 101 | source: "\(type(of: tweakProvider))") 102 | break 103 | } else { 104 | let logMessage = "Tweak with identifier '\(variable)' in configuration \(tweakProvider)) could NOT be found or has an invalid configuration" 105 | logClosure?(logMessage, .verbose) 106 | } 107 | } 108 | guard let result = result else { 109 | logClosure?("No Tweak found for identifier '\(variable)'", .verbose) 110 | throw TweakError.notFound 111 | } 112 | logClosure?("Tweak with feature '\(feature)' and variable '\(variable)' resolved. Using '\(result)'.", .debug) 113 | if useCache { 114 | if let _ = tweakCache[feature] { 115 | tweakCache[feature]?[variable] = result 116 | } else { 117 | tweakCache[feature] = [variable : result] 118 | } 119 | } 120 | return result 121 | } 122 | } 123 | 124 | public func set(_ value: TweakValue, feature: String, variable: String) { 125 | guard let mutableTweakProvider = self.mutableTweakProvider else { return } 126 | if useCache { 127 | queue.sync { 128 | // cannot use write-through cache because tweakWith(feature:variable:) returns a Tweak, but here we only have a TweakValue 129 | // we simply set the entry to nil so the next fetch will go through the list of configurations and subsequently re-cache 130 | tweakCache[feature]?[variable] = nil 131 | } 132 | } 133 | mutableTweakProvider.set(value, feature: feature, variable: variable) 134 | } 135 | 136 | public func deleteValue(feature: String, variable: String) { 137 | guard let mutableTweakProvider = self.mutableTweakProvider else { return } 138 | if useCache { 139 | queue.sync { 140 | tweakCache[feature]?[variable] = nil 141 | } 142 | } 143 | mutableTweakProvider.deleteValue(feature: feature, variable: variable) 144 | } 145 | } 146 | 147 | extension TweakManager { 148 | 149 | public func registerForConfigurationsUpdates(_ object: NSObject, closure: @escaping (Tweak) -> Void) { 150 | self.deregisterFromConfigurationsUpdates(object) 151 | queue.sync { 152 | let queue = OperationQueue.main 153 | let name = TweakProviderDidChangeNotification 154 | let notificationsCenter = NotificationCenter.default 155 | let observer = notificationsCenter.addObserver(forName: name, object: nil, queue: queue) { notification in 156 | guard let tweak = notification.userInfo?[TweakProviderDidChangeNotificationTweakKey] as? Tweak else { return } 157 | closure(tweak) 158 | } 159 | observersMap[object] = observer 160 | } 161 | } 162 | 163 | public func deregisterFromConfigurationsUpdates(_ object: NSObject) { 164 | queue.sync { 165 | guard let observer = observersMap[object] else { return } 166 | NotificationCenter.default.removeObserver(observer) 167 | observersMap.removeValue(forKey: object) 168 | } 169 | } 170 | 171 | @objc private func configurationDidChange() { 172 | if useCache { 173 | resetCache() 174 | } 175 | } 176 | } 177 | 178 | extension TweakManager { 179 | 180 | public func resetCache() { 181 | queue.sync { 182 | featureCache = [String : Bool]() 183 | tweakCache = [String : [String : Tweak]]() 184 | experimentCache = [String : String]() 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /Example/Tests/UI/TweakViewControllerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakViewControllerTests.swift 3 | // Copyright (c) 2019 Just Eat Holding Ltd. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | @testable import JustTweak 8 | 9 | class TweakViewControllerTests: XCTestCase { 10 | 11 | private var rootWindow: UIWindow! 12 | private var viewController: TweakViewController! 13 | private var tweakManager: TweakManager! 14 | 15 | override func setUp() { 16 | super.setUp() 17 | let bundle = Bundle(for: TweakViewControllerTests.self) 18 | let jsonURL = bundle.url(forResource: "LocalTweaks_test", withExtension: "json") 19 | let localTweakProvider = LocalTweakProvider(jsonURL: jsonURL!) 20 | let userDefaults = UserDefaults(suiteName: "com.JustTweaks.Tests\(NSDate.timeIntervalSinceReferenceDate)")! 21 | let userDefaultsTweakProvider = UserDefaultsTweakProvider(userDefaults: userDefaults) 22 | let tweakProviders: [TweakProvider] = [userDefaultsTweakProvider, localTweakProvider] 23 | tweakManager = TweakManager(tweakProviders: tweakProviders) 24 | viewController = TweakViewController(style: .plain, tweakManager: tweakManager) 25 | 26 | rootWindow = UIWindow(frame: UIScreen.main.bounds) 27 | rootWindow.makeKeyAndVisible() 28 | rootWindow.isHidden = false 29 | rootWindow.rootViewController = viewController 30 | _ = viewController.view 31 | viewController.viewWillAppear(false) 32 | viewController.viewDidAppear(false) 33 | } 34 | 35 | override func tearDown() { 36 | let mutableTweakProvider = tweakManager.mutableTweakProvider! 37 | mutableTweakProvider.deleteValue(feature: "feature_1", variable: "variable_1") 38 | viewController = nil 39 | 40 | let rootViewController = rootWindow!.rootViewController! 41 | rootViewController.viewWillDisappear(false) 42 | rootViewController.viewDidDisappear(false) 43 | rootWindow.rootViewController = nil 44 | rootWindow.isHidden = true 45 | self.rootWindow = nil 46 | self.viewController = nil 47 | 48 | super.tearDown() 49 | } 50 | 51 | // MARK: Generic Data Display 52 | 53 | func testHasExpectedNumberOfSections() { 54 | XCTAssertEqual(2, viewController.numberOfSections(in: viewController.tableView)) 55 | } 56 | 57 | func testGroupedTweaksAreDisplayedInTheirOwnSections() { 58 | XCTAssertEqual(2, viewController.tableView(viewController.tableView, numberOfRowsInSection: 0)) 59 | XCTAssertEqual("General", viewController.tableView(viewController.tableView, titleForHeaderInSection: 0)) 60 | XCTAssertEqual(5, viewController.tableView(viewController.tableView, numberOfRowsInSection: 1)) 61 | XCTAssertEqual("UI Customization", viewController.tableView(viewController.tableView, titleForHeaderInSection: 1)) 62 | } 63 | 64 | // MARK: Convenience Methods 65 | 66 | func testReturnsCorrectIndexPathForTweak_WhenTweakFound() { 67 | let indexPath = viewController.indexPathForTweak(with: Features.uiCustomization, variable: Variables.displayGreenView)! 68 | let expectedIndexPath = IndexPath(row: 0, section: 1) 69 | XCTAssertEqual(indexPath, expectedIndexPath) 70 | } 71 | 72 | func testReturnsCorrectIndexPathForTweak_WhenTweakFound_2() { 73 | let indexPath = viewController.indexPathForTweak(with: Features.uiCustomization, variable: Variables.displayRedView)! 74 | let expectedIndexPath = IndexPath(row: 1, section: 1) 75 | XCTAssertEqual(indexPath, expectedIndexPath) 76 | } 77 | 78 | func testReturnsCorrectIndexPathForTweak_WhenTweakFound_3() { 79 | let indexPath = viewController.indexPathForTweak(with: Features.uiCustomization, variable: Variables.displayYellowView)! 80 | let expectedIndexPath = IndexPath(row: 2, section: 1) 81 | XCTAssertEqual(indexPath, expectedIndexPath) 82 | } 83 | 84 | // MARK: Tweak Cells Display 85 | 86 | func testReturnsCorrectIndexPathForTweak_WhenTweakNotFound() { 87 | let indexPath = viewController.indexPathForTweak(with: Features.general, variable: "some_nonexisting_tweak") 88 | XCTAssertNil(indexPath) 89 | } 90 | 91 | func testDisplaysTweakOn_IfEnabled() { 92 | let indexPath = viewController.indexPathForTweak(with: Features.uiCustomization, variable: Variables.displayYellowView)! 93 | let cell = viewController.tableView(viewController.tableView, cellForRowAt: indexPath) as! BooleanTweakTableViewCell 94 | XCTAssertFalse(cell.switchControl.isOn) 95 | } 96 | 97 | func testDisplaysTweakOff_IfDisabled() { 98 | let indexPath = viewController.indexPathForTweak(with: Features.uiCustomization, variable: Variables.displayRedView)! 99 | let cell = viewController.tableView(viewController.tableView, cellForRowAt: indexPath) as! BooleanTweakTableViewCell 100 | XCTAssertTrue(cell.switchControl.isOn) 101 | } 102 | 103 | func testDisplaysTweakTitle_ForTweakThatHaveIt() { 104 | let indexPath = viewController.indexPathForTweak(with: Features.uiCustomization, variable: Variables.displayRedView)! 105 | let cell = viewController.tableView(viewController.tableView, cellForRowAt: indexPath) 106 | XCTAssertEqual(cell.textLabel?.text, "Display Red View") 107 | XCTAssertEqual((cell as! TweakViewControllerCell).title, "Display Red View") 108 | } 109 | 110 | func testDisplaysNumericTweaksCorrectly() { 111 | let indexPath = viewController.indexPathForTweak(with: Features.uiCustomization, variable: Variables.redViewAlpha)! 112 | let cell = viewController.tableView(viewController.tableView, cellForRowAt: indexPath) as? NumericTweakTableViewCell 113 | XCTAssertEqual(cell?.title, "Red View Alpha Component") 114 | XCTAssertEqual(cell?.textField.text, "1.0") 115 | } 116 | 117 | func testDisplaysTextTweaksCorrectly() { 118 | let indexPath = viewController.indexPathForTweak(with: Features.uiCustomization, variable: Variables.labelText)! 119 | let cell = viewController.tableView(viewController.tableView, cellForRowAt: indexPath) as? TextTweakTableViewCell 120 | XCTAssertEqual(cell?.title, "Label Text") 121 | XCTAssertEqual(cell?.textField.text, "Test value") 122 | } 123 | 124 | // MARK: Cells Actions 125 | 126 | func testUpdatesValueOfTweak_WhenUserTooglesSwitchOnBooleanCell() throws { 127 | viewController.beginAppearanceTransition(true, animated: false) 128 | viewController.endAppearanceTransition() 129 | 130 | let indexPath = viewController.indexPathForTweak(with: Features.uiCustomization, variable: Variables.displayYellowView)! 131 | let cell = viewController.tableView.cellForRow(at: indexPath) as! BooleanTweakTableViewCell 132 | cell.switchControl.isOn = true 133 | cell.switchControl.sendActions(for: .valueChanged) 134 | XCTAssertTrue(try XCTUnwrap(tweakManager.tweakWith(feature: Features.uiCustomization, variable: Variables.displayYellowView)).boolValue) 135 | } 136 | 137 | // MARK: Other Actions 138 | 139 | func testAsksToBeDismissedWhenDoneButtonIsTapped() { 140 | class FakeViewController: TweakViewController { 141 | fileprivate let mockPresentingViewController = MockPresentingViewController() 142 | override var presentingViewController: UIViewController? { 143 | get { 144 | return mockPresentingViewController 145 | } 146 | } 147 | } 148 | let vc = FakeViewController(style: .grouped, tweakManager: tweakManager) 149 | vc.dismissViewController() 150 | XCTAssertTrue(vc.mockPresentingViewController.didCallDismissal) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.5) 5 | rexml 6 | activesupport (5.2.8) 7 | concurrent-ruby (~> 1.0, >= 1.0.2) 8 | i18n (>= 0.7, < 2) 9 | minitest (~> 5.1) 10 | tzinfo (~> 1.1) 11 | addressable (2.8.0) 12 | public_suffix (>= 2.0.2, < 5.0) 13 | algoliasearch (1.27.5) 14 | httpclient (~> 2.8, >= 2.8.3) 15 | json (>= 1.5.1) 16 | artifactory (3.0.15) 17 | atomos (0.1.3) 18 | aws-eventstream (1.2.0) 19 | aws-partitions (1.601.0) 20 | aws-sdk-core (3.131.2) 21 | aws-eventstream (~> 1, >= 1.0.2) 22 | aws-partitions (~> 1, >= 1.525.0) 23 | aws-sigv4 (~> 1.1) 24 | jmespath (~> 1, >= 1.6.1) 25 | aws-sdk-kms (1.57.0) 26 | aws-sdk-core (~> 3, >= 3.127.0) 27 | aws-sigv4 (~> 1.1) 28 | aws-sdk-s3 (1.114.0) 29 | aws-sdk-core (~> 3, >= 3.127.0) 30 | aws-sdk-kms (~> 1) 31 | aws-sigv4 (~> 1.4) 32 | aws-sigv4 (1.5.0) 33 | aws-eventstream (~> 1, >= 1.0.2) 34 | babosa (1.0.4) 35 | claide (1.1.0) 36 | cocoapods (1.10.2) 37 | addressable (~> 2.6) 38 | claide (>= 1.0.2, < 2.0) 39 | cocoapods-core (= 1.10.2) 40 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 41 | cocoapods-downloader (>= 1.4.0, < 2.0) 42 | cocoapods-plugins (>= 1.0.0, < 2.0) 43 | cocoapods-search (>= 1.0.0, < 2.0) 44 | cocoapods-trunk (>= 1.4.0, < 2.0) 45 | cocoapods-try (>= 1.1.0, < 2.0) 46 | colored2 (~> 3.1) 47 | escape (~> 0.0.4) 48 | fourflusher (>= 2.3.0, < 3.0) 49 | gh_inspector (~> 1.0) 50 | molinillo (~> 0.6.6) 51 | nap (~> 1.0) 52 | ruby-macho (~> 1.4) 53 | xcodeproj (>= 1.19.0, < 2.0) 54 | cocoapods-core (1.10.2) 55 | activesupport (> 5.0, < 6) 56 | addressable (~> 2.6) 57 | algoliasearch (~> 1.0) 58 | concurrent-ruby (~> 1.1) 59 | fuzzy_match (~> 2.0.4) 60 | nap (~> 1.0) 61 | netrc (~> 0.11) 62 | public_suffix 63 | typhoeus (~> 1.0) 64 | cocoapods-deintegrate (1.0.5) 65 | cocoapods-downloader (1.6.3) 66 | cocoapods-plugins (1.0.0) 67 | nap 68 | cocoapods-search (1.0.1) 69 | cocoapods-trunk (1.6.0) 70 | nap (>= 0.8, < 2.0) 71 | netrc (~> 0.11) 72 | cocoapods-try (1.2.0) 73 | colored (1.2) 74 | colored2 (3.1.2) 75 | commander (4.6.0) 76 | highline (~> 2.0.0) 77 | concurrent-ruby (1.1.10) 78 | declarative (0.0.20) 79 | digest-crc (0.6.4) 80 | rake (>= 12.0.0, < 14.0.0) 81 | domain_name (0.5.20190701) 82 | unf (>= 0.0.5, < 1.0.0) 83 | dotenv (2.7.6) 84 | emoji_regex (3.2.3) 85 | escape (0.0.4) 86 | ethon (0.15.0) 87 | ffi (>= 1.15.0) 88 | excon (0.92.3) 89 | faraday (1.10.0) 90 | faraday-em_http (~> 1.0) 91 | faraday-em_synchrony (~> 1.0) 92 | faraday-excon (~> 1.1) 93 | faraday-httpclient (~> 1.0) 94 | faraday-multipart (~> 1.0) 95 | faraday-net_http (~> 1.0) 96 | faraday-net_http_persistent (~> 1.0) 97 | faraday-patron (~> 1.0) 98 | faraday-rack (~> 1.0) 99 | faraday-retry (~> 1.0) 100 | ruby2_keywords (>= 0.0.4) 101 | faraday-cookie_jar (0.0.7) 102 | faraday (>= 0.8.0) 103 | http-cookie (~> 1.0.0) 104 | faraday-em_http (1.0.0) 105 | faraday-em_synchrony (1.0.0) 106 | faraday-excon (1.1.0) 107 | faraday-httpclient (1.0.1) 108 | faraday-multipart (1.0.4) 109 | multipart-post (~> 2) 110 | faraday-net_http (1.0.1) 111 | faraday-net_http_persistent (1.2.0) 112 | faraday-patron (1.0.0) 113 | faraday-rack (1.0.0) 114 | faraday-retry (1.0.3) 115 | faraday_middleware (1.2.0) 116 | faraday (~> 1.0) 117 | fastimage (2.2.6) 118 | fastlane (2.191.0) 119 | CFPropertyList (>= 2.3, < 4.0.0) 120 | addressable (>= 2.8, < 3.0.0) 121 | artifactory (~> 3.0) 122 | aws-sdk-s3 (~> 1.0) 123 | babosa (>= 1.0.3, < 2.0.0) 124 | bundler (>= 1.12.0, < 3.0.0) 125 | colored 126 | commander (~> 4.6) 127 | dotenv (>= 2.1.1, < 3.0.0) 128 | emoji_regex (>= 0.1, < 4.0) 129 | excon (>= 0.71.0, < 1.0.0) 130 | faraday (~> 1.0) 131 | faraday-cookie_jar (~> 0.0.6) 132 | faraday_middleware (~> 1.0) 133 | fastimage (>= 2.1.0, < 3.0.0) 134 | gh_inspector (>= 1.1.2, < 2.0.0) 135 | google-apis-androidpublisher_v3 (~> 0.3) 136 | google-apis-playcustomapp_v1 (~> 0.1) 137 | google-cloud-storage (~> 1.31) 138 | highline (~> 2.0) 139 | json (< 3.0.0) 140 | jwt (>= 2.1.0, < 3) 141 | mini_magick (>= 4.9.4, < 5.0.0) 142 | multipart-post (~> 2.0.0) 143 | naturally (~> 2.2) 144 | plist (>= 3.1.0, < 4.0.0) 145 | rubyzip (>= 2.0.0, < 3.0.0) 146 | security (= 0.1.3) 147 | simctl (~> 1.6.3) 148 | terminal-notifier (>= 2.0.0, < 3.0.0) 149 | terminal-table (>= 1.4.5, < 2.0.0) 150 | tty-screen (>= 0.6.3, < 1.0.0) 151 | tty-spinner (>= 0.8.0, < 1.0.0) 152 | word_wrap (~> 1.0.0) 153 | xcodeproj (>= 1.13.0, < 2.0.0) 154 | xcpretty (~> 0.3.0) 155 | xcpretty-travis-formatter (>= 0.0.3) 156 | ffi (1.15.5) 157 | fourflusher (2.3.1) 158 | fuzzy_match (2.0.4) 159 | gh_inspector (1.1.3) 160 | google-apis-androidpublisher_v3 (0.23.0) 161 | google-apis-core (>= 0.6, < 2.a) 162 | google-apis-core (0.6.0) 163 | addressable (~> 2.5, >= 2.5.1) 164 | googleauth (>= 0.16.2, < 2.a) 165 | httpclient (>= 2.8.1, < 3.a) 166 | mini_mime (~> 1.0) 167 | representable (~> 3.0) 168 | retriable (>= 2.0, < 4.a) 169 | rexml 170 | webrick 171 | google-apis-iamcredentials_v1 (0.12.0) 172 | google-apis-core (>= 0.6, < 2.a) 173 | google-apis-playcustomapp_v1 (0.9.0) 174 | google-apis-core (>= 0.6, < 2.a) 175 | google-apis-storage_v1 (0.16.0) 176 | google-apis-core (>= 0.6, < 2.a) 177 | google-cloud-core (1.6.0) 178 | google-cloud-env (~> 1.0) 179 | google-cloud-errors (~> 1.0) 180 | google-cloud-env (1.6.0) 181 | faraday (>= 0.17.3, < 3.0) 182 | google-cloud-errors (1.2.0) 183 | google-cloud-storage (1.36.2) 184 | addressable (~> 2.8) 185 | digest-crc (~> 0.4) 186 | google-apis-iamcredentials_v1 (~> 0.1) 187 | google-apis-storage_v1 (~> 0.1) 188 | google-cloud-core (~> 1.6) 189 | googleauth (>= 0.16.2, < 2.a) 190 | mini_mime (~> 1.0) 191 | googleauth (1.2.0) 192 | faraday (>= 0.17.3, < 3.a) 193 | jwt (>= 1.4, < 3.0) 194 | memoist (~> 0.16) 195 | multi_json (~> 1.11) 196 | os (>= 0.9, < 2.0) 197 | signet (>= 0.16, < 2.a) 198 | highline (2.0.3) 199 | http-cookie (1.0.5) 200 | domain_name (~> 0.5) 201 | httpclient (2.8.3) 202 | i18n (1.10.0) 203 | concurrent-ruby (~> 1.0) 204 | jmespath (1.6.1) 205 | json (2.6.2) 206 | jwt (2.4.1) 207 | memoist (0.16.2) 208 | mini_magick (4.11.0) 209 | mini_mime (1.1.2) 210 | minitest (5.16.1) 211 | molinillo (0.6.6) 212 | multi_json (1.15.0) 213 | multipart-post (2.0.0) 214 | nanaimo (0.3.0) 215 | nap (1.1.0) 216 | naturally (2.2.1) 217 | netrc (0.11.0) 218 | os (1.1.4) 219 | plist (3.6.0) 220 | public_suffix (4.0.7) 221 | rake (13.0.6) 222 | representable (3.2.0) 223 | declarative (< 0.1.0) 224 | trailblazer-option (>= 0.1.1, < 0.2.0) 225 | uber (< 0.2.0) 226 | retriable (3.1.2) 227 | rexml (3.2.5) 228 | rouge (2.0.7) 229 | ruby-macho (1.4.0) 230 | ruby2_keywords (0.0.5) 231 | rubyzip (2.3.2) 232 | security (0.1.3) 233 | signet (0.17.0) 234 | addressable (~> 2.8) 235 | faraday (>= 0.17.5, < 3.a) 236 | jwt (>= 1.5, < 3.0) 237 | multi_json (~> 1.10) 238 | simctl (1.6.8) 239 | CFPropertyList 240 | naturally 241 | terminal-notifier (2.0.0) 242 | terminal-table (1.8.0) 243 | unicode-display_width (~> 1.1, >= 1.1.1) 244 | thread_safe (0.3.6) 245 | trailblazer-option (0.1.2) 246 | tty-cursor (0.7.1) 247 | tty-screen (0.8.1) 248 | tty-spinner (0.9.3) 249 | tty-cursor (~> 0.7) 250 | typhoeus (1.4.0) 251 | ethon (>= 0.9.0) 252 | tzinfo (1.2.10) 253 | thread_safe (~> 0.1) 254 | uber (0.1.0) 255 | unf (0.1.4) 256 | unf_ext 257 | unf_ext (0.0.8.2) 258 | unicode-display_width (1.8.0) 259 | webrick (1.7.0) 260 | word_wrap (1.0.0) 261 | xcodeproj (1.22.0) 262 | CFPropertyList (>= 2.3.3, < 4.0) 263 | atomos (~> 0.1.3) 264 | claide (>= 1.0.2, < 2.0) 265 | colored2 (~> 3.1) 266 | nanaimo (~> 0.3.0) 267 | rexml (~> 3.2.4) 268 | xcpretty (0.3.0) 269 | rouge (~> 2.0.7) 270 | xcpretty-travis-formatter (1.0.1) 271 | xcpretty (~> 0.2, >= 0.0.7) 272 | 273 | PLATFORMS 274 | ruby 275 | x86_64-darwin-18 276 | 277 | DEPENDENCIES 278 | cocoapods (~> 1.10.2) 279 | fastlane (~> 2.191.0) 280 | 281 | RUBY VERSION 282 | ruby 3.0.2p107 283 | 284 | BUNDLED WITH 285 | 2.2.22 286 | -------------------------------------------------------------------------------- /cli-tools/TweakAccessorGenerator/TweakAccessorGeneratorTests/Suites/TweakLoaderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TweakLoaderTests.swift 3 | // Copyright © 2021 Just Eat Takeaway. All rights reserved. 4 | // 5 | 6 | import XCTest 7 | 8 | class TweakLoaderTests: XCTestCase { 9 | 10 | var sut: TweakLoader! 11 | 12 | override func setUp() { 13 | super.setUp() 14 | sut = TweakLoader() 15 | } 16 | 17 | override func tearDown() { 18 | sut = nil 19 | super.tearDown() 20 | } 21 | 22 | func test_loadConfiguration_success() throws { 23 | let bundle = Bundle(for: type(of: self)) 24 | let tweaksFilename = "Tweaks" 25 | let tweaksFilePath = bundle.path(forResource: tweaksFilename, ofType: "json")! 26 | 27 | let testTweaks = try sut.load(tweaksFilePath) 28 | 29 | let expectedTweaks = [ 30 | Tweak(feature: "general", 31 | variable: "answer_to_the_universe", 32 | title: "Definitive answer", 33 | description: "Answer to the Ultimate Question of Life, the Universe, and Everything", 34 | group: "General", 35 | valueType: "Int", 36 | propertyName: "definitiveAnswer"), 37 | Tweak(feature: "general", 38 | variable: "encrypted_answer_to_the_universe", 39 | title: "Encrypted definitive answer", 40 | description: "Encrypted answer to the Ultimate Question of Life, the Universe, and Everything", 41 | group: "General", 42 | valueType: "String", 43 | propertyName: "definitiveAnswerEncrypted"), 44 | Tweak(feature: "general", 45 | variable: "greet_on_app_did_become_active", 46 | title: "Greet on app launch", 47 | description: "shows an alert on applicationDidBecomeActive", 48 | group: "General", 49 | valueType: "Bool", 50 | propertyName: nil), 51 | Tweak(feature: "general", 52 | variable: "tap_to_change_color_enabled", 53 | title: "Tap to change views color", 54 | description: "change the colour of the main view when receiving a tap", 55 | group: "General", 56 | valueType: "Bool", 57 | propertyName: nil), 58 | Tweak(feature: "ui_customization", 59 | variable: "display_green_view", 60 | title: "Display Green View", 61 | description: "shows a green view in the main view controller", 62 | group: "UI Customization", 63 | valueType: "Bool", 64 | propertyName: nil), 65 | Tweak(feature: "ui_customization", 66 | variable: "display_red_view", 67 | title: "Display Red View", 68 | description: "shows a red view in the main view controller", 69 | group: "UI Customization", 70 | valueType: "Bool", 71 | propertyName: nil), 72 | Tweak(feature: "ui_customization", 73 | variable: "display_yellow_view", 74 | title: "Display Yellow View", 75 | description: "shows a yellow view in the main view controller", 76 | group: "UI Customization", 77 | valueType: "Bool", 78 | propertyName: nil), 79 | Tweak(feature: "ui_customization", 80 | variable: "label_text", 81 | title: "Label Text", 82 | description: "the title of the main label", 83 | group: "UI Customization", 84 | valueType: "String", 85 | propertyName: nil), 86 | Tweak(feature: "ui_customization", 87 | variable: "red_view_alpha_component", 88 | title: "Red View Alpha Component", 89 | description: "defines the alpha level of the red view", 90 | group: "UI Customization", 91 | valueType: "Double", 92 | propertyName: nil)] 93 | XCTAssertEqual(testTweaks, expectedTweaks) 94 | } 95 | 96 | func test_loadConfiguration_failure_invalidJSON() throws { 97 | let bundle = Bundle(for: type(of: self)) 98 | let tweaksFilename = "InvalidTweaks_InvalidJSON" 99 | let tweaksFilePath = bundle.path(forResource: tweaksFilename, ofType: "json")! 100 | 101 | XCTAssertThrowsError(try sut.load(tweaksFilePath)) 102 | } 103 | 104 | func test_loadConfiguration_failure_missingValues() throws { 105 | let bundle = Bundle(for: type(of: self)) 106 | let tweaksFilename = "InvalidTweaks_MissingValues" 107 | let tweaksFilePath = bundle.path(forResource: tweaksFilename, ofType: "json")! 108 | 109 | XCTAssertThrowsError(try sut.load(tweaksFilePath)) 110 | } 111 | 112 | func test_loadConfiguration_failure_duplicateGeneratedPropertyName() throws { 113 | let bundle = Bundle(for: type(of: self)) 114 | let tweaksFilename = "InvalidTweaks_DuplicateGeneratedPropertyName" 115 | let tweaksFilePath = bundle.path(forResource: tweaksFilename, ofType: "json")! 116 | XCTAssertThrowsError(try sut.load(tweaksFilePath)) 117 | } 118 | 119 | func test_typeForValue_String() { 120 | let expectedValue = "String" 121 | XCTAssertEqual(try sut.type(for: "some string"), expectedValue) 122 | } 123 | 124 | func test_typeForValue_NSNumber_Double() { 125 | let expectedValue = "Double" 126 | XCTAssertEqual(try sut.type(for: NSNumber(value: 3.14)), expectedValue) 127 | } 128 | 129 | func test_typeForValue_NSNumber_Int() { 130 | let expectedValue = "Int" 131 | XCTAssertEqual(try sut.type(for: NSNumber(value: 42)), expectedValue) 132 | } 133 | 134 | func test_typeForValue_NSNumber_Bool() { 135 | let expectedValue = "Bool" 136 | XCTAssertEqual(try sut.type(for: NSNumber(value: true)), expectedValue) 137 | } 138 | 139 | func test_typeForValue_Bool() { 140 | let expectedValue = "Bool" 141 | XCTAssertEqual(try sut.type(for: true), expectedValue) 142 | } 143 | 144 | func test_typeForValue_Double() { 145 | let expectedValue = "Double" 146 | XCTAssertEqual(try sut.type(for: 3.14), expectedValue) 147 | } 148 | 149 | func test_typeForValue_Unsupported() { 150 | XCTAssertThrowsError(try sut.type(for: [""])) 151 | } 152 | 153 | func test_tweakForDictionary_AllRequiredValuesPresent() throws { 154 | let feature = "some feature" 155 | let variable = "some variable" 156 | let title = "some title" 157 | let description = "some description" 158 | let group = "some group" 159 | let dictionary: [String : Any] = [ 160 | "Title": title, 161 | "Description": description, 162 | "Group": group, 163 | "Value": 3.14 164 | ] 165 | let expectedValue = Tweak(feature: feature, 166 | variable: variable, 167 | title: title, 168 | description: description, 169 | group: group, 170 | valueType: "Double", 171 | propertyName: nil) 172 | let testValue = try sut.tweak(for: dictionary, 173 | feature: feature, 174 | variable: variable) 175 | XCTAssertEqual(testValue, expectedValue) 176 | } 177 | 178 | func test_tweakForDictionary_MissingTitle() { 179 | let feature = "some feature" 180 | let variable = "some variable" 181 | let description = "some description" 182 | let group = "some group" 183 | let dictionary: [String : Any] = [ 184 | "Description": description, 185 | "Group": group, 186 | "Value": 3.14 187 | ] 188 | XCTAssertThrowsError(try sut.tweak(for: dictionary, feature: feature, variable: variable)) 189 | } 190 | 191 | func test_tweakForDictionary_MissingDescription() { 192 | let feature = "some feature" 193 | let variable = "some variable" 194 | let title = "some title" 195 | let group = "some group" 196 | let dictionary: [String : Any] = [ 197 | "Title": title, 198 | "Group": group, 199 | "Value": 3.14 200 | ] 201 | XCTAssertNoThrow(try sut.tweak(for: dictionary, feature: feature, variable: variable)) 202 | } 203 | 204 | func test_tweakForDictionary_MissingGroup() { 205 | let feature = "some feature" 206 | let variable = "some variable" 207 | let title = "some title" 208 | let description = "some description" 209 | let dictionary: [String : Any] = [ 210 | "Title": title, 211 | "Description": description, 212 | "Value": 3.14 213 | ] 214 | XCTAssertThrowsError(try sut.tweak(for: dictionary, feature: feature, variable: variable)) 215 | } 216 | 217 | func test_tweakForDictionary_MissingValue() { 218 | let feature = "some feature" 219 | let variable = "some variable" 220 | let title = "some title" 221 | let description = "some description" 222 | let group = "some group" 223 | let dictionary: [String : Any] = [ 224 | "Title": title, 225 | "Description": description, 226 | "Group": group 227 | ] 228 | XCTAssertThrowsError(try sut.tweak(for: dictionary, feature: feature, variable: variable)) 229 | } 230 | } 231 | --------------------------------------------------------------------------------