Skip to content

Commit a4e4662

Browse files
[FSSDK-11998] chore: add cmab and holdout support (#94)
* Update cmab dependency * feat: add CMAB support with decideAsync API and cache control options Add support for Contextual Multi-Armed Bandit (CMAB) experimentation including CmabConfig for initialization, decideAsync methods for async decisions, and CMAB-specific cache options (ignoreCmabCache, resetCmabCache, invalidateUserCmabCache). * Predictionendpoint templating fixed * Cmab Sample api added * CMAB android basic implementation done * Add unit test cases * fix cmab decide options for android * Claude instruction file added * Updated claude instruction * fix test issue * Exclude example and test directory for analyzer * clean up
1 parent d26d776 commit a4e4662

File tree

25 files changed

+1423
-38
lines changed

25 files changed

+1423
-38
lines changed

.github/workflows/flutter.yml

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,21 +46,6 @@ jobs:
4646
repository: 'optimizely/travisci-tools'
4747
path: 'home/runner/travisci-tools'
4848
ref: 'master'
49-
# Set SDK Branch based on input or PR/Push
50-
# - name: Set SDK Branch and Test App Branch
51-
# run: |
52-
# # If manually triggered
53-
# if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
54-
# echo "SDK_BRANCH=${{ github.event.inputs.sdk_branch || 'master' }}" >> $GITHUB_ENV
55-
# echo "TESTAPP_BRANCH=${{ github.event.inputs.testapp_branch || 'master' }}" >> $GITHUB_ENV
56-
# # If triggered by PR
57-
# elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
58-
# echo "SDK_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV
59-
# # If triggered by push
60-
# else
61-
# echo "SDK_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
62-
# echo "TRAVIS_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
63-
# fi
6449
- name: set SDK Branch if PR
6550
env:
6651
HEAD_REF: ${{ github.head_ref }}

CLAUDE.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
Optimizely Flutter SDK - Cross-platform plugin wrapping native Optimizely SDKs (iOS, Android) for A/B testing, feature flags, CMAB, and ODP integration and others.
8+
9+
**Main Branch:** master
10+
11+
## Project Structure
12+
13+
```
14+
lib/ # Dart: Public API, data models, user context, platform bridge
15+
android/src/main/java/ # Java: OptimizelyFlutterClient.java, Plugin, helpers
16+
ios/Classes/ # Swift: Plugin, logger bridge, helpers
17+
test/ # Unit tests (SDK, CMAB, logger, nested objects)
18+
example/ # Example app
19+
```
20+
21+
## Essential Commands
22+
23+
```bash
24+
# Setup
25+
flutter pub get
26+
27+
# Testing
28+
flutter test # All tests
29+
flutter test test/cmab_test.dart # Specific test
30+
flutter test --coverage # With coverage
31+
32+
# Linting
33+
flutter analyze
34+
35+
# iOS setup
36+
cd ios && pod install
37+
38+
# Run example
39+
cd example && flutter run
40+
```
41+
42+
## Architecture
43+
44+
### Bridge Pattern
45+
```
46+
Dart API (OptimizelyFlutterSdk)
47+
48+
Wrapper (OptimizelyClientWrapper) + MethodChannel
49+
50+
Native (Swift/Java plugin implementations)
51+
52+
Native Optimizely SDKs
53+
```
54+
55+
### Critical Patterns
56+
57+
**1. Response Object Pattern**
58+
- ALL methods return `BaseResponse` derivatives (never throw exceptions)
59+
- Check `success` boolean and `reason` string for errors
60+
61+
**2. Multi-Instance State**
62+
- SDK instances tracked by `sdkKey`
63+
- User contexts: `sdkKey → userContextId → context`
64+
- Notification listeners: `sdkKey → listenerId → callback`
65+
- Call `close()` for cleanup
66+
67+
**3. Platform-Specific Type Encoding**
68+
- **iOS**: Attributes need type metadata: `{"value": 123, "type": "int"}`
69+
- **Android**: Direct primitives: `{"attribute": 123}`
70+
- Conversion in `convertToTypedMap()` (`optimizely_client_wrapper.dart`)
71+
72+
**4. Dual Channels**
73+
- `optimizely_flutter_sdk` - Main API
74+
- `optimizely_flutter_logger` - Native log forwarding
75+
76+
## Key Files
77+
78+
**Dart:**
79+
- `lib/optimizely_flutter_sdk.dart` - Public API entry point
80+
- `lib/src/optimizely_client_wrapper.dart` - Platform channel bridge
81+
- `lib/src/user_context/optimizely_user_context.dart` - User context API
82+
- `lib/src/data_objects/` - 21 response/request models
83+
84+
**Android:**
85+
- `android/src/.../OptimizelyFlutterSdkPlugin.java` - MethodChannel handler
86+
- `android/src/.../OptimizelyFlutterClient.java` - Core client wrapper
87+
- `android/build.gradle` - Dependencies & build config
88+
89+
**iOS:**
90+
- `ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift` - MethodChannel handler
91+
- `ios/optimizely_flutter_sdk.podspec` - Pod dependencies
92+
93+
## Adding Cross-Platform Features
94+
95+
1. Add data models in `lib/src/data_objects/` if needed
96+
2. Update `optimizely_client_wrapper.dart` with method channel call
97+
3. **Android**: Add case in `OptimizelyFlutterClient.java`, parse args, call native SDK
98+
4. **iOS**: Add case in `SwiftOptimizelyFlutterSdkPlugin.swift`, parse args, call native SDK
99+
5. Handle type conversions (iOS requires metadata)
100+
6. Add tests
101+
7. Update public API in `optimizely_flutter_sdk.dart`
102+
103+
104+
## Contributing
105+
106+
### Commit Format
107+
Follow [Angular guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`
108+
109+
### Requirements
110+
- **Never commit directly to `master` branch** - Always create a feature branch
111+
- Tests required for all changes
112+
- PR to `master` branch
113+
- All CI checks must pass (unit tests, build validation, integration tests)
114+
- Apache 2.0 license header on new files
115+
116+
### CI Pipeline
117+
- `unit_test_coverage` (macOS) - Coverage to Coveralls
118+
- `build_test_android/ios` - Build validation
119+
- `integration_android/ios_tests` - External test app triggers
120+
121+
## Platform Requirements
122+
123+
- Dart: >=2.16.2 <4.0.0, Flutter: >=2.5.0
124+
- Android: minSdk 21, compileSdk 35
125+
- iOS: 10.0+, Swift 5.0

analysis_options.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
include: package:flutter_lints/flutter.yaml
22

3+
analyzer:
4+
exclude:
5+
- example/**
6+
- test/**
7+
38
# Additional information about this file can be found at
49
# https://dart.dev/guides/language/analysis-options

android/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ dependencies {
6969
implementation 'org.slf4j:slf4j-api:2.0.7'
7070

7171
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0"
72-
implementation "com.optimizely.ab:android-sdk:5.0.1"
72+
implementation "com.optimizely.ab:android-sdk:5.1.0"
7373
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
7474
implementation ('com.google.guava:guava:19.0') {
7575
exclude group:'com.google.guava', module:'listenablefuture'

android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterClient.java

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@
5959
import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.SEGMENTS_CACHE_TIMEOUT_IN_SECONDS;
6060
import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.TIMEOUT_FOR_ODP_EVENT_IN_SECONDS;
6161
import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.TIMEOUT_FOR_SEGMENT_FETCH_IN_SECONDS;
62+
import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.CMAB_CONFIG;
63+
import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.CMAB_CACHE_SIZE;
64+
import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.CMAB_CACHE_TIMEOUT_IN_SECS;
65+
import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.CMAB_PREDICTION_ENDPOINT;
6266
import static com.optimizely.optimizely_flutter_sdk.helper_classes.Utils.getNotificationListenerType;
6367

6468
import java.util.Collections;
@@ -187,6 +191,25 @@ protected void initializeOptimizely(@NonNull ArgumentsParser argumentsParser, @N
187191
optimizelyManagerBuilder.withVuidEnabled();
188192
}
189193

194+
// CMAB Config
195+
Map<String, Object> cmabConfig = argumentsParser.getCmabConfig();
196+
if (cmabConfig != null) {
197+
if (cmabConfig.containsKey(CMAB_CACHE_SIZE)) {
198+
Integer cmabCacheSize = (Integer) cmabConfig.get(CMAB_CACHE_SIZE);
199+
optimizelyManagerBuilder.withCmabCacheSize(cmabCacheSize);
200+
}
201+
if (cmabConfig.containsKey(CMAB_CACHE_TIMEOUT_IN_SECS)) {
202+
Integer cmabCacheTimeout = (Integer) cmabConfig.get(CMAB_CACHE_TIMEOUT_IN_SECS);
203+
optimizelyManagerBuilder.withCmabCacheTimeout(cmabCacheTimeout, TimeUnit.SECONDS);
204+
}
205+
if (cmabConfig.containsKey(CMAB_PREDICTION_ENDPOINT)) {
206+
String endpoint = (String) cmabConfig.get(CMAB_PREDICTION_ENDPOINT);
207+
// Convert platform-agnostic placeholder {ruleId} to Android format %s
208+
String androidEndpoint = endpoint.replace("{ruleId}", "%s");
209+
optimizelyManagerBuilder.withCmabPredictionEndpoint(androidEndpoint);
210+
}
211+
}
212+
190213
OptimizelyManager optimizelyManager = optimizelyManagerBuilder.build(context);
191214

192215
optimizelyManager.initialize(context, null, (OptimizelyClient client) -> {
@@ -364,6 +387,55 @@ protected void decide(ArgumentsParser argumentsParser, @NonNull Result result) {
364387
result.success(createResponse(s));
365388
}
366389

390+
protected void decideAsync(ArgumentsParser argumentsParser, @NonNull Result result) {
391+
String sdkKey = argumentsParser.getSdkKey();
392+
OptimizelyUserContext userContext = getUserContext(argumentsParser);
393+
if (!isUserContextValid(sdkKey, userContext, result)) {
394+
return;
395+
}
396+
397+
List<String> decideKeys = argumentsParser.getDecideKeys();
398+
List<OptimizelyDecideOption> decideOptions = argumentsParser.getDecideOptions();
399+
400+
// Determine which async method to call based on keys
401+
if (decideKeys == null || decideKeys.isEmpty()) {
402+
// decideAllAsync
403+
userContext.decideAllAsync(decideOptions, decisions -> {
404+
Map<String, OptimizelyDecisionResponse> optimizelyDecisionResponseMap = new HashMap<>();
405+
if (decisions != null) {
406+
for (Map.Entry<String, OptimizelyDecision> entry : decisions.entrySet()) {
407+
optimizelyDecisionResponseMap.put(entry.getKey(), new OptimizelyDecisionResponse(entry.getValue()));
408+
}
409+
}
410+
ObjectMapper mapper = new ObjectMapper();
411+
Map<String, Object> s = mapper.convertValue(optimizelyDecisionResponseMap, LinkedHashMap.class);
412+
result.success(createResponse(s));
413+
});
414+
} else if (decideKeys.size() == 1) {
415+
// decideAsync for single key
416+
userContext.decideAsync(decideKeys.get(0), decideOptions, decision -> {
417+
Map<String, OptimizelyDecisionResponse> optimizelyDecisionResponseMap = new HashMap<>();
418+
optimizelyDecisionResponseMap.put(decideKeys.get(0), new OptimizelyDecisionResponse(decision));
419+
ObjectMapper mapper = new ObjectMapper();
420+
Map<String, Object> s = mapper.convertValue(optimizelyDecisionResponseMap, LinkedHashMap.class);
421+
result.success(createResponse(s));
422+
});
423+
} else {
424+
// decideForKeysAsync for multiple keys
425+
userContext.decideForKeysAsync(decideKeys, decideOptions, decisions -> {
426+
Map<String, OptimizelyDecisionResponse> optimizelyDecisionResponseMap = new HashMap<>();
427+
if (decisions != null) {
428+
for (Map.Entry<String, OptimizelyDecision> entry : decisions.entrySet()) {
429+
optimizelyDecisionResponseMap.put(entry.getKey(), new OptimizelyDecisionResponse(entry.getValue()));
430+
}
431+
}
432+
ObjectMapper mapper = new ObjectMapper();
433+
Map<String, Object> s = mapper.convertValue(optimizelyDecisionResponseMap, LinkedHashMap.class);
434+
result.success(createResponse(s));
435+
});
436+
}
437+
}
438+
367439
protected void setForcedDecision(ArgumentsParser argumentsParser, @NonNull Result result) {
368440
String sdkKey = argumentsParser.getSdkKey();
369441
OptimizelyUserContext userContext = getUserContext(argumentsParser);

android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterSdkPlugin.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
112112
decide(argumentsParser, result);
113113
break;
114114
}
115+
case APIs.DECIDE_ASYNC: {
116+
decideAsync(argumentsParser, result);
117+
break;
118+
}
115119
case APIs.SET_FORCED_DECISION: {
116120
setForcedDecision(argumentsParser, result);
117121
break;

android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/ArgumentsParser.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,8 @@ public List<ODPSegmentOption> getSegmentOptions() {
151151
public Map<String, Object> getOptimizelySdkSettings() {
152152
return (Map<String, Object>) arguments.get(Constants.RequestParameterKey.OPTIMIZELY_SDK_SETTINGS);
153153
}
154+
155+
public Map<String, Object> getCmabConfig() {
156+
return (Map<String, Object>) arguments.get(Constants.RequestParameterKey.CMAB_CONFIG);
157+
}
154158
}

android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Constants.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public static class APIs {
3434
public static final String SET_FORCED_DECISION = "setForcedDecision";
3535
public static final String TRACK_EVENT = "trackEvent";
3636
public static final String DECIDE = "decide";
37+
public static final String DECIDE_ASYNC = "decideAsync";
3738
public static final String ADD_NOTIFICATION_LISTENER = "addNotificationListener";
3839
public static final String REMOVE_NOTIFICATION_LISTENER = "removeNotificationListener";
3940
public static final String CLEAR_ALL_NOTIFICATION_LISTENERS = "clearAllNotificationListeners";
@@ -97,6 +98,12 @@ public static class RequestParameterKey {
9798
public static final String TIMEOUT_FOR_ODP_EVENT_IN_SECONDS = "timeoutForOdpEventInSecs";
9899
public static final String DISABLE_ODP = "disableOdp";
99100
public static final String ENABLE_VUID = "enableVuid";
101+
102+
// CMAB Config
103+
public static final String CMAB_CONFIG = "cmabConfig";
104+
public static final String CMAB_CACHE_SIZE = "cmabCacheSize";
105+
public static final String CMAB_CACHE_TIMEOUT_IN_SECS = "cmabCacheTimeoutInSecs";
106+
public static final String CMAB_PREDICTION_ENDPOINT = "cmabPredictionEndpoint";
100107
}
101108

102109
public static class ErrorMessage {
@@ -150,6 +157,9 @@ public static class DecideOption {
150157
public static final String IGNORE_USER_PROFILE_SERVICE = "ignoreUserProfileService";
151158
public static final String INCLUDE_REASONS = "includeReasons";
152159
public static final String EXCLUDE_VARIABLES = "excludeVariables";
160+
public static final String IGNORE_CMAB_CACHE = "ignoreCmabCache";
161+
public static final String RESET_CMAB_CACHE = "resetCmabCache";
162+
public static final String INVALIDATE_USER_CMAB_CACHE = "invalidateUserCmabCache";
153163
}
154164

155165
public static class SegmentOption {

android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Utils.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ public static List<OptimizelyDecideOption> getDecideOptions(List<String> options
6565
case Constants.DecideOption.INCLUDE_REASONS:
6666
convertedOptions.add(OptimizelyDecideOption.INCLUDE_REASONS);
6767
break;
68+
case Constants.DecideOption.IGNORE_CMAB_CACHE:
69+
convertedOptions.add(OptimizelyDecideOption.IGNORE_CMAB_CACHE);
70+
break;
71+
case Constants.DecideOption.RESET_CMAB_CACHE:
72+
convertedOptions.add(OptimizelyDecideOption.RESET_CMAB_CACHE);
73+
break;
74+
case Constants.DecideOption.INVALIDATE_USER_CMAB_CACHE:
75+
convertedOptions.add(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE);
76+
break;
6877
default:
6978
break;
7079
}

0 commit comments

Comments
 (0)