From 82f82f13fa55bc2a4c1d781919ff1de16dd8bc62 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 6 Mar 2026 16:41:48 +0600 Subject: [PATCH 1/5] fix: ensure FlutterResult and invokeMethod are always called on main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS 16 requires FlutterResult to be invoked on the main thread. When OptimizelyClient.start() or decideAsync closures call result() from a background thread under multi-SDK startup contention, iOS 16 silently drops the response causing the Dart Future to never resolve or return null, which previously crashed the app with an unhandled TypeError or PlatformException. Two layers fixed: 1. iOS native (SwiftOptimizelyFlutterSdkPlugin.swift) - Added mainThreadResult() private helper that wraps FlutterResult to always dispatch on the main thread (no-op if already on main) - Applied once in handle() so every current and future method handler is protected automatically — no per-handler changes needed 2. Dart layer (OptimizelyClientWrapper + OptimizelyUserContext) - Added _invoke() helper that wraps every MethodChannel.invokeMethod call with a null guard and PlatformException catch - Null response returns {success:false} instead of crashing via Map.from(null) → TypeError - PlatformException is caught and returned as {success:false} - All 18 call sites in OptimizelyClientWrapper and 14 in OptimizelyUserContext now route through _invoke Co-Authored-By: Claude Sonnet 4.6 --- .../SwiftOptimizelyFlutterSdkPlugin.swift | 87 +++++++++++------ lib/src/optimizely_client_wrapper.dart | 94 ++++++++++++------- .../user_context/optimizely_user_context.dart | 90 ++++++++++-------- 3 files changed, 164 insertions(+), 107 deletions(-) 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); } } From d65a9d81bd4079dca6655ce726874d0000747833 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 6 Mar 2026 17:02:55 +0600 Subject: [PATCH 2/5] chore: update CLAUDE.md with expanded guidance and add IDE/env gitignore entries Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 ++ CLAUDE.md | 191 ++++++++++++++++++++++++++++++++++++----------------- 2 files changed, 136 insertions(+), 60 deletions(-) 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 From 9f0fa268e89f982fca2eeb76b28af270de790e96 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 6 Mar 2026 17:12:08 +0600 Subject: [PATCH 3/5] test: add unit tests for _invoke() null and PlatformException safety branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the two previously-uncovered error branches in the iOS 16 fix: - Native returns null → success:false (no TypeError) - Native throws PlatformException → success:false (no unhandled exception) Tests both OptimizelyClientWrapper and OptimizelyUserContext. Overall unit test coverage: 85.9% → 87.3% Co-Authored-By: Claude Sonnet 4.6 --- test/invoke_safety_test.dart | 229 +++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 test/invoke_safety_test.dart diff --git a/test/invoke_safety_test.dart b/test/invoke_safety_test.dart new file mode 100644 index 0000000..f8f568f --- /dev/null +++ b/test/invoke_safety_test.dart @@ -0,0 +1,229 @@ +/// ************************************************************************** +/// 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(); + + group('OptimizelyClientWrapper._invoke — null return safety', () { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .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(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR', message: 'native error'); + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .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; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .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(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .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; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .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(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .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); + }); + }); +} From b86bbf519dfdf651c540abe6b83fa38df7fcd735 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 6 Mar 2026 17:41:20 +0600 Subject: [PATCH 4/5] fix: ensure MethodChannel.Result is always called on main thread (Android) Mirrors the iOS mainThreadResult() fix. Added safeResult() wrapper in onMethodCall() that dispatches success/error/notImplemented to the Android main thread via Handler(Looper.getMainLooper()) when called from a background thread. Applied once so all current and future handlers are automatically protected. Co-Authored-By: Claude Sonnet 4.6 --- .../OptimizelyFlutterSdkPlugin.java | 101 +++++++++++++----- 1 file changed, 73 insertions(+), 28 deletions(-) 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(); } } From de56c785103ed7e04f1979b8e2466af0b1e37c18 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 6 Mar 2026 17:48:52 +0600 Subject: [PATCH 5/5] fix: use nullable-safe TestDefaultBinaryMessengerBinding access in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flutter 3.0.5 (used in CI) exposes instance as nullable — use ?. pattern consistent with existing test suite to fix compilation failure. Co-Authored-By: Claude Sonnet 4.6 --- test/invoke_safety_test.dart | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/test/invoke_safety_test.dart b/test/invoke_safety_test.dart index f8f568f..4ff2a6c 100644 --- a/test/invoke_safety_test.dart +++ b/test/invoke_safety_test.dart @@ -37,15 +37,19 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessenger? tester; + + setUp(() { + tester = TestDefaultBinaryMessengerBinding.instance?.defaultBinaryMessenger; + }); + group('OptimizelyClientWrapper._invoke — null return safety', () { setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => null); + tester?.setMockMethodCallHandler(channel, (_) async => null); }); tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); + tester?.setMockMethodCallHandler(channel, null); }); test('initializeClient returns success:false instead of TypeError', () async { @@ -82,15 +86,13 @@ void main() { group('OptimizelyClientWrapper._invoke — PlatformException safety', () { setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { + tester?.setMockMethodCallHandler(channel, (_) async { throw PlatformException(code: 'ERROR', message: 'native error'); }); }); tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); + tester?.setMockMethodCallHandler(channel, null); }); test('initializeClient returns success:false with reason', () async { @@ -121,8 +123,7 @@ void main() { setUp(() async { // First call (createUserContext) returns a valid user context id. bool firstCall = true; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { + tester?.setMockMethodCallHandler(channel, (_) async { if (firstCall) { firstCall = false; return { @@ -138,8 +139,7 @@ void main() { }); tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); + tester?.setMockMethodCallHandler(channel, null); }); test('getUserId returns success:false instead of TypeError', () async { @@ -184,8 +184,7 @@ void main() { setUp(() async { bool firstCall = true; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async { + tester?.setMockMethodCallHandler(channel, (_) async { if (firstCall) { firstCall = false; return { @@ -201,8 +200,7 @@ void main() { }); tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); + tester?.setMockMethodCallHandler(channel, null); }); test('getUserId returns success:false with reason', () async {