From 71e78d29de7681ec3fccc805bc0887f17401d4be Mon Sep 17 00:00:00 2001 From: vahid torkaman <692343+vahidlazio@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:14:58 +0200 Subject: [PATCH 1/2] fix: always reply to track method channel calls and surface failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `track` was the only method channel case that never replied. Every other case calls result(...)/result.success(...), so a track call left the Dart-side reply pending forever — on both iOS and Android. iOS also swallowed every failure via `try?`. - iOS: replace `try?` with do/catch, reply result("") on success and a FlutterError(TRACK_FAILED) on failure, and log via NSLog like the other catch blocks. Also reply on the argument-guard path, matching neighbours. - Android: reply result.success(null), and translate a thrown exception into result.error(TRACK_FAILED) rather than letting it escape the channel handler. - Dart: track() stays `void` (making it a Future would break the public API), and the unawaited future now has a catchError so a native error reply is logged instead of becoming an unhandled async error. Adds two method channel tests; the error one fails without the Dart guard. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ConfidenceFlutterSdkPlugin.kt | 7 +++- .../ConfidenceFlutterSdkPlugin.swift | 12 ++++++- ...confidence_flutter_sdk_method_channel.dart | 9 ++++- ...dence_flutter_sdk_method_channel_test.dart | 34 +++++++++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) 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..e4c5c96 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 @@ -133,7 +133,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..f14372d 100644 --- a/ios/confidence_flutter_sdk/Sources/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.swift +++ b/ios/confidence_flutter_sdk/Sources/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.swift @@ -113,12 +113,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..c81421f 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 diff --git a/test/confidence_flutter_sdk_method_channel_test.dart b/test/confidence_flutter_sdk_method_channel_test.dart index db03861..856921a 100644 --- a/test/confidence_flutter_sdk_method_channel_test.dart +++ b/test/confidence_flutter_sdk_method_channel_test.dart @@ -50,4 +50,38 @@ 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('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'); + }); } From 60abf1a41793957e7852dc53bfc88a1ddac35826 Mon Sep 17 00:00:00 2001 From: vahidlazio <692343+vahidlazio@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:08:18 +0200 Subject: [PATCH 2/2] fix: always reply to flush method channel calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flush had the same dangling-reply defect as track, on BOTH platforms — not Android only as previously noted. iOS replied in the guard branch but fell through to `break;` after `confidence.flush()` with no reply, and Android never replied at all. Either way the Dart future never completed. iOS now replies after flushing. Confidence.flush() is non-throwing there, so no do/catch is added — an unreachable catch would only warn. Android wraps the call and replies success or FLUSH_FAILED, matching the shape track uses. The platform interface types flush() as void, so callers discard the future. Now that Android can reply with an error, that rejection would surface as an unhandled async error in the host app, so the Dart side swallows and logs it as track already does. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ConfidenceFlutterSdkPlugin.kt | 7 ++++- .../ConfidenceFlutterSdkPlugin.swift | 4 +++ ...confidence_flutter_sdk_method_channel.dart | 12 +++++++-- ...dence_flutter_sdk_method_channel_test.dart | 27 +++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) 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 e4c5c96..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")!! 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 f14372d..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 { diff --git a/lib/confidence_flutter_sdk_method_channel.dart b/lib/confidence_flutter_sdk_method_channel.dart index c81421f..6555388 100644 --- a/lib/confidence_flutter_sdk_method_channel.dart +++ b/lib/confidence_flutter_sdk_method_channel.dart @@ -136,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 856921a..ac16352 100644 --- a/test/confidence_flutter_sdk_method_channel_test.dart +++ b/test/confidence_flutter_sdk_method_channel_test.dart @@ -67,6 +67,33 @@ void main() { }); }); + 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