diff --git a/.gitignore b/.gitignore index 0ed355f..f31992b 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,8 @@ app.*.symbols !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages !/dev/ci/**/Gemfile.lock + +# Environment variables +.env +.mcp.json +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index b52faf5..65b2312 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,38 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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. +Optimizely Flutter SDK - Cross-platform plugin wrapping native Optimizely SDKs (iOS, Android) for A/B testing, feature flags, CMAB, and ODP integration. **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 +cd ios && pod install # iOS dependencies # Testing -flutter test # All tests -flutter test test/cmab_test.dart # Specific test -flutter test --coverage # With coverage +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 +# Run example app cd example && flutter run ``` @@ -47,9 +35,9 @@ Dart API (OptimizelyFlutterSdk) ↓ Wrapper (OptimizelyClientWrapper) + MethodChannel ↓ -Native (Swift/Java plugin implementations) +Native Plugins (Swift/Java) ↓ -Native Optimizely SDKs +Native Optimizely SDKs (5.2.1 iOS / 5.1.1 Android) ``` ### Critical Patterns @@ -58,68 +46,151 @@ Native Optimizely SDKs - ALL methods return `BaseResponse` derivatives (never throw exceptions) - Check `success` boolean and `reason` string for errors -**2. Multi-Instance State** +**2. Multi-Instance State Management** - SDK instances tracked by `sdkKey` - User contexts: `sdkKey → userContextId → context` - Notification listeners: `sdkKey → listenerId → callback` - Call `close()` for cleanup -**3. Platform-Specific Type Encoding** +**3. Platform-Specific Type Encoding (CRITICAL)** - **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 +- Conversion in `Utils.convertToTypedMap()` (lib/src/utils/utils.dart) +- Test override: `forceIOSFormat` parameter -**iOS:** -- `ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift` - MethodChannel handler -- `ios/optimizely_flutter_sdk.podspec` - Pod dependencies +**4. Dual MethodChannel Architecture** +- `optimizely_flutter_sdk` - Main API operations +- `optimizely_flutter_sdk_logger` - Native → Dart log forwarding ## 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 +2. Update `lib/src/optimizely_client_wrapper.dart` with MethodChannel 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` +5. Handle type conversions (iOS requires metadata wrapping) +6. Write tests in `test/` +7. Update public API in `lib/optimizely_flutter_sdk.dart` + +## Version Management + +**Three locations must stay synchronized:** +1. `pubspec.yaml` → `version: X.Y.Z` +2. `lib/package_info.dart` → `version = 'X.Y.Z'` +3. `README.md` → Installation example `^X.Y.Z` + +## Release Workflow + +### With Claude Code (Recommended) + +Simply ask Claude to create a release: +``` +"Create release 3.4.2 with ticket FSSDK-12345" +"Bump patch version with ticket FSSDK-12345" +``` + +Claude will execute the full workflow: +- ✅ Pre-flight checks (master branch, clean working tree) +- ✅ Create release branch (prepare-X.Y.Z) +- ✅ Update all 3 version files (pubspec.yaml, package_info.dart, README.md) +- ✅ Generate CHANGELOG.md template +- ✅ Commit and push +- ✅ Create PR + +### Manual Release +```bash +# 1. Create release branch +git checkout -b prepare-X.Y.Z + +# 2. Update versions (pubspec.yaml, package_info.dart, README.md) +# 3. Update CHANGELOG.md (add release notes at top) + +# 4. Commit with standard format +git commit -m "chore: prepare for release X.Y.Z + +Co-Authored-By: Claude Sonnet 4.5 " + +# 5. Push and create PR +git push -u origin prepare-X.Y.Z +gh pr create --title "[FSSDK-XXXXX] prepare for release X.Y.Z" + +# 6. After merge, create GitHub release +gh release create vX.Y.Z --title "Release X.Y.Z" --draft --target master + +# 7. Publish to pub.dev +flutter pub publish +``` + +### CHANGELOG Format +```markdown +## X.Y.Z +Month Day, Year + +### New Features / Enhancements / Bug Fixes +* Description ([#PR](link)) +``` -## Contributing +## Contributing Guidelines -### Commit Format -Follow [Angular guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines): `feat:`, `fix:`, `docs:`, `refactor:`, `test:` +### Commit Messages +Follow [Angular guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines): +- `feat:` - New features +- `fix:` - Bug fixes +- `chore:` - Maintenance +- `docs:` - Documentation +- `refactor:` - Code restructuring +- `test:` - Test additions + +### Branch Strategy +- **Never commit directly to `master`** +- Feature branches: `feature/name`, `prepare-X.Y.Z`, `fix/name` +- All PRs target `master` branch ### 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) +- Tests required for all code changes +- All CI checks must pass (4 parallel workflows) - Apache 2.0 license header on new files +- Sign CLA (Contributor License Agreement) ### CI Pipeline -- `unit_test_coverage` (macOS) - Coverage to Coveralls -- `build_test_android/ios` - Build validation -- `integration_android/ios_tests` - External test app triggers +- `unit_test_coverage` (macOS) - Dart tests + Coveralls upload +- `build_test_android` (Ubuntu) - Android build validation +- `build_test_ios` (macOS) - iOS build validation +- `integration_android_tests` (Ubuntu) - Triggers `optimizely-flutter-testapp` repo +- `integration_ios_tests` (Ubuntu) - Triggers `optimizely-flutter-testapp` repo ## Platform Requirements -- Dart: >=2.16.2 <4.0.0, Flutter: >=2.5.0 -- Android: minSdk 21, compileSdk 35 -- iOS: 10.0+, Swift 5.0 +**Flutter/Dart:** +- Dart: >=2.16.2 <4.0.0 +- Flutter: >=2.5.0 + +**Android:** +- minSdk: 21 (Android 5.0) +- compileSdk: 35 (Android 15) +- Kotlin: 2.1.0 +- Native SDK: android-sdk 5.1.1 + +**iOS:** +- Minimum: iOS 10.0 +- Swift: 5.0 +- Native SDK: OptimizelySwiftSDK 5.2.1 + +## Key Implementation Files + +**Dart Layer:** +- `lib/optimizely_flutter_sdk.dart` - Public API (initialize, decide, track) +- `lib/src/optimizely_client_wrapper.dart` - Platform bridge (545 LOC) +- `lib/src/user_context/optimizely_user_context.dart` - User context operations +- `lib/src/data_objects/` - 21 response/request models + +**Android Layer:** +- `android/src/.../OptimizelyFlutterSdkPlugin.java` - MethodChannel handler +- `android/src/.../OptimizelyFlutterClient.java` - Core client wrapper (921 LOC) +- `android/build.gradle` - Dependencies & SDK versions + +**iOS Layer:** +- `ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift` - Plugin implementation (786 LOC) +- `ios/Classes/OptimizelyFlutterLogger.swift` - Logger bridge with task queue +- `ios/optimizely_flutter_sdk.podspec` - CocoaPods dependencies 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 9e8dc3f..1893324 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 @@ -28,6 +28,9 @@ import static com.optimizely.optimizely_flutter_sdk.helper_classes.Constants.*; +import android.os.Handler; +import android.os.Looper; + import java.util.Map; import io.flutter.embedding.engine.plugins.activity.ActivityAware; @@ -46,122 +49,164 @@ public class OptimizelyFlutterSdkPlugin extends OptimizelyFlutterClient implemen public static MethodChannel channel; private Appender flutterLogbackAppender; + /** + * Wraps a {@link Result} so that all callbacks ({@code success}, {@code error}, + * {@code notImplemented}) are guaranteed to run on the Android main thread. + * + *

