Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ Pods
Podfile.lock
.DS_Store
build/

# Local secrets (copy from Secrets.xcconfig.example)
Secrets.xcconfig
*.pbxuser
!default.pbxuser
*.mode1v3
Expand Down
24 changes: 22 additions & 2 deletions Podfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
use_frameworks!
target 'hello-ios' do
platform :ios, '13.0'
pod 'LaunchDarkly', '>= 11.2'
# Points at the evaluation-exposure-dedupe branch until that work is released.
# After release, switch back to: pod 'LaunchDarkly', '>= 11.x'
pod 'LaunchDarkly', :git => 'https://github.com/launchdarkly/ios-client-sdk.git', :branch => 'andrey/flag-exposure-dedupe'
end

target 'hello-watchOS Extension' do
platform :watchos, '6.0'
pod 'LaunchDarkly', '>= 11.2'
pod 'LaunchDarkly', :git => 'https://github.com/launchdarkly/ios-client-sdk.git', :branch => 'andrey/flag-exposure-dedupe'
end

# CocoaPods resets the target's base xcconfig on integrate; keep our wrappers that
# include both the Pods settings and the local Secrets.xcconfig.
post_integrate do |installer|
project_path = File.join(installer.sandbox.root.parent, 'hello-ios.xcodeproj')
project = Xcodeproj::Project.open(project_path)
target = project.targets.find { |t| t.name == 'hello-ios' }
next unless target

debug_ref = project.files.find { |f| f.path == 'hello-ios/Debug.xcconfig' }
release_ref = project.files.find { |f| f.path == 'hello-ios/Release.xcconfig' }
next unless debug_ref && release_ref
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

target.build_configurations.each do |config|
config.base_configuration_reference = config.name == 'Debug' ? debug_ref : release_ref
end
project.save
end
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ Below, you'll find the build procedure. For more comprehensive instructions, you
## Build instructions

