diff --git a/android/src/main/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.kt b/android/src/main/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.kt index b815734..0ddfc95 100644 --- a/android/src/main/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.kt +++ b/android/src/main/kotlin/com/example/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.kt @@ -39,7 +39,12 @@ class ConfidenceFlutterSdkPlugin: FlutterPlugin, MethodCallHandler, ActivityAwar override fun onMethodCall(call: MethodCall, result: Result) { when(call.method) { "flush" -> { - confidence.flush() + try { + confidence.flush() + result.success(null) + } catch (e: Exception) { + result.error("FLUSH_FAILED", "Failed to flush: ${e.message}", null) + } } "setup" -> { val apiKey = call.argument("apiKey")!! @@ -133,7 +138,12 @@ class ConfidenceFlutterSdkPlugin: FlutterPlugin, MethodCallHandler, ActivityAwar val eventName = call.argument("eventName")!! val wrappedData = call.argument>>("data")!! val data: Map = wrappedData.mapValues { (_, value) -> value.convert() } - confidence.track(eventName, data) + try { + confidence.track(eventName, data) + result.success(null) + } catch (e: Exception) { + result.error("TRACK_FAILED", "Failed to track event '$eventName': ${e.message}", null) + } } else -> result.notImplemented() } diff --git a/ios/confidence_flutter_sdk/Sources/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.swift b/ios/confidence_flutter_sdk/Sources/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.swift index 7049da3..85346c4 100644 --- a/ios/confidence_flutter_sdk/Sources/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.swift +++ b/ios/confidence_flutter_sdk/Sources/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.swift @@ -18,6 +18,10 @@ public class ConfidenceFlutterSdkPlugin: NSObject, FlutterPlugin { return } confidence.flush() + // Confidence.flush() is non-throwing, so there is nothing to catch + // here — but the reply is still mandatory: without it the Dart + // future never completes. + result("") break; case "readAllFlags": guard let flags = try? readAllFlags() else { @@ -113,12 +117,22 @@ public class ConfidenceFlutterSdkPlugin: NSObject, FlutterPlugin { break; case "track": guard let args = call.arguments as? Dictionary else { + result("") return } let eventName = args["eventName"] as! String let data = args["data"] as! Dictionary> let convertedData = data.convert() - try? confidence?.track(eventName: eventName, data: convertedData) + do { + try confidence?.track(eventName: eventName, data: convertedData) + result("") + } catch { + NSLog("%@", "Confidence SDK: \(error)") + result(FlutterError( + code: "TRACK_FAILED", + message: "Failed to track event '\(eventName)': \(error)", + details: nil)) + } break; case "getBool": let arguments = call.arguments as! Dictionary diff --git a/lib/confidence_flutter_sdk_method_channel.dart b/lib/confidence_flutter_sdk_method_channel.dart index 9a63dcc..6555388 100644 --- a/lib/confidence_flutter_sdk_method_channel.dart +++ b/lib/confidence_flutter_sdk_method_channel.dart @@ -64,11 +64,18 @@ class MethodChannelConfidenceFlutterSdk extends ConfidenceFlutterSdkPlatform { if (kDebugMode) { print(wrappedData); } + // track() is intentionally fire-and-forget, so the returned future is not + // awaited. Without this handler a native error reply would surface as an + // unhandled async error in the host app. methodChannel .invokeMethod( 'track', {'eventName': eventName, 'data': wrappedData} - ); + ).catchError((Object error) { + if (kDebugMode) { + print('Confidence SDK: failed to track "$eventName": $error'); + } + }); } @override @@ -129,8 +136,16 @@ class MethodChannelConfidenceFlutterSdk extends ConfidenceFlutterSdkPlatform { @override Future flush() async { - await methodChannel - .invokeMethod('flush'); + // The platform interface declares flush() as void, so callers discard this + // future. Now that native replies with an error on failure, an unguarded + // rejection would surface as an unhandled async error in the host app. + try { + await methodChannel.invokeMethod('flush'); + } catch (error) { + if (kDebugMode) { + print('Confidence SDK: failed to flush: $error'); + } + } } diff --git a/test/confidence_flutter_sdk_method_channel_test.dart b/test/confidence_flutter_sdk_method_channel_test.dart index db03861..ac16352 100644 --- a/test/confidence_flutter_sdk_method_channel_test.dart +++ b/test/confidence_flutter_sdk_method_channel_test.dart @@ -50,4 +50,65 @@ void main() { 'loggingLevel': 'WARN', }); }); + + test('track forwards the event name and typed data', () async { + platform.track('my-event', {'plan': 'pro', 'seats': 3}); + + await pumpEventQueue(); + + expect(methodCalls, hasLength(1)); + expect(methodCalls.single.method, 'track'); + expect(methodCalls.single.arguments, { + 'eventName': 'my-event', + 'data': { + 'plan': {'type': 'string', 'value': 'pro'}, + 'seats': {'type': 'int', 'value': 3}, + }, + }); + }); + + test('flush completes once native replies', () async { + // Native must reply to every flush call. Without a reply the future below + // never completes, so this test would time out rather than fail an + // assertion — which is exactly how the dangling reply went unnoticed. + await platform.flush().timeout(const Duration(seconds: 5)); + + expect(methodCalls, hasLength(1)); + expect(methodCalls.single.method, 'flush'); + }); + + test('flush does not raise an unhandled async error when native fails', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platform.methodChannel, (methodCall) async { + methodCalls.add(methodCall); + throw PlatformException(code: 'FLUSH_FAILED', message: 'boom'); + }); + + // The platform interface types flush() as void, so callers discard this + // future and an unguarded rejection would become an unhandled async error. + // Awaiting it here asserts the guard directly: it must complete, not throw. + await expectLater(platform.flush(), completes); + + expect(methodCalls, hasLength(1)); + expect(methodCalls.single.method, 'flush'); + }); + + test('track does not raise an unhandled async error when native fails', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platform.methodChannel, (methodCall) async { + methodCalls.add(methodCall); + throw PlatformException(code: 'TRACK_FAILED', message: 'boom'); + }); + + // track() is void, so the future is never awaited by the caller. An + // unguarded rejection would escape the test zone and fail this test. + platform.track('my-event', {}); + + await pumpEventQueue(); + + expect(methodCalls, hasLength(1)); + expect(methodCalls.single.method, 'track'); + }); }