-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #7 from JonnyBeeGod/develop
merge develop into master
- Loading branch information
Showing
16 changed files
with
976 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
name: Swift | ||
|
||
on: [push] | ||
|
||
jobs: | ||
build: | ||
|
||
runs-on: macOS-latest | ||
|
||
steps: | ||
- uses: actions/checkout@v1 | ||
- name: Build | ||
run: swift build -v | ||
- name: Prepare xcodeproj | ||
run: swift package generate-xcodeproj | ||
- name: Run tests | ||
run: xcodebuild test -scheme CloudKitFeatureToggles-Package -destination platform="macOS" -enableCodeCoverage YES -derivedDataPath .build/derivedData | ||
- name: Codecov | ||
run: bash <(curl -s https://codecov.io/bash) -D .build/derivedData/ -t ${{ secrets.CODECOV_TOKEN }} -J '^CloudKitFeatureToggles$' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,99 @@ | ||
# CloudKitFeatureToggles | ||
# CloudKit FeatureToggles | ||
|
||
A description of this package. | ||
![](https://github.com/JonnyBeeGod/CloudKitFeatureToggles/workflows/Swift/badge.svg) | ||
[![codecov](https://codecov.io/gh/JonnyBeeGod/CloudKitFeatureToggles/branch/master/graph/badge.svg?token=y21zGNAsLL)](https://codecov.io/gh/JonnyBeeGod/CloudKitFeatureToggles) | ||
<img src="https://img.shields.io/badge/Swift-5.1-orange.svg" /> | ||
<a href="https://swift.org/package-manager"> | ||
<img src="https://img.shields.io/badge/swiftpm-compatible-brightgreen.svg?style=flat" alt="Swift Package Manager" /> | ||
</a> | ||
<img src="https://img.shields.io/badge/platforms-iOS-brightgreen.svg?style=flat" alt="iOS" /> | ||
<a href="https://twitter.com/jonezdotcom"> | ||
<img src="https://img.shields.io/badge/[email protected]?style=flat" alt="Twitter: @jonezdotcom" /> | ||
</a> | ||
|
||
## What does it do? | ||
Feature Toggles offer a way to enable or disable certain features that are present in your codebase, switch environments or configurations or toggle between multiple implementations of a protocol - even in your live system at runtime. *CloudKit FeatureToggles* are implemented using `CloudKit` and are therefor associated with no run costs for the developer. Existing Feature Toggles can be changed in the [CloudKit Dashboard](https://icloud.developer.apple.com/dashboard/) and are delivered immediately via silent push notifications to your users. | ||
|
||
## How to install? | ||
CloudKitFeatureToggles is compatible with Swift Package Manager. To install, simply add this repository URL to your swift packages as package dependency in Xcode. | ||
Alternatively, add this line to your `Package.swift` file: | ||
|
||
``` | ||
dependencies: [ | ||
.package(url: "https://github.com/JonnyBeeGod/CloudKitFeatureToggles", from: "0.1.0") | ||
] | ||
``` | ||
|
||
And don't forget to add the dependency to your target(s). | ||
|
||
## How to use? | ||
|
||
### CloudKit Preparations | ||
1. If your application does not support CloudKit yet start with adding the `CloudKit` and `remote background notification` entitlements to your application | ||
2. Add a new custom record type 'FeatureStatus' with two fields: | ||
|
||
| Field | Type | | ||
| --- | --- | | ||
| `featureName` | `String` | | ||
| `isActive` | `Int64` | | ||
|
||
For each feature toggle you want to support in your application later add a new record in your CloudKit *public database*. | ||
|
||
### In your project | ||
1. In your AppDelegate, initialize a `FeatureToggleApplicationService` and hook its two `UIApplicationDelegate` methods into the AppDelegate lifecycle like so: | ||
|
||
``` | ||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { | ||
// Override point for customization after application launch. | ||
return featureToggleApplicationService.application(application, didFinishLaunchingWithOptions: launchOptions) | ||
} | ||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { | ||
featureToggleApplicationService.application(application, didReceiveRemoteNotification: userInfo, fetchCompletionHandler: completionHandler) | ||
} | ||
``` | ||
2. Anywhere in your code you can create an instance of `FeatureToggleUserDefaultsRepository` and call `retrieve` to fetch the current status of a feature toggle. | ||
|
||
> :warning: Note that `retrieve` returns the locally saved status of your toggle, this command does not trigger a fetch from CloudKit. Feature Toggles are fetched from CloudKit once at app start from within the `FeatureToggleApplicationService` `UIApplicationDelegate` hook. Additionally you can subscribe to updates whenever there was a change to the feature toggles in CloudKit as shown in the next section. | ||
3. You have to call `retrieve` with your implementation of a `FeatureToggleIdentifiable`. What I think works well is creating an enum which implements `FeatureToggleIdentifiable`: | ||
|
||
``` | ||
enum FeatureToggle: String, FeatureToggleIdentifiable { | ||
case feature1 | ||
case feature2 | ||
var identifier: String { | ||
return self.rawValue | ||
} | ||
var fallbackValue: Bool { | ||
switch self { | ||
case .feature1: | ||
return false | ||
case .feature2: | ||
return true | ||
} | ||
} | ||
} | ||
``` | ||
### Notifications | ||
|
||
You can subscribe to updates from your feature toggles in CloudKit by subscribing to the `onRecordsUpdated` Notification like so: | ||
|
||
``` | ||
NotificationCenter.default.addObserver(self, selector: #selector(updateToggleStatusFromNotification), name: NSNotification.Name.onRecordsUpdated, object: nil) | ||
``` | ||
|
||
``` | ||
@objc | ||
private func updateToggleStatusFromNotification(notification NSNotification) { | ||
guard let updatedToggles = notification.userInfo[Notification.featureToggleUserInfoKey] as? [FeatureToggle] else { | ||
return | ||
} | ||
// do something with the updated toggle like e.g. disabling UI elements | ||
} | ||
``` | ||
|
||
Note that the updated Feature Toggles are attached to the notifications userInfo dictionary. When this notification has been sent the updated values are also already stored in the repository. |
This file was deleted.
Oops, something went wrong.
86 changes: 86 additions & 0 deletions
86
Sources/CloudKitFeatureToggles/CloudKitSubscriptionProtocol.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
// | ||
// CloudKitSubscriptionProtocol.swift | ||
// nSuns | ||
// | ||
// Created by Jonas Reichert on 01.09.18. | ||
// Copyright © 2018 Jonas Reichert. All rights reserved. | ||
// | ||
|
||
import Foundation | ||
import CloudKit | ||
|
||
protocol CloudKitDatabaseConformable { | ||
func add(_ operation: CKDatabaseOperation) | ||
func perform(_ query: CKQuery, inZoneWith zoneID: CKRecordZone.ID?, completionHandler: @escaping ([CKRecord]?, Error?) -> Void) | ||
} | ||
|
||
extension CKDatabase: CloudKitDatabaseConformable {} | ||
|
||
protocol CloudKitSubscriptionProtocol { | ||
var subscriptionID: String { get } | ||
var database: CloudKitDatabaseConformable { get } | ||
|
||
func fetchAll() | ||
func saveSubscription() | ||
func handleNotification() | ||
} | ||
|
||
extension CloudKitSubscriptionProtocol { | ||
func saveSubscription(subscriptionID: String, recordType: String, defaults: UserDefaults) { | ||
// Let's keep a local flag handy to avoid saving the subscription more than once. | ||
// Even if you try saving the subscription multiple times, the server doesn't save it more than once | ||
// Nevertheless, let's save some network operation and conserve resources | ||
let subscriptionSaved = defaults.bool(forKey: subscriptionID) | ||
guard !subscriptionSaved else { | ||
return | ||
} | ||
|
||
// Subscribing is nothing but saving a query which the server would use to generate notifications. | ||
// The below predicate (query) will raise a notification for all changes. | ||
let predicate = NSPredicate(value: true) | ||
let subscription = CKQuerySubscription(recordType: recordType, predicate: predicate, subscriptionID: subscriptionID, options: CKQuerySubscription.Options.firesOnRecordUpdate) | ||
|
||
let notificationInfo = CKSubscription.NotificationInfo() | ||
// Set shouldSendContentAvailable to true for receiving silent pushes | ||
// Silent notifications are not shown to the user and don’t require the user's permission. | ||
notificationInfo.shouldSendContentAvailable = true | ||
subscription.notificationInfo = notificationInfo | ||
|
||
// Use CKModifySubscriptionsOperation to save the subscription to CloudKit | ||
let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: []) | ||
operation.modifySubscriptionsCompletionBlock = { (_, _, error) in | ||
guard error == nil else { | ||
return | ||
} | ||
defaults.set(true, forKey: subscriptionID) | ||
} | ||
operation.qualityOfService = .utility | ||
// Add the operation to the corresponding private or public database | ||
database.add(operation) | ||
} | ||
|
||
func handleNotification(recordType: String, recordFetchedBlock: @escaping (CKRecord) -> Void) { | ||
let queryOperation = CKQueryOperation(query: query(recordType: recordType)) | ||
|
||
queryOperation.recordFetchedBlock = recordFetchedBlock | ||
queryOperation.qualityOfService = .utility | ||
|
||
database.add(queryOperation) | ||
} | ||
|
||
func fetchAll(recordType: String, handler: @escaping ([CKRecord]) -> Void) { | ||
database.perform(query(recordType: recordType), inZoneWith: nil) { (ckRecords, error) in | ||
guard error == nil, let ckRecords = ckRecords else { | ||
// don't update last fetched date, simply do nothing and try again next time | ||
return | ||
} | ||
|
||
handler(ckRecords) | ||
} | ||
} | ||
|
||
private func query(recordType: String) -> CKQuery { | ||
let predicate = NSPredicate(value: true) | ||
return CKQuery(recordType: recordType, predicate: predicate) | ||
} | ||
} |
72 changes: 72 additions & 0 deletions
72
Sources/CloudKitFeatureToggles/FeatureToggleApplicationService.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
// | ||
// FeatureToggleApplicationService.swift | ||
// CloudKitFeatureToggles | ||
// | ||
// Created by Jonas Reichert on 04.01.20. | ||
// | ||
|
||
import Foundation | ||
import CloudKit | ||
#if canImport(UIKit) | ||
import UIKit | ||
#endif | ||
|
||
public protocol FeatureToggleApplicationServiceProtocol { | ||
var featureToggleRepository: FeatureToggleRepository { get } | ||
|
||
#if canImport(UIKit) | ||
func register(application: UIApplication) | ||
func handleRemoteNotification(subscriptionID: String?, completionHandler: @escaping (UIBackgroundFetchResult) -> Void) | ||
#endif | ||
} | ||
|
||
public class FeatureToggleApplicationService: NSObject, FeatureToggleApplicationServiceProtocol { | ||
|
||
private var featureToggleSubscriptor: CloudKitSubscriptionProtocol | ||
private (set) public var featureToggleRepository: FeatureToggleRepository | ||
|
||
public convenience init(featureToggleRepository: FeatureToggleRepository = FeatureToggleUserDefaultsRepository()) { | ||
self.init(featureToggleSubscriptor: FeatureToggleSubscriptor(toggleRepository: featureToggleRepository), featureToggleRepository: featureToggleRepository) | ||
} | ||
|
||
init(featureToggleSubscriptor: CloudKitSubscriptionProtocol, featureToggleRepository: FeatureToggleRepository) { | ||
self.featureToggleSubscriptor = featureToggleSubscriptor | ||
self.featureToggleRepository = featureToggleRepository | ||
} | ||
|
||
#if canImport(UIKit) | ||
public func register(application: UIApplication) { | ||
application.registerForRemoteNotifications() | ||
featureToggleSubscriptor.saveSubscription() | ||
featureToggleSubscriptor.fetchAll() | ||
} | ||
|
||
public func handleRemoteNotification(subscriptionID: String?, completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { | ||
if let subscriptionID = subscriptionID, featureToggleSubscriptor.subscriptionID == subscriptionID { | ||
featureToggleSubscriptor.handleNotification() | ||
completionHandler(.newData) | ||
} | ||
else { | ||
completionHandler(.noData) | ||
} | ||
} | ||
#endif | ||
} | ||
|
||
#if canImport(UIKit) | ||
extension FeatureToggleApplicationService: UIApplicationDelegate { | ||
public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { | ||
register(application: application) | ||
|
||
return true | ||
} | ||
|
||
public func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { | ||
guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo), let subscriptionID = notification.subscriptionID else { | ||
return | ||
} | ||
|
||
handleRemoteNotification(subscriptionID: subscriptionID, completionHandler: completionHandler) | ||
} | ||
} | ||
#endif |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
// | ||
// FeatureToggleMapper.swift | ||
// CloudKitFeatureToggles | ||
// | ||
// Created by Jonas Reichert on 06.01.20. | ||
// | ||
|
||
import Foundation | ||
import CloudKit | ||
|
||
public protocol FeatureToggleRepresentable { | ||
var identifier: String { get } | ||
var isActive: Bool { get } | ||
} | ||
|
||
public protocol FeatureToggleIdentifiable { | ||
var identifier: String { get } | ||
var fallbackValue: Bool { get } | ||
} | ||
|
||
public struct FeatureToggle: FeatureToggleRepresentable, Equatable { | ||
public let identifier: String | ||
public let isActive: Bool | ||
} | ||
|
||
protocol FeatureToggleMappable { | ||
func map(record: CKRecord) -> FeatureToggle? | ||
} | ||
|
||
class FeatureToggleMapper: FeatureToggleMappable { | ||
private let featureToggleNameFieldID: String | ||
private let featureToggleIsActiveFieldID: String | ||
|
||
init(featureToggleNameFieldID: String, featureToggleIsActiveFieldID: String) { | ||
self.featureToggleNameFieldID = featureToggleNameFieldID | ||
self.featureToggleIsActiveFieldID = featureToggleIsActiveFieldID | ||
} | ||
|
||
func map(record: CKRecord) -> FeatureToggle? { | ||
guard let isActive = record[featureToggleIsActiveFieldID] as? Int64, let featureName = record[featureToggleNameFieldID] as? String else { | ||
return nil | ||
} | ||
|
||
return FeatureToggle(identifier: featureName, isActive: NSNumber(value: isActive).boolValue) | ||
} | ||
} |
37 changes: 37 additions & 0 deletions
37
Sources/CloudKitFeatureToggles/FeatureToggleRepository.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
// | ||
// FeatureToggleManager.swift | ||
// CloudKitFeatureToggles | ||
// | ||
// Created by Jonas Reichert on 01.01.20. | ||
// | ||
|
||
import Foundation | ||
|
||
public protocol FeatureToggleRepository { | ||
/// retrieves a stored `FeatureToggleRepresentable` from the underlying store. | ||
func retrieve(identifiable: FeatureToggleIdentifiable) -> FeatureToggleRepresentable | ||
/// saves a supplied `FeatureToggleRepresentable` to the underlying store | ||
func save(featureToggle: FeatureToggleRepresentable) | ||
} | ||
|
||
public class FeatureToggleUserDefaultsRepository { | ||
|
||
private static let defaultsSuiteName = "featureToggleUserDefaultsRepositorySuite" | ||
private let defaults: UserDefaults | ||
|
||
public init(defaults: UserDefaults? = nil) { | ||
self.defaults = defaults ?? UserDefaults(suiteName: FeatureToggleUserDefaultsRepository.defaultsSuiteName) ?? .standard | ||
} | ||
} | ||
|
||
extension FeatureToggleUserDefaultsRepository: FeatureToggleRepository { | ||
public func retrieve(identifiable: FeatureToggleIdentifiable) -> FeatureToggleRepresentable { | ||
let isActive = defaults.value(forKey: identifiable.identifier) as? Bool | ||
|
||
return FeatureToggle(identifier: identifiable.identifier, isActive: isActive ?? identifiable.fallbackValue) | ||
} | ||
|
||
public func save(featureToggle: FeatureToggleRepresentable) { | ||
defaults.set(featureToggle.isActive, forKey: featureToggle.identifier) | ||
} | ||
} |
Oops, something went wrong.