1. Make sure you have [Xcode](https://itunes.apple.com/us/app/xcode/id497799835?ls=1&mt=12) installed
1. Copy `Secrets.xcconfig.example` to `Secrets.xcconfig` and set `mobileKey` to your LaunchDarkly mobile key (`Secrets.xcconfig` is gitignored)
1. Make sure you're in this directory and then type `pod install`
1. Open `hello-ios.xcworkspace` in Xcode
1. Set the value of `sdkKey` in `hello-common/LDClientConfigurator.m` to your LaunchDarkly mobile key. If there is an existing boolean feature flag in your LaunchDarkly project that you want to evaluate, set `featureFlagKey` in `hello-ios/ViewController.m` and `hello-watchOS Extension/InterfaceController.m` to the flag key.
1. If there is an existing boolean feature flag in your LaunchDarkly project that you want to evaluate, set `featureFlagKey` in `hello-ios/ViewController.m` and `hello-watchOS Extension/InterfaceController.m` to the flag key. watchOS still uses `sdkKey` in `hello-common/LDClientConfigurator.m`.

```swift
// hello-common/LDClientConfigurator.m
NSString * const sdkKey = @"";
```xcconfig
// Secrets.xcconfig
mobileKey = your-mobile-key
```

```objc
// hello-ios/ViewController.m
self.featureFlagKey = @"sample-feature";

Expand All @@ -23,3 +26,16 @@ Below, you'll find the build procedure. For more comprehensive instructions, you
```

You should see the message "The <flagKey> feature flag evaluates to <flagValue>.". The application will run continuously and react to the flag changes in LaunchDarkly.

## Verifying evaluation exposure deduplication

The iOS target registers two hooks with different dedupe windows (5s and 10s), using the same per-hook API a customer would:

```swift
config.hooks = [
ExposureCountingHook(label: "fast", window: 5, onStage: ...),
ExposureCountingHook(label: "slow", window: 10, onStage: ...),
]
```

Each hook declares `evaluationExposureDeduper` on itself. Tap **Evaluate Flag** repeatedly: "Evaluations requested" climbs while each hook's count stays put until its window elapses. Tap **Identify** (leave the user key empty to re-identify the same context) to clear both caches so the next evaluation reaches both hooks again.
4 changes: 4 additions & 0 deletions Secrets.xcconfig.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copy this file to Secrets.xcconfig and fill in the values.
// Secrets.xcconfig is gitignored — do not commit real keys.

mobileKey = YOUR_MOBILE_KEY_HERE
21 changes: 19 additions & 2 deletions hello-ios.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
83C2E2B3228C9EC700F35883 /* LDClientConfigurator.m in Sources */ = {isa = PBXBuildFile; fileRef = 83C2E2B2228C9EC700F35883 /* LDClientConfigurator.m */; };
83C2E2B4228C9EC700F35883 /* LDClientConfigurator.m in Sources */ = {isa = PBXBuildFile; fileRef = 83C2E2B2228C9EC700F35883 /* LDClientConfigurator.m */; };
F02202EF1BD856F500EB7727 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = F02202EE1BD856F500EB7727 /* main.m */; };
A1B2C3D41E00000100000001 /* ExposureCountingHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41E00000100000002 /* ExposureCountingHook.swift */; };
A1B2C3D41E00000100000003 /* ExposureDedupeDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D41E00000100000004 /* ExposureDedupeDemo.swift */; };
F02202F21BD856F500EB7727 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = F02202F11BD856F500EB7727 /* AppDelegate.m */; };
F02202F51BD856F500EB7727 /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = F02202F41BD856F500EB7727 /* ViewController.m */; };
F02202F81BD856F500EB7727 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F02202F61BD856F500EB7727 /* Main.storyboard */; };
Expand Down Expand Up @@ -93,6 +95,10 @@
91708128117708E83F55478B /* Pods-hello-watchOS Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-hello-watchOS Extension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-hello-watchOS Extension/Pods-hello-watchOS Extension.debug.xcconfig"; sourceTree = "<group>"; };
94D7F53DD4E107E8DFFC4383 /* Pods_hello_watchOS_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_hello_watchOS_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E1853F889FC555DDAE7E6698 /* Pods_hello_watchOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_hello_watchOS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
A1B2C3D41E00000100000005 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
A1B2C3D41E00000100000006 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
A1B2C3D41E00000100000002 /* ExposureCountingHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExposureCountingHook.swift; sourceTree = "<group>"; };
A1B2C3D41E00000100000004 /* ExposureDedupeDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExposureDedupeDemo.swift; sourceTree = "<group>"; };
F02202EA1BD856F500EB7727 /* hello-ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "hello-ios.app"; sourceTree = BUILT_PRODUCTS_DIR; };
F02202EE1BD856F500EB7727 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
F02202F01BD856F500EB7727 /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = "<group>"; };
Expand Down Expand Up @@ -215,6 +221,10 @@
F02202F11BD856F500EB7727 /* AppDelegate.m */,
F02202F31BD856F500EB7727 /* ViewController.h */,
F02202F41BD856F500EB7727 /* ViewController.m */,
A1B2C3D41E00000100000002 /* ExposureCountingHook.swift */,
A1B2C3D41E00000100000004 /* ExposureDedupeDemo.swift */,
A1B2C3D41E00000100000005 /* Debug.xcconfig */,
A1B2C3D41E00000100000006 /* Release.xcconfig */,
F02202F61BD856F500EB7727 /* Main.storyboard */,
F02202F91BD856F500EB7727 /* Assets.xcassets */,
F02202FB1BD856F500EB7727 /* LaunchScreen.storyboard */,
Expand Down Expand Up @@ -473,6 +483,8 @@
files = (
F02202F51BD856F500EB7727 /* ViewController.m in Sources */,
F02202F21BD856F500EB7727 /* AppDelegate.m in Sources */,
A1B2C3D41E00000100000001 /* ExposureCountingHook.swift in Sources */,
A1B2C3D41E00000100000003 /* ExposureDedupeDemo.swift in Sources */,
F02202EF1BD856F500EB7727 /* main.m in Sources */,
83C2E2B3228C9EC700F35883 /* LDClientConfigurator.m in Sources */,
);
Expand Down Expand Up @@ -711,29 +723,34 @@
};
F02203021BD856F500EB7727 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 49CC570239A47D2D3FB6BB7C /* Pods-hello-ios.debug.xcconfig */;
baseConfigurationReference = A1B2C3D41E00000100000005 /* Debug.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
INFOPLIST_FILE = "hello-ios/Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = "com.launchdarkly.hello-ios";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
F02203031BD856F500EB7727 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 14518944012BA5A2DD84632C /* Pods-hello-ios.release.xcconfig */;
baseConfigurationReference = A1B2C3D41E00000100000006 /* Release.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
INFOPLIST_FILE = "hello-ios/Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = "com.launchdarkly.hello-ios";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
};
name = Release;
};
Expand Down
10 changes: 7 additions & 3 deletions hello-ios/AppDelegate.m
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
#import "AppDelegate.h"
#import "LDClientConfigurator.h"
#import "hello_ios-Swift.h"

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[LDClientConfigurator setupLDClient];

NSString *mobileKey = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"mobileKey"];
if (mobileKey.length == 0) {
[NSException raise:NSInternalInconsistencyException
format:@"Missing mobileKey in Info.plist. Copy Secrets.xcconfig.example to Secrets.xcconfig and set mobileKey."];
}
[[ExposureDedupeDemo shared] startClientWithMobileKey:mobileKey];
return YES;
}

Expand Down
2 changes: 2 additions & 0 deletions hello-ios/Debug.xcconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#include "../Pods/Target Support Files/Pods-hello-ios/Pods-hello-ios.debug.xcconfig"
#include? "../Secrets.xcconfig"
71 changes: 71 additions & 0 deletions hello-ios/ExposureCountingHook.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Foundation
import LaunchDarkly

/// Counts the evaluation series stages it observes, so the example can show what exposure
/// deduplication does. Deduplication is declared on the hook via `evaluationExposureDeduper`,
/// the same way a customer would configure any other hook.
///
/// Deduplication skips the whole series, so both counts stay equal and both stop climbing while
/// repeated evaluations resolve to the same result.
final class ExposureCountingHook: Hook {
private let label: String
private let window: TimeInterval
private let onStage: () -> Void
private let befores = Counter()
private let afters = Counter()

/// The per-hook policy. Declaring it here is the Swift equivalent of Android's
/// `new MetricsHook().evaluationExposureDeduper(window, maxSize)`.
let evaluationExposureDeduper: EvaluationExposureDeduper?

init(label: String, window: TimeInterval, onStage: @escaping () -> Void) {
self.label = label
self.window = window
self.onStage = onStage
self.evaluationExposureDeduper = EvaluationExposureDeduper(window: window, maxSize: 2_000)
}

func metadata() -> Metadata {
Metadata(name: label)
}

func beforeEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData) -> EvaluationSeriesData {
befores.increment()
onStage()
return seriesData
}

func afterEvaluation(seriesContext: EvaluationSeriesContext, seriesData: EvaluationSeriesData, evaluationDetail: LDEvaluationDetail<LDValue>) -> EvaluationSeriesData {
afters.increment()
onStage()
return seriesData
}

/// A line describing this hook's window and how many evaluations have reached it.
func status() -> String {
String(format: "%@ (%.0f s): %d (before %d / after %d)",
label,
window,
afters.value,
befores.value,
afters.value)
}
}

/// Tiny thread-safe counter for hook stages invoked from any queue.
private final class Counter {
private let lock = NSLock()
private var _value = 0

var value: Int {
lock.lock()
defer { lock.unlock() }
return _value
}

func increment() {
lock.lock()
_value += 1
lock.unlock()
}
}
83 changes: 83 additions & 0 deletions hello-ios/ExposureDedupeDemo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import Foundation
import LaunchDarkly

/// Owns the two example hooks and starts the LaunchDarkly client the way a customer would:
/// each hook carries its own `evaluationExposureDeduper`.
@objc public final class ExposureDedupeDemo: NSObject {
@objc public static let shared = ExposureDedupeDemo()

// Two hooks with different windows, to show that each one is deduplicated on its own.
// TimeInterval is seconds on iOS (Android uses milliseconds).
private static let fastWindow: TimeInterval = 5
private static let slowWindow: TimeInterval = 10
private static let defaultUserKey = "example-user-key"

private var fastHook: ExposureCountingHook!
private var slowHook: ExposureCountingHook!
private var evaluationsRequested = 0
private var statusHandler: ((String) -> Void)?

private override init() {
super.init()
}

/// Starts the client with two independently-deduplicated hooks. Call once from AppDelegate.
@objc public func startClient(mobileKey: String) {
fastHook = ExposureCountingHook(label: "fast", window: Self.fastWindow) { [weak self] in
self?.publishStatus()
}
slowHook = ExposureCountingHook(label: "slow", window: Self.slowWindow) { [weak self] in
self?.publishStatus()
}

var config = LDConfig(mobileKey: mobileKey, autoEnvAttributes: .enabled)
// Same shape a customer uses: each hook declares its own deduper.
config.hooks = [fastHook, slowHook]

var builder = LDContextBuilder(key: Self.defaultUserKey)
builder.kind("user")
builder.name("Sandy")
guard case .success(let context) = builder.build() else {
return
}

LDClient.start(config: config, context: context, startWaitSeconds: 5)
publishStatus()
}

@objc public func setStatusHandler(_ handler: @escaping (String) -> Void) {
statusHandler = handler
publishStatus()
}

@objc public func evaluate(flagKey: String) -> Bool {
evaluationsRequested += 1
let value = LDClient.get()?.boolVariation(forKey: flagKey, defaultValue: false) ?? false
publishStatus()
return value
}

/// Identifies to `userKey`, or the default key when empty — empty keys are invalid and would
/// skip the dedupe-cache reset the demo is meant to show.
@objc public func identify(userKey: String) -> String {
let key = userKey.trimmingCharacters(in: .whitespacesAndNewlines)
let resolved = key.isEmpty ? Self.defaultUserKey : key
guard case .success(let context) = LDContextBuilder(key: resolved).build() else {
return resolved
}
LDClient.get()?.identify(context: context) { _ in }
publishStatus()
return resolved
}

private func publishStatus() {
guard let fastHook, let slowHook else { return }
let text = String(format: "Evaluations requested: %d\n%@\n%@",
evaluationsRequested,
fastHook.status(),
slowHook.status())
DispatchQueue.main.async { [statusHandler] in
statusHandler?(text)
}
}
}
2 changes: 2 additions & 0 deletions hello-ios/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>mobileKey</key>
<string>$(mobileKey)</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
Expand Down
2 changes: 2 additions & 0 deletions hello-ios/Release.xcconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#include "../Pods/Target Support Files/Pods-hello-ios/Pods-hello-ios.release.xcconfig"
#include? "../Secrets.xcconfig"
Loading