├── Gemfile ├── Example.swiftpm ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── RemoteConfigKeys+Extension.swift ├── ContentView.swift ├── MyApp.swift ├── ViewModel.swift └── Package.swift ├── renovate.json ├── .opensource └── project.json ├── Cartfile ├── Sources └── SwiftyRemoteConfig │ ├── RemoteConfigKeys.swift │ ├── RemoteConfigSerializable.swift │ ├── RemoteConfig.swift │ ├── Utils │ └── OptionalType.swift │ ├── Info.plist │ ├── RemoteConfigAdapter.swift │ ├── RemoteConfigKey.swift │ ├── RemoteConfig+Observing.swift │ ├── RemoteConfig+Subscripts.swift │ ├── PropertyWrappers.swift │ ├── BuiltIns.swift │ ├── RemoteConfigObserver.swift │ ├── RemoteConfigCombine.swift │ └── RemoteConfigBridge.swift ├── Tests └── SwiftyRemoteConfigTests │ ├── XCTestManifests.swift │ ├── Info.plist │ ├── BuiltIns │ ├── RemoteConfig+Int.swift │ ├── RemoteConfig+Data.swift │ ├── RemoteConfig+Double.swift │ ├── RemoteConfig+Bool.swift │ ├── RemoteConfig+String.swift │ └── RemoteConfig+URL.swift │ ├── External types │ ├── RemoteConfig+Codable.swift │ ├── RemoteConfig+Enum.swift │ ├── RemoteConfig+Serializable.swift │ ├── RemoteConfig+CustomSerializable.swift │ └── RemoteConfig+Color.swift │ ├── SwiftyRemoteConfigTests.swift │ └── Helpers │ ├── TestHelper.swift │ └── RemoteConfigSerializableSpec.swift ├── .github └── workflows │ ├── run-publish-cocoapods-on-tag.yml │ └── run-test-on-push.yml ├── SwiftyRemoteConfig.h ├── Package.swift ├── SwiftyRemoteConfig.podspec ├── SwiftyRemoteConfig.xcodeproj └── xcshareddata │ └── xcschemes │ └── SwiftyRemoteConfig-Package.xcscheme ├── Gemfile.lock ├── .gitignore ├── LICENSE └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'cocoapods' 4 | -------------------------------------------------------------------------------- /Example.swiftpm/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.opensource/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SwiftyRemoteConfig", 3 | "type": "library", 4 | "platforms": ["iOS"], 5 | "content": "README.md", 6 | "pages": [], 7 | "related": [] 8 | } -------------------------------------------------------------------------------- /Example.swiftpm/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Cartfile: -------------------------------------------------------------------------------- 1 | binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseRemoteConfigBinary.json" ~> 12.1.0 2 | binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseProtobufBinary.json" ~> 12.1.0 3 | binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAnalyticsBinary.json" ~> 12.1.0 4 | -------------------------------------------------------------------------------- /Example.swiftpm/RemoteConfigKeys+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteConfigKeys+Extension.swift 3 | // Example 4 | // 5 | // Created by Fumito Ito on 2022/02/23. 6 | // 7 | 8 | import Foundation 9 | import SwiftyRemoteConfig 10 | 11 | extension RemoteConfigKeys { 12 | var contentText: RemoteConfigKey { .init("content_text", defaultValue: "Hello, World!!") } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/RemoteConfigKeys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteConfigKeys.swift 3 | // SwiftyRemoteConfig 4 | // 5 | // Created by 伊藤史 on 2020/08/15. 6 | // Copyright © 2020 Fumito Ito. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol RemoteConfigKeyStore {} 12 | 13 | public struct RemoteConfigKeys: RemoteConfigKeyStore { 14 | public init() {} 15 | } 16 | -------------------------------------------------------------------------------- /Example.swiftpm/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | struct ContentView: View { 5 | @ObservedObject var viewModel = ViewModel() 6 | 7 | var body: some View { 8 | VStack { 9 | Image(systemName: "globe") 10 | .imageScale(.large) 11 | .foregroundColor(.accentColor) 12 | Text(viewModel.contentText) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTestManifests.swift 3 | // SwiftyRemoteConfigTests 4 | // 5 | // Created by 伊藤史 on 2020/08/19. 6 | // Copyright © 2020 Fumito Ito. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | #if !canImport(ObjectiveC) 12 | public func allTests() -> [XCTestCaseEntry] { 13 | return [ 14 | testCase(SwiftyRemoteConfigTests.allTests), 15 | ] 16 | } 17 | #endif 18 | -------------------------------------------------------------------------------- /.github/workflows/run-publish-cocoapods-on-tag.yml: -------------------------------------------------------------------------------- 1 | name: Publish Cocoapods 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: macos-15 11 | 12 | steps: 13 | - uses: actions/checkout@v6 14 | - name: Lint Cocoapods 15 | run: pod lib lint --allow-warnings 16 | - name: Publish to Cocoapods registry 17 | env: 18 | COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} 19 | run: pod trunk push SwiftyRemoteConfig.podspec 20 | -------------------------------------------------------------------------------- /Example.swiftpm/MyApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Firebase 3 | 4 | @main 5 | struct MyApp: App { 6 | var body: some Scene { 7 | WindowGroup { 8 | ContentView() 9 | } 10 | } 11 | 12 | init() { 13 | FirebaseApp.configure() 14 | 15 | let remoteConfig = RemoteConfig.remoteConfig() 16 | let settings = RemoteConfigSettings() 17 | settings.minimumFetchInterval = 0 18 | remoteConfig.configSettings = settings 19 | remoteConfig.fetch(completionHandler: nil) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/RemoteConfigSerializable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteConfigSerializable.swift 3 | // SwiftyRemoteConfig 4 | // 5 | // Created by 伊藤史 on 2020/08/15. 6 | // Copyright © 2020 Fumito Ito. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol RemoteConfigSerializable { 12 | typealias T = Bridge.T 13 | associatedtype Bridge: RemoteConfigBridge 14 | associatedtype ArrayBridge: RemoteConfigBridge 15 | 16 | static var _remoteConfig: Bridge { get } 17 | static var _remoteConfigArray: ArrayBridge { get } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/RemoteConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteConfig.swift 3 | // SwiftyRemoteConfig 4 | // 5 | // Created by 伊藤史 on 2020/08/13. 6 | // Copyright © 2020 Fumito Ito. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import FirebaseRemoteConfig 11 | 12 | public var RemoteConfigs = RemoteConfigAdapter(remoteConfig: RemoteConfig.remoteConfig(), keyStore: .init()) 13 | 14 | public extension RemoteConfig { 15 | func hasKey(_ key: RemoteConfigKey) -> Bool { 16 | self.configValue(forKey: key._key).stringValue.isEmpty == false 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /SwiftyRemoteConfig.h: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyRemoteConfig.h 3 | // SwiftyRemoteConfig 4 | // 5 | // Created by 伊藤史 on 2020/08/04. 6 | // Copyright © 2020 Fumito Ito. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for SwiftyRemoteConfig. 12 | FOUNDATION_EXPORT double SwiftyRemoteConfigVersionNumber; 13 | 14 | //! Project version string for SwiftyRemoteConfig. 15 | FOUNDATION_EXPORT const unsigned char SwiftyRemoteConfigVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/Utils/OptionalType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OptionalType.swift 3 | // SwiftyRemoteConfig 4 | // 5 | // Created by 伊藤史 on 2020/08/21. 6 | // Copyright © 2020 Fumito Ito. All rights reserved. 7 | // 8 | 9 | protocol OptionalTypeCheck { 10 | var isNil: Bool { get } 11 | } 12 | 13 | public protocol OptionalType { 14 | associatedtype Wrapped 15 | 16 | var wrapped: Wrapped? { get } 17 | 18 | static var empty: Self { get } 19 | } 20 | 21 | extension Optional: OptionalType, OptionalTypeCheck { 22 | public var wrapped: Wrapped? { 23 | return self 24 | } 25 | 26 | public static var empty: Optional { 27 | return nil 28 | } 29 | 30 | var isNil: Bool { 31 | return self == nil 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/run-test-on-push.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: macos-15 9 | 10 | env: 11 | TEST: 1 12 | 13 | steps: 14 | - uses: actions/checkout@v6 15 | 16 | - name: Create GoogleService-Info.plist from GOOGLE_SERVICE_INFO_PLIST 17 | run: | 18 | echo GOOGLE_SERVICE_INFO_PLIST | base64 -d >> Tests/GoogleService-Info.plist 19 | shell: bash 20 | env: 21 | GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.GOOGLE_SERVICE_INFO_PLIST }} 22 | 23 | - name: Change Xcode version 24 | run: sudo xcode-select -s /Applications/Xcode_16.2.app 25 | 26 | - name: Build 27 | run: swift build 28 | 29 | - name: Run tests 30 | run: swift test -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/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 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/BuiltIns/RemoteConfig+Int.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/21. 6 | // 7 | 8 | import Foundation 9 | 10 | final class RemoteConfigIntSpec: RemoteConfigSerializableSpec { 11 | var defaultValue: Int = 1 12 | var keyStore = FrogKeyStore() 13 | 14 | override class func setUp() { 15 | super.setupFirebase() 16 | } 17 | 18 | func testValues() { 19 | super.testValues(defaultValue: defaultValue, keyStore: keyStore) 20 | } 21 | 22 | func testOptionalValues() { 23 | super.testOptionalValues(defaultValue: defaultValue, keyStore: keyStore) 24 | } 25 | 26 | func testOptionalValuesWithoutDefaultValue() { 27 | super.testOptionalValuesWithoutDefaultValue(defaultValue: defaultValue, keyStore: keyStore) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/BuiltIns/RemoteConfig+Data.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/21. 6 | // 7 | 8 | import Foundation 9 | 10 | final class RemoteConfigDataSpec: RemoteConfigSerializableSpec { 11 | var defaultValue: Data = Data() 12 | var keyStore = FrogKeyStore() 13 | 14 | override class func setUp() { 15 | super.setupFirebase() 16 | } 17 | 18 | func testValues() { 19 | super.testValues(defaultValue: defaultValue, keyStore: keyStore) 20 | } 21 | 22 | func testOptionalValues() { 23 | super.testOptionalValues(defaultValue: defaultValue, keyStore: keyStore) 24 | } 25 | 26 | func testOptionalValuesWithoutDefaultValue() { 27 | super.testOptionalValuesWithoutDefaultValue(defaultValue: defaultValue, keyStore: keyStore) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/BuiltIns/RemoteConfig+Double.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/21. 6 | // 7 | 8 | import Foundation 9 | 10 | final class RemoteConfigDoubleSpec: RemoteConfigSerializableSpec { 11 | var defaultValue: Double = 1.0 12 | var keyStore = FrogKeyStore() 13 | 14 | override class func setUp() { 15 | super.setupFirebase() 16 | } 17 | 18 | func testValues() { 19 | super.testValues(defaultValue: defaultValue, keyStore: keyStore) 20 | } 21 | 22 | func testOptionalValues() { 23 | super.testOptionalValues(defaultValue: defaultValue, keyStore: keyStore) 24 | } 25 | 26 | func testOptionalValuesWithoutDefaultValue() { 27 | super.testOptionalValuesWithoutDefaultValue(defaultValue: defaultValue, keyStore: keyStore) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/BuiltIns/RemoteConfig+Bool.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteConfig+Bool.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/18. 6 | // 7 | 8 | import Foundation 9 | 10 | final class RemoteConfigBoolSpec: RemoteConfigSerializableSpec { 11 | var defaultValue: Bool = true 12 | var keyStore = FrogKeyStore() 13 | 14 | override class func setUp() { 15 | super.setupFirebase() 16 | } 17 | 18 | func testValues() { 19 | super.testValues(defaultValue: defaultValue, keyStore: keyStore) 20 | } 21 | 22 | func testOptionalValues() { 23 | super.testOptionalValues(defaultValue: defaultValue, keyStore: keyStore) 24 | } 25 | 26 | func testOptionalValuesWithoutDefaultValue() { 27 | super.testOptionalValuesWithoutDefaultValue(defaultValue: defaultValue, keyStore: keyStore) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/BuiltIns/RemoteConfig+String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/21. 6 | // 7 | 8 | import Foundation 9 | 10 | final class RemoteConfigStringSpec: RemoteConfigSerializableSpec { 11 | var defaultValue: String = "Firebase" 12 | var keyStore = FrogKeyStore() 13 | 14 | override class func setUp() { 15 | super.setupFirebase() 16 | } 17 | 18 | func testValues() { 19 | super.testValues(defaultValue: defaultValue, keyStore: keyStore) 20 | } 21 | 22 | func testOptionalValues() { 23 | super.testOptionalValues(defaultValue: defaultValue, keyStore: keyStore) 24 | } 25 | 26 | func testOptionalValuesWithoutDefaultValue() { 27 | super.testOptionalValuesWithoutDefaultValue(defaultValue: defaultValue, keyStore: keyStore) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/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 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/BuiltIns/RemoteConfig+URL.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/21. 6 | // 7 | 8 | import Foundation 9 | 10 | final class RemoteConfigURLSpec: RemoteConfigSerializableSpec { 11 | var defaultValue: URL = URL(string: "https://console.firebase.google.com/")! 12 | var keyStore = FrogKeyStore() 13 | 14 | override class func setUp() { 15 | super.setupFirebase() 16 | } 17 | 18 | func testValues() { 19 | super.testValues(defaultValue: defaultValue, keyStore: keyStore) 20 | } 21 | 22 | func testOptionalValues() { 23 | super.testOptionalValues(defaultValue: defaultValue, keyStore: keyStore) 24 | } 25 | 26 | func testOptionalValuesWithoutDefaultValue() { 27 | super.testOptionalValuesWithoutDefaultValue(defaultValue: defaultValue, keyStore: keyStore) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/External types/RemoteConfig+Codable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/23. 6 | // 7 | 8 | import Foundation 9 | 10 | final class RemoteConfigCodableSpec: RemoteConfigSerializableSpec { 11 | var defaultValue: FrogCodable = FrogCodable(name: "default") 12 | var keyStore = FrogKeyStore() 13 | 14 | override class func setUp() { 15 | super.setupFirebase() 16 | } 17 | 18 | func testValues() { 19 | super.testValues(defaultValue: defaultValue, keyStore: keyStore) 20 | } 21 | 22 | func testOptionalValues() { 23 | super.testOptionalValues(defaultValue: defaultValue, keyStore: keyStore) 24 | } 25 | 26 | func testOptionalValuesWithoutDefaultValue() { 27 | super.testOptionalValuesWithoutDefaultValue(defaultValue: defaultValue, keyStore: keyStore) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/External types/RemoteConfig+Enum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/23. 6 | // 7 | 8 | import Foundation 9 | 10 | final class RemoteConfigBestFroggiesEnumSerializableSpec: RemoteConfigSerializableSpec { 11 | var defaultValue: BestFroggiesEnum = .Dandy 12 | var keyStore = FrogKeyStore() 13 | 14 | override class func setUp() { 15 | super.setupFirebase() 16 | } 17 | 18 | func testValues() { 19 | super.testValues(defaultValue: defaultValue, keyStore: keyStore) 20 | } 21 | 22 | func testOptionalValues() { 23 | super.testOptionalValues(defaultValue: defaultValue, keyStore: keyStore) 24 | } 25 | 26 | func testOptionalValuesWithoutDefaultValue() { 27 | super.testOptionalValuesWithoutDefaultValue(defaultValue: defaultValue, keyStore: keyStore) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/External types/RemoteConfig+Serializable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/23. 6 | // 7 | 8 | import Foundation 9 | 10 | final class RemoteConfigFrogSerializableSpec: RemoteConfigSerializableSpec { 11 | var defaultValue: FrogSerializable = FrogSerializable(name: "default") 12 | var keyStore = FrogKeyStore() 13 | 14 | override class func setUp() { 15 | super.setupFirebase() 16 | } 17 | 18 | func testValues() { 19 | super.testValues(defaultValue: defaultValue, keyStore: keyStore) 20 | } 21 | 22 | func testOptionalValues() { 23 | super.testOptionalValues(defaultValue: defaultValue, keyStore: keyStore) 24 | } 25 | 26 | func testOptionalValuesWithoutDefaultValue() { 27 | super.testOptionalValuesWithoutDefaultValue(defaultValue: defaultValue, keyStore: keyStore) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Example.swiftpm/ViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModel.swift 3 | // Example 4 | // 5 | // Created by Fumito Ito on 2022/02/26. 6 | // 7 | 8 | import FirebaseRemoteConfig 9 | import SwiftyRemoteConfig 10 | import Combine 11 | 12 | final class ViewModel: ObservableObject { 13 | @Published var contentText: String 14 | 15 | @SwiftyRemoteConfig(keyPath: \.contentText) 16 | var fuga: String 17 | 18 | private var cancellables: Set = [] 19 | 20 | init() { 21 | contentText = RemoteConfigs.contentText 22 | 23 | RemoteConfig.remoteConfig() 24 | .combine 25 | .fetchedPublisher(for: \.contentText) 26 | .receive(on: RunLoop.main) 27 | .assign(to: \.contentText, on: self) 28 | .store(in: &cancellables) 29 | } 30 | } 31 | 32 | final class Foo { 33 | init() { 34 | let viewModel = ViewModel() 35 | 36 | let foo = viewModel.$fuga.lastFetchTime 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/External types/RemoteConfig+CustomSerializable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/23. 6 | // 7 | 8 | import Foundation 9 | 10 | final class RemoteConfigCustomSerializableSpec: RemoteConfigSerializableSpec { 11 | var defaultValue: FrogCustomSerializable = FrogCustomSerializable(name: "default") 12 | var keyStore = FrogKeyStore() 13 | 14 | override class func setUp() { 15 | super.setupFirebase() 16 | } 17 | 18 | func testValues() { 19 | super.testValues(defaultValue: defaultValue, keyStore: keyStore) 20 | } 21 | 22 | func testOptionalValues() { 23 | super.testOptionalValues(defaultValue: defaultValue, keyStore: keyStore) 24 | } 25 | 26 | func testOptionalValuesWithoutDefaultValue() { 27 | super.testOptionalValuesWithoutDefaultValue(defaultValue: defaultValue, keyStore: keyStore) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/RemoteConfigAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteConfigAdapter.swift 3 | // SwiftyRemoteConfig 4 | // 5 | // Created by 伊藤史 on 2020/08/15. 6 | // Copyright © 2020 Fumito Ito. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import FirebaseRemoteConfig 11 | 12 | @dynamicMemberLookup 13 | public struct RemoteConfigAdapter { 14 | 15 | public let remoteConfig: RemoteConfig 16 | public let keyStore: KeyStore 17 | 18 | public init(remoteConfig: RemoteConfig, keyStore: KeyStore) { 19 | self.remoteConfig = remoteConfig 20 | self.keyStore = keyStore 21 | } 22 | 23 | @available(*, unavailable) 24 | public subscript(dynamicMember member: String) -> Never { 25 | fatalError() 26 | } 27 | 28 | public func hasKey(_ key: RemoteConfigKey) -> Bool { 29 | return self.remoteConfig.hasKey(key) 30 | } 31 | 32 | public func hasKey(_ keyPath: KeyPath>) -> Bool { 33 | return self.remoteConfig.hasKey(self.keyStore[keyPath: keyPath]) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/External types/RemoteConfig+Color.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/21. 6 | // 7 | 8 | import Foundation 9 | @testable import SwiftyRemoteConfig 10 | 11 | #if canImport(UIKit) || canImport(AppKit) 12 | #if canImport(UIKit) 13 | import UIKit.UIColor 14 | public typealias Color = UIColor 15 | #elseif canImport(AppKit) 16 | import AppKit.NSColor 17 | public typealias Color = NSColor 18 | #endif 19 | 20 | extension Color: RemoteConfigSerializable {} 21 | 22 | final class RemoteConfigColorSerializableSpec: RemoteConfigSerializableSpec { 23 | var defaultValue: Color = .blue 24 | var keyStore = FrogKeyStore() 25 | 26 | override class func setUp() { 27 | super.setupFirebase() 28 | } 29 | 30 | func testValues() { 31 | super.testValues(defaultValue: defaultValue, keyStore: keyStore) 32 | } 33 | 34 | func testOptionalValues() { 35 | super.testOptionalValues(defaultValue: defaultValue, keyStore: keyStore) 36 | } 37 | 38 | func testOptionalValuesWithoutDefaultValue() { 39 | super.testOptionalValuesWithoutDefaultValue(defaultValue: defaultValue, keyStore: keyStore) 40 | } 41 | } 42 | #endif 43 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/SwiftyRemoteConfigTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftyRemoteConfigTests.swift 3 | // SwiftyRemoteConfigTests 4 | // 5 | // Created by 伊藤史 on 2020/08/04. 6 | // Copyright © 2020 Fumito Ito. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import SwiftyRemoteConfig 11 | 12 | class SwiftyRemoteConfigTests: XCTestCase { 13 | static var allTests = [ 14 | ("testExample", testExample), 15 | ] 16 | 17 | override func setUpWithError() throws { 18 | // Put setup code here. This method is called before the invocation of each test method in the class. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // This is an example of a functional test case. 27 | // Use XCTAssert and related functions to verify your tests produce the correct results. 28 | } 29 | 30 | func testPerformanceExample() throws { 31 | // This is an example of a performance test case. 32 | self.measure { 33 | // Put the code you want to measure the time of here. 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftyRemoteConfig", 8 | platforms: [ 9 | .iOS(.v15), 10 | .macOS(.v10_15), 11 | .tvOS(.v15), 12 | .watchOS(.v7), 13 | ], 14 | products: [ 15 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 16 | .library( 17 | name: "SwiftyRemoteConfig", 18 | targets: ["SwiftyRemoteConfig"] 19 | ), 20 | ], 21 | dependencies: [ 22 | .package( 23 | url: "https://github.com/firebase/firebase-ios-sdk.git", 24 | .upToNextMajor(from: "12.6.0") 25 | ), 26 | ], 27 | targets: [ 28 | .target( 29 | name: "SwiftyRemoteConfig", 30 | dependencies: [ 31 | .product(name: "FirebaseRemoteConfig", package: "firebase-ios-sdk") 32 | ], 33 | path: "Sources" 34 | ), 35 | .testTarget( 36 | name: "SwiftyRemoteConfigTests", 37 | dependencies: [ 38 | "SwiftyRemoteConfig" 39 | ], 40 | path: "Tests" 41 | ), 42 | ] 43 | ) 44 | -------------------------------------------------------------------------------- /SwiftyRemoteConfig.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |spec| 2 | spec.name = "SwiftyRemoteConfig" 3 | spec.version = "1.0.0" 4 | spec.summary = "Modern Swift API for FirebaseRemoteConfig" 5 | 6 | spec.description = <<-DESC 7 | SwiftyRemoteConfig makes Firebase Remote Config enjoyable to use by combining expressive Swifty API with the benefits fo static typing. This library is strongly inspired by [SwiftyUserDefaults](https://github.com/sunshinejr/SwiftyUserDefaults). 8 | DESC 9 | 10 | spec.homepage = "https://github.com/fumito-ito/SwiftyRemoteConfig" 11 | spec.license = { :type => "Apache2.0", :file => "LICENSE" } 12 | spec.authors = { "Fumito Ito" => "weathercook@gmail.com" } 13 | spec.social_media_url = "https://twitter.com/fumito_ito" 14 | 15 | spec.ios.deployment_target = "15.0" 16 | spec.osx.deployment_target = "10.15" 17 | spec.tvos.deployment_target = "15.0" 18 | spec.watchos.deployment_target = "7.0" 19 | spec.source = { :git => "https://github.com/fumito-ito/SwiftyRemoteConfig.git", :tag => "#{spec.version}" } 20 | spec.source_files = "Sources", "Sources/**/*.swift" 21 | spec.requires_arc = true 22 | spec.swift_versions = "5.0", "5.1", "5.2", "5.3", "5.4", "5.5", "5.6" 23 | 24 | spec.static_framework = true 25 | spec.dependency "FirebaseRemoteConfig", "~> 12.1.0" 26 | 27 | end 28 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/RemoteConfigKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteConfigKey.swift 3 | // SwiftyRemoteConfig 4 | // 5 | // Created by 伊藤史 on 2020/08/15. 6 | // Copyright © 2020 Fumito Ito. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct RemoteConfigKey { 12 | 13 | public let _key: String 14 | public let defaultValue: ValueType.T? 15 | internal var isOptional: Bool 16 | 17 | public init(_ key: String, defaultValue: ValueType.T) { 18 | self._key = key 19 | self.defaultValue = defaultValue 20 | self.isOptional = false 21 | } 22 | 23 | private init(key: String) { 24 | self._key = key 25 | self.defaultValue = nil 26 | self.isOptional = true 27 | } 28 | 29 | @available(*, unavailable, message: "This key needs a `defaultValue` parameter. If this type does not have a default value, consider using an optional key.") 30 | public init(_ key: String) { 31 | fatalError() 32 | } 33 | } 34 | 35 | public extension RemoteConfigKey where ValueType: RemoteConfigSerializable, ValueType: OptionalType, ValueType.Wrapped: RemoteConfigSerializable { 36 | 37 | init(_ key: String) { 38 | self.init(key: key) 39 | } 40 | 41 | init(_ key: String, defaultValue: ValueType.T) { 42 | self._key = key 43 | self.defaultValue = defaultValue 44 | self.isOptional = true 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Example.swiftpm/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.5 2 | 3 | // WARNING: 4 | // This file is automatically generated. 5 | // Do not edit it by hand because the contents will be replaced. 6 | 7 | import PackageDescription 8 | import AppleProductTypes 9 | 10 | let package = Package( 11 | name: "Example", 12 | platforms: [ 13 | .iOS("15.2") 14 | ], 15 | products: [ 16 | .iOSApplication( 17 | name: "Example", 18 | targets: ["AppModule"], 19 | bundleIdentifier: "com.github.fumitoito.Example", 20 | teamIdentifier: "K489QY5CFD", 21 | displayVersion: "1.0", 22 | bundleVersion: "1", 23 | iconAssetName: "AppIcon", 24 | accentColorAssetName: "AccentColor", 25 | supportedDeviceFamilies: [ 26 | .pad, 27 | .phone 28 | ], 29 | supportedInterfaceOrientations: [ 30 | .portrait, 31 | .landscapeRight, 32 | .landscapeLeft, 33 | .portraitUpsideDown(.when(deviceFamilies: [.pad])) 34 | ] 35 | ) 36 | ], 37 | dependencies: [ 38 | .package(name: "SwiftyRemoteConfig", path: "../") 39 | ], 40 | targets: [ 41 | .executableTarget( 42 | name: "AppModule", 43 | dependencies: [ 44 | .productItem(name: "SwiftyRemoteConfig", package: "SwiftyRemoteConfig", condition: nil) 45 | ], 46 | path: "." 47 | ) 48 | ] 49 | ) 50 | -------------------------------------------------------------------------------- /SwiftyRemoteConfig.xcodeproj/xcshareddata/xcschemes/SwiftyRemoteConfig-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | 21 | 22 | 24 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/RemoteConfig+Observing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteConfig+Observing.swift 3 | // 4 | // 5 | // Created by Fumito Ito on 2022/02/24. 6 | // 7 | 8 | import Foundation 9 | import FirebaseRemoteConfig 10 | 11 | public extension RemoteConfigAdapter { 12 | func observe(_ key: RemoteConfigKey, 13 | options: NSKeyValueObservingOptions = [.new, .old], 14 | handler: @escaping (RemoteConfigObserver.Update) -> Void) -> RemoteConfigDisposable { 15 | return remoteConfig.observe(key, options: options, handler: handler) 16 | } 17 | 18 | func observe(_ keyPath: KeyPath>, 19 | options: NSKeyValueObservingOptions = [.new, .old], 20 | handler: @escaping (RemoteConfigObserver.Update) -> Void) -> RemoteConfigDisposable { 21 | return remoteConfig.observe(keyStore[keyPath: keyPath], options: options, handler: handler) 22 | } 23 | } 24 | 25 | public extension RemoteConfig { 26 | func observe(_ key: RemoteConfigKey, 27 | options: NSKeyValueObservingOptions = [.new, .old], 28 | handler: @escaping (RemoteConfigObserver.Update) -> Void) -> RemoteConfigDisposable { 29 | return RemoteConfigObserver(key: key, remoteConfig: self, options: options, handler: handler) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Example.swiftpm/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/RemoteConfig+Subscripts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defaults+Subscripts.swift 3 | // SwiftyRemoteConfig 4 | // 5 | // Created by 伊藤史 on 2020/08/21. 6 | // Copyright © 2020 Fumito Ito. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import FirebaseRemoteConfig 11 | 12 | public extension RemoteConfigAdapter { 13 | 14 | subscript(key: RemoteConfigKey) -> T.T where T: OptionalType, T.T == T { 15 | get { 16 | return self.remoteConfig[key] 17 | } 18 | } 19 | 20 | subscript(key: RemoteConfigKey) -> T.T where T.T == T { 21 | get { 22 | return self.remoteConfig[key] 23 | } 24 | } 25 | 26 | subscript(keyPath: KeyPath>) -> T.T where T: OptionalType, T.T == T { 27 | get { 28 | return self.remoteConfig[self.keyStore[keyPath: keyPath]] 29 | } 30 | } 31 | 32 | subscript(keyPath: KeyPath>) -> T.T where T.T == T { 33 | get { 34 | return self.remoteConfig[self.keyStore[keyPath: keyPath]] 35 | } 36 | } 37 | 38 | subscript(dynamicMember keyPath: KeyPath>) -> T.T where T: OptionalType, T.T == T { 39 | get { 40 | return self[keyPath] 41 | } 42 | } 43 | 44 | subscript(dynamicMember keyPath: KeyPath>) -> T.T where T.T == T { 45 | get { 46 | return self[keyPath] 47 | } 48 | } 49 | } 50 | 51 | public extension RemoteConfig { 52 | 53 | subscript(key: RemoteConfigKey) -> T.T where T: OptionalType, T.T == T { 54 | get { 55 | if let value = T._remoteConfig.get(key: key._key, remoteConfig: self), let _value = value as? T.T.Wrapped { 56 | return _value as! T 57 | } else if let defaultValue = key.defaultValue { 58 | return defaultValue 59 | } else { 60 | return T.T.empty 61 | } 62 | } 63 | } 64 | 65 | subscript(key: RemoteConfigKey) -> T.T where T.T == T { 66 | get { 67 | if let value = T._remoteConfig.get(key: key._key, remoteConfig: self) { 68 | return value 69 | } else if let defaultValue = key.defaultValue { 70 | return defaultValue 71 | } else { 72 | fatalError("Unexpected path is executed. please report to https://github.com/fumito-ito/SwiftyRemoteConfig") 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.7) 5 | base64 6 | nkf 7 | rexml 8 | activesupport (6.1.7.10) 9 | concurrent-ruby (~> 1.0, >= 1.0.2) 10 | i18n (>= 1.6, < 2) 11 | minitest (>= 5.1) 12 | tzinfo (~> 2.0) 13 | zeitwerk (~> 2.3) 14 | addressable (2.8.7) 15 | public_suffix (>= 2.0.2, < 7.0) 16 | algoliasearch (1.27.5) 17 | httpclient (~> 2.8, >= 2.8.3) 18 | json (>= 1.5.1) 19 | atomos (0.1.3) 20 | base64 (0.2.0) 21 | claide (1.1.0) 22 | cocoapods (1.13.0) 23 | addressable (~> 2.8) 24 | claide (>= 1.0.2, < 2.0) 25 | cocoapods-core (= 1.13.0) 26 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 27 | cocoapods-downloader (>= 1.6.0, < 2.0) 28 | cocoapods-plugins (>= 1.0.0, < 2.0) 29 | cocoapods-search (>= 1.0.0, < 2.0) 30 | cocoapods-trunk (>= 1.6.0, < 2.0) 31 | cocoapods-try (>= 1.1.0, < 2.0) 32 | colored2 (~> 3.1) 33 | escape (~> 0.0.4) 34 | fourflusher (>= 2.3.0, < 3.0) 35 | gh_inspector (~> 1.0) 36 | molinillo (~> 0.8.0) 37 | nap (~> 1.0) 38 | ruby-macho (>= 2.3.0, < 3.0) 39 | xcodeproj (>= 1.23.0, < 2.0) 40 | cocoapods-core (1.13.0) 41 | activesupport (>= 5.0, < 8) 42 | addressable (~> 2.8) 43 | algoliasearch (~> 1.0) 44 | concurrent-ruby (~> 1.1) 45 | fuzzy_match (~> 2.0.4) 46 | nap (~> 1.0) 47 | netrc (~> 0.11) 48 | public_suffix (~> 4.0) 49 | typhoeus (~> 1.0) 50 | cocoapods-deintegrate (1.0.5) 51 | cocoapods-downloader (1.6.3) 52 | cocoapods-plugins (1.0.0) 53 | nap 54 | cocoapods-search (1.0.1) 55 | cocoapods-trunk (1.6.0) 56 | nap (>= 0.8, < 2.0) 57 | netrc (~> 0.11) 58 | cocoapods-try (1.2.0) 59 | colored2 (3.1.2) 60 | concurrent-ruby (1.3.4) 61 | escape (0.0.4) 62 | ethon (0.16.0) 63 | ffi (>= 1.15.0) 64 | ffi (1.17.0-x86_64-darwin) 65 | ffi (1.17.0-x86_64-linux-gnu) 66 | fourflusher (2.3.1) 67 | fuzzy_match (2.0.4) 68 | gh_inspector (1.1.3) 69 | httpclient (2.8.3) 70 | i18n (1.14.6) 71 | concurrent-ruby (~> 1.0) 72 | json (2.9.1) 73 | minitest (5.25.4) 74 | molinillo (0.8.0) 75 | nanaimo (0.4.0) 76 | nap (1.1.0) 77 | netrc (0.11.0) 78 | nkf (0.2.0) 79 | public_suffix (4.0.7) 80 | rexml (3.4.0) 81 | ruby-macho (2.5.1) 82 | typhoeus (1.4.1) 83 | ethon (>= 0.9.0) 84 | tzinfo (2.0.6) 85 | concurrent-ruby (~> 1.0) 86 | xcodeproj (1.27.0) 87 | CFPropertyList (>= 2.3.3, < 4.0) 88 | atomos (~> 0.1.3) 89 | claide (>= 1.0.2, < 2.0) 90 | colored2 (~> 3.1) 91 | nanaimo (~> 0.4.0) 92 | rexml (>= 3.3.6, < 4.0) 93 | zeitwerk (2.7.1) 94 | 95 | PLATFORMS 96 | x86_64-darwin-20 97 | x86_64-linux 98 | 99 | DEPENDENCIES 100 | cocoapods 101 | 102 | BUNDLED WITH 103 | 2.2.27 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | /Package 3 | /.build 4 | *.DS_Store 5 | Example/SwiftyRemoteConfigExample/GoogleService-Info.plist 6 | Tests/GoogleService-Info.plist 7 | 8 | # Created by https://www.toptal.com/developers/gitignore/api/swift,xcode,ios 9 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift,xcode,ios 10 | 11 | #!! ERROR: ios is undefined. Use list command to see defined gitignore types !!# 12 | 13 | ### Swift ### 14 | # Xcode 15 | # 16 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 17 | 18 | ## User settings 19 | xcuserdata/ 20 | 21 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 22 | *.xcscmblueprint 23 | *.xccheckout 24 | 25 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 26 | build/ 27 | DerivedData/ 28 | *.moved-aside 29 | *.pbxuser 30 | !default.pbxuser 31 | *.mode1v3 32 | !default.mode1v3 33 | *.mode2v3 34 | !default.mode2v3 35 | *.perspectivev3 36 | !default.perspectivev3 37 | 38 | ## Obj-C/Swift specific 39 | *.hmap 40 | 41 | ## App packaging 42 | *.ipa 43 | *.dSYM.zip 44 | *.dSYM 45 | 46 | ## Playgrounds 47 | timeline.xctimeline 48 | playground.xcworkspace 49 | 50 | # Swift Package Manager 51 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 52 | # Packages/ 53 | # Package.pins 54 | Package.resolved 55 | # *.xcodeproj 56 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 57 | # hence it is not needed unless you have added a package configuration file to your project 58 | # .swiftpm 59 | 60 | .build/ 61 | 62 | # CocoaPods 63 | # We recommend against adding the Pods directory to your .gitignore. However 64 | # you should judge for yourself, the pros and cons are mentioned at: 65 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 66 | Pods/ 67 | # Add this line if you want to avoid checking in source code from the Xcode workspace 68 | *.xcworkspace 69 | 70 | # Carthage 71 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 72 | # Carthage/Checkouts 73 | 74 | Carthage/Build/ 75 | Cartfile.resolved 76 | 77 | # Accio dependency management 78 | Dependencies/ 79 | .accio/ 80 | 81 | # fastlane 82 | # It is recommended to not store the screenshots in the git repo. 83 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 84 | # For more information about the recommended setup visit: 85 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 86 | 87 | fastlane/report.xml 88 | fastlane/Preview.html 89 | fastlane/screenshots/**/*.png 90 | fastlane/test_output 91 | 92 | # Code Injection 93 | # After new code Injection tools there's a generated folder /iOSInjectionProject 94 | # https://github.com/johnno1962/injectionforxcode 95 | 96 | iOSInjectionProject/ 97 | 98 | ### Xcode ### 99 | # Xcode 100 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 101 | 102 | 103 | 104 | 105 | ## Gcc Patch 106 | /*.gcno 107 | 108 | ### Xcode Patch ### 109 | *.xcodeproj/* 110 | !*.xcodeproj/project.pbxproj 111 | !*.xcodeproj/xcshareddata/ 112 | !*.xcworkspace/contents.xcworkspacedata 113 | **/xcshareddata/WorkspaceSettings.xcsettings 114 | 115 | # End of https://www.toptal.com/developers/gitignore/api/swift,xcode,ios 116 | 117 | bundler/ 118 | .bundle/ 119 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/PropertyWrappers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PropertyWrappers.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2022/01/07. 6 | // 7 | 8 | #if swift(>=5.1) 9 | import FirebaseRemoteConfig 10 | 11 | public struct SwiftyRemoteConfigOptions: OptionSet { 12 | public static let observed = SwiftyRemoteConfigOptions(rawValue: 1 << 0) 13 | public static let cached = SwiftyRemoteConfigOptions(rawValue: 1 << 2) 14 | 15 | public let rawValue: Int 16 | 17 | public init(rawValue: Int) { 18 | self.rawValue = rawValue 19 | } 20 | } 21 | 22 | @propertyWrapper 23 | public final class SwiftyRemoteConfig where T.T == T { 24 | 25 | private let config: RemoteConfig 26 | 27 | /// `RemoteConfigKey` for this value 28 | public let key: RemoteConfigKey 29 | 30 | /// `SwiftyRemoteConfigOptions` for this value 31 | public let options: SwiftyRemoteConfigOptions 32 | 33 | /// Last fetch status. The status can be any enumerated value from `RemoteConfigFetchStatus`. 34 | public var lastFetchStatus: RemoteConfigFetchStatus { 35 | return self.config.lastFetchStatus 36 | } 37 | 38 | /// Last successful fetch completion time. 39 | public var lastFetchTime: Date? { 40 | return self.config.lastFetchTime 41 | } 42 | 43 | public var projectedValue: SwiftyRemoteConfig { 44 | get { 45 | return self 46 | } 47 | @available(*, unavailable, message: "SwiftyRemoteConfig's projected value does not support setting values yet.") 48 | set { 49 | fatalError("SwiftyRemoteConfig's projected value does not support setting values yet.") 50 | } 51 | } 52 | 53 | public var wrappedValue: T { 54 | get { 55 | if options.contains(.cached) { 56 | return value ?? RemoteConfigs[key] 57 | } else { 58 | return RemoteConfigs[self.key] 59 | } 60 | } 61 | @available(*, unavailable, message: "SwiftyRemoteConfig's property wrapper does not support setting values yet.") 62 | set { 63 | fatalError("SwiftyRemoteConfig property wrapper does not support setting values yet.") 64 | } 65 | } 66 | 67 | private var value: T.T? 68 | private var observation: RemoteConfigDisposable? 69 | 70 | public init( 71 | keyPath: KeyPath>, 72 | adapter: RemoteConfigAdapter, 73 | options: SwiftyRemoteConfigOptions = [] 74 | ) { 75 | self.config = adapter.remoteConfig 76 | self.key = adapter.keyStore[keyPath: keyPath] 77 | self.options = options 78 | 79 | if options.contains(.observed) { 80 | observation = adapter.observe(key) { [weak self] update in 81 | self?.value = update.newValue 82 | } 83 | } 84 | } 85 | 86 | public init( 87 | keyPath: KeyPath>, 88 | options: SwiftyRemoteConfigOptions = [] 89 | ) { 90 | self.config = RemoteConfigs.remoteConfig 91 | self.key = RemoteConfigs.keyStore[keyPath: keyPath] 92 | self.options = options 93 | 94 | if options.contains(.observed) { 95 | observation = RemoteConfigs.observe(key) { [weak self] update in 96 | self?.value = update.newValue 97 | } 98 | } 99 | } 100 | 101 | deinit { 102 | observation?.dispose() 103 | } 104 | } 105 | #endif 106 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/BuiltIns.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BuiltIns.swift 3 | // SwiftyRemoteConfigExample 4 | // 5 | // Created by 伊藤史 on 2020/08/25. 6 | // Copyright © 2020 Fumito Ito. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension RemoteConfigSerializable { 12 | public static var _remoteConfigArray: RemoteConfigArrayBridge<[T]> { RemoteConfigArrayBridge() } 13 | } 14 | 15 | extension Date: RemoteConfigSerializable { 16 | public static var _remoteConfig: RemoteConfigObjectBridge { RemoteConfigObjectBridge() } 17 | } 18 | 19 | extension String: RemoteConfigSerializable { 20 | public static var _remoteConfig: RemoteConfigStringBridge { RemoteConfigStringBridge() } 21 | } 22 | 23 | extension Int: RemoteConfigSerializable { 24 | public static var _remoteConfig: RemoteConfigIntBridge { RemoteConfigIntBridge() } 25 | } 26 | 27 | extension Double: RemoteConfigSerializable { 28 | public static var _remoteConfig: RemoteConfigDoubleBridge { return RemoteConfigDoubleBridge() } 29 | } 30 | 31 | extension Bool: RemoteConfigSerializable { 32 | public static var _remoteConfig: RemoteConfigBoolBridge { RemoteConfigBoolBridge() } 33 | } 34 | 35 | extension Data: RemoteConfigSerializable { 36 | public static var _remoteConfig: RemoteConfigDataBridge { RemoteConfigDataBridge() } 37 | } 38 | 39 | extension URL: RemoteConfigSerializable { 40 | public static var _remoteConfig: RemoteConfigUrlBridge { RemoteConfigUrlBridge() } 41 | public static var _remoteConfigArray: RemoteConfigCodableBridge<[URL]> { RemoteConfigCodableBridge() } 42 | } 43 | 44 | extension RemoteConfigSerializable where Self: Codable { 45 | public static var _remoteConfig: RemoteConfigCodableBridge { RemoteConfigCodableBridge() } 46 | public static var _remoteConfigArray: RemoteConfigCodableBridge<[Self]> { RemoteConfigCodableBridge() } 47 | } 48 | 49 | extension RemoteConfigSerializable where Self: RawRepresentable { 50 | public static var _remoteConfig: RemoteConfigRawRepresentableBridge { RemoteConfigRawRepresentableBridge() } 51 | public static var _remoteConfigArray: RemoteConfigRawRepresentableArrayBridge<[Self]> { RemoteConfigRawRepresentableArrayBridge() } 52 | } 53 | 54 | extension RemoteConfigSerializable where Self: NSCoding { 55 | public static var _remoteConfig: RemoteConfigKeyedArchiverBridge { RemoteConfigKeyedArchiverBridge() } 56 | public static var _remoteConfigArray: RemoteConfigKeyedArchiverArrayBridge<[Self]> { RemoteConfigKeyedArchiverArrayBridge() } 57 | } 58 | 59 | extension Dictionary: RemoteConfigSerializable where Key == String { 60 | public typealias T = [Key: Value] 61 | public typealias Bridge = RemoteConfigObjectBridge 62 | public typealias ArrayBridge = RemoteConfigArrayBridge<[T]> 63 | 64 | public static var _remoteConfig: Bridge { Bridge() } 65 | public static var _remoteConfigArray: ArrayBridge { ArrayBridge() } 66 | } 67 | 68 | extension Array: RemoteConfigSerializable where Element: RemoteConfigSerializable { 69 | public typealias T = [Element.T] 70 | public typealias Bridge = Element.ArrayBridge 71 | public typealias ArrayBridge = RemoteConfigObjectBridge<[T]> 72 | 73 | public static var _remoteConfig: Bridge { Element._remoteConfigArray } 74 | public static var _remoteConfigArray: ArrayBridge { 75 | fatalError("Multidimensional arrays are not supported yet") 76 | } 77 | } 78 | 79 | extension Optional: RemoteConfigSerializable where Wrapped: RemoteConfigSerializable { 80 | public typealias Bridge = RemoteConfigOptionalBridge 81 | public typealias ArrayBridge = RemoteConfigOptionalBridge 82 | 83 | public static var _remoteConfig: Bridge { RemoteConfigOptionalBridge(bridge: Wrapped._remoteConfig) } 84 | public static var _remoteConfigArray: ArrayBridge { RemoteConfigOptionalBridge(bridge: Wrapped._remoteConfigArray) } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/RemoteConfigObserver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteConfigObserver.swift 3 | // 4 | // 5 | // Created by Fumito Ito on 2022/02/23. 6 | // 7 | 8 | import Foundation 9 | import Firebase 10 | 11 | public protocol RemoteConfigDisposable { 12 | func dispose() 13 | } 14 | 15 | public final class RemoteConfigObserver: NSObject, RemoteConfigDisposable where T == T.T { 16 | public struct Update { 17 | public let kind: NSKeyValueChange 18 | public let isPrior: Bool 19 | public let newValue: T.T? 20 | public let oldValue: T.T? 21 | 22 | init(dict: [NSKeyValueChangeKey: Any], key: RemoteConfigKey) { 23 | kind = NSKeyValueChange(rawValue: dict[.kindKey] as! UInt)! 24 | isPrior = dict[.notificationIsPriorKey] as? Bool ?? false 25 | if let oldConfigValue = dict[.oldKey] as? RemoteConfigValue { 26 | oldValue = Self.deserialize(oldConfigValue, for: key) 27 | } else { 28 | oldValue = key.defaultValue 29 | } 30 | 31 | if let newConfigValue = dict[.newKey] as? RemoteConfigValue { 32 | newValue = Self.deserialize(newConfigValue, for: key) 33 | } else { 34 | newValue = key.defaultValue 35 | } 36 | } 37 | 38 | private static func deserialize(_ value: RemoteConfigValue?, for key: RemoteConfigKey) -> T.T? where T.T == T { 39 | guard let value = value else { 40 | return nil 41 | } 42 | 43 | let deserialized = T._remoteConfig.deserialize(value) 44 | 45 | if key.isOptional { 46 | return deserialized 47 | } else { 48 | assert(deserialized != nil, "non-optional RemoteConfigValue should be unwrapped") 49 | return deserialized! 50 | } 51 | } 52 | } 53 | 54 | private let key: RemoteConfigKey 55 | private let remoteConfig: RemoteConfig 56 | private let handler: ((Update) -> Void) 57 | private var didRemoteObserver: Bool = false 58 | private let observeKeyPathName: String 59 | private var oldValue: T.T? 60 | 61 | init(key: RemoteConfigKey, remoteConfig: RemoteConfig, options: NSKeyValueObservingOptions, handler: @escaping ((Update) -> Void)) { 62 | self.key = key 63 | self.remoteConfig = remoteConfig 64 | self.handler = handler 65 | self.observeKeyPathName = #keyPath(RemoteConfig.lastFetchTime) 66 | self.oldValue = remoteConfig[key] 67 | super.init() 68 | 69 | remoteConfig.addObserver(self, forKeyPath: observeKeyPathName, options: options, context: nil) 70 | } 71 | 72 | deinit { 73 | dispose() 74 | } 75 | 76 | public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { 77 | guard let change = change, object != nil, keyPath == observeKeyPathName else { 78 | return 79 | } 80 | 81 | var dictionary = change 82 | dictionary.removeValue(forKey: .newKey) 83 | dictionary.removeValue(forKey: .oldKey) 84 | let values: [NSKeyValueChangeKey : Any] = [ 85 | NSKeyValueChangeKey.newKey: remoteConfig[key], 86 | NSKeyValueChangeKey.oldKey: oldValue as Any 87 | ] 88 | dictionary.merge(values, uniquingKeysWith: { (l, r) in l }) 89 | 90 | let update = Update(dict: change, key: key) 91 | handler(update) 92 | 93 | oldValue = remoteConfig[key] 94 | } 95 | 96 | public func dispose() { 97 | if didRemoteObserver { return } 98 | 99 | didRemoteObserver = true 100 | remoteConfig.removeObserver(self, forKeyPath: observeKeyPathName, context: nil) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/Helpers/TestHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestHelper.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/16. 6 | // 7 | 8 | import Foundation 9 | import SwiftyRemoteConfig 10 | import Firebase 11 | import XCTest 12 | 13 | func given(_ description: String, closure: @escaping (XCTActivity) -> Void) { 14 | XCTContext.runActivity(named: description, block: closure) 15 | } 16 | 17 | func when(_ description: String, closure: @escaping (XCTActivity) -> Void) { 18 | XCTContext.runActivity(named: description, block: closure) 19 | } 20 | 21 | func then(_ description: String, closure: @escaping (XCTActivity) -> Void) { 22 | XCTContext.runActivity(named: description, block: closure) 23 | } 24 | 25 | final class FrogSerializable: NSObject, RemoteConfigSerializable, NSCoding { 26 | typealias T = FrogSerializable 27 | 28 | let name: String 29 | 30 | init(name: String = "Froggy") { 31 | self.name = name 32 | } 33 | 34 | init?(coder: NSCoder) { 35 | guard let name = coder.decodeObject(forKey: "name") as? String else { 36 | return nil 37 | } 38 | 39 | self.name = name 40 | } 41 | 42 | func encode(with coder: NSCoder) { 43 | coder.encode(name, forKey: "name") 44 | } 45 | 46 | override func isEqual(_ object: Any?) -> Bool { 47 | guard let object = object as? FrogSerializable else { 48 | return false 49 | } 50 | 51 | return name == object.name 52 | } 53 | } 54 | 55 | struct FrogCodable: Codable, Equatable, RemoteConfigSerializable { 56 | let name: String 57 | 58 | init(name: String = "Froggy") { 59 | self.name = name 60 | } 61 | } 62 | 63 | enum BestFroggiesEnum: String, RemoteConfigSerializable { 64 | case Andy 65 | case Dandy 66 | } 67 | 68 | struct FrogCustomSerializable: RemoteConfigSerializable, Equatable { 69 | static var _remoteConfig: RemoteConfigFrogBridge { return RemoteConfigFrogBridge() } 70 | static var _remoteConfigArray: RemoteConfigFrogArrayBridge { return RemoteConfigFrogArrayBridge() } 71 | 72 | typealias Bridge = RemoteConfigFrogBridge 73 | 74 | typealias ArrayBridge = RemoteConfigFrogArrayBridge 75 | 76 | 77 | let name: String 78 | } 79 | 80 | final class RemoteConfigFrogBridge: RemoteConfigBridge { 81 | func get(key: String, remoteConfig: RemoteConfig) -> FrogCustomSerializable? { 82 | let name = remoteConfig.configValue(forKey: key).stringValue 83 | guard name.isEmpty == false else { 84 | return nil 85 | } 86 | 87 | return FrogCustomSerializable.init(name: name) 88 | } 89 | 90 | func deserialize(_ object: RemoteConfigValue) -> FrogCustomSerializable? { 91 | guard object.stringValue.isEmpty == false else { 92 | return nil 93 | } 94 | 95 | return FrogCustomSerializable.init(name: object.stringValue) 96 | } 97 | } 98 | 99 | final class RemoteConfigFrogArrayBridge: RemoteConfigBridge { 100 | func get(key: String, remoteConfig: RemoteConfig) -> [FrogCustomSerializable]? { 101 | return remoteConfig.configValue(forKey: key) 102 | .jsonValue 103 | .map({ $0 as? [String] }) 104 | .flatMap({ $0 })? 105 | .map(FrogCustomSerializable.init) 106 | } 107 | 108 | func deserialize(_ object: RemoteConfigValue) -> Array? { 109 | // In remote config, array is configured as JSON value 110 | guard let names = object.jsonValue as? [String] else { 111 | return nil 112 | } 113 | 114 | return names.map(FrogCustomSerializable.init) 115 | } 116 | } 117 | 118 | final class FrogKeyStore: RemoteConfigKeyStore { 119 | lazy var testValue: RemoteConfigKey = { fatalError("not initialized yet") }() 120 | lazy var testArray: RemoteConfigKey<[Serializable]> = { fatalError("not initialized yet") }() 121 | lazy var testOptionalValue: RemoteConfigKey = { fatalError("not initialized yet") }() 122 | lazy var testOptionalArray: RemoteConfigKey<[Serializable]?> = { fatalError("not initialized yet") }() 123 | } 124 | 125 | struct FrogFirebaseConfig { 126 | static var firebaseOptions: FirebaseOptions { 127 | let options = FirebaseOptions(googleAppID: "1:528030662976:ios:f9691ae052efde771dbaa5", 128 | gcmSenderID: "528030662976") 129 | options.apiKey = "AIzaSyDoeTH6r43Y9vZ0Y98IzpxAn2dV2YBAcKg" 130 | options.projectID = "swiftyremoteconfigexample" 131 | 132 | options.bundleID = "com.github.fumito-ito.SwiftyRemoteConfigExample" 133 | options.clientID = "528030662976-0q2liuia5e4lthon3rpp84tgrn70pios.apps.googleusercontent.com" 134 | options.databaseURL = "https://swiftyremoteconfigexample.firebaseio.com" 135 | options.storageBucket = "swiftyremoteconfigexample.appspot.com" 136 | 137 | return options 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/RemoteConfigCombine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteConfigCombine.swift 3 | // 4 | // 5 | // Created by Fumito Ito on 2022/02/26. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import FirebaseRemoteConfig 11 | 12 | @available(iOS 13.0, macOS 10.15, *) 13 | public extension RemoteConfig { 14 | /// An Combine accessor for RemoteConfig 15 | var combine: RemoteConfigCombine { 16 | return RemoteConfigCombine(self) 17 | } 18 | } 19 | 20 | /// An extension class with Combine. 21 | /// 22 | /// It allows you handle RemoteConfig with Combine 23 | @available(iOS 13.0, macOS 10.15, *) 24 | public class RemoteConfigCombine { 25 | private let remoteConfig: RemoteConfig 26 | 27 | init(_ remoteConfig: RemoteConfig) { 28 | self.remoteConfig = remoteConfig 29 | } 30 | } 31 | 32 | @available(iOS 13.0, macOS 10.15, *) 33 | extension RemoteConfigCombine { 34 | class Subscription { 35 | private(set) var subscriber: S? 36 | private(set) var cancellable: AnyCancellable? 37 | let remoteConfig: RemoteConfig 38 | 39 | init(subscriber: S, remoteConfig: RemoteConfig) { 40 | self.subscriber = subscriber 41 | self.remoteConfig = remoteConfig 42 | 43 | cancellable = remoteConfig.combine 44 | .fetchedPublisher() 45 | .sink( 46 | receiveCompletion: { _ in }, 47 | receiveValue: { [weak self] in self?.received() } 48 | ) 49 | } 50 | 51 | func cancelSubscription() { 52 | cancellable?.cancel() 53 | subscriber = nil 54 | } 55 | 56 | func received() { 57 | } 58 | } 59 | 60 | final class RemoteConfigValueSubscription: Subscription, Combine.Subscription where S.Input == T.T, S.Failure == Never, T.T == T { 61 | private let key: RemoteConfigKey 62 | 63 | init(subscriber: S, remoteConfig: RemoteConfig, key: RemoteConfigKey) { 64 | self.key = key 65 | super.init(subscriber: subscriber, remoteConfig: remoteConfig) 66 | } 67 | 68 | public func request(_ demand: Subscribers.Demand) {} 69 | 70 | public func cancel() { 71 | super.cancelSubscription() 72 | } 73 | 74 | override func received() { 75 | _ = subscriber?.receive(remoteConfig[key]) 76 | } 77 | } 78 | 79 | struct RemoteConfigValuePublisher: Combine.Publisher where T.T == T { 80 | public typealias Output = T.T 81 | public typealias Failure = Never 82 | 83 | private let remoteConfig: RemoteConfig 84 | private let key: RemoteConfigKey 85 | 86 | init(remoteConfig: RemoteConfig, key: RemoteConfigKey) { 87 | self.remoteConfig = remoteConfig 88 | self.key = key 89 | } 90 | 91 | public func receive(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input { 92 | subscriber.receive(subscription: RemoteConfigValueSubscription( 93 | subscriber: subscriber, 94 | remoteConfig: remoteConfig, 95 | key: key 96 | )) 97 | } 98 | } 99 | } 100 | 101 | @available(iOS 13.0, macOS 10.15, *) 102 | public extension RemoteConfigCombine { 103 | /// Returns Publisher that tells you that RemoteConfig has fetched latest valeus from Backend. 104 | /// 105 | /// - Returns: A publisher `` 106 | func fetchedPublisher() -> AnyPublisher { 107 | return remoteConfig.publisher(for: \.lastFetchTime) 108 | .map({ _ in Void() }) 109 | .eraseToAnyPublisher() 110 | } 111 | 112 | /// Returns Publisher that gives you a value matched a config key after fetching from RemoteConfig. 113 | /// 114 | /// - Parameters: keyPath: A keyPath for RemoteConfig key 115 | /// - Returns: A publisher `` 116 | func fetchedPublisher(for keyPath: KeyPath>) -> AnyPublisher where T.T == T { 117 | return RemoteConfigValuePublisher(remoteConfig: remoteConfig, key: RemoteConfigs.keyStore[keyPath: keyPath]).eraseToAnyPublisher() 118 | } 119 | 120 | /// Returns Publisher that gives you a value matched a config key after fetching from RemoteConfig. 121 | /// 122 | /// - Parameters keyPath: A keyPath for RemoteConfig key 123 | /// - Parameters adapter: A RemoteConfig key adapeter for RemoteConfig keys 124 | /// - Returns: A publisher `` 125 | func fetchedPublisher(for keyPath: KeyPath>, 126 | adapter: RemoteConfigAdapter) -> AnyPublisher where T.T == T { 127 | return RemoteConfigValuePublisher(remoteConfig: remoteConfig, key: adapter.keyStore[keyPath: keyPath]).eraseToAnyPublisher() 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Tests/SwiftyRemoteConfigTests/Helpers/RemoteConfigSerializableSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by 伊藤史 on 2021/11/06. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | @testable import SwiftyRemoteConfig 11 | import FirebaseRemoteConfig 12 | import FirebaseCore 13 | 14 | class RemoteConfigSerializableSpec: XCTestCase { 15 | } 16 | 17 | extension RemoteConfigSerializableSpec where Serializable.T: Equatable, Serializable.T == Serializable, Serializable.ArrayBridge.T == [Serializable.T] { 18 | 19 | static func setupFirebase() { 20 | if FirebaseApp.app() == nil { 21 | FirebaseApp.configure(options: FrogFirebaseConfig.firebaseOptions) 22 | } 23 | } 24 | 25 | func testValues(defaultValue: Serializable.T, keyStore: FrogKeyStore) { 26 | given(String(describing: Serializable.self)) { _ in 27 | when("key-default value") { _ in 28 | var config: RemoteConfigAdapter>! 29 | let remoteConfig = RemoteConfig.remoteConfig() 30 | config = RemoteConfigAdapter(remoteConfig: remoteConfig, 31 | keyStore: keyStore) 32 | 33 | then("create a key") { _ in 34 | let key = RemoteConfigKey("test", defaultValue: defaultValue) 35 | XCTAssert(key._key == "test") 36 | XCTAssert(key.defaultValue == defaultValue) 37 | } 38 | 39 | then("create an array key") { _ in 40 | let key = RemoteConfigKey<[Serializable]>("test", defaultValue: [defaultValue]) 41 | XCTAssert(key._key == "test") 42 | XCTAssert(key.defaultValue == [defaultValue]) 43 | } 44 | 45 | then("get a default value") { _ in 46 | let key = RemoteConfigKey("test", defaultValue: defaultValue) 47 | XCTAssert(config[key] == defaultValue) 48 | } 49 | 50 | #if swift(>=5.1) 51 | then("get a default value with dynamicMemberLookup") { _ in 52 | keyStore.testValue = RemoteConfigKey("test", defaultValue: defaultValue) 53 | XCTAssert(config.testValue == defaultValue) 54 | } 55 | #endif 56 | 57 | then("get a default array value") { _ in 58 | let key = RemoteConfigKey<[Serializable]>("test", defaultValue: [defaultValue]) 59 | XCTAssert(config[key] == [defaultValue]) 60 | } 61 | 62 | #if swift(>=5.1) 63 | then("get a default array value with dynamicMemberLookup") { _ in 64 | keyStore.testArray = RemoteConfigKey<[Serializable]>("test", defaultValue: [defaultValue]) 65 | XCTAssert(config.testArray == [defaultValue]) 66 | } 67 | #endif 68 | } 69 | } 70 | } 71 | 72 | func testOptionalValues(defaultValue: Serializable.T, keyStore: FrogKeyStore) { 73 | given(String(describing: Serializable.self)) { _ in 74 | when("key-default optional value") { _ in 75 | var config: RemoteConfigAdapter>! 76 | let remoteConfig = RemoteConfig.remoteConfig() 77 | config = RemoteConfigAdapter(remoteConfig: remoteConfig, 78 | keyStore: keyStore) 79 | 80 | then("create a key") { _ in 81 | let key = RemoteConfigKey("test", defaultValue: defaultValue) 82 | XCTAssert(key._key == "test") 83 | XCTAssert(key.defaultValue == defaultValue) 84 | } 85 | 86 | then("create an array key") { _ in 87 | let key = RemoteConfigKey<[Serializable]?>("test", defaultValue: [defaultValue]) 88 | XCTAssert(key._key == "test") 89 | XCTAssert(key.defaultValue == [defaultValue]) 90 | } 91 | 92 | then("get a default value") { _ in 93 | let key = RemoteConfigKey("test", defaultValue: defaultValue) 94 | XCTAssert(config[key] == defaultValue) 95 | } 96 | 97 | #if swift(>=5.1) 98 | then("get a default value with dynamicMemberLookup") { _ in 99 | keyStore.testOptionalValue = RemoteConfigKey("test", defaultValue: defaultValue) 100 | XCTAssert(config.testOptionalValue == defaultValue) 101 | } 102 | #endif 103 | 104 | then("get a default array value") { _ in 105 | let key = RemoteConfigKey<[Serializable]?>("test", defaultValue: [defaultValue]) 106 | XCTAssert(config[key] == [defaultValue]) 107 | } 108 | 109 | #if swift(>=5.1) 110 | then("get a default array value with dynamicMemberLookup") { _ in 111 | keyStore.testOptionalArray = RemoteConfigKey<[Serializable]?>("test", defaultValue: [defaultValue]) 112 | XCTAssert(config.testOptionalArray == [defaultValue]) 113 | } 114 | #endif 115 | } 116 | } 117 | } 118 | 119 | func testOptionalValuesWithoutDefaultValue(defaultValue: Serializable.T, keyStore: FrogKeyStore) { 120 | given(String(describing: Serializable.self)) { _ in 121 | when("key-nil optional value") { _ in 122 | var config: RemoteConfigAdapter>! 123 | let remoteConfig = RemoteConfig.remoteConfig() 124 | config = RemoteConfigAdapter(remoteConfig: remoteConfig, 125 | keyStore: keyStore) 126 | 127 | then("create a key") { _ in 128 | let key = RemoteConfigKey("test") 129 | XCTAssert(key._key == "test") 130 | XCTAssert(key.defaultValue == nil) 131 | } 132 | 133 | then("create an array key") { _ in 134 | let key = RemoteConfigKey<[Serializable]?>("test") 135 | XCTAssert(key._key == "test") 136 | XCTAssert(key.defaultValue == nil) 137 | } 138 | 139 | then("compare optional value to non-optional value") { _ in 140 | let key = RemoteConfigKey("test") 141 | XCTAssertTrue(config[key] == nil) 142 | XCTAssertTrue(config[key] != defaultValue) 143 | } 144 | 145 | #if swift(>=5.1) 146 | then("compare optional value to non-optional value with dynamicMemberLookup") { _ in 147 | keyStore.testOptionalValue = RemoteConfigKey("test") 148 | XCTAssertTrue(config.testOptionalValue == nil) 149 | XCTAssertTrue(config.testOptionalValue != defaultValue) 150 | } 151 | #endif 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Sources/SwiftyRemoteConfig/RemoteConfigBridge.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteConfigBridge.swift 3 | // SwiftyRemoteConfig 4 | // 5 | // Created by 伊藤史 on 2020/08/13. 6 | // Copyright © 2020 Fumito Ito. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import FirebaseRemoteConfig 11 | 12 | public protocol RemoteConfigBridge { 13 | associatedtype T 14 | 15 | func get(key: String, remoteConfig: RemoteConfig) -> T? 16 | func deserialize(_ object: RemoteConfigValue) -> T? 17 | } 18 | 19 | public struct RemoteConfigObjectBridge: RemoteConfigBridge { 20 | public init() {} 21 | 22 | public func get(key: String, remoteConfig: RemoteConfig) -> T? { 23 | return remoteConfig.configValue(forKey: key) as? T 24 | } 25 | 26 | public func deserialize(_ object: RemoteConfigValue) -> T? { 27 | return nil 28 | } 29 | } 30 | 31 | public struct RemoteConfigArrayBridge: RemoteConfigBridge { 32 | public init() {} 33 | 34 | public func get(key: String, remoteConfig: RemoteConfig) -> T? { 35 | return remoteConfig.configValue(forKey: key) as? T 36 | } 37 | 38 | public func deserialize(_ object: RemoteConfigValue) -> T? { 39 | return nil 40 | } 41 | } 42 | 43 | public struct RemoteConfigStringBridge: RemoteConfigBridge { 44 | public init() {} 45 | 46 | public func get(key: String, remoteConfig: RemoteConfig) -> String? { 47 | let configValue = remoteConfig.configValue(forKey: key) 48 | 49 | if configValue.stringValue.isEmpty == true { 50 | return nil 51 | } 52 | 53 | return configValue.stringValue 54 | } 55 | 56 | public func deserialize(_ object: RemoteConfigValue) -> String? { 57 | return nil 58 | } 59 | } 60 | 61 | public struct RemoteConfigIntBridge: RemoteConfigBridge { 62 | public init() {} 63 | 64 | public func get(key: String, remoteConfig: RemoteConfig) -> Int? { 65 | let configValue = remoteConfig.configValue(forKey: key) 66 | 67 | if configValue.stringValue.isEmpty == true { 68 | return nil 69 | } 70 | 71 | return configValue.numberValue.intValue 72 | } 73 | 74 | public func deserialize(_ object: RemoteConfigValue) -> Int? { 75 | return nil 76 | } 77 | } 78 | 79 | public struct RemoteConfigDoubleBridge: RemoteConfigBridge { 80 | public init() {} 81 | 82 | public func get(key: String, remoteConfig: RemoteConfig) -> Double? { 83 | let configValue = remoteConfig.configValue(forKey: key) 84 | 85 | if configValue.stringValue.isEmpty == true { 86 | return nil 87 | } 88 | 89 | return configValue.numberValue.doubleValue 90 | } 91 | 92 | public func deserialize(_ object: RemoteConfigValue) -> Double? { 93 | return nil 94 | } 95 | } 96 | 97 | public struct RemoteConfigBoolBridge: RemoteConfigBridge { 98 | public init() {} 99 | 100 | public func get(key: String, remoteConfig: RemoteConfig) -> Bool? { 101 | let configValue = remoteConfig.configValue(forKey: key) 102 | 103 | if configValue.stringValue.isEmpty == true { 104 | return nil 105 | } 106 | 107 | return remoteConfig.configValue(forKey: key).boolValue 108 | } 109 | 110 | public func deserialize(_ object: RemoteConfigValue) -> Bool? { 111 | return nil 112 | } 113 | } 114 | 115 | public struct RemoteConfigDataBridge: RemoteConfigBridge { 116 | public init() {} 117 | 118 | public func get(key: String, remoteConfig: RemoteConfig) -> Data? { 119 | let dataValue = remoteConfig.configValue(forKey: key).dataValue 120 | return dataValue.isEmpty ? nil : dataValue 121 | } 122 | 123 | public func deserialize(_ object: RemoteConfigValue) -> Data? { 124 | return nil 125 | } 126 | } 127 | 128 | public struct RemoteConfigUrlBridge: RemoteConfigBridge { 129 | public init() {} 130 | 131 | public func get(key: String, remoteConfig: RemoteConfig) -> URL? { 132 | return self.deserialize(remoteConfig.configValue(forKey: key)) 133 | } 134 | 135 | public func deserialize(_ object: RemoteConfigValue) -> URL? { 136 | if object.stringValue.isEmpty == false { 137 | if let url = URL(string: object.stringValue) { 138 | return url 139 | } 140 | 141 | let path = (object.stringValue as NSString).expandingTildeInPath 142 | return URL(fileURLWithPath: path) 143 | } 144 | 145 | return nil 146 | } 147 | } 148 | 149 | public struct RemoteConfigCodableBridge: RemoteConfigBridge { 150 | public func get(key: String, remoteConfig: RemoteConfig) -> T? { 151 | return self.deserialize(remoteConfig.configValue(forKey: key)) 152 | } 153 | 154 | public func deserialize(_ object: RemoteConfigValue) -> T? { 155 | return try? JSONDecoder().decode(T.self, from: object.dataValue) 156 | } 157 | 158 | public init() {} 159 | } 160 | 161 | public struct RemoteConfigKeyedArchiverBridge: RemoteConfigBridge { 162 | public func get(key: String, remoteConfig: RemoteConfig) -> T? { 163 | return self.deserialize(remoteConfig.configValue(forKey: key)) 164 | } 165 | 166 | public func deserialize(_ object: RemoteConfigValue) -> T? { 167 | guard #available(iOS 11.0, macOS 10.13, tvOS 11.0, *) else { 168 | return NSKeyedUnarchiver.unarchiveObject(with: object.dataValue) as? T 169 | } 170 | 171 | guard let object = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [T.self], from: object.dataValue) as? T else { 172 | return nil 173 | } 174 | 175 | return object 176 | } 177 | 178 | public init() {} 179 | } 180 | 181 | public struct RemoteConfigKeyedArchiverArrayBridge: RemoteConfigBridge where T.Element: NSCoding { 182 | public func get(key: String, remoteConfig: RemoteConfig) -> T? { 183 | return self.deserialize(remoteConfig.configValue(forKey: key)) 184 | } 185 | 186 | public func deserialize(_ object: RemoteConfigValue) -> T? { 187 | guard #available(iOS 11.0, macOS 10.13, tvOS 11.0, *) else { 188 | return NSKeyedUnarchiver.unarchiveObject(with: object.dataValue) as? T 189 | } 190 | 191 | guard let objects = object.jsonValue as? [Data] else { 192 | return nil 193 | } 194 | 195 | return objects.compactMap({ 196 | try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [T.Element.self], from: $0) as? T.Element 197 | }) as? T 198 | } 199 | 200 | public init() {} 201 | } 202 | 203 | public struct RemoteConfigRawRepresentableBridge: RemoteConfigBridge { 204 | public func get(key: String, remoteConfig: RemoteConfig) -> T? { 205 | return self.deserialize(remoteConfig.configValue(forKey: key)) 206 | } 207 | 208 | public func deserialize(_ object: RemoteConfigValue) -> T? { 209 | if let rawValue = object.stringValue as? T.RawValue { 210 | return T(rawValue: rawValue) 211 | } 212 | 213 | if let rawValue = object.numberValue as? T.RawValue { 214 | return T(rawValue: rawValue) 215 | } 216 | 217 | return nil 218 | } 219 | 220 | public init() {} 221 | } 222 | 223 | public struct RemoteConfigRawRepresentableArrayBridge: RemoteConfigBridge where T.Element: RawRepresentable { 224 | public func get(key: String, remoteConfig: RemoteConfig) -> T? { 225 | return self.deserialize(remoteConfig.configValue(forKey: key)) 226 | } 227 | 228 | public func deserialize(_ object: RemoteConfigValue) -> T? { 229 | guard let rawValues = object.jsonValue as? [T.Element.RawValue] else { 230 | return nil 231 | } 232 | 233 | return rawValues.compactMap({ T.Element(rawValue: $0) }) as? T 234 | } 235 | 236 | public init() {} 237 | } 238 | 239 | public struct RemoteConfigOptionalBridge: RemoteConfigBridge { 240 | public typealias T = Bridge.T? 241 | 242 | private let bridge: Bridge 243 | 244 | public init(bridge: Bridge) { 245 | self.bridge = bridge 246 | } 247 | 248 | public func get(key: String, remoteConfig: RemoteConfig) -> T? { 249 | return self.bridge.get(key: key, remoteConfig: remoteConfig) 250 | } 251 | 252 | public func deserialize(_ object: RemoteConfigValue) -> T? { 253 | return self.bridge.deserialize(object) 254 | } 255 | } 256 | 257 | public struct RemoteConfigOptionalArrayBridge: RemoteConfigBridge where Bridge.T: Collection { 258 | public typealias T = Bridge.T 259 | 260 | private let bridge: Bridge 261 | 262 | public init(bridge: Bridge) { 263 | self.bridge = bridge 264 | } 265 | 266 | public func get(key: String, remoteConfig: RemoteConfig) -> T? { 267 | return self.bridge.get(key: key, remoteConfig: remoteConfig) 268 | } 269 | 270 | public func deserialize(_ object: RemoteConfigValue) -> T? { 271 | return self.bridge.deserialize(object) 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2020 Fumito Ito 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | 204 | All code in any directories or sub-directories that end with *.html or 205 | *.css is licensed under the Creative Commons Attribution International 206 | 4.0 License, which full text can be found here: 207 | https://creativecommons.org/licenses/by/4.0/legalcode. 208 | 209 | As an exception to this license, all html or css that is generated by 210 | the software at the direction of the user is copyright the user. The 211 | user has full ownership and control over such content, including 212 | whether and how they wish to license it. 213 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftyRemoteConfig 2 | 3 | ![Platforms](https://img.shields.io/badge/platforms-ios%20%7C%20osx%20%7C%20watchos%20%7C%20tvos-lightgrey.svg) 4 | ![CocoaPods compatible](https://img.shields.io/badge/CocoaPods-compatible-4BC51D.svg?style=flat) 5 | ![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat) 6 | ![SPM compatible](https://img.shields.io/badge/SPM-compatible-4BC51D.svg?style=flat) 7 | ![Swift version](https://img.shields.io/badge/swift-5.0-orange.svg) 8 | ![Swift version](https://img.shields.io/badge/swift-5.1-orange.svg) 9 | ![Swift version](https://img.shields.io/badge/swift-5.2-orange.svg) 10 | 11 | **Modern Swift API for `FirebaseRemoteConfig`** 12 | 13 | SwiftyRemoteConfig makes Firebase Remote Config enjoyable to use by combining expressive Swifty API with the benefits fo static typing. This library is strongly inspired by [SwiftyUserDefaults](https://github.com/sunshinejr/SwiftyUserDefaults). 14 | 15 | ## HEADS UP ! You need workaround to use with Xcode 13.3 or later 16 | 17 | Because of [Xcode compiler bug](https://github.com/apple/swift/issues/58084), you need workaround to use this library with Xcode 13.3 or later. 18 | Followings are recommended steps for workaround. 19 | 20 | 1. Create `SwiftyRemoteConfig+Workaround.swift` file in module which is using `SwiftyRemoteConfig`. 21 | 1. Copy the codes below into `SwiftyRemoteConfig+Workaround.swift`. This is pretty much a copy from the `BuiltIns.swift` file in the Sources folder: https://raw.githubusercontent.com/fumito-ito/SwiftyRemoteConfig/master/Sources/SwiftyRemoteConfig/BuiltIns.swift 22 | 23 | ```swift 24 | import Foundation 25 | import SwiftyRemoteConfig 26 | 27 | extension RemoteConfigSerializable { 28 | public static var _remoteConfigArray: RemoteConfigArrayBridge<[T]> { RemoteConfigArrayBridge() } 29 | } 30 | 31 | extension Date: RemoteConfigSerializable { 32 | public static var _remoteConfig: RemoteConfigObjectBridge { RemoteConfigObjectBridge() } 33 | } 34 | 35 | extension String: RemoteConfigSerializable { 36 | public static var _remoteConfig: RemoteConfigStringBridge { RemoteConfigStringBridge() } 37 | } 38 | 39 | extension Int: RemoteConfigSerializable { 40 | public static var _remoteConfig: RemoteConfigIntBridge { RemoteConfigIntBridge() } 41 | } 42 | 43 | extension Double: RemoteConfigSerializable { 44 | public static var _remoteConfig: RemoteConfigDoubleBridge { return RemoteConfigDoubleBridge() } 45 | } 46 | 47 | extension Bool: RemoteConfigSerializable { 48 | public static var _remoteConfig: RemoteConfigBoolBridge { RemoteConfigBoolBridge() } 49 | } 50 | 51 | extension Data: RemoteConfigSerializable { 52 | public static var _remoteConfig: RemoteConfigDataBridge { RemoteConfigDataBridge() } 53 | } 54 | 55 | extension URL: RemoteConfigSerializable { 56 | public static var _remoteConfig: RemoteConfigUrlBridge { RemoteConfigUrlBridge() } 57 | public static var _remoteConfigArray: RemoteConfigCodableBridge<[URL]> { RemoteConfigCodableBridge() } 58 | } 59 | 60 | extension RemoteConfigSerializable where Self: Codable { 61 | public static var _remoteConfig: RemoteConfigCodableBridge { RemoteConfigCodableBridge() } 62 | public static var _remoteConfigArray: RemoteConfigCodableBridge<[Self]> { RemoteConfigCodableBridge() } 63 | } 64 | 65 | extension RemoteConfigSerializable where Self: RawRepresentable { 66 | public static var _remoteConfig: RemoteConfigRawRepresentableBridge { RemoteConfigRawRepresentableBridge() } 67 | public static var _remoteConfigArray: RemoteConfigRawRepresentableArrayBridge<[Self]> { RemoteConfigRawRepresentableArrayBridge() } 68 | } 69 | 70 | extension RemoteConfigSerializable where Self: NSCoding { 71 | public static var _remoteConfig: RemoteConfigKeyedArchiverBridge { RemoteConfigKeyedArchiverBridge() } 72 | public static var _remoteConfigArray: RemoteConfigKeyedArchiverArrayBridge<[Self]> { RemoteConfigKeyedArchiverArrayBridge() } 73 | } 74 | 75 | extension Dictionary: RemoteConfigSerializable where Key == String { 76 | public typealias T = [Key: Value] 77 | public typealias Bridge = RemoteConfigObjectBridge 78 | public typealias ArrayBridge = RemoteConfigArrayBridge<[T]> 79 | 80 | public static var _remoteConfig: Bridge { Bridge() } 81 | public static var _remoteConfigArray: ArrayBridge { ArrayBridge() } 82 | } 83 | 84 | extension Array: RemoteConfigSerializable where Element: RemoteConfigSerializable { 85 | public typealias T = [Element.T] 86 | public typealias Bridge = Element.ArrayBridge 87 | public typealias ArrayBridge = RemoteConfigObjectBridge<[T]> 88 | 89 | public static var _remoteConfig: Bridge { Element._remoteConfigArray } 90 | public static var _remoteConfigArray: ArrayBridge { 91 | fatalError("Multidimensional arrays are not supported yet") 92 | } 93 | } 94 | 95 | extension Optional: RemoteConfigSerializable where Wrapped: RemoteConfigSerializable { 96 | public typealias Bridge = RemoteConfigOptionalBridge 97 | public typealias ArrayBridge = RemoteConfigOptionalBridge 98 | 99 | public static var _remoteConfig: Bridge { RemoteConfigOptionalBridge(bridge: Wrapped._remoteConfig) } 100 | public static var _remoteConfigArray: ArrayBridge { RemoteConfigOptionalBridge(bridge: Wrapped._remoteConfigArray) } 101 | } 102 | ``` 103 | 104 | ## Features 105 | 106 | There is only one step to start using SwiftyRemoteConfig. 107 | 108 | Define your Keys ! 109 | 110 | ```swift 111 | extension RemoteConfigKeys { 112 | var recommendedAppVersion: RemoteConfigKey { .init("recommendedAppVersion")} 113 | var isEnableExtendedFeature: RemoteConfigKey { .init("isEnableExtendedFeature", defaultValue: false) } 114 | } 115 | ``` 116 | 117 | ... and just use it ! 118 | 119 | ```swift 120 | // get remote config value easily 121 | let recommendedVersion = RemoteConfigs[.recommendedAppVersion] 122 | 123 | // eality work with custom deserialized types 124 | let themaColor: UIColor = RemoteConfigs[.themaColor] 125 | ``` 126 | 127 | If you use Swift 5.1 or later, you can also use keyPath `dynamicMemberLookup`: 128 | 129 | ```swift 130 | let subColor: UIColor = RemoteConfigs.subColor 131 | ``` 132 | 133 | ## Usage 134 | 135 | ### Define your keys 136 | 137 | To get the most out of SwiftyRemoteConfig, define your remote config keys ahead of time: 138 | 139 | ```swift 140 | let flag = RemoteConfigKey("flag", defaultValue: false) 141 | ``` 142 | 143 | Just create a `RemoteConfigKey` object. If you want to have a non-optional value, just provide a `defaultValue` in the key (look at the example above). 144 | 145 | You can now use `RemoteConfig` shortcut to access those values: 146 | 147 | ```swift 148 | RemoteConfigs[key: flag] // => false, type as "Bool" 149 | ``` 150 | 151 | The compiler won't let you fetching conveniently returns `Bool`. 152 | 153 | ### Take shortcuts 154 | 155 | For extra convenience, define your keys by extending magic `RemoteConfigKeys` class and adding static properties: 156 | 157 | ```swift 158 | extension RemoteConfigKeys { 159 | var flag: RemoteConfigKey { .init("flag", defaultValue: false) } 160 | var userSectionName: RemoteConfigKey { .init("default") } 161 | } 162 | ``` 163 | 164 | and use the shortcut dot syntax: 165 | 166 | ```swift 167 | RemoteConfigs[\.flag] // => false 168 | ``` 169 | 170 | ### Supported types 171 | 172 | SwiftyRemoteConfig supports standard types as following: 173 | 174 | | Single value | Array | 175 | |:---:|:---:| 176 | | `String` | `[String]` | 177 | | `Int` | `[Int]` | 178 | | `Double` | `[Double]` | 179 | | `Bool` | `[Bool]` | 180 | | `Data` | `[Data]` | 181 | | `Date` | `[Date]` | 182 | | `URL` | `[URL]` | 183 | | `[String: Any]` | `[[String: Any]]` | 184 | 185 | and that's not all ! 186 | 187 | ## Extending existing types 188 | 189 | ### Codable 190 | 191 | `SwiftyRemoteConfig` supports `Codable` ! Just conform to `RemoteConfigSerializable` in your type: 192 | 193 | ```swift 194 | final class UserSection: Codable, RemoteConfigSerializable { 195 | let name: String 196 | } 197 | ``` 198 | 199 | No implementation needed ! By doing this you will get an option to specify an optional `RemoteConfigKey`: 200 | 201 | ```swift 202 | let userSection = RemoteConfigKey("userSection") 203 | ``` 204 | 205 | Additionally, you've get an array support for free: 206 | 207 | ```swift 208 | let userSections = RemoteConfigKey<[UserSection]?>("userSections") 209 | ``` 210 | 211 | ### NSCoding 212 | 213 | Support your custom NSCoding type the same way as with Codable support: 214 | 215 | ```swift 216 | final class UserSection: NSObject, NSCoding, RemoteConfigSerializable { 217 | ... 218 | } 219 | ``` 220 | 221 | ### RawRepresentable 222 | 223 | And the last, `RawRepresentable` support ! Again, the same situation like with `Codable` and `NSCoding`: 224 | 225 | ```swift 226 | enum UserSection: String, RemoteConfigSerializable { 227 | case Basic 228 | case Royal 229 | } 230 | ``` 231 | 232 | ### Custom types 233 | 234 | If you want to add your own custom type that we don't support yet, we've got you covered. We use `RemoteConfigBridge` s of many kinds to specify how you get values and arrays of values. When you look at `RemoteConfigSerializable` protocol, it expects two properties in each type: `_remoteConfig` and `_remoteConfigArray`, where both are of type `RemoteConfigBridge`. 235 | 236 | For instance, this is a bridge for single value data retrieving using `NSKeyedUnarchiver`: 237 | 238 | ```swift 239 | public struct RemoteConfigKeyedArchiveBridge: RemoteConfigBridge { 240 | 241 | public func get(key: String, remoteConfig: RemoteConfig) -> T? { 242 | remoteConfig.data(forKey: key).flatMap(NSKeyedUnarchiver.unarchiveObject) as? T 243 | } 244 | 245 | public func deserialize(_ object: RemoteConfigValue) -> T? { 246 | guard let data = object as? Data else { 247 | return nil 248 | } 249 | 250 | NSKeyedUnarchiver.unarchiveObject(with: data) 251 | } 252 | } 253 | ``` 254 | 255 | Bridge for default retrieving array values: 256 | 257 | ```swift 258 | public struct RemoteConfigArrayBridge: RemoteConfigBridge { 259 | public func get(key: String, remoteConfig: RemoteConfig) -> T? { 260 | remoteConfig.array(forKey: key) as? T 261 | } 262 | 263 | public func deserialize(_ object: RemoteConfigValue) -> T? { 264 | return nil 265 | } 266 | } 267 | ``` 268 | 269 | Now, to use these bridges in your type you simply declare it as follows: 270 | 271 | ```swift 272 | struct CustomSerializable: RemoteConfigSerializable { 273 | static var _remoteConfig: RemoteConfigBridge { RemoteConfigKeyedArchiverBridge() } 274 | static var _remoteConfigArray: RemoteConfigBridge<[CustomSerializable]> { RemoteConfigKeyedArchiverBridge() } 275 | 276 | let key: String 277 | } 278 | ``` 279 | 280 | Unfortunately, if you find yourself in a situation where you need a custom bridge, you'll probably need to write your own: 281 | 282 | ```swift 283 | final class RemoteConfigCustomBridge: RemoteConfigBridge { 284 | func get(key: String, remoteConfig: RemoteConfig) -> RemoteConfigCustomSerializable? { 285 | let value = remoteConfig.string(forKey: key) 286 | return value.map(RemoteConfigCustomSerializable.init) 287 | } 288 | 289 | func deserialize(_ object: Any) -> RemoteConfigCustomSerializable? { 290 | guard let value = object as? String { 291 | return nil 292 | } 293 | 294 | return RemoteConfigCustomSerializable(value: value) 295 | } 296 | } 297 | 298 | final class RemoteConfigCustomArrayBridge: RemoteConfigBridge { 299 | func get(key: String, remoteConfig: RemoteConfig) -> [RemoteConfigCustomSerializable]? { 300 | remoteConfig.array(forKey: key)? 301 | .compactMap({ $0 as? String }) 302 | .map(RemoteConfigCustomSerializable.init) 303 | } 304 | 305 | func deserialize(_ object: Any) -> [RemoteConfigCustomSerializable]? { 306 | guard let values as? [String] else { 307 | return nil 308 | } 309 | 310 | return values.map({ RemoteConfigCustomSerializable.init }) 311 | } 312 | } 313 | 314 | struct RemoteConfigCustomSerializable: RemoteConfigSerializable, Equatable { 315 | static var _remoteConfig: RemoteConfigCustomBridge { RemoteConfigCustomBridge() } 316 | static var _remoteConfigArrray: RemoteConfigCustomArrayBridge: { RemoteConfigCustomArrayBridge() } 317 | 318 | let value: String 319 | } 320 | ``` 321 | 322 | To support existing types with different bridges, you can extend it similarly: 323 | 324 | ```swift 325 | extension Data: RemoteConfigSerializable { 326 | public static var _remoteConfigArray: RemoteConfigArrayBridge<[T]> { RemoteConfigArrayBridge() } 327 | public static var _remoteConfig: RemoteConfigBridge { RemoteConfigBridge() } 328 | }d 329 | ``` 330 | Also, take a look at our source code or tests to see more examples of bridges. If you find yourself confused with all these bridges, please create an issue and we will figure something out. 331 | 332 | ## Property Wrappers 333 | 334 | SwiftyRemoteConfig provides property wrappers for Swift 5.1! The property wrapper, `@SwiftyRemoteConfig`, provides an option to use it with key path. 335 | 336 | _Note: This property wrappers only `read` support. You can set new value to the property, but any changes will NOT be reflected to remote config value_ 337 | 338 | ### usage 339 | 340 | Given keys: 341 | 342 | ```swift 343 | extension RemoteConfigKeys { 344 | var userColorScheme: RemoteConfigKey { .init("userColorScheme", defaultValue: "default") } 345 | } 346 | ``` 347 | 348 | You can declare a Settings struct: 349 | 350 | ```swift 351 | struct Settings { 352 | @SwiftyRemoteConfig(keyPath: \.userColorScheme) 353 | var userColorScheme: String 354 | } 355 | ``` 356 | 357 | You can also check property details with projected value: 358 | 359 | ```swift 360 | struct Settings { 361 | @SwiftyRemoteConfig(keyPath: \.newFeatureAvailable) 362 | var newFeatureAvailable: String 363 | } 364 | 365 | struct NewFeatureRouter { 366 | func show(with settings: Settings) { 367 | if settings.$newFeatureAvailable.lastFetchTime != nil { 368 | // show new feature 369 | } else { 370 | // fetch and activate remote config before routing 371 | } 372 | } 373 | } 374 | ``` 375 | 376 | ## KeyPath dynamicMemberLookup 377 | 378 | SwiftyRemoteConfig makes KeyPath dynamicMemberLookup usable in Swift 5.1. 379 | 380 | ```swift 381 | extension RemoteConfigKeys { 382 | var recommendedAppVersion: RemoteConfigKey { .init("recommendedAppVersion")} 383 | var themaColor: RemoteConfigKey { .init("themaColor", defaultValue: .white) } 384 | } 385 | ``` 386 | 387 | and just use it ;-) 388 | 389 | ```swift 390 | // get remote config value easily 391 | let recommendedVersion = RemoteConfig.recommendedAppVersion 392 | 393 | // eality work with custom deserialized types 394 | let themaColor: UIColor = RemoteConfig.themaColor 395 | ``` 396 | 397 | ## Combine 398 | 399 | SwiftyRemoteConfig provides values from RemoteConfig with Combine's stream. 400 | 401 | ```swift 402 | extension RemoteConfigKeys { 403 | var contentText: RemoteConfigKey { .init("content_text", defaultValue: "Hello, World!!") } 404 | } 405 | ``` 406 | 407 | and get a RemoteConfig's value from Combine stream ! 408 | 409 | ```swift 410 | import FirebaseRemoteConfig 411 | import SwiftyRemoteConfig 412 | import Combine 413 | 414 | final class ViewModel: ObservableObject { 415 | @Published var contentText: String 416 | 417 | private var cancellables: Set = [] 418 | 419 | init() { 420 | contentText = RemoteConfigs.contentText 421 | 422 | RemoteConfig.remoteConfig() 423 | .combine 424 | .fetchedPublisher(for: \.contentText) 425 | .receive(on: RunLoop.main) 426 | .assign(to: \.contentText, on: self) 427 | .store(in: &cancellables) 428 | } 429 | } 430 | ``` 431 | 432 | ## Dependencies 433 | 434 | - **Swift** version >= 5.0 435 | 436 | ### SDKs 437 | 438 | - **iOS** version >= 15.0 439 | - **macOS** version >= 10.15 440 | - **tvOS** version >= 15.0 441 | - **watchOS** version >= 7.0 442 | 443 | ### Frameworks 444 | 445 | - **Firebase iOS SDK** >= 12.1.0 446 | 447 | ## Installation 448 | 449 | ### Cocoapods 450 | 451 | If you're using Cocoapods, just add this line to your `Podfile`: 452 | 453 | ```ruby 454 | pod 'SwiftyRemoteConfig`, `~> 1.0.0` 455 | ``` 456 | 457 | Install by running this command in your terminal: 458 | 459 | ```sh 460 | $ pod install 461 | ``` 462 | 463 | Then import the library in all files where you use it: 464 | 465 | ```swift 466 | import SwiftyRemoteConfig 467 | ``` 468 | 469 | ### Carthage 470 | 471 | Just add your Cartfile 472 | 473 | ``` 474 | github "fumito-ito/SwiftyRemoteConfig" ~> 1.0.0 475 | ``` 476 | 477 | ### Swift Package Manager 478 | 479 | Just add to your `Package.swift` under dependencies 480 | 481 | ```swift 482 | let package = Package( 483 | name: "MyPackage", 484 | products: [...], 485 | dependencies: [ 486 | .package(url: "https://github.com/fumito-ito/SwiftyRemoteConfig.git", .upToNextMajor(from: "1.0.0")) 487 | ] 488 | ) 489 | ``` 490 | 491 | SwiftyRemoteConfig is available under the Apache License 2.0. See the LICENSE file for more detail. 492 | --------------------------------------------------------------------------------