diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml
index fe24b49..4aa2445 100644
--- a/.github/workflows/flutter.yml
+++ b/.github/workflows/flutter.yml
@@ -43,24 +43,9 @@ jobs:
with:
# You should create a personal access token and store it in your repository
token: ${{ secrets.CI_USER_TOKEN }}
- repository: 'optimizely/travisci-tools'
- path: 'home/runner/travisci-tools'
+ repository: 'optimizely/ci-helper-tools'
+ path: 'home/runner/ci-helper-tools'
ref: 'master'
- # Set SDK Branch based on input or PR/Push
- # - name: Set SDK Branch and Test App Branch
- # run: |
- # # If manually triggered
- # if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
- # echo "SDK_BRANCH=${{ github.event.inputs.sdk_branch || 'master' }}" >> $GITHUB_ENV
- # echo "TESTAPP_BRANCH=${{ github.event.inputs.testapp_branch || 'master' }}" >> $GITHUB_ENV
- # # If triggered by PR
- # elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
- # echo "SDK_BRANCH=${{ github.head_ref }}" >> $GITHUB_ENV
- # # If triggered by push
- # else
- # echo "SDK_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- # echo "TRAVIS_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- # fi
- name: set SDK Branch if PR
env:
HEAD_REF: ${{ github.head_ref }}
@@ -91,7 +76,7 @@ jobs:
EVENT_MESSAGE: ${{ github.event.message }}
HOME: 'home/runner'
run: |
- home/runner/travisci-tools/trigger-script-with-status-update.sh
+ home/runner/ci-helper-tools/trigger-script-with-status-update.sh
integration_ios_tests:
runs-on: ubuntu-latest
@@ -100,8 +85,8 @@ jobs:
with:
# You should create a personal access token and store it in your repository
token: ${{ secrets.CI_USER_TOKEN }}
- repository: 'optimizely/travisci-tools'
- path: 'home/runner/travisci-tools'
+ repository: 'optimizely/ci-helper-tools'
+ path: 'home/runner/ci-helper-tools'
ref: 'master'
- name: set SDK Branch if PR
env:
@@ -133,7 +118,7 @@ jobs:
EVENT_MESSAGE: ${{ github.event.message }}
HOME: 'home/runner'
run: |
- home/runner/travisci-tools/trigger-script-with-status-update.sh
+ home/runner/ci-helper-tools/trigger-script-with-status-update.sh
build_test_android:
runs-on: ubuntu-latest
diff --git a/.pubignore b/.pubignore
new file mode 100644
index 0000000..320af7b
--- /dev/null
+++ b/.pubignore
@@ -0,0 +1,5 @@
+# Project-specific documentation not needed in published package
+CLAUDE.md
+
+# Claude Code local configurations
+.claude/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c2f2b8..cdd9d42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,55 @@
# Optimizely Flutter SDK Changelog
+## 3.4.1
+January 28th, 2026
+
+### Enhancements
+* Exclude CMAB decision from UPS ([#96](https://github.com/optimizely/optimizely-flutter-sdk/pull/96))
+
+## 3.4.0
+January 7th, 2026
+
+### New Features
+
+* **CMAB (Contextual Multi-Armed Bandit) Support** ([#94](https://github.com/optimizely/optimizely-flutter-sdk/pull/94))
+ - Added `CmabConfig` class for CMAB initialization with configurable cache settings and custom prediction endpoints.
+ - Added `decideAsync()` methods to `OptimizelyUserContext` for asynchronous CMAB decision-making.
+ - New CMAB-specific decide options: `ignoreCmabCache`, `resetCmabCache`, `invalidateUserCmabCache`
+* **Add Holdout support for feature experimentation.**
+* **Add Multi-Region Support for Data Hosting.**
+
+### Bug Fixes
+* **Nested Object Support in Event Metadata for Swift** ([#92](https://github.com/optimizely/optimizely-flutter-sdk/pull/92))
+ - Enhanced event metadata handling to support complex nested objects in iOS/Swift.
+
+## 3.3.0
+October 29th, 2025
+
+### New Feature
+* Android custom logger support added ([#90](https://github.com/optimizely/optimizely-flutter-sdk/pull/90))
+
+## 3.2.0
+October 24th, 2025
+
+### New Feature
+* Swift custom logger support added ([#88](https://github.com/optimizely/optimizely-flutter-sdk/pull/88))
+
+## 3.1.0
+October 9th, 2025
+
+This minor release added the following support:
+* Android 15 support ([#84](https://github.com/optimizely/optimizely-flutter-sdk/pull/84))
+* Update AGP version to 8.7.0
+* Update gradle version to 8.10.2
+* Update kotlin version to 2.1.0
+
+## 3.0.1
+Jun 4th, 2025
+
+### Functionality Enhancements
+
+* Add experiment id and variation id added into decision notification payload ([#80](https://github.com/optimizely/optimizely-flutter-sdk/pull/80))
+
## 3.0.0
November 28th, 2024
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..b52faf5
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,125 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Optimizely Flutter SDK - Cross-platform plugin wrapping native Optimizely SDKs (iOS, Android) for A/B testing, feature flags, CMAB, and ODP integration and others.
+
+**Main Branch:** master
+
+## Project Structure
+
+```
+lib/ # Dart: Public API, data models, user context, platform bridge
+android/src/main/java/ # Java: OptimizelyFlutterClient.java, Plugin, helpers
+ios/Classes/ # Swift: Plugin, logger bridge, helpers
+test/ # Unit tests (SDK, CMAB, logger, nested objects)
+example/ # Example app
+```
+
+## Essential Commands
+
+```bash
+# Setup
+flutter pub get
+
+# Testing
+flutter test # All tests
+flutter test test/cmab_test.dart # Specific test
+flutter test --coverage # With coverage
+
+# Linting
+flutter analyze
+
+# iOS setup
+cd ios && pod install
+
+# Run example
+cd example && flutter run
+```
+
+## Architecture
+
+### Bridge Pattern
+```
+Dart API (OptimizelyFlutterSdk)
+ ↓
+Wrapper (OptimizelyClientWrapper) + MethodChannel
+ ↓
+Native (Swift/Java plugin implementations)
+ ↓
+Native Optimizely SDKs
+```
+
+### Critical Patterns
+
+**1. Response Object Pattern**
+- ALL methods return `BaseResponse` derivatives (never throw exceptions)
+- Check `success` boolean and `reason` string for errors
+
+**2. Multi-Instance State**
+- SDK instances tracked by `sdkKey`
+- User contexts: `sdkKey → userContextId → context`
+- Notification listeners: `sdkKey → listenerId → callback`
+- Call `close()` for cleanup
+
+**3. Platform-Specific Type Encoding**
+- **iOS**: Attributes need type metadata: `{"value": 123, "type": "int"}`
+- **Android**: Direct primitives: `{"attribute": 123}`
+- Conversion in `convertToTypedMap()` (`optimizely_client_wrapper.dart`)
+
+**4. Dual Channels**
+- `optimizely_flutter_sdk` - Main API
+- `optimizely_flutter_logger` - Native log forwarding
+
+## Key Files
+
+**Dart:**
+- `lib/optimizely_flutter_sdk.dart` - Public API entry point
+- `lib/src/optimizely_client_wrapper.dart` - Platform channel bridge
+- `lib/src/user_context/optimizely_user_context.dart` - User context API
+- `lib/src/data_objects/` - 21 response/request models
+
+**Android:**
+- `android/src/.../OptimizelyFlutterSdkPlugin.java` - MethodChannel handler
+- `android/src/.../OptimizelyFlutterClient.java` - Core client wrapper
+- `android/build.gradle` - Dependencies & build config
+
+**iOS:**
+- `ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift` - MethodChannel handler
+- `ios/optimizely_flutter_sdk.podspec` - Pod dependencies
+
+## Adding Cross-Platform Features
+
+1. Add data models in `lib/src/data_objects/` if needed
+2. Update `optimizely_client_wrapper.dart` with method channel call
+3. **Android**: Add case in `OptimizelyFlutterClient.java`, parse args, call native SDK
+4. **iOS**: Add case in `SwiftOptimizelyFlutterSdkPlugin.swift`, parse args, call native SDK
+5. Handle type conversions (iOS requires metadata)
+6. Add tests
+7. Update public API in `optimizely_flutter_sdk.dart`
+
+
+## Contributing
+
+### Commit Format
+Follow [Angular guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`
+
+### Requirements
+- **Never commit directly to `master` branch** - Always create a feature branch
+- Tests required for all changes
+- PR to `master` branch
+- All CI checks must pass (unit tests, build validation, integration tests)
+- Apache 2.0 license header on new files
+
+### CI Pipeline
+- `unit_test_coverage` (macOS) - Coverage to Coveralls
+- `build_test_android/ios` - Build validation
+- `integration_android/ios_tests` - External test app triggers
+
+## Platform Requirements
+
+- Dart: >=2.16.2 <4.0.0, Flutter: >=2.5.0
+- Android: minSdk 21, compileSdk 35
+- iOS: 10.0+, Swift 5.0
diff --git a/README.md b/README.md
index c851ae5..e196c0c 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ Other Flutter platforms are not currently supported by this SDK.
To add the flutter-sdk to your project dependencies, include the following in your app's pubspec.yaml:
```
- optimizely_flutter_sdk: ^3.0.0
+ optimizely_flutter_sdk: ^3.4.1
```
Then run
diff --git a/analysis_options.yaml b/analysis_options.yaml
index a5744c1..67d44e7 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -1,4 +1,9 @@
include: package:flutter_lints/flutter.yaml
+analyzer:
+ exclude:
+ - example/**
+ - test/**
+
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
diff --git a/android/build.gradle b/android/build.gradle
index 35df9b3..56c7462 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -24,23 +24,22 @@ rootProject.allprojects {
ext {
- compile_sdk_version = 32
- build_tools_version = "30.0.3"
+ compile_sdk_version = 35
min_sdk_version = 21
- target_sdk_version = 29
}
android {
- namespace = "com.optimizely.optimizely_flutter_sdk"
- compileSdkVersion compile_sdk_version
- buildToolsVersion build_tools_version
+ namespace 'com.optimizely.optimizely_flutter_sdk'
+ compileSdkVersion rootProject.hasProperty('flutter.compileSdkVersion')
+ ? rootProject.flutter.compileSdkVersion.toInteger()
+ : compile_sdk_version
+
buildFeatures {
buildConfig true
}
defaultConfig {
minSdkVersion min_sdk_version
- targetSdkVersion target_sdk_version
versionCode 1
versionName version_name
buildConfigField "String", "CLIENT_VERSION", "\"$version_name\""
@@ -77,10 +76,10 @@ dependencies {
implementation 'com.github.tony19:logback-android:3.0.0'
implementation 'org.slf4j:slf4j-api:2.0.7'
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10"
- implementation "com.optimizely.ab:android-sdk:5.0.0"
- implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.4'
- implementation('com.google.guava:guava:19.0') {
- exclude group: 'com.google.guava', module: 'listenablefuture'
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0"
+ implementation "com.optimizely.ab:android-sdk:5.1.1"
+ implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
+ implementation ('com.google.guava:guava:19.0') {
+ exclude group:'com.google.guava', module:'listenablefuture'
}
}
diff --git a/android/proguard-rules.txt b/android/proguard-rules.txt
index 25f9b8e..e9d6827 100644
--- a/android/proguard-rules.txt
+++ b/android/proguard-rules.txt
@@ -12,5 +12,10 @@
-keep class com.fasterxml.jackson.** {*;}
# Logback
-keep class ch.qos.** { *; }
--dontwarn javax.mail.** # Suppress warnings for javax.mail, we don't use SMTP
+
+# Mail classes (Logback SMTP appender)
+-dontwarn javax.mail.**
+-dontwarn javax.mail.internet.**
+-dontwarn javax.activation.**
+
##---------------End: proguard configuration ----------
diff --git a/android/src/main/assets/logback.xml b/android/src/main/assets/logback.xml
index 8e6e0d6..7f531a8 100644
--- a/android/src/main/assets/logback.xml
+++ b/android/src/main/assets/logback.xml
@@ -1,18 +1 @@
-
-
-
- Optimizely
-
-
- %msg
-
-
-
-
-
-
-
+
diff --git a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/FlutterLogbackAppender.java b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/FlutterLogbackAppender.java
new file mode 100644
index 0000000..2252cdd
--- /dev/null
+++ b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/FlutterLogbackAppender.java
@@ -0,0 +1,62 @@
+package com.optimizely.optimizely_flutter_sdk;
+import com.optimizely.optimizely_flutter_sdk.helper_classes.Constants;
+
+import android.os.Handler;
+import android.os.Looper;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.AppenderBase;
+import io.flutter.plugin.common.MethodChannel;
+
+public class FlutterLogbackAppender extends AppenderBase {
+
+ public static final String CHANNEL_NAME = "optimizely_flutter_sdk_logger";
+ public static MethodChannel channel;
+ private static final Handler mainThreadHandler = new Handler(Looper.getMainLooper());
+
+ public static void setChannel(MethodChannel channel) {
+ FlutterLogbackAppender.channel = channel;
+ }
+
+ @Override
+ protected void append(ILoggingEvent event) {
+ if (channel == null) {
+ return;
+ }
+
+ String message = event.getFormattedMessage();
+ String level = event.getLevel().toString();
+ int logLevel = convertLogLevel(level);
+ Map logData = new HashMap<>();
+ logData.put("level", logLevel);
+ logData.put("message", message);
+
+ mainThreadHandler.post(() -> {
+ if (channel != null) {
+ channel.invokeMethod("log", logData);
+ }
+ });
+ }
+
+ int convertLogLevel(String logLevel) {
+ if (logLevel == null || logLevel.isEmpty()) {
+ return 3;
+ }
+
+ switch (logLevel.toUpperCase()) {
+ case "ERROR":
+ return 1;
+ case "WARN":
+ return 2;
+ case "INFO":
+ return 3;
+ case "DEBUG":
+ return 4;
+ default:
+ return 3;
+ }
+ }
+}
diff --git a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterClient.java b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterClient.java
index 6603e8b..cb716e6 100644
--- a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterClient.java
+++ b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterClient.java
@@ -25,7 +25,6 @@
import com.optimizely.ab.UnknownEventTypeException;
import com.optimizely.ab.android.event_handler.DefaultEventHandler;
import com.optimizely.ab.android.sdk.OptimizelyClient;
-
import java.util.HashMap;
import java.util.Map;
@@ -61,6 +60,10 @@
import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.SEGMENTS_CACHE_TIMEOUT_IN_SECONDS;
import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.TIMEOUT_FOR_ODP_EVENT_IN_SECONDS;
import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.TIMEOUT_FOR_SEGMENT_FETCH_IN_SECONDS;
+import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.CMAB_CONFIG;
+import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.CMAB_CACHE_SIZE;
+import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.CMAB_CACHE_TIMEOUT_IN_SECS;
+import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.RequestParameterKey.CMAB_PREDICTION_ENDPOINT;
import static com.optimizely.optimizely_flutter_sdk.helper_classes.Utils.getNotificationListenerType;
import java.util.Collections;
@@ -191,6 +194,26 @@ protected void initializeOptimizely(@NonNull ArgumentsParser argumentsParser, @N
if (enableVuid) {
optimizelyManagerBuilder.withVuidEnabled();
}
+
+ // CMAB Config
+ Map cmabConfig = argumentsParser.getCmabConfig();
+ if (cmabConfig != null) {
+ if (cmabConfig.containsKey(CMAB_CACHE_SIZE)) {
+ Integer cmabCacheSize = (Integer) cmabConfig.get(CMAB_CACHE_SIZE);
+ optimizelyManagerBuilder.withCmabCacheSize(cmabCacheSize);
+ }
+ if (cmabConfig.containsKey(CMAB_CACHE_TIMEOUT_IN_SECS)) {
+ Integer cmabCacheTimeout = (Integer) cmabConfig.get(CMAB_CACHE_TIMEOUT_IN_SECS);
+ optimizelyManagerBuilder.withCmabCacheTimeout(cmabCacheTimeout, TimeUnit.SECONDS);
+ }
+ if (cmabConfig.containsKey(CMAB_PREDICTION_ENDPOINT)) {
+ String endpoint = (String) cmabConfig.get(CMAB_PREDICTION_ENDPOINT);
+ // Convert platform-agnostic placeholder {ruleId} to Android format %s
+ String androidEndpoint = endpoint.replace("{ruleId}", "%s");
+ optimizelyManagerBuilder.withCmabPredictionEndpoint(androidEndpoint);
+ }
+ }
+
OptimizelyManager optimizelyManager = optimizelyManagerBuilder.build(context);
optimizelyManager.initialize(context, null, (OptimizelyClient client) -> {
@@ -368,6 +391,55 @@ protected void decide(ArgumentsParser argumentsParser, @NonNull Result result) {
result.success(createResponse(s));
}
+ protected void decideAsync(ArgumentsParser argumentsParser, @NonNull Result result) {
+ String sdkKey = argumentsParser.getSdkKey();
+ OptimizelyUserContext userContext = getUserContext(argumentsParser);
+ if (!isUserContextValid(sdkKey, userContext, result)) {
+ return;
+ }
+
+ List decideKeys = argumentsParser.getDecideKeys();
+ List decideOptions = argumentsParser.getDecideOptions();
+
+ // Determine which async method to call based on keys
+ if (decideKeys == null || decideKeys.isEmpty()) {
+ // decideAllAsync
+ userContext.decideAllAsync(decideOptions, decisions -> {
+ Map optimizelyDecisionResponseMap = new HashMap<>();
+ if (decisions != null) {
+ for (Map.Entry entry : decisions.entrySet()) {
+ optimizelyDecisionResponseMap.put(entry.getKey(), new OptimizelyDecisionResponse(entry.getValue()));
+ }
+ }
+ ObjectMapper mapper = new ObjectMapper();
+ Map s = mapper.convertValue(optimizelyDecisionResponseMap, LinkedHashMap.class);
+ result.success(createResponse(s));
+ });
+ } else if (decideKeys.size() == 1) {
+ // decideAsync for single key
+ userContext.decideAsync(decideKeys.get(0), decideOptions, decision -> {
+ Map optimizelyDecisionResponseMap = new HashMap<>();
+ optimizelyDecisionResponseMap.put(decideKeys.get(0), new OptimizelyDecisionResponse(decision));
+ ObjectMapper mapper = new ObjectMapper();
+ Map s = mapper.convertValue(optimizelyDecisionResponseMap, LinkedHashMap.class);
+ result.success(createResponse(s));
+ });
+ } else {
+ // decideForKeysAsync for multiple keys
+ userContext.decideForKeysAsync(decideKeys, decideOptions, decisions -> {
+ Map optimizelyDecisionResponseMap = new HashMap<>();
+ if (decisions != null) {
+ for (Map.Entry entry : decisions.entrySet()) {
+ optimizelyDecisionResponseMap.put(entry.getKey(), new OptimizelyDecisionResponse(entry.getValue()));
+ }
+ }
+ ObjectMapper mapper = new ObjectMapper();
+ Map s = mapper.convertValue(optimizelyDecisionResponseMap, LinkedHashMap.class);
+ result.success(createResponse(s));
+ });
+ }
+ }
+
protected void setForcedDecision(ArgumentsParser argumentsParser, @NonNull Result result) {
String sdkKey = argumentsParser.getSdkKey();
OptimizelyUserContext userContext = getUserContext(argumentsParser);
diff --git a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterSdkPlugin.java b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterSdkPlugin.java
index 89f787c..9e8dc3f 100644
--- a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterSdkPlugin.java
+++ b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterSdkPlugin.java
@@ -32,10 +32,19 @@
import io.flutter.embedding.engine.plugins.activity.ActivityAware;
+import org.slf4j.LoggerFactory;
+
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.Appender;
+
+
/** OptimizelyFlutterSdkPlugin */
public class OptimizelyFlutterSdkPlugin extends OptimizelyFlutterClient implements FlutterPlugin, ActivityAware, MethodCallHandler {
public static MethodChannel channel;
+ private Appender flutterLogbackAppender;
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
@@ -103,6 +112,10 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
decide(argumentsParser, result);
break;
}
+ case APIs.DECIDE_ASYNC: {
+ decideAsync(argumentsParser, result);
+ break;
+ }
case APIs.SET_FORCED_DECISION: {
setForcedDecision(argumentsParser, result);
break;
@@ -157,11 +170,32 @@ public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) {
channel = new MethodChannel(binding.getBinaryMessenger(), "optimizely_flutter_sdk");
channel.setMethodCallHandler(this);
context = binding.getApplicationContext();
+
+ MethodChannel loggerChannel = new MethodChannel(binding.getBinaryMessenger(), FlutterLogbackAppender.CHANNEL_NAME);
+ FlutterLogbackAppender.setChannel(loggerChannel);
+
+ // Add appender to logback
+ flutterLogbackAppender = new FlutterLogbackAppender();
+ LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory();
+ flutterLogbackAppender.setContext(lc);
+ flutterLogbackAppender.start();
+ Logger rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
+ rootLogger.setLevel(ch.qos.logback.classic.Level.ALL);
+ rootLogger.addAppender(flutterLogbackAppender);
}
@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
channel.setMethodCallHandler(null);
+ // Stop and detach the appender
+ if (flutterLogbackAppender != null) {
+ Logger rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
+ rootLogger.detachAppender(flutterLogbackAppender);
+ flutterLogbackAppender.stop();
+ flutterLogbackAppender = null;
+ }
+ // Clean up the channel
+ FlutterLogbackAppender.setChannel(null);
}
@Override
diff --git a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/ArgumentsParser.java b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/ArgumentsParser.java
index 6d2741f..7758bcf 100644
--- a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/ArgumentsParser.java
+++ b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/ArgumentsParser.java
@@ -151,4 +151,8 @@ public List getSegmentOptions() {
public Map getOptimizelySdkSettings() {
return (Map) arguments.get(Constants.RequestParameterKey.OPTIMIZELY_SDK_SETTINGS);
}
+
+ public Map getCmabConfig() {
+ return (Map) arguments.get(Constants.RequestParameterKey.CMAB_CONFIG);
+ }
}
diff --git a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Constants.java b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Constants.java
index 62f0ce9..2dc4a42 100644
--- a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Constants.java
+++ b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Constants.java
@@ -34,6 +34,7 @@ public static class APIs {
public static final String SET_FORCED_DECISION = "setForcedDecision";
public static final String TRACK_EVENT = "trackEvent";
public static final String DECIDE = "decide";
+ public static final String DECIDE_ASYNC = "decideAsync";
public static final String ADD_NOTIFICATION_LISTENER = "addNotificationListener";
public static final String REMOVE_NOTIFICATION_LISTENER = "removeNotificationListener";
public static final String CLEAR_ALL_NOTIFICATION_LISTENERS = "clearAllNotificationListeners";
@@ -97,6 +98,12 @@ public static class RequestParameterKey {
public static final String TIMEOUT_FOR_ODP_EVENT_IN_SECONDS = "timeoutForOdpEventInSecs";
public static final String DISABLE_ODP = "disableOdp";
public static final String ENABLE_VUID = "enableVuid";
+
+ // CMAB Config
+ public static final String CMAB_CONFIG = "cmabConfig";
+ public static final String CMAB_CACHE_SIZE = "cmabCacheSize";
+ public static final String CMAB_CACHE_TIMEOUT_IN_SECS = "cmabCacheTimeoutInSecs";
+ public static final String CMAB_PREDICTION_ENDPOINT = "cmabPredictionEndpoint";
}
public static class ErrorMessage {
@@ -150,6 +157,9 @@ public static class DecideOption {
public static final String IGNORE_USER_PROFILE_SERVICE = "ignoreUserProfileService";
public static final String INCLUDE_REASONS = "includeReasons";
public static final String EXCLUDE_VARIABLES = "excludeVariables";
+ public static final String IGNORE_CMAB_CACHE = "ignoreCmabCache";
+ public static final String RESET_CMAB_CACHE = "resetCmabCache";
+ public static final String INVALIDATE_USER_CMAB_CACHE = "invalidateUserCmabCache";
}
public static class SegmentOption {
diff --git a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Utils.java b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Utils.java
index ba4e5a4..2269e21 100644
--- a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Utils.java
+++ b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Utils.java
@@ -65,6 +65,15 @@ public static List getDecideOptions(List options
case Constants.DecideOption.INCLUDE_REASONS:
convertedOptions.add(OptimizelyDecideOption.INCLUDE_REASONS);
break;
+ case Constants.DecideOption.IGNORE_CMAB_CACHE:
+ convertedOptions.add(OptimizelyDecideOption.IGNORE_CMAB_CACHE);
+ break;
+ case Constants.DecideOption.RESET_CMAB_CACHE:
+ convertedOptions.add(OptimizelyDecideOption.RESET_CMAB_CACHE);
+ break;
+ case Constants.DecideOption.INVALIDATE_USER_CMAB_CACHE:
+ convertedOptions.add(OptimizelyDecideOption.INVALIDATE_USER_CMAB_CACHE);
+ break;
default:
break;
}
diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle
index 415ec79..bd22a12 100644
--- a/example/android/app/build.gradle
+++ b/example/android/app/build.gradle
@@ -24,8 +24,9 @@ if (flutterVersionName == null) {
}
android {
- compileSdkVersion 32
- ndkVersion flutter.ndkVersion
+ namespace "com.optimizely.optimizely_flutter_sdk_example"
+
+ compileSdkVersion flutter.compileSdkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
@@ -37,8 +38,9 @@ android {
applicationId "com.optimizely.optimizely_flutter_sdk_example"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
- minSdkVersion 21
- targetSdkVersion 32
+ minSdkVersion flutter.minSdkVersion
+ targetSdkVersion flutter.targetSdkVersion
+
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties
index cc5527d..db18181 100644
--- a/example/android/gradle/wrapper/gradle-wrapper.properties
+++ b/example/android/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
diff --git a/example/android/settings.gradle b/example/android/settings.gradle
index 5710b01..97284d6 100644
--- a/example/android/settings.gradle
+++ b/example/android/settings.gradle
@@ -26,8 +26,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
- id "com.android.application" version "7.2.1" apply false
- id "org.jetbrains.kotlin.android" version "1.6.10" apply false
+ id "com.android.application" version "8.7.0" apply false
+ id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"
diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj
index 815ca79..56af059 100644
--- a/example/ios/Runner.xcodeproj/project.pbxproj
+++ b/example/ios/Runner.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 50;
+ objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -68,7 +68,6 @@
8E60C66DA76D705E5A9DCACA /* Pods-Runner.release.xcconfig */,
3D86A8B550CB0FBA7A8F2A03 /* Pods-Runner.profile.xcconfig */,
);
- name = Pods;
path = Pods;
sourceTree = "";
};
@@ -156,7 +155,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
- LastUpgradeCheck = 1300;
+ LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
@@ -200,10 +199,12 @@
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
@@ -231,6 +232,7 @@
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
@@ -356,7 +358,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
- DEVELOPMENT_TEAM = 6CHVYYTX7N;
+ DEVELOPMENT_TEAM = BDMC9C2X5M;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
@@ -486,7 +488,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
- DEVELOPMENT_TEAM = 6CHVYYTX7N;
+ DEVELOPMENT_TEAM = BDMC9C2X5M;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
@@ -510,7 +512,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
- DEVELOPMENT_TEAM = 6CHVYYTX7N;
+ DEVELOPMENT_TEAM = BDMC9C2X5M;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 10.0;
diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index c87d15a..9c12df5 100644
--- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -1,6 +1,6 @@
diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift
index 70693e4..b636303 100644
--- a/example/ios/Runner/AppDelegate.swift
+++ b/example/ios/Runner/AppDelegate.swift
@@ -1,7 +1,7 @@
import UIKit
import Flutter
-@UIApplicationMain
+@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist
index b4499c8..b1d7d7e 100644
--- a/example/ios/Runner/Info.plist
+++ b/example/ios/Runner/Info.plist
@@ -45,5 +45,7 @@
CADisableMinimumFrameDurationOnPhone
+ UIApplicationSupportsIndirectInputEvents
+
diff --git a/example/lib/custom_logger.dart b/example/lib/custom_logger.dart
new file mode 100644
index 0000000..e031fff
--- /dev/null
+++ b/example/lib/custom_logger.dart
@@ -0,0 +1,11 @@
+import 'package:optimizely_flutter_sdk/optimizely_flutter_sdk.dart';
+import 'package:flutter/foundation.dart';
+
+class CustomLogger implements OptimizelyLogger {
+ @override
+ void log(OptimizelyLogLevel level, String message) {
+ if (kDebugMode) {
+ print('[OPTIMIZELY] ${level.name.toUpperCase()}: $message');
+ }
+ }
+}
diff --git a/example/lib/main.dart b/example/lib/main.dart
index e7db8fa..78a324f 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'dart:async';
import 'dart:math';
import 'package:optimizely_flutter_sdk/optimizely_flutter_sdk.dart';
+import 'package:optimizely_flutter_sdk_example/custom_logger.dart';
+import 'package:optimizely_flutter_sdk_example/sample_api.dart';
void main() {
runApp(const MyApp());
@@ -28,16 +30,22 @@ class _MyAppState extends State {
OptimizelyDecideOption.includeReasons,
OptimizelyDecideOption.excludeVariables
};
- var flutterSDK = OptimizelyFlutterSdk("X9mZd2WDywaUL9hZXyh9A",
- datafilePeriodicDownloadInterval: 10 * 60,
- eventOptions: const EventOptions(
- batchSize: 1, timeInterval: 60, maxQueueSize: 10000),
- defaultLogLevel: OptimizelyLogLevel.debug,
- defaultDecideOptions: defaultOptions);
+
+ final customLogger = CustomLogger();
+
+ var flutterSDK = OptimizelyFlutterSdk(
+ "X9mZd2WDywaUL9hZXyh9A",
+ datafilePeriodicDownloadInterval: 10 * 60,
+ eventOptions: const EventOptions(
+ batchSize: 1, timeInterval: 60, maxQueueSize: 10000),
+ defaultLogLevel: OptimizelyLogLevel.debug,
+ defaultDecideOptions: defaultOptions,
+ logger: customLogger,
+ );
var response = await flutterSDK.initializeClient();
setState(() {
- uiResponse = "Optimizely Client initialized: ${response.success} ";
+ uiResponse = "[Optimizely] Client initialized: ${response.success} ";
});
var rng = Random();
@@ -55,7 +63,7 @@ class _MyAppState extends State {
"stringValue": "121"
});
- // To add decide listener
+ // Add decide listener
var decideListenerId =
await flutterSDK.addDecisionNotificationListener((notification) {
print("Parsed decision event ....................");
@@ -68,17 +76,13 @@ class _MyAppState extends State {
Set options = {
OptimizelyDecideOption.ignoreUserProfileService,
};
+
// Decide call
var decideResponse = await userContext.decide('flag1', options);
uiResponse +=
"\nFirst decide call variationKey: ${decideResponse.decision!.variationKey}";
- // should return following response without forced decision
- // flagKey: flag1
- // ruleKey: default-rollout-7371-20896892800
- // variationKey: off
-
- // Setting forced decision
+ // Set forced decision
await userContext.setForcedDecision(
OptimizelyDecisionContext("flag1", "flag1_experiment"),
OptimizelyForcedDecision("variation_a"));
@@ -88,11 +92,6 @@ class _MyAppState extends State {
uiResponse +=
"\nSecond decide call variationKey: ${decideResponse.decision!.variationKey}";
- // should return following response with forced decision
- // flagKey: flag1
- // ruleKey: flag1_experiment
- // variationKey: variation_a
-
// removing forced decision
await userContext.removeForcedDecision(
OptimizelyDecisionContext("flag1", "flag1_experiment"));
@@ -106,14 +105,6 @@ class _MyAppState extends State {
uiResponse = uiResponse;
});
- // should return original response without forced decision
- // flagKey: flag1
- // ruleKey: default-rollout-7371-20896892800
- // variationKey: off
-
- // To cancel decide listener
- // await flutterSDK.removeNotificationListener(decideListenerId);
-
// To add track listener
var trackListenerID =
await flutterSDK.addTrackNotificationListener((notification) {
@@ -132,12 +123,19 @@ class _MyAppState extends State {
print("log event notification received");
});
- // Track call
+ // Track call with nested objects
response = await userContext.trackEvent("myevent", {
- "age": 20,
- "doubleValue": 12.12,
- "boolValue": false,
- "stringValue": "121"
+ "revenue": 99.99,
+ "user": {
+ "id": "user123",
+ "premium": true,
+ "tags": ["vip", "loyal"]
+ },
+ "items": [
+ {"name": "Product A", "quantity": 2, "price": 49.99},
+ {"name": "Product B", "quantity": 1, "price": 50.00}
+ ],
+ "metadata": {"source": "mobile_app", "platform": "ios"}
});
// To cancel track listener
@@ -146,6 +144,18 @@ class _MyAppState extends State {
if (!mounted) return;
}
+ Future _runCmabExamples() async {
+ setState(() {
+ uiResponse = 'Running CMAB examples... Check console for output.';
+ });
+
+ await CmabSampleApi.runAllCmabExamples();
+
+ setState(() {
+ uiResponse = 'CMAB examples completed! Check console for detailed output.';
+ });
+ }
+
@override
Widget build(BuildContext context) {
return MaterialApp(
@@ -154,7 +164,25 @@ class _MyAppState extends State {
title: const Text('Plugin example app'),
),
body: Center(
- child: Text(uiResponse),
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Expanded(
+ child: SingleChildScrollView(
+ child: Text(uiResponse),
+ ),
+ ),
+ const SizedBox(height: 20),
+ ElevatedButton(
+ onPressed: _runCmabExamples,
+ child: const Text('Run CMAB Examples'),
+ ),
+ const SizedBox(height: 10),
+ ],
+ ),
+ ),
),
),
);
diff --git a/example/lib/sample_api.dart b/example/lib/sample_api.dart
new file mode 100644
index 0000000..1a9ae0f
--- /dev/null
+++ b/example/lib/sample_api.dart
@@ -0,0 +1,378 @@
+/// **************************************************************************
+/// Copyright 2025, Optimizely, Inc. and contributors *
+/// *
+/// Licensed under the Apache License, Version 2.0 (the "License"); *
+/// you may not use this file except in compliance with the License. *
+/// You may obtain a copy of the License at *
+/// *
+/// http://www.apache.org/licenses/LICENSE-2.0 *
+/// *
+/// Unless required by applicable law or agreed to in writing, software *
+/// distributed under the License is distributed on an "AS IS" BASIS, *
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
+/// See the License for the specific language governing permissions and *
+/// limitations under the License. *
+///**************************************************************************/
+
+import 'package:optimizely_flutter_sdk/optimizely_flutter_sdk.dart';
+import 'package:optimizely_flutter_sdk_example/custom_logger.dart';
+
+
+/// CMAB (Contextual Multi-Armed Bandit) API usage examples
+///
+/// This class demonstrates how to use CMAB features in the Optimizely Flutter SDK:
+/// - Initializing SDK with CmabConfig
+/// - Using decideAsync for CMAB-enabled experiments
+/// - CMAB cache control options
+/// - Combining CMAB with other decide options
+class CmabSampleApi {
+ // Replace with your actual SDK key
+ static const String SDK_KEY = '2ExWptsTiSx1EZbpwSnoD';
+
+ // Replace with your CMAB-enabled flag key
+ static const String CMAB_FLAG_KEY = 'cmab-flag';
+
+ /// Example 1: Basic CMAB initialization and single flag decision
+ ///
+ /// This example shows:
+ /// - How to initialize the SDK with default CmabConfig
+ /// - How to create a user context with attributes
+ /// - How to use decideAsync() for a single flag
+ /// - How to access decision results
+ static Future basicCmabExample() async {
+ print('\n========== Example 1: Basic CMAB Usage ==========');
+
+ try {
+ // Initialize SDK with default CMAB configuration
+ // Default cache size: 100, cache timeout: 1800 seconds (30 minutes)
+ var flutterSDK = OptimizelyFlutterSdk(
+ SDK_KEY,
+ cmabConfig: CmabConfig(), // Uses defaults
+ );
+
+ var response = await flutterSDK.initializeClient();
+ if (!response.success) {
+ print('Failed to initialize SDK: ${response.reason}');
+ return;
+ }
+ print('✓ SDK initialized successfully');
+
+ // Create user context with attributes
+ // CMAB uses these attributes to make personalized decisions
+ var userContext = await flutterSDK.createUserContext(
+ userId: 'user_123',
+ attributes: {
+ 'country': 'us'
+ },
+ );
+
+ if (userContext == null) {
+ print('Failed to create user context');
+ return;
+ }
+ print('✓ User context created for user_123');
+
+ // Use decideAsync for CMAB-enabled flag
+ // This makes an async call to the CMAB service for personalized variation
+ // Always use ignoreUserProfileService with CMAB to get correct decisions
+ print('\nMaking async decision for flag: $CMAB_FLAG_KEY');
+ var decision = await userContext.decideAsync(
+ CMAB_FLAG_KEY,
+ );
+
+ // Access decision results
+ if (decision.decision != null) {
+ print('✓ Decision received:');
+ print(' - Flag Key: ${decision.decision!.flagKey}');
+ print(' - Variation Key: ${decision.decision!.variationKey}');
+ print(' - Enabled: ${decision.decision!.enabled}');
+ print(' - Variables: ${decision.decision!.variables}');
+ } else {
+ print('✗ No decision returned');
+ }
+ } catch (e) {
+ print('Error in basicCmabExample: $e');
+ }
+ }
+
+ /// Example 2: CMAB cache control options
+ ///
+ /// This example demonstrates the three CMAB-specific cache options:
+ /// - ignoreCmabCache: Bypass cache and make fresh CMAB request
+ /// - resetCmabCache: Clear entire CMAB cache before decision
+ /// - invalidateUserCmabCache: Clear cache for current user only
+ static Future cmabCacheOptionsExample() async {
+ print('\n========== Example 2: CMAB Cache Options ==========');
+
+ try {
+ var flutterSDK = OptimizelyFlutterSdk(
+ SDK_KEY,
+ cmabConfig: CmabConfig(),
+ );
+
+ await flutterSDK.initializeClient();
+ var userContext = await flutterSDK.createUserContext(
+ userId: 'user_456',
+ attributes: {'country': 'us'},
+ );
+
+ if (userContext == null) return;
+
+ // Option 1: Ignore CMAB Cache
+ // Use this when you want to bypass the cache and get a fresh decision
+ // from the CMAB service (e.g., for real-time personalization)
+ print('\n1. Using ignoreCmabCache option:');
+ var decision1 = await userContext.decideAsync(
+ CMAB_FLAG_KEY,
+ {
+ OptimizelyDecideOption.ignoreCmabCache,
+ },
+ );
+ print(' ✓ Fresh decision from CMAB service (cache bypassed)');
+ print(' - Variation: ${decision1.decision?.variationKey}');
+
+ // Option 2: Reset CMAB Cache
+ // Use this to clear the entire CMAB cache before making a decision
+ // Useful when you want to refresh all cached CMAB decisions
+ print('\n2. Using resetCmabCache option:');
+ var decision2 = await userContext.decideAsync(
+ CMAB_FLAG_KEY,
+ {
+ OptimizelyDecideOption.resetCmabCache
+ },
+ );
+ print(' ✓ Entire CMAB cache cleared, new decision fetched');
+ print(' - Variation: ${decision2.decision?.variationKey}');
+
+ // Option 3: Invalidate User CMAB Cache
+ // Use this to clear cache for the current user only
+ // Other users' cached decisions remain intact
+ print('\n3. Using invalidateUserCmabCache option:');
+ var decision3 = await userContext.decideAsync(
+ CMAB_FLAG_KEY,
+ {
+ OptimizelyDecideOption.invalidateUserCmabCache
+ },
+ );
+ print(' ✓ User-specific cache cleared, new decision fetched');
+ print(' - Variation: ${decision3.decision?.variationKey}');
+
+ // Regular cached decision (for comparison)
+ print('\n4. Regular decision (uses cache if available):');
+ var decision4 = await userContext.decideAsync(
+ CMAB_FLAG_KEY
+ );
+ print(' ✓ Decision returned (may be from cache)');
+ print(' - Variation: ${decision4.decision?.variationKey}');
+
+ } catch (e) {
+ print('Error in cmabCacheOptionsExample: $e');
+ }
+ }
+
+ /// Example 3: Custom CMAB configuration
+ ///
+ /// This example shows how to customize CMAB settings:
+ /// - Custom cache size
+ /// - Custom cache timeout
+ /// - Custom prediction endpoint (optional)
+ static Future customCmabConfigExample() async {
+ print('\n========== Example 3: Custom CMAB Configuration ==========');
+
+ try {
+ // Initialize with custom CMAB configuration
+ var flutterSDK = OptimizelyFlutterSdk(
+ SDK_KEY,
+ cmabConfig: CmabConfig(
+ cacheSize: 200, // Store up to 200 decisions in cache
+ cacheTimeoutInSecs: 3600, // Cache expires after 1 hour (3600 seconds)
+ predictionEndpoint: 'https://custom-endpoint.example.com/predict/{ruleId}', // Optional custom endpoint template
+ ),
+ );
+
+ print('✓ SDK initialized with custom CMAB config:');
+ print(' - Cache Size: 200 decisions');
+ print(' - Cache Timeout: 3600 seconds (1 hour)');
+ print(' - Prediction Endpoint: https://custom-endpoint.example.com/predict/{ruleId}');
+
+ await flutterSDK.initializeClient();
+
+ var userContext = await flutterSDK.createUserContext(
+ userId: 'user_789',
+ attributes: {'country': 'us'},
+ );
+
+ if (userContext == null) return;
+
+ // Make decision with custom config
+ var decision = await userContext.decideAsync(
+ CMAB_FLAG_KEY
+ );
+
+ print('\n✓ Decision made with custom cache settings:');
+ print(' - Variation: ${decision.decision?.variationKey}');
+ print(' - This decision will be cached for 1 hour');
+ print(' - Cache can store up to 200 user decisions');
+
+ } catch (e) {
+ print('Error in customCmabConfigExample: $e');
+ }
+ }
+
+ /// Example 4: Combining CMAB options with other decide options
+ ///
+ /// This example shows how to use CMAB cache options together with
+ /// other OptimizelyDecideOption values like includeReasons
+ static Future combinedOptionsExample() async {
+ print('\n========== Example 4: Combined Options ==========');
+
+ try {
+ var flutterSDK = OptimizelyFlutterSdk(
+ SDK_KEY,
+ cmabConfig: CmabConfig(),
+ );
+
+ await flutterSDK.initializeClient();
+
+ var userContext = await flutterSDK.createUserContext(
+ userId: 'user_999',
+ attributes: {'country': 'us'},
+ );
+
+ if (userContext == null) return;
+
+ // Combine ignoreCmabCache with includeReasons and ignoreUserProfileService
+ // This gives you a fresh decision with detailed reasoning
+ print('\nCombining ignoreCmabCache + includeReasons + ignoreUserProfileService:');
+ var decision = await userContext.decideAsync(
+ CMAB_FLAG_KEY,
+ {
+ OptimizelyDecideOption.ignoreCmabCache,
+ OptimizelyDecideOption.includeReasons,
+ OptimizelyDecideOption.ignoreUserProfileService,
+ },
+ );
+
+ print('✓ Decision with combined options:');
+ print(' - Variation: ${decision.decision?.variationKey}');
+ print(' - Enabled: ${decision.decision?.enabled}');
+
+ if (decision.decision?.reasons != null &&
+ decision.decision!.reasons.isNotEmpty) {
+ print(' - Reasons:');
+ for (var reason in decision.decision!.reasons) {
+ print(' • $reason');
+ }
+ }
+
+ // Another combination: resetCmabCache + excludeVariables + ignoreUserProfileService
+ print('\nCombining resetCmabCache + excludeVariables + ignoreUserProfileService:');
+ var decision2 = await userContext.decideAsync(
+ CMAB_FLAG_KEY,
+ {
+ OptimizelyDecideOption.resetCmabCache,
+ OptimizelyDecideOption.excludeVariables,
+ OptimizelyDecideOption.ignoreUserProfileService,
+ },
+ );
+
+ print('✓ Decision without variables:');
+ print(' - Variation: ${decision2.decision?.variationKey}');
+ print(' - Variables excluded: ${decision2.decision?.variables.isEmpty}');
+
+ } catch (e) {
+ print('Error in combinedOptionsExample: $e');
+ }
+ }
+
+ static Future testInvalidateUserCmabCacheOption() async {
+ try {
+ final sdk = OptimizelyFlutterSdk(SDK_KEY, cmabConfig: CmabConfig(), defaultLogLevel: OptimizelyLogLevel.debug, logger: CustomLogger());
+ await sdk.initializeClient();
+
+ // Create two users and populate cache
+ const Map cmabAttrUS = {'country': 'us'};
+ const user1Id = 'user_invalidate_1';
+ final user1Context = (await sdk.createUserContext(
+ userId: user1Id,
+ attributes: cmabAttrUS,
+ ))!;
+
+ const user2Id = 'user_invalidate_2';
+ final user2Context = (await sdk.createUserContext(
+ userId: user2Id,
+ attributes: cmabAttrUS,
+ ))!;
+
+ // Populate cache for both users
+ await user1Context.decideAsync(
+ CMAB_FLAG_KEY,
+ {
+ OptimizelyDecideOption.includeReasons,
+ },
+ );
+
+ await user2Context.decideAsync(
+ CMAB_FLAG_KEY,
+ {
+ OptimizelyDecideOption.includeReasons,
+ },
+ );
+
+ // Invalidate cache for user1 only
+ await user1Context.decideAsync(
+ CMAB_FLAG_KEY,
+ {
+ OptimizelyDecideOption.includeReasons,
+ OptimizelyDecideOption.invalidateUserCmabCache,
+ },
+ );
+
+ // User2's call should still use cache (not affected by user1 invalidation)
+ await user2Context.decideAsync(
+ CMAB_FLAG_KEY,
+ {
+ OptimizelyDecideOption.includeReasons,
+ },
+ );
+
+ } catch (e) {
+ print('Error in testInvalidateUserCmabCacheOption: $e');
+ }
+ }
+ /// Run all CMAB examples sequentially
+ ///
+ /// This runs all the example methods in order, demonstrating
+ /// the complete CMAB API functionality
+ static Future runAllCmabExamples() async {
+ print('\n╔════════════════════════════════════════════════════════╗');
+ print('║ CMAB API Examples - Optimizely Flutter SDK ║');
+ print('╚════════════════════════════════════════════════════════╝');
+
+ print('\nIMPORTANT: Update SDK_KEY and CMAB_FLAG_KEY constants');
+ print('in sample_api.dart before running these examples.\n');
+
+ try {
+ await basicCmabExample();
+ await Future.delayed(Duration(seconds: 1)); // Pause between examples
+
+ await cmabCacheOptionsExample();
+ await Future.delayed(Duration(seconds: 1));
+
+ await customCmabConfigExample();
+ await Future.delayed(Duration(seconds: 1));
+
+ await combinedOptionsExample();
+
+ await Future.delayed(Duration(seconds: 1));
+ await testInvalidateUserCmabCacheOption();
+
+ print('\n╔════════════════════════════════════════════════════════╗');
+ print('║ All CMAB Examples Completed Successfully! ✓ ║');
+ print('╚════════════════════════════════════════════════════════╝\n');
+
+ } catch (e) {
+ print('\n✗ Error running CMAB examples: $e');
+ }
+ }
+}
diff --git a/ios/Classes/HelperClasses/Constants.swift b/ios/Classes/HelperClasses/Constants.swift
index a29370a..0b86175 100644
--- a/ios/Classes/HelperClasses/Constants.swift
+++ b/ios/Classes/HelperClasses/Constants.swift
@@ -29,6 +29,7 @@ struct API {
static let setAttributes = "setAttributes"
static let trackEvent = "trackEvent"
static let decide = "decide"
+ static let decideAsync = "decideAsync"
static let setForcedDecision = "setForcedDecision"
static let getForcedDecision = "getForcedDecision"
static let removeForcedDecision = "removeForcedDecision"
@@ -62,6 +63,9 @@ struct DecideOption {
static let ignoreUserProfileService = "ignoreUserProfileService"
static let includeReasons = "includeReasons"
static let excludeVariables = "excludeVariables"
+ static let ignoreCmabCache = "ignoreCmabCache"
+ static let resetCmabCache = "resetCmabCache"
+ static let invalidateUserCmabCache = "invalidateUserCmabCache"
}
struct SegmentOption {
@@ -115,7 +119,13 @@ struct RequestParameterKey {
static let timeoutForOdpEventInSecs = "timeoutForOdpEventInSecs"
static let disableOdp = "disableOdp"
static let enableVuid = "enableVuid"
- static let sdkVersion = "sdkVersion";
+ static let sdkVersion = "sdkVersion"
+
+ // CMAB Config
+ static let cmabConfig = "cmabConfig"
+ static let cmabCacheSize = "cmabCacheSize"
+ static let cmabCacheTimeoutInSecs = "cmabCacheTimeoutInSecs"
+ static let cmabPredictionEndpoint = "cmabPredictionEndpoint"
}
struct ResponseKey {
@@ -139,4 +149,6 @@ struct TypeValue {
static let int = "int"
static let double = "double"
static let bool = "bool"
+ static let map = "map"
+ static let list = "list"
}
diff --git a/ios/Classes/HelperClasses/Utils.swift b/ios/Classes/HelperClasses/Utils.swift
index 41b39c1..675990d 100644
--- a/ios/Classes/HelperClasses/Utils.swift
+++ b/ios/Classes/HelperClasses/Utils.swift
@@ -26,36 +26,59 @@ public class Utils: NSObject {
}
var typedDictionary = [String: Any]()
for (k,v) in args {
- if let typedValue = v as? Dictionary, let value = typedValue["value"] as? Any, let type = typedValue["type"] as? String {
- switch type {
- case TypeValue.string:
- if let strValue = value as? String {
- typedDictionary[k] = strValue
- }
- break
- case TypeValue.int:
- if let intValue = value as? Int {
- typedDictionary[k] = NSNumber(value: intValue).intValue
- }
- break
- case TypeValue.double:
- if let doubleValue = value as? Double {
- typedDictionary[k] = NSNumber(value: doubleValue).doubleValue
- }
- break
- case TypeValue.bool:
- if let booleanValue = value as? Bool {
- typedDictionary[k] = NSNumber(value: booleanValue).boolValue
- }
- break
- default:
- break
- }
+ if let processedValue = processTypedValue(v) {
+ typedDictionary[k] = processedValue
}
- continue
}
return typedDictionary
}
+
+ /// Recursively processes typed values from Flutter to native Swift types
+ private static func processTypedValue(_ value: Any?) -> Any? {
+ guard let typedValue = value as? Dictionary,
+ let val = typedValue["value"],
+ let type = typedValue["type"] as? String else {
+ return nil
+ }
+
+ switch type {
+ case TypeValue.string:
+ return val as? String
+ case TypeValue.int:
+ if let intValue = val as? Int {
+ return NSNumber(value: intValue).intValue
+ }
+ return nil
+ case TypeValue.double:
+ if let doubleValue = val as? Double {
+ return NSNumber(value: doubleValue).doubleValue
+ }
+ return nil
+ case TypeValue.bool:
+ if let booleanValue = val as? Bool {
+ return NSNumber(value: booleanValue).boolValue
+ }
+ return nil
+ case TypeValue.map:
+ guard let nestedMap = val as? Dictionary else {
+ return nil
+ }
+ var result = [String: Any]()
+ for (k, v) in nestedMap {
+ if let processedValue = processTypedValue(v) {
+ result[k] = processedValue
+ }
+ }
+ return result
+ case TypeValue.list:
+ guard let nestedArray = val as? [Any?] else {
+ return nil
+ }
+ return nestedArray.compactMap { processTypedValue($0) }
+ default:
+ return nil
+ }
+ }
/// Returns callback required for LogEventListener
static func getLogEventCallback(id: Int, sdkKey: String) -> LogEventListener {
@@ -146,6 +169,12 @@ public class Utils: NSObject {
convertedOptions.append(OptimizelyDecideOption.excludeVariables)
case DecideOption.includeReasons:
convertedOptions.append(OptimizelyDecideOption.includeReasons)
+ case DecideOption.ignoreCmabCache:
+ convertedOptions.append(OptimizelyDecideOption.ignoreCmabCache)
+ case DecideOption.resetCmabCache:
+ convertedOptions.append(OptimizelyDecideOption.resetCmabCache)
+ case DecideOption.invalidateUserCmabCache:
+ convertedOptions.append(OptimizelyDecideOption.invalidateUserCmabCache)
default: break
}
}
diff --git a/ios/Classes/OptimizelyFlutterLogger.swift b/ios/Classes/OptimizelyFlutterLogger.swift
new file mode 100644
index 0000000..7b9217a
--- /dev/null
+++ b/ios/Classes/OptimizelyFlutterLogger.swift
@@ -0,0 +1,39 @@
+import Flutter
+import Optimizely
+
+public class OptimizelyFlutterLogger: NSObject, OPTLogger {
+ static var LOGGER_CHANNEL: String = "optimizely_flutter_sdk_logger";
+
+ public static var logLevel: OptimizelyLogLevel = .info
+
+ private static var loggerChannel: FlutterMethodChannel?
+
+ public required override init() {
+ super.init()
+ }
+
+ public static func setChannel(_ channel: FlutterMethodChannel) {
+ loggerChannel = channel
+ }
+
+ public func log(level: OptimizelyLogLevel, message: String) {
+ // Early return if level check fails
+ guard level.rawValue <= OptimizelyFlutterLogger.logLevel.rawValue else {
+ return
+ }
+
+ // Ensure we have a valid channel
+ guard let channel = Self.loggerChannel else {
+ print("[OptimizelyFlutterLogger] ERROR: No logger channel available!")
+ return
+ }
+
+ // https://docs.flutter.dev/platform-integration/platform-channels#jumping-to-the-main-thread-in-ios
+ DispatchQueue.main.async {
+ channel.invokeMethod("log", arguments: [
+ "level": level.rawValue,
+ "message": message
+ ])
+ }
+ }
+}
diff --git a/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift b/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift
index 80b39bc..c93b553 100644
--- a/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift
+++ b/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift
@@ -52,6 +52,14 @@ public class SwiftOptimizelyFlutterSdkPlugin: NSObject, FlutterPlugin {
channel = FlutterMethodChannel(name: "optimizely_flutter_sdk", binaryMessenger: registrar.messenger())
let instance = SwiftOptimizelyFlutterSdkPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
+
+ // Separate logger channel for outgoing log calls
+ let taskQueue = registrar.messenger().makeBackgroundTaskQueue?()
+ let loggerChannel = FlutterMethodChannel(name: OptimizelyFlutterLogger.LOGGER_CHANNEL,
+ binaryMessenger: registrar.messenger(),
+ codec: FlutterStandardMethodCodec.sharedInstance(),
+ taskQueue: taskQueue)
+ OptimizelyFlutterLogger.setChannel(loggerChannel)
}
/// Part of FlutterPlugin protocol to handle communication with flutter sdk
@@ -73,6 +81,7 @@ public class SwiftOptimizelyFlutterSdkPlugin: NSObject, FlutterPlugin {
case API.setAttributes: setAttributes(call, result: result)
case API.trackEvent: trackEvent(call, result: result)
case API.decide: decide(call, result: result)
+ case API.decideAsync: decideAsync(call, result: result)
case API.setForcedDecision: setForcedDecision(call, result: result)
case API.getForcedDecision: getForcedDecision(call, result: result)
case API.removeForcedDecision: removeForcedDecision(call, result: result)
@@ -121,6 +130,7 @@ public class SwiftOptimizelyFlutterSdkPlugin: NSObject, FlutterPlugin {
var defaultLogLevel = OptimizelyLogLevel.info
if let logLevel = parameters[RequestParameterKey.defaultLogLevel] as? String {
defaultLogLevel = Utils.getDefaultLogLevel(logLevel)
+ OptimizelyFlutterLogger.logLevel = defaultLogLevel
}
// SDK Settings Default Values
@@ -154,7 +164,32 @@ public class SwiftOptimizelyFlutterSdkPlugin: NSObject, FlutterPlugin {
}
}
let optimizelySdkSettings = OptimizelySdkSettings(segmentsCacheSize: segmentsCacheSize, segmentsCacheTimeoutInSecs: segmentsCacheTimeoutInSecs, timeoutForSegmentFetchInSecs: timeoutForSegmentFetchInSecs, timeoutForOdpEventInSecs: timeoutForOdpEventInSecs, disableOdp: disableOdp, enableVuid: enableVuid, sdkName: sdkName, sdkVersion: sdkVersion)
-
+
+ // CMAB Config
+ var cmabConfig: CmabConfig?
+ if let cmabConfigDict = parameters[RequestParameterKey.cmabConfig] as? Dictionary {
+ var cacheSize = 100
+ var cacheTimeoutInSecs = 1800
+ var predictionEndpoint: String? = nil
+
+ if let size = cmabConfigDict[RequestParameterKey.cmabCacheSize] as? Int {
+ cacheSize = size
+ }
+ if let timeout = cmabConfigDict[RequestParameterKey.cmabCacheTimeoutInSecs] as? Int {
+ cacheTimeoutInSecs = timeout
+ }
+ if let endpoint = cmabConfigDict[RequestParameterKey.cmabPredictionEndpoint] as? String {
+ // Convert platform-agnostic placeholder {ruleId} to Swift format %@
+ predictionEndpoint = endpoint.replacingOccurrences(of: "{ruleId}", with: "%@")
+ }
+
+ cmabConfig = CmabConfig(
+ cacheSize: cacheSize,
+ cacheTimeoutInSecs: cacheTimeoutInSecs,
+ predictionEndpoint: predictionEndpoint
+ )
+ }
+
// Datafile Download Interval
var datafilePeriodicDownloadInterval = 10 * 60 // seconds
@@ -174,15 +209,21 @@ public class SwiftOptimizelyFlutterSdkPlugin: NSObject, FlutterPlugin {
notificationIdsTracker.removeValue(forKey: sdkKey)
optimizelyClientsTracker.removeValue(forKey: sdkKey)
+ // OptimizelyFlutterLogger bridges iOS logs to Flutter via Method Channel
+ // iOS SDK log → OptimizelyFlutterLogger → Flutter Method Channel → Flutter console
+ var logger: OPTLogger = OptimizelyFlutterLogger()
+
// Creating new instance
let optimizelyInstance = OptimizelyClient(
- sdkKey:sdkKey,
- eventDispatcher: eventDispatcher,
- datafileHandler: datafileHandler,
- periodicDownloadInterval: datafilePeriodicDownloadInterval,
+ sdkKey:sdkKey,
+ logger:logger,
+ eventDispatcher: eventDispatcher,
+ datafileHandler: datafileHandler,
+ periodicDownloadInterval: datafilePeriodicDownloadInterval,
defaultLogLevel: defaultLogLevel,
- defaultDecideOptions: defaultDecideOptions,
- settings: optimizelySdkSettings)
+ defaultDecideOptions: defaultDecideOptions,
+ settings: optimizelySdkSettings,
+ cmabConfig: cmabConfig)
optimizelyInstance.start{ [weak self] res in
switch res {
@@ -577,7 +618,59 @@ public class SwiftOptimizelyFlutterSdkPlugin: NSObject, FlutterPlugin {
result(self.createResponse(success: true, result: resultMap))
}
-
+
+ /// Asynchronously returns a key-map of decision results for flag keys and a user context.
+ /// This method supports CMAB (Contextual Multi-Armed Bandit) experiments.
+ func decideAsync(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ guard let (parameters, userContext) = getParametersAndUserContext(
+ arguments: call.arguments, result: result) else {
+ return
+ }
+
+ var decideKeys: [String]?
+ if let keys = parameters[RequestParameterKey.decideKeys] as? [String] {
+ decideKeys = keys
+ }
+
+ var decideOptions: [String]?
+ if let options = parameters[RequestParameterKey.decideOptions] as? [String] {
+ decideOptions = options
+ }
+
+ let options = Utils.getDecideOptions(options: decideOptions)
+
+ // Call appropriate async method based on keys
+ if let keys = decideKeys, keys.count == 1 {
+ // Single key async
+ userContext.decideAsync(key: keys[0], options: options) { [weak self] decision in
+ guard let self = self else { return }
+ var resultMap = [String: Any]()
+ resultMap[keys[0]] = Utils.convertDecisionToDictionary(decision: decision)
+ result(self.createResponse(success: true, result: resultMap))
+ }
+ } else if let keys = decideKeys, keys.count > 1 {
+ // Multiple keys async
+ userContext.decideAsync(keys: keys, options: options) { [weak self] decisions in
+ guard let self = self else { return }
+ var resultMap = [String: Any]()
+ for (key, decision) in decisions {
+ resultMap[key] = Utils.convertDecisionToDictionary(decision: decision)
+ }
+ result(self.createResponse(success: true, result: resultMap))
+ }
+ } else {
+ // All flags async
+ userContext.decideAllAsync(options: options) { [weak self] decisions in
+ guard let self = self else { return }
+ var resultMap = [String: Any]()
+ for (key, decision) in decisions {
+ resultMap[key] = Utils.convertDecisionToDictionary(decision: decision)
+ }
+ result(self.createResponse(success: true, result: resultMap))
+ }
+ }
+ }
+
/// Sets the forced decision for a given decision context.
func setForcedDecision(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let (parameters, userContext) = getParametersAndUserContext(arguments: call.arguments, result: result) else {
diff --git a/ios/optimizely_flutter_sdk.podspec b/ios/optimizely_flutter_sdk.podspec
index b4aeeba..038b2ab 100644
--- a/ios/optimizely_flutter_sdk.podspec
+++ b/ios/optimizely_flutter_sdk.podspec
@@ -13,7 +13,7 @@ Pod::Spec.new do |s|
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'Flutter'
- s.dependency 'OptimizelySwiftSDK', '5.0.0'
+ s.dependency 'OptimizelySwiftSDK', '5.2.1'
s.platform = :ios, '15.5'
# Flutter.framework does not contain a i386 slice.
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
diff --git a/lib/optimizely_flutter_sdk.dart b/lib/optimizely_flutter_sdk.dart
index 51dc9af..f05e45a 100644
--- a/lib/optimizely_flutter_sdk.dart
+++ b/lib/optimizely_flutter_sdk.dart
@@ -23,11 +23,14 @@ import 'package:optimizely_flutter_sdk/src/data_objects/datafile_options.dart';
import 'package:optimizely_flutter_sdk/src/data_objects/event_options.dart';
import 'package:optimizely_flutter_sdk/src/data_objects/get_vuid_response.dart';
import 'package:optimizely_flutter_sdk/src/data_objects/sdk_settings.dart';
+import 'package:optimizely_flutter_sdk/src/data_objects/cmab_config.dart';
import 'package:optimizely_flutter_sdk/src/data_objects/get_variation_response.dart';
import 'package:optimizely_flutter_sdk/src/data_objects/optimizely_config_response.dart';
import 'package:optimizely_flutter_sdk/src/optimizely_client_wrapper.dart';
import 'package:optimizely_flutter_sdk/src/user_context/optimizely_user_context.dart';
import 'package:optimizely_flutter_sdk/src/data_objects/log_level.dart';
+import 'package:optimizely_flutter_sdk/src/logger/flutter_logger.dart';
+import 'package:optimizely_flutter_sdk/src/logger/logger_bridge.dart';
export 'package:optimizely_flutter_sdk/src/optimizely_client_wrapper.dart'
show ClientPlatform, ListenerType;
@@ -49,10 +52,14 @@ export 'package:optimizely_flutter_sdk/src/data_objects/event_options.dart'
show EventOptions;
export 'package:optimizely_flutter_sdk/src/data_objects/sdk_settings.dart'
show SDKSettings;
+export 'package:optimizely_flutter_sdk/src/data_objects/cmab_config.dart'
+ show CmabConfig;
export 'package:optimizely_flutter_sdk/src/data_objects/datafile_options.dart'
show DatafileHostOptions;
export 'package:optimizely_flutter_sdk/src/data_objects/log_level.dart'
show OptimizelyLogLevel;
+export 'package:optimizely_flutter_sdk/src/logger/flutter_logger.dart'
+ show OptimizelyLogger;
/// The main client class for the Optimizely Flutter SDK.
///
@@ -68,20 +75,32 @@ class OptimizelyFlutterSdk {
final Set _defaultDecideOptions;
final OptimizelyLogLevel _defaultLogLevel;
final SDKSettings _sdkSettings;
+ final CmabConfig? _cmabConfig;
+ static OptimizelyLogger? _customLogger;
+ /// Get the current logger
+ static OptimizelyLogger? get logger {
+ return _customLogger;
+ }
OptimizelyFlutterSdk(this._sdkKey,
- {EventOptions eventOptions = const EventOptions(),
- int datafilePeriodicDownloadInterval =
- 10 * 60, // Default time interval in seconds
- Map datafileHostOptions = const {},
- Set defaultDecideOptions = const {},
- OptimizelyLogLevel defaultLogLevel = OptimizelyLogLevel.info,
- SDKSettings sdkSettings = const SDKSettings()})
- : _eventOptions = eventOptions,
- _datafilePeriodicDownloadInterval = datafilePeriodicDownloadInterval,
- _datafileHostOptions = datafileHostOptions,
- _defaultDecideOptions = defaultDecideOptions,
- _defaultLogLevel = defaultLogLevel,
- _sdkSettings = sdkSettings;
+ {EventOptions eventOptions = const EventOptions(),
+ int datafilePeriodicDownloadInterval = 10 * 60,
+ Map datafileHostOptions = const {},
+ Set defaultDecideOptions = const {},
+ OptimizelyLogLevel defaultLogLevel = OptimizelyLogLevel.info,
+ SDKSettings sdkSettings = const SDKSettings(),
+ CmabConfig? cmabConfig,
+ OptimizelyLogger? logger})
+ : _eventOptions = eventOptions,
+ _datafilePeriodicDownloadInterval = datafilePeriodicDownloadInterval,
+ _datafileHostOptions = datafileHostOptions,
+ _defaultDecideOptions = defaultDecideOptions,
+ _defaultLogLevel = defaultLogLevel,
+ _sdkSettings = sdkSettings,
+ _cmabConfig = cmabConfig {
+ // Set the logger if provided
+ _customLogger = logger ?? DefaultOptimizelyLogger();
+ LoggerBridge.initialize(_customLogger);
+ }
/// Starts Optimizely SDK (Synchronous) with provided sdkKey.
Future initializeClient() async {
@@ -92,7 +111,10 @@ class OptimizelyFlutterSdk {
_datafileHostOptions,
_defaultDecideOptions,
_defaultLogLevel,
- _sdkSettings);
+ _sdkSettings,
+ _cmabConfig,
+ _customLogger
+ );
}
/// Use the activate method to start an experiment.
diff --git a/lib/package_info.dart b/lib/package_info.dart
index cbf0165..384c27a 100644
--- a/lib/package_info.dart
+++ b/lib/package_info.dart
@@ -3,5 +3,5 @@
class PackageInfo {
static const String name = 'optimizely_flutter_sdk';
- static const String version = '3.0.0';
+ static const String version = '3.4.1';
}
diff --git a/lib/src/data_objects/cmab_config.dart b/lib/src/data_objects/cmab_config.dart
new file mode 100644
index 0000000..5fdfaa6
--- /dev/null
+++ b/lib/src/data_objects/cmab_config.dart
@@ -0,0 +1,42 @@
+/****************************************************************************
+ * Copyright 2025, Optimizely, Inc. and contributors *
+ * *
+ * Licensed under the Apache License, Version 2.0 (the "License"); *
+ * you may not use this file except in compliance with the License. *
+ * You may obtain a copy of the License at *
+ * *
+ * http://www.apache.org/licenses/LICENSE-2.0 *
+ * *
+ * Unless required by applicable law or agreed to in writing, software *
+ * distributed under the License is distributed on an "AS IS" BASIS, *
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
+ * See the License for the specific language governing permissions and *
+ * limitations under the License. *
+ ***************************************************************************/
+
+/// Configuration for CMAB (Contextual Multi-Armed Bandit) service
+class CmabConfig {
+ /// The maximum size of CMAB decision cache (default = 100)
+ final int cacheSize;
+
+ /// The timeout in seconds of CMAB cache (default = 1800 / 30 minutes)
+ final int cacheTimeoutInSecs;
+
+ /// The CMAB prediction endpoint template (optional, default endpoint used if null)
+ ///
+ /// Provide a URL template with '{ruleId}' placeholder which will be replaced
+ /// with the actual rule ID at runtime.
+ ///
+ /// Example: 'https://custom-endpoint.example.com/predict/{ruleId}'
+ /// Default: 'https://prediction.cmab.optimizely.com/predict/{ruleId}'
+ ///
+ /// Note: The placeholder is automatically converted to platform-specific format
+ /// (%@ for iOS, %s for Android) when passed to native SDKs.
+ final String? predictionEndpoint;
+
+ const CmabConfig({
+ this.cacheSize = 100,
+ this.cacheTimeoutInSecs = 1800,
+ this.predictionEndpoint,
+ });
+}
diff --git a/lib/src/logger/flutter_logger.dart b/lib/src/logger/flutter_logger.dart
new file mode 100644
index 0000000..f561ed9
--- /dev/null
+++ b/lib/src/logger/flutter_logger.dart
@@ -0,0 +1,29 @@
+import 'package:flutter/foundation.dart';
+import 'package:optimizely_flutter_sdk/src/data_objects/log_level.dart';
+
+abstract class OptimizelyLogger {
+ /// Log a message at a certain level
+ void log(OptimizelyLogLevel level, String message);
+}
+
+class DefaultOptimizelyLogger implements OptimizelyLogger {
+ @override
+ void log(OptimizelyLogLevel level, String message) {
+ if (kDebugMode) {
+ print('[OPTIMIZELY] [${level.name.toUpperCase()}]: $message');
+ }
+ }
+}
+
+/// App logger instance
+final _appLogger = DefaultOptimizelyLogger();
+
+/// App logging functions
+void logError(String message) =>
+ _appLogger.log(OptimizelyLogLevel.error, message);
+void logWarning(String message) =>
+ _appLogger.log(OptimizelyLogLevel.warning, message);
+void logInfo(String message) =>
+ _appLogger.log(OptimizelyLogLevel.info, message);
+void logDebug(String message) =>
+ _appLogger.log(OptimizelyLogLevel.debug, message);
diff --git a/lib/src/logger/logger_bridge.dart b/lib/src/logger/logger_bridge.dart
new file mode 100644
index 0000000..2e2271a
--- /dev/null
+++ b/lib/src/logger/logger_bridge.dart
@@ -0,0 +1,98 @@
+import 'dart:async';
+import 'package:flutter/services.dart';
+import 'package:optimizely_flutter_sdk/src/logger/flutter_logger.dart';
+import 'package:optimizely_flutter_sdk/optimizely_flutter_sdk.dart';
+
+class LoggerBridge {
+ static const MethodChannel _loggerChannel =
+ MethodChannel('optimizely_flutter_sdk_logger');
+ static OptimizelyLogger? _customLogger;
+
+ /// Initialize the logger bridge to receive calls from native
+ static void initialize(OptimizelyLogger? logger) {
+ logInfo('[LoggerBridge] Initializing with logger: ${logger != null}');
+ _customLogger = logger;
+ _loggerChannel.setMethodCallHandler(_handleMethodCall);
+ }
+
+ /// Handle incoming method calls from native Swift/Java code
+ static Future _handleMethodCall(MethodCall call) async {
+ try {
+ switch (call.method) {
+ case 'log':
+ await _handleLogCall(call);
+ break;
+ default:
+ logWarning('[LoggerBridge] Unknown method call: ${call.method}');
+ }
+ } catch (e) {
+ logError('[LoggerBridge] Error handling method call: $e');
+ }
+ }
+
+ /// Process the log call from Swift/Java
+ static Future _handleLogCall(MethodCall call) async {
+ try {
+ final args = Map.from(call.arguments ?? {});
+
+ final levelRawValue = args['level'] as int?;
+ final message = args['message'] as String?;
+
+ if (levelRawValue == null || message == null) {
+ logError('[LoggerBridge] Warning: Missing level or message in log call');
+ return;
+ }
+
+ final level = _convertLogLevel(levelRawValue);
+
+ if (_customLogger != null) {
+ _customLogger!.log(level, message);
+ } else {
+ logInfo('[Optimizely ${level.name}] $message');
+ }
+ } catch (e) {
+ logError('[LoggerBridge] Error processing log call: $e');
+ }
+ }
+
+ /// Convert native log level to Flutter enum
+ static OptimizelyLogLevel _convertLogLevel(int rawValue) {
+ switch (rawValue) {
+ case 1:
+ return OptimizelyLogLevel.error;
+ case 2:
+ return OptimizelyLogLevel.warning;
+ case 3:
+ return OptimizelyLogLevel.info;
+ case 4:
+ return OptimizelyLogLevel.debug;
+ default:
+ return OptimizelyLogLevel.info;
+ }
+ }
+
+ /// Expose convertLogLevel
+ static OptimizelyLogLevel convertLogLevel(int rawValue) {
+ return _convertLogLevel(rawValue);
+ }
+
+ /// Check if a custom logger is set
+ static bool hasLogger() {
+ return _customLogger != null;
+ }
+
+ /// Get the current logger
+ static OptimizelyLogger? getCurrentLogger() {
+ return _customLogger;
+ }
+
+ /// Reset logger state
+ static void reset() {
+ _customLogger = null;
+ }
+
+ /// Simulate method calls
+ static Future handleMethodCallForTesting(MethodCall call) async {
+ await _handleMethodCall(call);
+ }
+}
diff --git a/lib/src/optimizely_client_wrapper.dart b/lib/src/optimizely_client_wrapper.dart
index fb4fce0..46e1627 100644
--- a/lib/src/optimizely_client_wrapper.dart
+++ b/lib/src/optimizely_client_wrapper.dart
@@ -63,7 +63,9 @@ class OptimizelyClientWrapper {
Map datafileHostOptions,
Set defaultDecideOptions,
OptimizelyLogLevel defaultLogLevel,
- SDKSettings sdkSettings) async {
+ SDKSettings sdkSettings,
+ CmabConfig? cmabConfig,
+ OptimizelyLogger? logger) async {
_channel.setMethodCallHandler(methodCallHandler);
final convertedOptions = Utils.convertDecideOptions(defaultDecideOptions);
final convertedLogLevel = Utils.convertLogLevel(defaultLogLevel);
@@ -94,6 +96,19 @@ class OptimizelyClientWrapper {
};
requestDict[Constants.optimizelySdkSettings] = optimizelySdkSettings;
+ // CMAB Config params
+ if (cmabConfig != null) {
+ Map cmabConfigMap = {
+ Constants.cmabCacheSize: cmabConfig.cacheSize,
+ Constants.cmabCacheTimeoutInSecs: cmabConfig.cacheTimeoutInSecs,
+ };
+ if (cmabConfig.predictionEndpoint != null) {
+ cmabConfigMap[Constants.cmabPredictionEndpoint] =
+ cmabConfig.predictionEndpoint;
+ }
+ requestDict[Constants.cmabConfig] = cmabConfigMap;
+ }
+
// clearing notification listeners, if they are mapped to the same sdkKey.
activateCallbacksById.remove(sdkKey);
decisionCallbacksById.remove(sdkKey);
@@ -371,7 +386,6 @@ class OptimizelyClientWrapper {
if (checkCallBackExist(sdkKey, callback)) {
// ignore: avoid_print
- print("callback already exists.");
return -1;
}
@@ -417,7 +431,6 @@ class OptimizelyClientWrapper {
if (checkCallBackExist(sdkKey, callback)) {
// ignore: avoid_print
- print("callback already exists.");
return -1;
}
@@ -440,7 +453,6 @@ class OptimizelyClientWrapper {
if (checkCallBackExist(sdkKey, callback)) {
// ignore: avoid_print
- print("callback already exists.");
return -1;
}
@@ -464,7 +476,6 @@ class OptimizelyClientWrapper {
if (checkCallBackExist(sdkKey, callback)) {
// ignore: avoid_print
- print("callback already exists.");
return -1;
}
diff --git a/lib/src/user_context/optimizely_user_context.dart b/lib/src/user_context/optimizely_user_context.dart
index 906951f..d0f4cb1 100644
--- a/lib/src/user_context/optimizely_user_context.dart
+++ b/lib/src/user_context/optimizely_user_context.dart
@@ -41,7 +41,16 @@ enum OptimizelyDecideOption {
includeReasons,
/// exclude variable values from the decision result.
- excludeVariables
+ excludeVariables,
+
+ /// ignore CMAB cache (bypass cache, make fresh request).
+ ignoreCmabCache,
+
+ /// reset entire CMAB cache.
+ resetCmabCache,
+
+ /// invalidate CMAB cache for current user only.
+ invalidateUserCmabCache
}
/// Options controlling audience segments.
@@ -218,6 +227,56 @@ class OptimizelyUserContext {
return result;
}
+ /// Asynchronously returns a decision result for a given flag key and a user context.
+ /// This method supports CMAB (Contextual Multi-Armed Bandit) experiments.
+ ///
+ /// Takes [key] A flag key for which a decision will be made.
+ /// Optional [options] A set of [OptimizelyDecideOption] for decision-making.
+ /// Returns [DecideResponse] A decision result.
+ Future decideAsync(String key,
+ [Set options = const {}]) async {
+ final result = await _decideAsync([key], options);
+ return DecideResponse(result);
+ }
+
+ /// Asynchronously returns a key-map of decision results for multiple flag keys.
+ /// This method supports CMAB (Contextual Multi-Armed Bandit) experiments.
+ ///
+ /// Takes [keys] A [List] of flag keys for which decisions will be made.
+ /// Optional [options] A set of [OptimizelyDecideOption] for decision-making.
+ /// Returns [DecideForKeysResponse] All decision results mapped by flag keys.
+ Future decideForKeysAsync(List keys,
+ [Set options = const {}]) async {
+ final result = await _decideAsync(keys, options);
+ return DecideForKeysResponse(result);
+ }
+
+ /// Asynchronously returns a key-map of decision results for all active flag keys.
+ /// This method supports CMAB (Contextual Multi-Armed Bandit) experiments.
+ ///
+ /// Optional [options] A set of [OptimizelyDecideOption] for decision-making.
+ /// Returns [DecideForKeysResponse] All decision results mapped by flag keys.
+ Future decideAllAsync(
+ [Set options = const {}]) async {
+ final result = await _decideAsync([], options);
+ return DecideForKeysResponse(result);
+ }
+
+ /// Private helper for async decide operations
+ Future