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> _decideAsync( + [List keys = const [], + Set options = const {}]) async { + final convertedOptions = Utils.convertDecideOptions(options); + var result = Map.from( + await _channel.invokeMethod(Constants.decideAsyncMethod, { + Constants.sdkKey: _sdkKey, + Constants.userContextId: _userContextId, + Constants.keys: keys, + Constants.optimizelyDecideOption: convertedOptions, + })); + return result; + } + /// Sets the forced decision for a given decision context. /// /// Takes [context] The [OptimizelyDecisionContext] containing flagKey and ruleKey. diff --git a/lib/src/utils/constants.dart b/lib/src/utils/constants.dart index fb33033..7e299db 100644 --- a/lib/src/utils/constants.dart +++ b/lib/src/utils/constants.dart @@ -20,6 +20,8 @@ class Constants { static const String intType = "int"; static const String doubleType = "double"; static const String boolType = "bool"; + static const String mapType = "map"; + static const String listType = "list"; // Supported Method Names static const String initializeMethod = "initialize"; @@ -35,6 +37,7 @@ class Constants { static const String getAttributesMethod = "getAttributes"; static const String trackEventMethod = "trackEvent"; static const String decideMethod = "decide"; + static const String decideAsyncMethod = "decideAsync"; static const String setForcedDecision = "setForcedDecision"; static const String getForcedDecision = "getForcedDecision"; static const String removeForcedDecision = "removeForcedDecision"; @@ -62,7 +65,9 @@ class Constants { static const String userContextId = "userContextId"; static const String userContext = "userContext"; static const String experiment = "experiment"; + static const String experimentId = "experimentId"; static const String variation = "variation"; + static const String variationId = "variationId"; static const String userId = "userId"; static const String vuid = "vuid"; static const String experimentKey = "experimentKey"; @@ -133,6 +138,12 @@ class Constants { static const String disableOdp = "disableOdp"; static const String enableVuid = "enableVuid"; + // CMAB Config params + static const String cmabConfig = "cmabConfig"; + static const String cmabCacheSize = "cmabCacheSize"; + static const String cmabCacheTimeoutInSecs = "cmabCacheTimeoutInSecs"; + static const String cmabPredictionEndpoint = "cmabPredictionEndpoint"; + // Response keys static const String responseSuccess = "success"; static const String responseResult = "result"; diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index 8b18b13..db936bf 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -26,6 +26,9 @@ class Utils { OptimizelyDecideOption.ignoreUserProfileService: "ignoreUserProfileService", OptimizelyDecideOption.includeReasons: "includeReasons", OptimizelyDecideOption.excludeVariables: "excludeVariables", + OptimizelyDecideOption.ignoreCmabCache: "ignoreCmabCache", + OptimizelyDecideOption.resetCmabCache: "resetCmabCache", + OptimizelyDecideOption.invalidateUserCmabCache: "invalidateUserCmabCache", }; static Map segmentOptions = { @@ -33,7 +36,17 @@ class Utils { OptimizelySegmentOption.resetCache: "resetCache", }; - static Map convertToTypedMap(Map map) { + /// Converts a map to platform-specific typed format + /// + /// On iOS, returns a typed map with type information for proper native conversion. + /// On Android, returns the original primitive map. + /// + /// The [forceIOSFormat] parameter is used for testing purposes only to test + /// iOS format conversion without running on actual iOS platform. + static Map convertToTypedMap( + Map map, { + bool forceIOSFormat = false, + }) { if (map.isEmpty) { return map; } @@ -43,48 +56,78 @@ class Utils { // Only keep primitive values Map primitiveMap = {}; for (MapEntry e in map.entries) { - if (e.value is String) { + dynamic processedValue = _processValue(e.value); + if (processedValue != null) { primitiveMap[e.key] = e.value; - typedMap[e.key] = { - Constants.value: e.value, - Constants.type: Constants.stringType - }; - continue; + typedMap[e.key] = processedValue; } - if (e.value is double) { - primitiveMap[e.key] = e.value; - typedMap[e.key] = { - Constants.value: e.value, - Constants.type: Constants.doubleType - }; - continue; - } - if (e.value is int) { - primitiveMap[e.key] = e.value; - typedMap[e.key] = { - Constants.value: e.value, - Constants.type: Constants.intType - }; - continue; - } - if (e.value is bool) { - primitiveMap[e.key] = e.value; - typedMap[e.key] = { - Constants.value: e.value, - Constants.type: Constants.boolType - }; - continue; - } - // ignore: avoid_print - print('Unsupported value type for key: ${e.key}.'); } - if (Platform.isIOS) { + if (Platform.isIOS || forceIOSFormat) { return typedMap; } return primitiveMap; } + /// Recursively processes values to add type information for iOS + static dynamic _processValue(dynamic value) { + if (value is String) { + return { + Constants.value: value, + Constants.type: Constants.stringType + }; + } + if (value is double) { + return { + Constants.value: value, + Constants.type: Constants.doubleType + }; + } + if (value is int) { + return { + Constants.value: value, + Constants.type: Constants.intType + }; + } + if (value is bool) { + return { + Constants.value: value, + Constants.type: Constants.boolType + }; + } + if (value is Map) { + // Handle nested maps + Map nestedMap = {}; + value.forEach((k, v) { + dynamic processedValue = _processValue(v); + if (processedValue != null) { + nestedMap[k.toString()] = processedValue; + } + }); + return { + Constants.value: nestedMap, + Constants.type: Constants.mapType + }; + } + if (value is List) { + // Handle arrays + List nestedList = []; + for (var item in value) { + dynamic processedValue = _processValue(item); + if (processedValue != null) { + nestedList.add(processedValue); + } + } + return { + Constants.value: nestedList, + Constants.type: Constants.listType + }; + } + // ignore: avoid_print + print('Unsupported value type: ${value.runtimeType}'); + return null; + } + static List convertDecideOptions( Set options) { return options.map((option) => Utils.decideOptions[option]!).toList(); diff --git a/pubspec.yaml b/pubspec.yaml index fb899c2..24f5f8d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,10 +1,10 @@ name: optimizely_flutter_sdk description: This repository houses the Flutter SDK for use with Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts. -version: 3.0.0 +version: 3.4.1 homepage: https://github.com/optimizely/optimizely-flutter-sdk environment: - sdk: '>=2.16.2 <4.0.0' + sdk: ">=2.16.2 <4.0.0" flutter: ">=2.5.0" dependencies: diff --git a/test/cmab_test.dart b/test/cmab_test.dart new file mode 100644 index 0000000..1970cc2 --- /dev/null +++ b/test/cmab_test.dart @@ -0,0 +1,493 @@ +/// ************************************************************************** +/// Copyright 2022-2023, 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:flutter/services.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:optimizely_flutter_sdk/optimizely_flutter_sdk.dart"; +import 'package:optimizely_flutter_sdk/src/optimizely_client_wrapper.dart'; +import 'package:optimizely_flutter_sdk/src/utils/constants.dart'; +import 'package:optimizely_flutter_sdk/src/utils/utils.dart'; +import 'test_utils.dart'; + +void main() { + const String testSDKKey = "KZbunNn9bVfBWLpZPq2XC4"; + const String userId = "uid-351ea8"; + const String flagKey = "flag_1"; + const String userContextId = "123"; + const Map attributes = {"abc": 123}; + + const MethodChannel channel = MethodChannel("optimizely_flutter_sdk"); + TestDefaultBinaryMessenger? tester; + + setUp(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + OptimizelyClientWrapper.decisionCallbacksById = {}; + OptimizelyClientWrapper.trackCallbacksById = {}; + OptimizelyClientWrapper.configUpdateCallbacksById = {}; + OptimizelyClientWrapper.logEventCallbacksById = {}; + OptimizelyClientWrapper.nextCallbackId = 0; + tester = TestDefaultBinaryMessengerBinding.instance?.defaultBinaryMessenger; + }); + + tearDown(() { + tester?.setMockMethodCallHandler(channel, null); + }); + + group('CmabConfig', () { + test('creates CmabConfig with default values', () { + const config = CmabConfig(); + + expect(config.cacheSize, equals(100)); + expect(config.cacheTimeoutInSecs, equals(1800)); + expect(config.predictionEndpoint, isNull); + }); + + test('creates CmabConfig with custom values', () { + const config = CmabConfig( + cacheSize: 200, + cacheTimeoutInSecs: 3600, + predictionEndpoint: "https://custom-endpoint.com/predict/{ruleId}", + ); + + expect(config.cacheSize, equals(200)); + expect(config.cacheTimeoutInSecs, equals(3600)); + expect(config.predictionEndpoint, equals("https://custom-endpoint.com/predict/{ruleId}")); + }); + + test('creates CmabConfig with null predictionEndpoint', () { + const config = CmabConfig( + cacheSize: 150, + cacheTimeoutInSecs: 2400, + ); + + expect(config.cacheSize, equals(150)); + expect(config.cacheTimeoutInSecs, equals(2400)); + expect(config.predictionEndpoint, isNull); + }); + + test('CmabConfig with same values are equal', () { + const config1 = CmabConfig(cacheSize: 100, cacheTimeoutInSecs: 1800); + const config2 = CmabConfig(cacheSize: 100, cacheTimeoutInSecs: 1800); + + expect(config1.cacheSize, equals(config2.cacheSize)); + expect(config1.cacheTimeoutInSecs, equals(config2.cacheTimeoutInSecs)); + expect(config1.predictionEndpoint, equals(config2.predictionEndpoint)); + }); + }); + + group('OptimizelyFlutterSdk initialization with CmabConfig', () { + test('initializes SDK with default CmabConfig', () async { + Map? receivedCmabConfig; + const defaultConfig = CmabConfig(); + + tester?.setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == Constants.initializeMethod) { + var cmabArg = methodCall.arguments[Constants.cmabConfig]; + if (cmabArg != null) { + receivedCmabConfig = Map.from(cmabArg as Map); + } + return {Constants.responseSuccess: true}; + } + return null; + }); + + var sdk = OptimizelyFlutterSdk(testSDKKey, cmabConfig: defaultConfig); + await sdk.initializeClient(); + + expect(receivedCmabConfig, isNotNull); + expect(receivedCmabConfig![Constants.cmabCacheSize], equals(100)); + expect(receivedCmabConfig![Constants.cmabCacheTimeoutInSecs], equals(1800)); + expect(receivedCmabConfig!.containsKey(Constants.cmabPredictionEndpoint), isFalse); + }); + + test('initializes SDK with custom CmabConfig', () async { + Map? receivedCmabConfig; + const customConfig = CmabConfig( + cacheSize: 250, + cacheTimeoutInSecs: 3000, + predictionEndpoint: "https://test.com/predict/{ruleId}", + ); + + tester?.setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == Constants.initializeMethod) { + var cmabArg = methodCall.arguments[Constants.cmabConfig]; + if (cmabArg != null) { + receivedCmabConfig = Map.from(cmabArg as Map); + } + return {Constants.responseSuccess: true}; + } + return null; + }); + + var sdk = OptimizelyFlutterSdk(testSDKKey, cmabConfig: customConfig); + await sdk.initializeClient(); + + expect(receivedCmabConfig, isNotNull); + expect(receivedCmabConfig![Constants.cmabCacheSize], equals(250)); + expect(receivedCmabConfig![Constants.cmabCacheTimeoutInSecs], equals(3000)); + expect(receivedCmabConfig![Constants.cmabPredictionEndpoint], equals("https://test.com/predict/{ruleId}")); + }); + + test('initializes SDK with CmabConfig without predictionEndpoint', () async { + Map? receivedCmabConfig; + const customConfig = CmabConfig( + cacheSize: 300, + cacheTimeoutInSecs: 2500, + ); + + tester?.setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == Constants.initializeMethod) { + var cmabArg = methodCall.arguments[Constants.cmabConfig]; + if (cmabArg != null) { + receivedCmabConfig = Map.from(cmabArg as Map); + } + return {Constants.responseSuccess: true}; + } + return null; + }); + + var sdk = OptimizelyFlutterSdk(testSDKKey, cmabConfig: customConfig); + await sdk.initializeClient(); + + expect(receivedCmabConfig, isNotNull); + expect(receivedCmabConfig![Constants.cmabCacheSize], equals(300)); + expect(receivedCmabConfig![Constants.cmabCacheTimeoutInSecs], equals(2500)); + expect(receivedCmabConfig!.containsKey(Constants.cmabPredictionEndpoint), isFalse); + }); + + test('CmabConfig is serialized correctly in initialization', () async { + Map? receivedCmabConfig; + const customConfig = CmabConfig( + cacheSize: 500, + cacheTimeoutInSecs: 4000, + predictionEndpoint: "https://production.com/ml/{ruleId}", + ); + + tester?.setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == Constants.initializeMethod) { + expect(methodCall.arguments[Constants.sdkKey], equals(testSDKKey)); + var cmabArg = methodCall.arguments[Constants.cmabConfig]; + if (cmabArg != null) { + receivedCmabConfig = Map.from(cmabArg as Map); + } + return {Constants.responseSuccess: true}; + } + return null; + }); + + var sdk = OptimizelyFlutterSdk(testSDKKey, cmabConfig: customConfig); + await sdk.initializeClient(); + + expect(receivedCmabConfig, isNotNull); + expect(receivedCmabConfig, isA>()); + expect(receivedCmabConfig!.keys.length, equals(3)); + }); + + test('multiple SDKs can have different CmabConfigs', () async { + const config1 = CmabConfig(cacheSize: 100); + const config2 = CmabConfig(cacheSize: 200); + + var sdk1 = OptimizelyFlutterSdk(testSDKKey, cmabConfig: config1); + var sdk2 = OptimizelyFlutterSdk("different_key", cmabConfig: config2); + + expect(sdk1, isNotNull); + expect(sdk2, isNotNull); + }); + }); + group('decideAsync methods', () { + test('decideAsync single flag sends correct method call', () async { + List? receivedKeys; + List? receivedOptions; + + tester?.setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == Constants.initializeMethod) { + return {Constants.responseSuccess: true}; + } + if (methodCall.method == Constants.createUserContextMethod) { + return { + Constants.responseSuccess: true, + Constants.responseResult: {Constants.userContextId: userContextId}, + }; + } + if (methodCall.method == Constants.decideAsyncMethod) { + receivedKeys = List.from(methodCall.arguments[Constants.keys]); + receivedOptions = List.from(methodCall.arguments[Constants.optimizelyDecideOption]); + return { + Constants.responseSuccess: true, + Constants.responseResult: {flagKey: TestUtils.decideResponseMap}, + }; + } + return null; + }); + + var sdk = OptimizelyFlutterSdk(testSDKKey); + await sdk.initializeClient(); + var userContext = await sdk.createUserContext(userId: userId, attributes: attributes); + + await userContext!.decideAsync(flagKey); + + expect(receivedKeys, equals([flagKey])); + expect(receivedOptions, isNotNull); + }); + + test('decideAsync returns correct DecideResponse', () async { + tester?.setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == Constants.initializeMethod) { + return {Constants.responseSuccess: true}; + } + if (methodCall.method == Constants.createUserContextMethod) { + return { + Constants.responseSuccess: true, + Constants.responseResult: {Constants.userContextId: userContextId}, + }; + } + if (methodCall.method == Constants.decideAsyncMethod) { + return { + Constants.responseSuccess: true, + Constants.responseResult: {flagKey: TestUtils.decideResponseMap}, + }; + } + return null; + }); + + var sdk = OptimizelyFlutterSdk(testSDKKey); + await sdk.initializeClient(); + var userContext = await sdk.createUserContext(userId: userId, attributes: attributes); + + var response = await userContext!.decideAsync(flagKey); + + expect(response.success, isTrue); + expect(response.decision, isNotNull); + expect(response.decision!.flagKey, equals("feature_1")); // From TestUtils.decideResponseMap + expect(response.decision!.variationKey, isNotNull); + expect(response.decision!.enabled, isTrue); + }); + + test('decideForKeysAsync sends multiple keys', () async { + List? receivedKeys; + const keys = ["flag_1", "flag_2", "flag_3"]; + + tester?.setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == Constants.initializeMethod) { + return {Constants.responseSuccess: true}; + } + if (methodCall.method == Constants.createUserContextMethod) { + return { + Constants.responseSuccess: true, + Constants.responseResult: {Constants.userContextId: userContextId}, + }; + } + if (methodCall.method == Constants.decideAsyncMethod) { + receivedKeys = List.from(methodCall.arguments[Constants.keys]); + Map results = {}; + for (var key in keys) { + results[key] = TestUtils.decideResponseMap; + } + return { + Constants.responseSuccess: true, + Constants.responseResult: results, + }; + } + return null; + }); + + var sdk = OptimizelyFlutterSdk(testSDKKey); + await sdk.initializeClient(); + var userContext = await sdk.createUserContext(userId: userId, attributes: attributes); + + await userContext!.decideForKeysAsync(keys); + + expect(receivedKeys, equals(keys)); + }); + + test('decideAllAsync sends empty keys array', () async { + List? receivedKeys; + + tester?.setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == Constants.initializeMethod) { + return {Constants.responseSuccess: true}; + } + if (methodCall.method == Constants.createUserContextMethod) { + return { + Constants.responseSuccess: true, + Constants.responseResult: {Constants.userContextId: userContextId}, + }; + } + if (methodCall.method == Constants.decideAsyncMethod) { + receivedKeys = List.from(methodCall.arguments[Constants.keys]); + return { + Constants.responseSuccess: true, + Constants.responseResult: { + "flag_1": TestUtils.decideResponseMap, + "flag_2": TestUtils.decideResponseMap, + }, + }; + } + return null; + }); + + var sdk = OptimizelyFlutterSdk(testSDKKey); + await sdk.initializeClient(); + var userContext = await sdk.createUserContext(userId: userId, attributes: attributes); + + await userContext!.decideAllAsync(); + + expect(receivedKeys, equals([])); + }); + + test('decideAsync methods accept OptimizelyDecideOption', () async { + List? receivedOptions; + const options = {OptimizelyDecideOption.includeReasons}; + + tester?.setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == Constants.initializeMethod) { + return {Constants.responseSuccess: true}; + } + if (methodCall.method == Constants.createUserContextMethod) { + return { + Constants.responseSuccess: true, + Constants.responseResult: {Constants.userContextId: userContextId}, + }; + } + if (methodCall.method == Constants.decideAsyncMethod) { + receivedOptions = List.from(methodCall.arguments[Constants.optimizelyDecideOption]); + return { + Constants.responseSuccess: true, + Constants.responseResult: {flagKey: TestUtils.decideResponseMap}, + }; + } + return null; + }); + + var sdk = OptimizelyFlutterSdk(testSDKKey); + await sdk.initializeClient(); + var userContext = await sdk.createUserContext(userId: userId, attributes: attributes); + + await userContext!.decideAsync(flagKey, options); + + expect(receivedOptions, contains(OptimizelyDecideOption.includeReasons.name)); + }); + + test('decideAllAsync returns DecideForKeysResponse', () async { + tester?.setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == Constants.initializeMethod) { + return {Constants.responseSuccess: true}; + } + if (methodCall.method == Constants.createUserContextMethod) { + return { + Constants.responseSuccess: true, + Constants.responseResult: {Constants.userContextId: userContextId}, + }; + } + if (methodCall.method == Constants.decideAsyncMethod) { + return { + Constants.responseSuccess: true, + Constants.responseResult: { + "flag_1": TestUtils.decideResponseMap, + "flag_2": TestUtils.decideResponseMap, + "flag_3": TestUtils.decideResponseMap, + }, + }; + } + return null; + }); + + var sdk = OptimizelyFlutterSdk(testSDKKey); + await sdk.initializeClient(); + var userContext = await sdk.createUserContext(userId: userId, attributes: attributes); + + var response = await userContext!.decideAllAsync(); + + expect(response.success, isTrue); + expect(response.decisions.length, equals(3)); + }); + }); + + group('CMAB DecideOptions', () { + test('ignoreCmabCache option is converted correctly', () { + final options = {OptimizelyDecideOption.ignoreCmabCache}; + final converted = Utils.convertDecideOptions(options); + + expect(converted, contains(OptimizelyDecideOption.ignoreCmabCache.name)); + }); + + test('resetCmabCache option is converted correctly', () { + final options = {OptimizelyDecideOption.resetCmabCache}; + final converted = Utils.convertDecideOptions(options); + + expect(converted, contains(OptimizelyDecideOption.resetCmabCache.name)); + }); + + test('invalidateUserCmabCache option is converted correctly', () { + final options = {OptimizelyDecideOption.invalidateUserCmabCache}; + final converted = Utils.convertDecideOptions(options); + + expect(converted, contains(OptimizelyDecideOption.invalidateUserCmabCache.name)); + }); + + test('multiple CMAB options are converted correctly', () { + final options = { + OptimizelyDecideOption.ignoreCmabCache, + OptimizelyDecideOption.resetCmabCache, + OptimizelyDecideOption.includeReasons, + }; + final converted = Utils.convertDecideOptions(options); + + expect(converted.length, equals(3)); + expect(converted, contains(OptimizelyDecideOption.ignoreCmabCache.name)); + expect(converted, contains(OptimizelyDecideOption.resetCmabCache.name)); + expect(converted, contains(OptimizelyDecideOption.includeReasons.name)); + }); + + test('CMAB options work with standard options', () { + final options = { + OptimizelyDecideOption.ignoreCmabCache, + OptimizelyDecideOption.disableDecisionEvent, + OptimizelyDecideOption.excludeVariables, + }; + final converted = Utils.convertDecideOptions(options); + + expect(converted.length, equals(3)); + expect(converted, contains(OptimizelyDecideOption.ignoreCmabCache.name)); + expect(converted, contains(OptimizelyDecideOption.disableDecisionEvent.name)); + expect(converted, contains(OptimizelyDecideOption.excludeVariables.name)); + }); + + test('all three CMAB cache options can be used together', () { + final options = { + OptimizelyDecideOption.ignoreCmabCache, + OptimizelyDecideOption.resetCmabCache, + OptimizelyDecideOption.invalidateUserCmabCache, + }; + final converted = Utils.convertDecideOptions(options); + + expect(converted.length, equals(3)); + expect(converted, contains(OptimizelyDecideOption.ignoreCmabCache.name)); + expect(converted, contains(OptimizelyDecideOption.resetCmabCache.name)); + expect(converted, contains(OptimizelyDecideOption.invalidateUserCmabCache.name)); + }); + }); + + group('CMAB Constants', () { + test('CMAB constants are defined correctly', () { + expect(Constants.cmabConfig, equals("cmabConfig")); + expect(Constants.cmabCacheSize, equals("cmabCacheSize")); + expect(Constants.cmabCacheTimeoutInSecs, equals("cmabCacheTimeoutInSecs")); + expect(Constants.cmabPredictionEndpoint, equals("cmabPredictionEndpoint")); + expect(Constants.decideAsyncMethod, equals("decideAsync")); + }); + }); +} \ No newline at end of file diff --git a/test/logger_test.dart b/test/logger_test.dart new file mode 100644 index 0000000..8c0161e --- /dev/null +++ b/test/logger_test.dart @@ -0,0 +1,506 @@ +import "package:flutter/services.dart"; +import "package:flutter_test/flutter_test.dart"; +import 'package:optimizely_flutter_sdk/src/logger/logger_bridge.dart'; +import 'package:optimizely_flutter_sdk/src/logger/flutter_logger.dart'; +import 'package:optimizely_flutter_sdk/src/data_objects/log_level.dart'; + +/// Test implementation of OptimizelyLogger for testing +class TestLogger implements OptimizelyLogger { + final List logs = []; + + @override + void log(OptimizelyLogLevel level, String message) { + logs.add(LogEntry(level, message)); + } + + void clear() { + logs.clear(); + } +} + +/// Data class for log entries +class LogEntry { + final OptimizelyLogLevel level; + final String message; + + LogEntry(this.level, this.message); + + @override + String toString() => '${level.name}: $message'; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group("Logger Tests", () { + setUp(() { + // Reset logger state before each test + LoggerBridge.reset(); + }); + + test("should handle log method call from native", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + // Simulate native log call by directly invoking the method handler + final methodCall = const MethodCall('log', { + 'level': 3, // INFO + 'message': 'Test log message from native' + }); + + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.length, equals(1)); + expect(testLogger.logs.first.level, equals(OptimizelyLogLevel.info)); + expect(testLogger.logs.first.message, equals('Test log message from native')); + }); + + test("should convert log levels correctly", () { + expect(LoggerBridge.convertLogLevel(1), equals(OptimizelyLogLevel.error)); + expect(LoggerBridge.convertLogLevel(2), equals(OptimizelyLogLevel.warning)); + expect(LoggerBridge.convertLogLevel(3), equals(OptimizelyLogLevel.info)); + expect(LoggerBridge.convertLogLevel(4), equals(OptimizelyLogLevel.debug)); + }); + + test("should default to info for invalid log levels", () { + expect(LoggerBridge.convertLogLevel(0), equals(OptimizelyLogLevel.info)); + expect(LoggerBridge.convertLogLevel(5), equals(OptimizelyLogLevel.info)); + expect(LoggerBridge.convertLogLevel(-1), equals(OptimizelyLogLevel.info)); + }); + + test("should reset state correctly", () { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + expect(LoggerBridge.hasLogger(), isTrue); + + LoggerBridge.reset(); + + expect(LoggerBridge.hasLogger(), isFalse); + expect(LoggerBridge.getCurrentLogger(), isNull); + }); + + group("Error Handling", () { + test("should handle null arguments gracefully", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final methodCall = const MethodCall('log', null); + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.isEmpty, isTrue); + }); + + test("should handle empty arguments gracefully", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final methodCall = const MethodCall('log', {}); + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.isEmpty, isTrue); + }); + + test("should handle missing level argument", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final methodCall = const MethodCall('log', { + 'message': 'Test message without level' + }); + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.isEmpty, isTrue); + }); + + test("should handle missing message argument", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final methodCall = const MethodCall('log', { + 'level': 3 + }); + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.isEmpty, isTrue); + }); + + test("should handle invalid level data types", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + // Test with string level + var methodCall = const MethodCall('log', { + 'level': 'invalid', + 'message': 'Test message' + }); + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.isEmpty, isTrue); + + // Test with null level + methodCall = const MethodCall('log', { + 'level': null, + 'message': 'Test message' + }); + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.isEmpty, isTrue); + + testLogger.clear(); + }); + + test("should handle unknown method calls", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final methodCall = const MethodCall('unknownMethod', { + 'level': 3, + 'message': 'Test message' + }); + + // Should not throw + expect(() async { + await LoggerBridge.handleMethodCallForTesting(methodCall); + }, returnsNormally); + + expect(testLogger.logs.isEmpty, isTrue); + }); + }); + + group("Multiple Log Levels", () { + test("should handle all log levels in sequence", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final testCases = [ + {'level': 1, 'message': 'Error message', 'expected': OptimizelyLogLevel.error}, + {'level': 2, 'message': 'Warning message', 'expected': OptimizelyLogLevel.warning}, + {'level': 3, 'message': 'Info message', 'expected': OptimizelyLogLevel.info}, + {'level': 4, 'message': 'Debug message', 'expected': OptimizelyLogLevel.debug}, + ]; + + for (var testCase in testCases) { + final methodCall = MethodCall('log', { + 'level': testCase['level'], + 'message': testCase['message'] + }); + + await LoggerBridge.handleMethodCallForTesting(methodCall); + } + + expect(testLogger.logs.length, equals(4)); + + for (int i = 0; i < testCases.length; i++) { + expect(testLogger.logs[i].level, equals(testCases[i]['expected'])); + expect(testLogger.logs[i].message, equals(testCases[i]['message'])); + } + }); + + test("should handle mixed valid and invalid log levels", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final testCases = [ + {'level': 1, 'message': 'Valid error', 'shouldLog': true}, + {'level': 'invalid', 'message': 'Invalid level', 'shouldLog': false}, + {'level': 3, 'message': 'Valid info', 'shouldLog': true}, + {'level': 999, 'message': 'Out of range level', 'shouldLog': true}, // Maps to info + {'level': -1, 'message': 'Negative level', 'shouldLog': true}, // Maps to info + ]; + + for (var testCase in testCases) { + final methodCall = MethodCall('log', { + 'level': testCase['level'], + 'message': testCase['message'] + }); + + await LoggerBridge.handleMethodCallForTesting(methodCall); + } + + // Should have 4 logs (all except the 'invalid' string level) + expect(testLogger.logs.length, equals(4)); + expect(testLogger.logs[0].level, equals(OptimizelyLogLevel.error)); + expect(testLogger.logs[1].level, equals(OptimizelyLogLevel.info)); + expect(testLogger.logs[2].level, equals(OptimizelyLogLevel.info)); // 999 maps to info + expect(testLogger.logs[3].level, equals(OptimizelyLogLevel.info)); // -1 maps to info + }); + }); + + group("DefaultOptimizelyLogger", () { + test("should create default logger instance", () { + var defaultLogger = DefaultOptimizelyLogger(); + expect(defaultLogger, isNotNull); + }); + + test("should handle logging without throwing", () { + var defaultLogger = DefaultOptimizelyLogger(); + + expect(() { + defaultLogger.log(OptimizelyLogLevel.error, "Error message"); + defaultLogger.log(OptimizelyLogLevel.warning, "Warning message"); + defaultLogger.log(OptimizelyLogLevel.info, "Info message"); + defaultLogger.log(OptimizelyLogLevel.debug, "Debug message"); + }, returnsNormally); + }); + }); + group("Global Logging Functions", () { + test("should call global logging functions without error", () { + expect(() { + logError("Global error message"); + logWarning("Global warning message"); + logInfo("Global info message"); + logDebug("Global debug message"); + }, returnsNormally); + }); + + test("should handle empty messages in global functions", () { + expect(() { + logError(""); + logWarning(""); + logInfo(""); + logDebug(""); + }, returnsNormally); + }); + + test("should handle special characters in global functions", () { + var specialMessage = "Special: 🚀 \n\t 世界"; + + expect(() { + logError(specialMessage); + logWarning(specialMessage); + logInfo(specialMessage); + logDebug(specialMessage); + }, returnsNormally); + }); + + test("should handle rapid calls to global functions", () { + expect(() { + for (int i = 0; i < 25; i++) { + logError("Rapid error $i"); + logWarning("Rapid warning $i"); + logInfo("Rapid info $i"); + logDebug("Rapid debug $i"); + } + }, returnsNormally); + }); + }); + group("Concurrent Access", () { + test("should handle multiple concurrent log calls", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + // Create multiple concurrent log calls + var futures = []; + for (int i = 0; i < 25; i++) { + futures.add(LoggerBridge.handleMethodCallForTesting( + MethodCall('log', { + 'level': (i % 4) + 1, // Cycle through levels 1-4 + 'message': 'Concurrent message $i' + }) + )); + } + + await Future.wait(futures); + + expect(testLogger.logs.length, equals(25)); + + // Verify all messages are present + for (int i = 0; i < 25; i++) { + expect(testLogger.logs.any((log) => log.message == 'Concurrent message $i'), isTrue); + } + }); + + test("should handle logger reinitialization during concurrent access", () async { + var testLogger1 = TestLogger(); + var testLogger2 = TestLogger(); + + LoggerBridge.initialize(testLogger1); + + // Start some async operations + var futures = []; + for (int i = 0; i < 5; i++) { + futures.add(LoggerBridge.handleMethodCallForTesting( + MethodCall('log', { + 'level': 3, + 'message': 'Message before reinit $i' + }) + )); + } + + // Reinitialize with a different logger mid-flight + LoggerBridge.initialize(testLogger2); + + // Add more operations + for (int i = 0; i < 5; i++) { + futures.add(LoggerBridge.handleMethodCallForTesting( + MethodCall('log', { + 'level': 3, + 'message': 'Message after reinit $i' + }) + )); + } + + await Future.wait(futures); + + // The total logs should be distributed between the two loggers + var totalLogs = testLogger1.logs.length + testLogger2.logs.length; + expect(totalLogs, equals(10)); + expect(LoggerBridge.getCurrentLogger(), equals(testLogger2)); + }); + }); + + group("Performance", () { + test("should handle high volume of logs efficiently", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + var stopwatch = Stopwatch()..start(); + + // Send 100 log messages + for (int i = 0; i < 100; i++) { + await LoggerBridge.handleMethodCallForTesting( + MethodCall('log', { + 'level': (i % 4) + 1, + 'message': 'Performance test log $i' + }) + ); + } + + stopwatch.stop(); + + expect(testLogger.logs.length, equals(100)); + expect(stopwatch.elapsedMilliseconds, lessThan(2000)); // Should complete in < 2 seconds + + // Verify first and last messages + expect(testLogger.logs.first.message, equals('Performance test log 0')); + expect(testLogger.logs.last.message, equals('Performance test log 99')); + }); + + test("should handle large message content efficiently", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + // Create a large message (10KB) + var largeMessage = 'X' * 10240; + + var stopwatch = Stopwatch()..start(); + + await LoggerBridge.handleMethodCallForTesting( + MethodCall('log', { + 'level': 3, + 'message': largeMessage + }) + ); + + stopwatch.stop(); + + expect(testLogger.logs.length, equals(1)); + expect(testLogger.logs.first.message.length, equals(10240)); + expect(stopwatch.elapsedMilliseconds, lessThan(100)); // Should be very fast + }); + }); + + group("State Management", () { + test("should maintain state across multiple operations", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + // Perform various operations + await LoggerBridge.handleMethodCallForTesting( + const MethodCall('log', {'level': 1, 'message': 'First message'}) + ); + + expect(LoggerBridge.hasLogger(), isTrue); + expect(testLogger.logs.length, equals(1)); + + await LoggerBridge.handleMethodCallForTesting( + const MethodCall('log', {'level': 2, 'message': 'Second message'}) + ); + + expect(LoggerBridge.hasLogger(), isTrue); + expect(testLogger.logs.length, equals(2)); + + LoggerBridge.reset(); + + expect(LoggerBridge.hasLogger(), isFalse); + expect(testLogger.logs.length, equals(2)); // Logger keeps its own state + }); + + test("should handle logger replacement", () async { + var testLogger1 = TestLogger(); + var testLogger2 = TestLogger(); + + // Initialize with first logger + LoggerBridge.initialize(testLogger1); + await LoggerBridge.handleMethodCallForTesting( + const MethodCall('log', {'level': 3, 'message': 'Message to logger 1'}) + ); + + expect(testLogger1.logs.length, equals(1)); + expect(testLogger2.logs.length, equals(0)); + + // Replace with second logger + LoggerBridge.initialize(testLogger2); + await LoggerBridge.handleMethodCallForTesting( + const MethodCall('log', {'level': 3, 'message': 'Message to logger 2'}) + ); + + expect(testLogger1.logs.length, equals(1)); // Unchanged + expect(testLogger2.logs.length, equals(1)); // New message + expect(LoggerBridge.getCurrentLogger(), equals(testLogger2)); + }); + }); + + group("Edge Cases", () { + test("should handle empty message", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + await LoggerBridge.handleMethodCallForTesting( + const MethodCall('log', { + 'level': 3, + 'message': '' + }) + ); + + expect(testLogger.logs.length, equals(1)); + expect(testLogger.logs.first.message, equals('')); + expect(testLogger.logs.first.level, equals(OptimizelyLogLevel.info)); + }); + + test("should handle special characters in message", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + var specialMessage = 'Special chars: 🚀 ñáéíóú 中文 \n\t\r\\'; + + await LoggerBridge.handleMethodCallForTesting( + MethodCall('log', { + 'level': 3, + 'message': specialMessage + }) + ); + + expect(testLogger.logs.length, equals(1)); + expect(testLogger.logs.first.message, equals(specialMessage)); + }); + + test("should handle invalid data types gracefully", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + // Test with double level - should fail gracefully + await LoggerBridge.handleMethodCallForTesting( + const MethodCall('log', { + 'level': 3.0, // Double instead of int + 'message': 'Message with double level' + }) + ); + + // Should not log anything due to type casting error + expect(testLogger.logs.length, equals(0)); + }); + }); + }); +} diff --git a/test/nested_object_test.dart b/test/nested_object_test.dart new file mode 100644 index 0000000..5ad903b --- /dev/null +++ b/test/nested_object_test.dart @@ -0,0 +1,487 @@ +/// ************************************************************************** +/// Copyright 2022-2024, 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:flutter_test/flutter_test.dart'; +import 'package:optimizely_flutter_sdk/src/utils/utils.dart'; +import 'package:optimizely_flutter_sdk/src/utils/constants.dart'; + +void main() { + group('Utils.convertToTypedMap - Android Format (primitiveMap)', () { + // These tests verify Android behavior where original structure is preserved + + test('should preserve nested maps in Android format', () { + final input = { + 'simple': 'value', + 'user': { + 'id': '123', + 'name': 'John', + 'age': 30, + } + }; + + final result = Utils.convertToTypedMap(input); // Default is Android format + + expect(result, isNotNull); + expect(result.containsKey('simple'), true); + expect(result.containsKey('user'), true); + + // Android format: original structure preserved + expect(result['simple'], 'value'); + expect(result['user'], isA()); + final userMap = result['user'] as Map; + expect(userMap['id'], '123'); + expect(userMap['name'], 'John'); + expect(userMap['age'], 30); + }); + + test('should preserve deeply nested maps in Android format', () { + final input = { + 'level1': { + 'level2': { + 'level3': { + 'value': 'deep', + } + } + } + }; + + final result = Utils.convertToTypedMap(input); + + final level1 = result['level1'] as Map; + final level2 = level1['level2'] as Map; + final level3 = level2['level3'] as Map; + expect(level3['value'], 'deep'); + }); + + test('should preserve lists of primitives in Android format', () { + final input = { + 'tags': ['flutter', 'optimizely', 'sdk'], + 'scores': [1, 2, 3, 4, 5], + }; + + final result = Utils.convertToTypedMap(input); + + expect(result['tags'], isA()); + expect((result['tags'] as List).length, 3); + expect((result['tags'] as List)[0], 'flutter'); + + expect(result['scores'], isA()); + expect((result['scores'] as List).length, 5); + expect((result['scores'] as List)[0], 1); + }); + + test('should preserve lists of maps in Android format', () { + final input = { + 'users': [ + {'name': 'Alice', 'age': 30}, + {'name': 'Bob', 'age': 25}, + ] + }; + + final result = Utils.convertToTypedMap(input); + + final users = result['users'] as List; + expect(users.length, 2); + expect((users[0] as Map)['name'], 'Alice'); + expect((users[0] as Map)['age'], 30); + expect((users[1] as Map)['name'], 'Bob'); + expect((users[1] as Map)['age'], 25); + }); + + test('should handle empty collections in Android format', () { + final input = { + 'emptyMap': {}, + 'emptyList': [], + 'name': 'test', + }; + + final result = Utils.convertToTypedMap(input); + + expect(result['emptyMap'], isA()); + expect((result['emptyMap'] as Map).isEmpty, true); + expect(result['emptyList'], isA()); + expect((result['emptyList'] as List).isEmpty, true); + expect(result['name'], 'test'); + }); + }); + + group('Utils.convertToTypedMap - iOS Format (typedMap)', () { + // These tests verify iOS behavior where types are wrapped + + test('should wrap primitive types with type information for iOS', () { + final input = { + 'stringKey': 'value', + 'intKey': 42, + 'doubleKey': 3.14, + 'boolKey': true, + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + // iOS format: values are wrapped with type information + expect(result['stringKey'], isA()); + expect(result['stringKey']['value'], 'value'); + expect(result['stringKey']['type'], Constants.stringType); + + expect(result['intKey']['value'], 42); + expect(result['intKey']['type'], Constants.intType); + + expect(result['doubleKey']['value'], 3.14); + expect(result['doubleKey']['type'], Constants.doubleType); + + expect(result['boolKey']['value'], true); + expect(result['boolKey']['type'], Constants.boolType); + }); + + test('should wrap nested maps with type information for iOS', () { + final input = { + 'user': { + 'id': '123', + 'name': 'John', + 'age': 30, + } + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + // Check outer map type + expect(result['user'], isA()); + expect(result['user']['type'], Constants.mapType); + expect(result['user']['value'], isA()); + + // Check nested values + final nestedMap = result['user']['value'] as Map; + expect(nestedMap['id']['value'], '123'); + expect(nestedMap['id']['type'], Constants.stringType); + expect(nestedMap['name']['value'], 'John'); + expect(nestedMap['name']['type'], Constants.stringType); + expect(nestedMap['age']['value'], 30); + expect(nestedMap['age']['type'], Constants.intType); + }); + + test('should wrap deeply nested maps for iOS', () { + final input = { + 'user': { + 'profile': { + 'preferences': { + 'theme': 'dark', + 'notifications': true, + } + } + } + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + expect(result['user']['type'], Constants.mapType); + final userMap = result['user']['value'] as Map; + + expect(userMap['profile']['type'], Constants.mapType); + final profileMap = userMap['profile']['value'] as Map; + + expect(profileMap['preferences']['type'], Constants.mapType); + final preferencesMap = profileMap['preferences']['value'] as Map; + + expect(preferencesMap['theme']['value'], 'dark'); + expect(preferencesMap['theme']['type'], Constants.stringType); + expect(preferencesMap['notifications']['value'], true); + expect(preferencesMap['notifications']['type'], Constants.boolType); + }); + + test('should wrap lists of primitives for iOS', () { + final input = { + 'tags': ['flutter', 'optimizely', 'sdk'], + 'scores': [1, 2, 3, 4, 5], + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + // Check list is wrapped + expect(result['tags']['type'], Constants.listType); + final tagsList = result['tags']['value'] as List; + expect(tagsList.length, 3); + expect(tagsList[0]['value'], 'flutter'); + expect(tagsList[0]['type'], Constants.stringType); + + expect(result['scores']['type'], Constants.listType); + final scoresList = result['scores']['value'] as List; + expect(scoresList.length, 5); + expect(scoresList[0]['value'], 1); + expect(scoresList[0]['type'], Constants.intType); + }); + + test('should wrap lists of maps for iOS', () { + final input = { + 'users': [ + {'name': 'Alice', 'age': 30}, + {'name': 'Bob', 'age': 25}, + ] + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + expect(result['users']['type'], Constants.listType); + final usersList = result['users']['value'] as List; + expect(usersList.length, 2); + + // Check first user + expect(usersList[0]['type'], Constants.mapType); + final firstUser = usersList[0]['value'] as Map; + expect(firstUser['name']['value'], 'Alice'); + expect(firstUser['name']['type'], Constants.stringType); + expect(firstUser['age']['value'], 30); + expect(firstUser['age']['type'], Constants.intType); + + // Check second user + expect(usersList[1]['type'], Constants.mapType); + final secondUser = usersList[1]['value'] as Map; + expect(secondUser['name']['value'], 'Bob'); + expect(secondUser['age']['value'], 25); + }); + + test('should handle empty collections in iOS format', () { + final input = { + 'emptyMap': {}, + 'emptyList': [], + 'name': 'test', + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + expect(result['emptyMap']['type'], Constants.mapType); + final emptyMapValue = result['emptyMap']['value'] as Map; + expect(emptyMapValue.isEmpty, true); + + expect(result['emptyList']['type'], Constants.listType); + final emptyListValue = result['emptyList']['value'] as List; + expect(emptyListValue.isEmpty, true); + + expect(result['name']['value'], 'test'); + expect(result['name']['type'], Constants.stringType); + }); + + test('should handle mixed complex structures for iOS', () { + final input = { + 'event': 'purchase', + 'revenue': 99.99, + 'user': { + 'id': 'user123', + 'premium': true, + 'tags': ['vip', 'loyal'], + }, + 'items': [ + {'name': 'Product A', 'quantity': 2}, + {'name': 'Product B', 'quantity': 1}, + ], + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + // Check primitives + expect(result['event']['value'], 'purchase'); + expect(result['event']['type'], Constants.stringType); + expect(result['revenue']['value'], 99.99); + expect(result['revenue']['type'], Constants.doubleType); + + // Check nested map + expect(result['user']['type'], Constants.mapType); + final userMap = result['user']['value'] as Map; + expect(userMap['id']['value'], 'user123'); + expect(userMap['premium']['value'], true); + + // Check nested list in map + expect(userMap['tags']['type'], Constants.listType); + final tagsList = userMap['tags']['value'] as List; + expect(tagsList[0]['value'], 'vip'); + + // Check list of maps + expect(result['items']['type'], Constants.listType); + final itemsList = result['items']['value'] as List; + expect(itemsList.length, 2); + expect(itemsList[0]['type'], Constants.mapType); + final item0 = itemsList[0]['value'] as Map; + expect(item0['name']['value'], 'Product A'); + expect(item0['quantity']['value'], 2); + }); + }); + + group('Utils.convertToTypedMap - Real World Scenarios', () { + test('should handle real-world trackEvent example in both formats', () { + final input = { + 'event_type': 'checkout', + 'revenue': 199.99, + 'user': { + 'id': 'user_12345', + 'email': 'user@example.com', + 'is_premium': true, + 'account_age_days': 365, + }, + 'cart': { + 'items': [ + { + 'product_id': 'prod_1', + 'name': 'Widget', + 'price': 99.99, + 'quantity': 1, + }, + { + 'product_id': 'prod_2', + 'name': 'Gadget', + 'price': 100.00, + 'quantity': 1, + }, + ], + 'total_items': 2, + }, + 'metadata': { + 'source': 'mobile_app', + 'platform': 'ios', + 'version': '2.1.0', + }, + }; + + // Test Android format + final androidResult = Utils.convertToTypedMap(input); + expect(androidResult, isNotNull); + expect(androidResult.containsKey('event_type'), true); + expect(androidResult['event_type'], 'checkout'); + + final androidUserMap = androidResult['user'] as Map; + expect(androidUserMap['id'], 'user_12345'); + expect(androidUserMap['is_premium'], true); + + final androidCartMap = androidResult['cart'] as Map; + final androidItems = androidCartMap['items'] as List; + expect(androidItems.length, 2); + expect((androidItems[0] as Map)['product_id'], 'prod_1'); + + // Test iOS format + final iosResult = Utils.convertToTypedMap(input, forceIOSFormat: true); + expect(iosResult, isNotNull); + expect(iosResult.containsKey('event_type'), true); + expect(iosResult['event_type']['value'], 'checkout'); + expect(iosResult['event_type']['type'], Constants.stringType); + + final iosUserMap = iosResult['user']['value'] as Map; + expect(iosUserMap['id']['value'], 'user_12345'); + expect(iosUserMap['is_premium']['value'], true); + + final iosCartMap = iosResult['cart']['value'] as Map; + final iosItems = iosCartMap['items']['value'] as List; + expect(iosItems.length, 2); + final iosFirstItem = iosItems[0]['value'] as Map; + expect(iosFirstItem['product_id']['value'], 'prod_1'); + }); + + test('should not throw error on nested objects (regression test)', () { + // This ensures we no longer silently drop nested objects like before + final input = { + 'supported': 'value', + 'nested': { + 'should': 'work', + 'now': true, + } + }; + + // Should work in both formats without error + expect(() => Utils.convertToTypedMap(input), returnsNormally); + expect(() => Utils.convertToTypedMap(input, forceIOSFormat: true), returnsNormally); + + final androidResult = Utils.convertToTypedMap(input); + expect(androidResult.containsKey('nested'), true); + expect((androidResult['nested'] as Map)['should'], 'work'); + + final iosResult = Utils.convertToTypedMap(input, forceIOSFormat: true); + expect(iosResult.containsKey('nested'), true); + expect(iosResult['nested']['type'], Constants.mapType); + }); + + test('should handle list with mixed types in both formats', () { + final input = { + 'mixed': [1, 'two', 3.0, true], + }; + + // Android format + final androidResult = Utils.convertToTypedMap(input); + final androidMixed = androidResult['mixed'] as List; + expect(androidMixed[0], 1); + expect(androidMixed[1], 'two'); + expect(androidMixed[2], 3.0); + expect(androidMixed[3], true); + + // iOS format + final iosResult = Utils.convertToTypedMap(input, forceIOSFormat: true); + final iosMixed = iosResult['mixed']['value'] as List; + expect(iosMixed[0]['value'], 1); + expect(iosMixed[0]['type'], Constants.intType); + expect(iosMixed[1]['value'], 'two'); + expect(iosMixed[1]['type'], Constants.stringType); + expect(iosMixed[2]['value'], 3.0); + expect(iosMixed[2]['type'], Constants.doubleType); + expect(iosMixed[3]['value'], true); + expect(iosMixed[3]['type'], Constants.boolType); + }); + }); + + group('Utils.convertToTypedMap - Edge Cases', () { + test('should handle empty map', () { + final input = {}; + + final androidResult = Utils.convertToTypedMap(input); + expect(androidResult.isEmpty, true); + + final iosResult = Utils.convertToTypedMap(input, forceIOSFormat: true); + expect(iosResult.isEmpty, true); + }); + + test('should handle map with only primitives', () { + final input = { + 'a': 1, + 'b': 'text', + 'c': true, + 'd': 3.14, + }; + + final androidResult = Utils.convertToTypedMap(input); + expect(androidResult['a'], 1); + expect(androidResult['b'], 'text'); + + final iosResult = Utils.convertToTypedMap(input, forceIOSFormat: true); + expect(iosResult['a']['value'], 1); + expect(iosResult['b']['value'], 'text'); + }); + + test('should handle deeply nested arrays', () { + final input = { + 'nested': [ + [1, 2, 3], + [4, 5, 6], + ] + }; + + // Android + final androidResult = Utils.convertToTypedMap(input); + expect(((androidResult['nested'] as List)[0] as List)[0], 1); + + // iOS + final iosResult = Utils.convertToTypedMap(input, forceIOSFormat: true); + final iosOuter = iosResult['nested']['value'] as List; + final iosInner = iosOuter[0]['value'] as List; + expect(iosInner[0]['value'], 1); + expect(iosInner[0]['type'], Constants.intType); + }); + }); +} diff --git a/test/optimizely_flutter_sdk_test.dart b/test/optimizely_flutter_sdk_test.dart index 862c4b0..555af58 100644 --- a/test/optimizely_flutter_sdk_test.dart +++ b/test/optimizely_flutter_sdk_test.dart @@ -109,6 +109,14 @@ void main() { ); } + // To Check if CmabConfig was received + var cmabConfigMap = methodCall.arguments[Constants.cmabConfig]; + if (cmabConfigMap is Map) { + // CmabConfig received - validate it has expected keys + expect(cmabConfigMap.containsKey(Constants.cmabCacheSize), isTrue); + expect(cmabConfigMap.containsKey(Constants.cmabCacheTimeoutInSecs), isTrue); + } + // Resetting to default for every test datafileHostOptions = const DatafileHostOptions("", ""); if (methodCall.arguments[Constants.datafileHostPrefix] != null && @@ -311,6 +319,25 @@ void main() { Constants.responseSuccess: true, Constants.responseResult: result, }; + case Constants.decideAsyncMethod: + expect(methodCall.arguments[Constants.sdkKey], isNotEmpty); + expect(methodCall.arguments[Constants.userContextId], + equals(userContextId)); + var asyncKeys = List.from(methodCall.arguments[Constants.keys]); + decideOptions.addAll(List.from( + methodCall.arguments[Constants.optimizelyDecideOption])); + // for decideAllAsync + if (asyncKeys.isEmpty) { + asyncKeys = ["123", "456", "789"]; + } + Map asyncResult = {}; + for (final key in asyncKeys) { + asyncResult[key] = TestUtils.decideResponseMap; + } + return { + Constants.responseSuccess: true, + Constants.responseResult: asyncResult, + }; case Constants.setForcedDecision: expect(methodCall.arguments[Constants.sdkKey], isNotEmpty); expect(methodCall.arguments[Constants.userContextId], diff --git a/test/test_utils.dart b/test/test_utils.dart index 986bd8e..350c35e 100644 --- a/test/test_utils.dart +++ b/test/test_utils.dart @@ -89,7 +89,14 @@ class TestUtils { handler(MethodCall(Constants.decisionCallBackListener, { Constants.id: id, Constants.sdkKey: sdkKey, - Constants.payload: {Constants.type: "$id", Constants.userId: "test"} + Constants.payload: { + Constants.type: "$id", + Constants.userId: "test", + Constants.decisionInfo: const { + Constants.experimentId: "experiment_12345", + Constants.variationId: "variation_12345", + }, + } })); } @@ -129,7 +136,15 @@ class TestUtils { Constants.eventKey: "$id", Constants.userId: "test", Constants.attributes: {"test": id}, - Constants.eventTags: {"testTag": id} + Constants.eventTags: { + "testTag": id, + "nestedTag": { + "string_key": "stringValue", + "int_key": 123, + "double_key": 123.456, + "bool_key": true + } + } }; handler(MethodCall(Constants.trackCallBackListener, { Constants.id: id, @@ -145,6 +160,12 @@ class TestUtils { Constants.attributes: {"test": id}, Constants.eventTags: { "testTag": id, + "nestedTag": { + "string_key": "stringValue", + "int_key": 123, + "double_key": 123.456, + "bool_key": true + }, "client_name": clientName, "client_version": sdkVersion } @@ -179,7 +200,11 @@ class TestUtils { static bool testDecisionNotificationPayload( List notifications, int id, int actualID) { if (notifications[id].type != "$actualID" || - notifications[id].userId != "test") { + notifications[id].userId != "test" || + notifications[id].decisionInfo[Constants.experimentId] != + "experiment_12345" || + notifications[id].decisionInfo[Constants.variationId] != + "variation_12345") { return false; } return true;