Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions .github/workflows/flutter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,6 @@ jobs:
repository: 'optimizely/travisci-tools'
path: 'home/runner/travisci-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 }}
Expand Down
125 changes: 125 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# CLAUDE.md
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asking out of curiosity, are we planning to add md files like this for every sdk?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is nice to have


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
5 changes: 5 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ dependencies {
implementation 'org.slf4j:slf4j-api:2.0.7'

implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0"
implementation "com.optimizely.ab:android-sdk:5.0.1"
implementation "com.optimizely.ab:android-sdk:5.1.0"
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
implementation ('com.google.guava:guava:19.0') {
exclude group:'com.google.guava', module:'listenablefuture'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,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;
Expand Down Expand Up @@ -187,6 +191,25 @@ protected void initializeOptimizely(@NonNull ArgumentsParser argumentsParser, @N
optimizelyManagerBuilder.withVuidEnabled();
}

// CMAB Config
Map<String, Object> 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) -> {
Expand Down Expand Up @@ -364,6 +387,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<String> decideKeys = argumentsParser.getDecideKeys();
List<OptimizelyDecideOption> decideOptions = argumentsParser.getDecideOptions();

// Determine which async method to call based on keys
if (decideKeys == null || decideKeys.isEmpty()) {
// decideAllAsync
userContext.decideAllAsync(decideOptions, decisions -> {
Map<String, OptimizelyDecisionResponse> optimizelyDecisionResponseMap = new HashMap<>();
if (decisions != null) {
for (Map.Entry<String, OptimizelyDecision> entry : decisions.entrySet()) {
optimizelyDecisionResponseMap.put(entry.getKey(), new OptimizelyDecisionResponse(entry.getValue()));
}
}
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> 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<String, OptimizelyDecisionResponse> optimizelyDecisionResponseMap = new HashMap<>();
optimizelyDecisionResponseMap.put(decideKeys.get(0), new OptimizelyDecisionResponse(decision));
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> s = mapper.convertValue(optimizelyDecisionResponseMap, LinkedHashMap.class);
result.success(createResponse(s));
});
} else {
// decideForKeysAsync for multiple keys
userContext.decideForKeysAsync(decideKeys, decideOptions, decisions -> {
Map<String, OptimizelyDecisionResponse> optimizelyDecisionResponseMap = new HashMap<>();
if (decisions != null) {
for (Map.Entry<String, OptimizelyDecision> entry : decisions.entrySet()) {
optimizelyDecisionResponseMap.put(entry.getKey(), new OptimizelyDecisionResponse(entry.getValue()));
}
}
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,8 @@ public List<ODPSegmentOption> getSegmentOptions() {
public Map<String, Object> getOptimizelySdkSettings() {
return (Map<String, Object>) arguments.get(Constants.RequestParameterKey.OPTIMIZELY_SDK_SETTINGS);
}

public Map<String, Object> getCmabConfig() {
return (Map<String, Object>) arguments.get(Constants.RequestParameterKey.CMAB_CONFIG);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ public static List<OptimizelyDecideOption> getDecideOptions(List<String> 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;
}
Expand Down
Loading