Flutter's {@code MethodChannel.Result} must be called from the main thread. + * Native SDK callbacks (e.g. {@code decideAsync}, {@code initialize}) may fire on + * background threads. Wrapping {@code result} here once in {@code onMethodCall} + * protects every handler automatically — current and future. + */ + private Result safeResult(@NonNull Result result) { + Handler mainHandler = new Handler(Looper.getMainLooper()); + return new Result() { + @Override + public void success(Object o) { + if (Looper.myLooper() == Looper.getMainLooper()) { + result.success(o); + } else { + mainHandler.post(() -> result.success(o)); + } + } + + @Override + public void error(@NonNull String code, String message, Object details) { + if (Looper.myLooper() == Looper.getMainLooper()) { + result.error(code, message, details); + } else { + mainHandler.post(() -> result.error(code, message, details)); + } + } + + @Override + public void notImplemented() { + if (Looper.myLooper() == Looper.getMainLooper()) { + result.notImplemented(); + } else { + mainHandler.post(result::notImplemented); + } + } + }; + } + @Override public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { + Result safeResult = safeResult(result); Map arguments = call.arguments(); ArgumentsParser argumentsParser = new ArgumentsParser(arguments); switch (call.method) { case APIs.INITIALIZE: { - initializeOptimizely(argumentsParser, result); + initializeOptimizely(argumentsParser, safeResult); break; } case APIs.ACTIVATE: { - activate(argumentsParser, result); + activate(argumentsParser, safeResult); break; } case APIs.GET_VARIATION: { - getVariation(argumentsParser, result); + getVariation(argumentsParser, safeResult); break; } case APIs.GET_FORCED_VARIATION: { - getForcedVariation(argumentsParser, result); + getForcedVariation(argumentsParser, safeResult); break; } case APIs.SET_FORCED_VARIATION: { - setForcedVariation(argumentsParser, result); + setForcedVariation(argumentsParser, safeResult); break; } case APIs.ADD_NOTIFICATION_LISTENER: { - addNotificationListener(argumentsParser, result); + addNotificationListener(argumentsParser, safeResult); break; } case APIs.REMOVE_NOTIFICATION_LISTENER: { - removeNotificationListener(argumentsParser, result); + removeNotificationListener(argumentsParser, safeResult); break; } case APIs.CLEAR_NOTIFICATION_LISTENERS: case APIs.CLEAR_ALL_NOTIFICATION_LISTENERS: { - clearAllNotificationListeners(argumentsParser, result); + clearAllNotificationListeners(argumentsParser, safeResult); break; } case APIs.GET_OPTIMIZELY_CONFIG: { - getOptimizelyConfig(argumentsParser, result); + getOptimizelyConfig(argumentsParser, safeResult); break; } case APIs.CREATE_USER_CONTEXT: { - createUserContext(argumentsParser, result); + createUserContext(argumentsParser, safeResult); break; } case APIs.GET_USER_ID: { - getUserId(argumentsParser, result); + getUserId(argumentsParser, safeResult); break; } case APIs.GET_ATTRIBUTES: { - getAttributes(argumentsParser, result); + getAttributes(argumentsParser, safeResult); break; } case APIs.SET_ATTRIBUTES: { - setAttribute(argumentsParser, result); + setAttribute(argumentsParser, safeResult); break; } case APIs.TRACK_EVENT: { - trackEvent(argumentsParser, result); + trackEvent(argumentsParser, safeResult); break; } case APIs.DECIDE: { - decide(argumentsParser, result); + decide(argumentsParser, safeResult); break; } case APIs.DECIDE_ASYNC: { - decideAsync(argumentsParser, result); + decideAsync(argumentsParser, safeResult); break; } case APIs.SET_FORCED_DECISION: { - setForcedDecision(argumentsParser, result); + setForcedDecision(argumentsParser, safeResult); break; } case APIs.GET_FORCED_DECISION: { - getForcedDecision(argumentsParser, result); + getForcedDecision(argumentsParser, safeResult); break; } case APIs.REMOVE_FORCED_DECISION: { - removeForcedDecision(argumentsParser, result); + removeForcedDecision(argumentsParser, safeResult); break; } case APIs.REMOVE_ALL_FORCED_DECISIONS: { - removeAllForcedDecisions(argumentsParser, result); + removeAllForcedDecisions(argumentsParser, safeResult); break; } case APIs.GET_QUALIFIED_SEGMENTS: { - getQualifiedSegments(argumentsParser, result); + getQualifiedSegments(argumentsParser, safeResult); break; } case APIs.SET_QUALIFIED_SEGMENTS: { - setQualifiedSegments(argumentsParser, result); + setQualifiedSegments(argumentsParser, safeResult); break; } case APIs.GET_VUID: { - getVuid(argumentsParser, result); + getVuid(argumentsParser, safeResult); break; } case APIs.IS_QUALIFIED_FOR: { - isQualifiedFor(argumentsParser, result); + isQualifiedFor(argumentsParser, safeResult); break; } case APIs.SEND_ODP_EVENT: { - sendODPEvent(argumentsParser, result); + sendODPEvent(argumentsParser, safeResult); break; } case APIs.FETCH_QUALIFIED_SEGMENTS: { - fetchQualifiedSegments(argumentsParser, result); + fetchQualifiedSegments(argumentsParser, safeResult); break; } case APIs.CLOSE: { - close(argumentsParser, result); + close(argumentsParser, safeResult); break; } default: - result.notImplemented(); + safeResult.notImplemented(); } } diff --git a/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift b/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift index 1978119..da2affb 100644 --- a/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift +++ b/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift @@ -51,40 +51,65 @@ public class SwiftOptimizelyFlutterSdkPlugin: NSObject, FlutterPlugin { OptimizelyFlutterLogger.setChannel(loggerChannel) } - /// Part of FlutterPlugin protocol to handle communication with flutter sdk + /// Part of FlutterPlugin protocol to handle communication with flutter sdk. + /// All method handlers receive a main-thread-safe result callback so that + /// any handler calling result() from a background thread (e.g. async SDK + /// completion handlers) still delivers the response correctly on iOS 16. public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - + let safeResult = mainThreadResult(result) switch call.method { - case API.initialize: initialize(call, result: result) - case API.addNotificationListener: addNotificationListener(call, result: result) - case API.removeNotificationListener: removeNotificationListener(call, result: result) - case API.clearNotificationListeners, API.clearAllNotificationListeners: clearAllNotificationListeners(call, result: result) - case API.getOptimizelyConfig: getOptimizelyConfig(call, result: result) - case API.activate: activate(call, result: result) - case API.getVariation: getVariation(call, result: result) - case API.getForcedVariation: getForcedVariation(call, result: result) - case API.setForcedVariation: setForcedVariation(call, result: result) - case API.createUserContext: createUserContext(call, result: result) - case API.getUserId: getUserId(call, result: result) - case API.getAttributes: getAttributes(call, result: result) - 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) - case API.removeAllForcedDecisions: removeAllForcedDecisions(call, result: result) - case API.close: close(call, result: result) - + case API.initialize: initialize(call, result: safeResult) + case API.addNotificationListener: addNotificationListener(call, result: safeResult) + case API.removeNotificationListener: removeNotificationListener(call, result: safeResult) + case API.clearNotificationListeners, API.clearAllNotificationListeners: clearAllNotificationListeners(call, result: safeResult) + case API.getOptimizelyConfig: getOptimizelyConfig(call, result: safeResult) + case API.activate: activate(call, result: safeResult) + case API.getVariation: getVariation(call, result: safeResult) + case API.getForcedVariation: getForcedVariation(call, result: safeResult) + case API.setForcedVariation: setForcedVariation(call, result: safeResult) + case API.createUserContext: createUserContext(call, result: safeResult) + case API.getUserId: getUserId(call, result: safeResult) + case API.getAttributes: getAttributes(call, result: safeResult) + case API.setAttributes: setAttributes(call, result: safeResult) + case API.trackEvent: trackEvent(call, result: safeResult) + case API.decide: decide(call, result: safeResult) + case API.decideAsync: decideAsync(call, result: safeResult) + case API.setForcedDecision: setForcedDecision(call, result: safeResult) + case API.getForcedDecision: getForcedDecision(call, result: safeResult) + case API.removeForcedDecision: removeForcedDecision(call, result: safeResult) + case API.removeAllForcedDecisions: removeAllForcedDecisions(call, result: safeResult) + case API.close: close(call, result: safeResult) + // ODP - case API.getQualifiedSegments: getQualifiedSegments(call, result: result) - case API.setQualifiedSegments: setQualifiedSegments(call, result: result) - case API.getVuid: getVuid(call, result: result) - case API.isQualifiedFor: isQualifiedFor(call, result: result) - case API.sendOdpEvent: sendOdpEvent(call, result: result) - case API.fetchQualifiedSegments: fetchQualifiedSegments(call, result: result) - default: result(FlutterMethodNotImplemented) + case API.getQualifiedSegments: getQualifiedSegments(call, result: safeResult) + case API.setQualifiedSegments: setQualifiedSegments(call, result: safeResult) + case API.getVuid: getVuid(call, result: safeResult) + case API.isQualifiedFor: isQualifiedFor(call, result: safeResult) + case API.sendOdpEvent: sendOdpEvent(call, result: safeResult) + case API.fetchQualifiedSegments: fetchQualifiedSegments(call, result: safeResult) + default: safeResult(FlutterMethodNotImplemented) + } + } + + /// Wraps a FlutterResult so it is always invoked on the main thread. + /// + /// iOS 16 requires FlutterResult to be called on the main thread. When an + /// async native callback (e.g. OptimizelyClient.start completion) calls + /// result() from a background thread, iOS 16 silently drops the response + /// under multi-SDK startup contention, causing the Dart side to hang or + /// receive nil. iOS 17+ relaxed this requirement, which is why the bug is + /// version-specific. + /// + /// Applying this wrapper once in handle() protects every current and future + /// method handler automatically — no individual handler needs to remember + /// to dispatch to main. + private func mainThreadResult(_ result: @escaping FlutterResult) -> FlutterResult { + return { value in + if Thread.isMainThread { + result(value) + } else { + DispatchQueue.main.async { result(value) } + } } } diff --git a/lib/src/optimizely_client_wrapper.dart b/lib/src/optimizely_client_wrapper.dart index 46e1627..ce452ff 100644 --- a/lib/src/optimizely_client_wrapper.dart +++ b/lib/src/optimizely_client_wrapper.dart @@ -128,8 +128,7 @@ class OptimizelyClientWrapper { } }); - final result = Map.from( - await _channel.invokeMethod(Constants.initializeMethod, requestDict)); + final result = await _invoke(Constants.initializeMethod, requestDict); return BaseResponse(result); } @@ -140,13 +139,12 @@ class OptimizelyClientWrapper { static Future activate( String sdkKey, String experimentKey, String userId, [Map attributes = const {}]) async { - final result = Map.from( - await _channel.invokeMethod(Constants.activate, { + final result = await _invoke(Constants.activate, { Constants.sdkKey: sdkKey, Constants.experimentKey: experimentKey, Constants.userId: userId, Constants.attributes: Utils.convertToTypedMap(attributes) - })); + }); return ActivateResponse(result); } @@ -154,25 +152,23 @@ class OptimizelyClientWrapper { static Future getVariation( String sdkKey, String experimentKey, String userId, [Map attributes = const {}]) async { - final result = Map.from( - await _channel.invokeMethod(Constants.getVariation, { + final result = await _invoke(Constants.getVariation, { Constants.sdkKey: sdkKey, Constants.experimentKey: experimentKey, Constants.userId: userId, Constants.attributes: Utils.convertToTypedMap(attributes) - })); + }); return GetVariationResponse(result); } /// Get forced variation for experiment and user ID. static Future getForcedVariation( String sdkKey, String experimentKey, String userId) async { - final result = Map.from( - await _channel.invokeMethod(Constants.getForcedVariation, { + final result = await _invoke(Constants.getForcedVariation, { Constants.sdkKey: sdkKey, Constants.experimentKey: experimentKey, Constants.userId: userId, - })); + }); return GetVariationResponse(result); } @@ -188,16 +184,15 @@ class OptimizelyClientWrapper { if (variationKey != "") { request[Constants.variationKey] = variationKey; } - final result = Map.from( - await _channel.invokeMethod(Constants.setForcedVariation, request)); + final result = await _invoke(Constants.setForcedVariation, request); return BaseResponse(result); } /// Returns a snapshot of the current project configuration. static Future getOptimizelyConfig( String sdkKey) async { - final result = Map.from(await _channel.invokeMethod( - Constants.getOptimizelyConfigMethod, {Constants.sdkKey: sdkKey})); + final result = await _invoke( + Constants.getOptimizelyConfigMethod, {Constants.sdkKey: sdkKey}); return OptimizelyConfigResponse(result); } @@ -216,17 +211,15 @@ class OptimizelyClientWrapper { request[Constants.type] = type; } - final result = Map.from( - await _channel.invokeMethod(Constants.sendOdpEventMethod, request)); + final result = await _invoke(Constants.sendOdpEventMethod, request); return BaseResponse(result); } /// Returns the device vuid (read only) static Future getVuid(String sdkKey) async { - final result = Map.from( - await _channel.invokeMethod(Constants.getVuidMethod, { + final result = await _invoke(Constants.getVuidMethod, { Constants.sdkKey: sdkKey, - })); + }); return GetVuidResponse(result); } @@ -241,8 +234,8 @@ class OptimizelyClientWrapper { configUpdateCallbacksById[sdkKey]?.remove(id); trackCallbacksById[sdkKey]?.remove(id); - final result = Map.from(await _channel.invokeMethod( - Constants.removeNotificationListenerMethod, request)); + final result = await _invoke( + Constants.removeNotificationListenerMethod, request); return BaseResponse(result); } @@ -255,8 +248,8 @@ class OptimizelyClientWrapper { Constants.type: listenerType.name, Constants.callbackIds: callbackIds }; - final result = Map.from(await _channel.invokeMethod( - Constants.clearNotificationListenersMethod, request)); + final result = await _invoke( + Constants.clearNotificationListenersMethod, request); return BaseResponse(result); } @@ -268,15 +261,15 @@ class OptimizelyClientWrapper { Constants.sdkKey: sdkKey, Constants.callbackIds: callbackIds }; - final result = Map.from(await _channel.invokeMethod( - Constants.clearAllNotificationListenersMethod, request)); + final result = await _invoke( + Constants.clearAllNotificationListenersMethod, request); return BaseResponse(result); } /// Returns a success true if optimizely client closed successfully. static Future close(String sdkKey) async { - final result = Map.from(await _channel - .invokeMethod(Constants.close, {Constants.sdkKey: sdkKey})); + final result = await _invoke( + Constants.close, {Constants.sdkKey: sdkKey}); return BaseResponse(result); } @@ -292,8 +285,7 @@ class OptimizelyClientWrapper { if (userId != null) { request[Constants.userId] = userId; } - final result = Map.from(await _channel.invokeMethod( - Constants.createUserContextMethod, request)); + final result = await _invoke(Constants.createUserContextMethod, request); if (result[Constants.responseSuccess] == true) { final response = @@ -393,7 +385,7 @@ class OptimizelyClientWrapper { activateCallbacksById.putIfAbsent(sdkKey, () => {}); activateCallbacksById[sdkKey]?[currentListenerId] = callback; final listenerTypeStr = ListenerType.activate.name; - await _channel.invokeMethod(Constants.addNotificationListenerMethod, { + await _invoke(Constants.addNotificationListenerMethod, { Constants.sdkKey: sdkKey, Constants.id: currentListenerId, Constants.type: listenerTypeStr @@ -416,7 +408,7 @@ class OptimizelyClientWrapper { decisionCallbacksById.putIfAbsent(sdkKey, () => {}); decisionCallbacksById[sdkKey]?[currentListenerId] = callback; final listenerTypeStr = ListenerType.decision.name; - await _channel.invokeMethod(Constants.addNotificationListenerMethod, { + await _invoke(Constants.addNotificationListenerMethod, { Constants.sdkKey: sdkKey, Constants.id: currentListenerId, Constants.type: listenerTypeStr @@ -438,7 +430,7 @@ class OptimizelyClientWrapper { trackCallbacksById.putIfAbsent(sdkKey, () => {}); trackCallbacksById[sdkKey]?[currentListenerId] = callback; final listenerTypeStr = ListenerType.track.name; - await _channel.invokeMethod(Constants.addNotificationListenerMethod, { + await _invoke(Constants.addNotificationListenerMethod, { Constants.sdkKey: sdkKey, Constants.id: currentListenerId, Constants.type: listenerTypeStr @@ -460,7 +452,7 @@ class OptimizelyClientWrapper { logEventCallbacksById.putIfAbsent(sdkKey, () => {}); logEventCallbacksById[sdkKey]?[currentListenerId] = callback; final listenerTypeStr = ListenerType.logEvent.name; - await _channel.invokeMethod(Constants.addNotificationListenerMethod, { + await _invoke(Constants.addNotificationListenerMethod, { Constants.sdkKey: sdkKey, Constants.id: currentListenerId, Constants.type: listenerTypeStr @@ -483,7 +475,7 @@ class OptimizelyClientWrapper { configUpdateCallbacksById.putIfAbsent(sdkKey, () => {}); configUpdateCallbacksById[sdkKey]?[currentListenerId] = callback; final listenerTypeStr = ListenerType.projectConfigUpdate.name; - await _channel.invokeMethod(Constants.addNotificationListenerMethod, { + await _invoke(Constants.addNotificationListenerMethod, { Constants.sdkKey: sdkKey, Constants.id: currentListenerId, Constants.type: listenerTypeStr @@ -492,6 +484,38 @@ class OptimizelyClientWrapper { return currentListenerId; } + /// Safe wrapper around [MethodChannel.invokeMethod]. + /// + /// Returns a [Map] in all cases — never throws. + /// - If native returns `null` (e.g. dropped response on iOS 16 when + /// FlutterResult is called from a background thread), returns a + /// `{success: false}` map instead of crashing with [TypeError]. + /// - If native throws [PlatformException], returns `{success: false}` + /// instead of propagating an unhandled exception to the caller. + /// + /// Every [MethodChannel.invokeMethod] call in this class must go through + /// [_invoke] so that new methods added in future automatically inherit + /// these safety guarantees. + static Future> _invoke( + String method, [dynamic args]) async { + try { + final raw = await _channel.invokeMethod(method, args); + if (raw == null) { + return { + Constants.responseSuccess: false, + Constants.responseReason: + 'Native channel returned null for method: $method', + }; + } + return Map.from(raw); + } on PlatformException catch (e) { + return { + Constants.responseSuccess: false, + Constants.responseReason: e.message ?? e.toString(), + }; + } + } + static Future methodCallHandler(MethodCall call) async { final id = call.arguments[Constants.id]; final sdkKey = call.arguments[Constants.sdkKey]; diff --git a/lib/src/user_context/optimizely_user_context.dart b/lib/src/user_context/optimizely_user_context.dart index d0f4cb1..22b5903 100644 --- a/lib/src/user_context/optimizely_user_context.dart +++ b/lib/src/user_context/optimizely_user_context.dart @@ -72,23 +72,45 @@ class OptimizelyUserContext { OptimizelyUserContext(this._sdkKey, this._userContextId, this._channel); + /// Safe wrapper around [MethodChannel.invokeMethod] for this user context. + /// + /// Mirrors [OptimizelyClientWrapper._invoke]: returns a [Map] + /// in all cases, never throws. Null returns and [PlatformException]s are both + /// converted to `{success: false}` so callers never see an unhandled exception. + Future> _invoke(String method, [dynamic args]) async { + try { + final raw = await _channel.invokeMethod(method, args); + if (raw == null) { + return { + Constants.responseSuccess: false, + Constants.responseReason: + 'Native channel returned null for method: $method', + }; + } + return Map.from(raw); + } on PlatformException catch (e) { + return { + Constants.responseSuccess: false, + Constants.responseReason: e.message ?? e.toString(), + }; + } + } + /// Returns [GetUserIdResponse] object containing userId for the user context. Future getUserId() async { - final result = Map.from( - await _channel.invokeMethod(Constants.getUserIdMethod, { + final result = await _invoke(Constants.getUserIdMethod, { Constants.sdkKey: _sdkKey, Constants.userContextId: _userContextId, - })); + }); return GetUserIdResponse(result); } /// Returns [GetAttributesResponse] object containing attributes for the user context. Future getAttributes() async { - final result = Map.from( - await _channel.invokeMethod(Constants.getAttributesMethod, { + final result = await _invoke(Constants.getAttributesMethod, { Constants.sdkKey: _sdkKey, Constants.userContextId: _userContextId, - })); + }); return GetAttributesResponse(result); } @@ -97,22 +119,20 @@ class OptimizelyUserContext { /// Takes [attributes] A [Map] of custom key-value string pairs specifying attributes for the user. /// Returns [BaseResponse] Future setAttributes(Map attributes) async { - final result = Map.from( - await _channel.invokeMethod(Constants.setAttributesMethod, { + final result = await _invoke(Constants.setAttributesMethod, { Constants.sdkKey: _sdkKey, Constants.userContextId: _userContextId, Constants.attributes: Utils.convertToTypedMap(attributes) - })); + }); return BaseResponse(result); } /// Returns [GetQualifiedSegmentsResponse] object containing an array of segment names that the user is qualified for. Future getQualifiedSegments() async { - final result = Map.from( - await _channel.invokeMethod(Constants.getQualifiedSegmentsMethod, { + final result = await _invoke(Constants.getQualifiedSegmentsMethod, { Constants.sdkKey: _sdkKey, Constants.userContextId: _userContextId, - })); + }); return GetQualifiedSegmentsResponse(result); } @@ -122,12 +142,11 @@ class OptimizelyUserContext { /// Returns [BaseResponse] Future setQualifiedSegments( List qualifiedSegments) async { - final result = Map.from( - await _channel.invokeMethod(Constants.setQualifiedSegmentsMethod, { + final result = await _invoke(Constants.setQualifiedSegmentsMethod, { Constants.sdkKey: _sdkKey, Constants.userContextId: _userContextId, Constants.qualifiedSegments: qualifiedSegments - })); + }); return BaseResponse(result); } @@ -136,12 +155,11 @@ class OptimizelyUserContext { /// Takes [segment] The segment name to check qualification for. /// Returns [BaseResponse] Future isQualifiedFor(String segment) async { - final result = Map.from( - await _channel.invokeMethod(Constants.isQualifiedForMethod, { + final result = await _invoke(Constants.isQualifiedForMethod, { Constants.sdkKey: _sdkKey, Constants.userContextId: _userContextId, Constants.segment: segment - })); + }); return BaseResponse(result); } @@ -153,12 +171,11 @@ class OptimizelyUserContext { /// Returns [BaseResponse] Future fetchQualifiedSegments( [Set options = const {}]) async { - final result = Map.from( - await _channel.invokeMethod(Constants.fetchQualifiedSegmentsMethod, { + final result = await _invoke(Constants.fetchQualifiedSegmentsMethod, { Constants.sdkKey: _sdkKey, Constants.userContextId: _userContextId, Constants.optimizelySegmentOption: Utils.convertSegmentOptions(options), - })); + }); return BaseResponse(result); } @@ -169,13 +186,12 @@ class OptimizelyUserContext { /// Returns [BaseResponse] Future trackEvent(String eventKey, [Map eventTags = const {}]) async { - final result = Map.from( - await _channel.invokeMethod(Constants.trackEventMethod, { + final result = await _invoke(Constants.trackEventMethod, { Constants.sdkKey: _sdkKey, Constants.userContextId: _userContextId, Constants.eventKey: eventKey, Constants.eventTags: Utils.convertToTypedMap(eventTags) - })); + }); return BaseResponse(result); } @@ -217,14 +233,12 @@ class OptimizelyUserContext { [List keys = const [], Set options = const {}]) async { final convertedOptions = Utils.convertDecideOptions(options); - var result = Map.from( - await _channel.invokeMethod(Constants.decideMethod, { + return await _invoke(Constants.decideMethod, { Constants.sdkKey: _sdkKey, Constants.userContextId: _userContextId, Constants.keys: keys, Constants.optimizelyDecideOption: convertedOptions, - })); - return result; + }); } /// Asynchronously returns a decision result for a given flag key and a user context. @@ -267,14 +281,12 @@ class OptimizelyUserContext { [List keys = const [], Set options = const {}]) async { final convertedOptions = Utils.convertDecideOptions(options); - var result = Map.from( - await _channel.invokeMethod(Constants.decideAsyncMethod, { + return await _invoke(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. @@ -293,8 +305,7 @@ class OptimizelyUserContext { if (context.ruleKey != null) { request[Constants.ruleKey] = context.ruleKey; } - final result = Map.from( - await _channel.invokeMethod(Constants.setForcedDecision, request)); + final result = await _invoke(Constants.setForcedDecision, request); return BaseResponse(result); } @@ -313,8 +324,7 @@ class OptimizelyUserContext { request[Constants.ruleKey] = context.ruleKey; } - final result = Map.from( - await _channel.invokeMethod(Constants.getForcedDecision, request)); + final result = await _invoke(Constants.getForcedDecision, request); return GetForcedDecisionResponse(result); } @@ -332,8 +342,7 @@ class OptimizelyUserContext { if (context.ruleKey != null) { request[Constants.ruleKey] = context.ruleKey; } - final result = Map.from( - await _channel.invokeMethod(Constants.removeForcedDecision, request)); + final result = await _invoke(Constants.removeForcedDecision, request); return BaseResponse(result); } @@ -341,11 +350,10 @@ class OptimizelyUserContext { /// /// Returns [BaseResponse] Future removeAllForcedDecisions() async { - final result = Map.from( - await _channel.invokeMethod(Constants.removeAllForcedDecisions, { + final result = await _invoke(Constants.removeAllForcedDecisions, { Constants.sdkKey: _sdkKey, Constants.userContextId: _userContextId, - })); + }); return BaseResponse(result); } } diff --git a/test/invoke_safety_test.dart b/test/invoke_safety_test.dart new file mode 100644 index 0000000..4ff2a6c --- /dev/null +++ b/test/invoke_safety_test.dart @@ -0,0 +1,227 @@ +/// ************************************************************************** +/// Copyright 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. * +///**************************************************************************/ + +// Unit tests for the _invoke() safety wrapper in OptimizelyClientWrapper and +// OptimizelyUserContext. +// +// These tests cover the two error branches that the existing test suite does +// not exercise: +// 1. Native channel returns null → result has success:false, no TypeError +// 2. Native channel throws PlatformException → result has success:false, no throw +// +// These branches are the core of the iOS 16 threading fix: they prevent +// Map.from(null) TypeErrors and unhandled PlatformExceptions +// from surfacing to callers. + +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/utils/constants.dart'; + +void main() { + const channel = MethodChannel('optimizely_flutter_sdk'); + const sdkKey = 'test-sdk-key'; + + TestWidgetsFlutterBinding.ensureInitialized(); + + TestDefaultBinaryMessenger? tester; + + setUp(() { + tester = TestDefaultBinaryMessengerBinding.instance?.defaultBinaryMessenger; + }); + + group('OptimizelyClientWrapper._invoke — null return safety', () { + setUp(() { + tester?.setMockMethodCallHandler(channel, (_) async => null); + }); + + tearDown(() { + tester?.setMockMethodCallHandler(channel, null); + }); + + test('initializeClient returns success:false instead of TypeError', () async { + final sdk = OptimizelyFlutterSdk(sdkKey); + final result = await sdk.initializeClient(); + expect(result.success, isFalse); + }); + + test('getOptimizelyConfig returns success:false instead of TypeError', + () async { + final sdk = OptimizelyFlutterSdk(sdkKey); + final result = await sdk.getOptimizelyConfig(); + expect(result.success, isFalse); + }); + + test('getVuid returns success:false instead of TypeError', () async { + final sdk = OptimizelyFlutterSdk(sdkKey); + final result = await sdk.getVuid(); + expect(result.success, isFalse); + }); + + test('sendOdpEvent returns success:false instead of TypeError', () async { + final sdk = OptimizelyFlutterSdk(sdkKey); + final result = await sdk.sendOdpEvent('test-action'); + expect(result.success, isFalse); + }); + + test('close returns success:false instead of TypeError', () async { + final sdk = OptimizelyFlutterSdk(sdkKey); + final result = await sdk.close(); + expect(result.success, isFalse); + }); + }); + + group('OptimizelyClientWrapper._invoke — PlatformException safety', () { + setUp(() { + tester?.setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR', message: 'native error'); + }); + }); + + tearDown(() { + tester?.setMockMethodCallHandler(channel, null); + }); + + test('initializeClient returns success:false with reason', () async { + final sdk = OptimizelyFlutterSdk(sdkKey); + final result = await sdk.initializeClient(); + expect(result.success, isFalse); + expect(result.reason, isNotEmpty); + }); + + test('getOptimizelyConfig returns success:false with reason', () async { + final sdk = OptimizelyFlutterSdk(sdkKey); + final result = await sdk.getOptimizelyConfig(); + expect(result.success, isFalse); + expect(result.reason, isNotEmpty); + }); + + test('sendOdpEvent returns success:false with reason', () async { + final sdk = OptimizelyFlutterSdk(sdkKey); + final result = await sdk.sendOdpEvent('test-action'); + expect(result.success, isFalse); + expect(result.reason, isNotEmpty); + }); + }); + + group('OptimizelyUserContext._invoke — null return safety', () { + late OptimizelyUserContext? userContext; + + setUp(() async { + // First call (createUserContext) returns a valid user context id. + bool firstCall = true; + tester?.setMockMethodCallHandler(channel, (_) async { + if (firstCall) { + firstCall = false; + return { + Constants.responseSuccess: true, + Constants.responseResult: {Constants.userContextId: 'uc-1'}, + }; + } + return null; // subsequent calls return null + }); + + final sdk = OptimizelyFlutterSdk(sdkKey); + userContext = await sdk.createUserContext(userId: 'user-1'); + }); + + tearDown(() { + tester?.setMockMethodCallHandler(channel, null); + }); + + test('getUserId returns success:false instead of TypeError', () async { + expect(userContext, isNotNull); + final result = await userContext!.getUserId(); + expect(result.success, isFalse); + }); + + test('getAttributes returns success:false instead of TypeError', () async { + expect(userContext, isNotNull); + final result = await userContext!.getAttributes(); + expect(result.success, isFalse); + }); + + test('setAttributes returns success:false instead of TypeError', () async { + expect(userContext, isNotNull); + final result = await userContext!.setAttributes({'key': 'value'}); + expect(result.success, isFalse); + }); + + test('decide returns success:false instead of TypeError', () async { + expect(userContext, isNotNull); + final result = await userContext!.decide('flag-key'); + expect(result.success, isFalse); + }); + + test('decideAll returns success:false instead of TypeError', () async { + expect(userContext, isNotNull); + final result = await userContext!.decideAll(); + expect(result.success, isFalse); + }); + + test('trackEvent returns success:false instead of TypeError', () async { + expect(userContext, isNotNull); + final result = await userContext!.trackEvent('event-key'); + expect(result.success, isFalse); + }); + }); + + group('OptimizelyUserContext._invoke — PlatformException safety', () { + late OptimizelyUserContext? userContext; + + setUp(() async { + bool firstCall = true; + tester?.setMockMethodCallHandler(channel, (_) async { + if (firstCall) { + firstCall = false; + return { + Constants.responseSuccess: true, + Constants.responseResult: {Constants.userContextId: 'uc-2'}, + }; + } + throw PlatformException(code: 'ERROR', message: 'native error'); + }); + + final sdk = OptimizelyFlutterSdk(sdkKey); + userContext = await sdk.createUserContext(userId: 'user-2'); + }); + + tearDown(() { + tester?.setMockMethodCallHandler(channel, null); + }); + + test('getUserId returns success:false with reason', () async { + expect(userContext, isNotNull); + final result = await userContext!.getUserId(); + expect(result.success, isFalse); + expect(result.reason, isNotEmpty); + }); + + test('decide returns success:false with reason', () async { + expect(userContext, isNotNull); + final result = await userContext!.decide('flag-key'); + expect(result.success, isFalse); + expect(result.reason, isNotEmpty); + }); + + test('trackEvent returns success:false with reason', () async { + expect(userContext, isNotNull); + final result = await userContext!.trackEvent('event-key'); + expect(result.success, isFalse); + expect(result.reason, isNotEmpty); + }); + }); +}