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 0ddfc95..e4e19bb 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 @@ -187,6 +187,11 @@ private fun Map.convert(): ConfidenceValue { "double" -> return ConfidenceValue.Double(this["value"] as Double) "bool" -> return ConfidenceValue.Boolean(this["value"] as Boolean) "int" -> return ConfidenceValue.Integer(this["value"] as Int) + // Dart could not map this type (a DateTime, a null, a custom object) and + // has already sent `value.toString()`. Keep that string rather than + // throwing: `track` is fire-and-forget from Dart, so an exception here + // would surface only as a logged handler error while the event is lost. + "unknown" -> return ConfidenceValue.String(this["value"]?.toString() ?: "") "list" -> { val list = (this["value"] as List>).map { it.convert() } return ConfidenceValue.List(list) 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 85346c4..e5a8c0b 100644 --- a/ios/confidence_flutter_sdk/Sources/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.swift +++ b/ios/confidence_flutter_sdk/Sources/confidence_flutter_sdk/ConfidenceFlutterSdkPlugin.swift @@ -259,7 +259,17 @@ func convertValue(_ type: String, _ value: Any) -> ConfidenceValue { }) case "string": return ConfidenceValue.init(string: value as! String) + case "unknown": + // Dart could not map this type (a DateTime, a null, a custom object) + // and has already sent `value.toString()`. Keep that string: coercing + // it to a number would publish a wrong value with nothing to show the + // caller their data was discarded. + return ConfidenceValue.init(string: value as? String ?? String(describing: value)) default: - return ConfidenceValue.init(integer: 0) + // An unrecognised type marker means the Dart and native sides have + // drifted. Preserve the value as a string and make the mismatch + // visible rather than silently publishing a number. + NSLog("%@", "Confidence SDK: unsupported value type '\(type)', publishing it as a string") + return ConfidenceValue.init(string: value as? String ?? String(describing: value)) } } diff --git a/lib/confidence_flutter_sdk_method_channel.dart b/lib/confidence_flutter_sdk_method_channel.dart index 6555388..8c1216b 100644 --- a/lib/confidence_flutter_sdk_method_channel.dart +++ b/lib/confidence_flutter_sdk_method_channel.dart @@ -160,6 +160,14 @@ class MethodChannelConfidenceFlutterSdk extends ConfidenceFlutterSdkPlatform { return value!; } + /// Wraps [value] in the `{'type': ..., 'value': ...}` envelope the native + /// plugins decode. + /// + /// Anything Confidence has no type for — a `DateTime`, a `null`, a custom + /// object — is sent as `type: 'unknown'` carrying `value.toString()`. Both + /// native sides publish that as a string. They must not coerce it to a + /// number or drop it: the caller's data would be silently wrong with + /// nothing to indicate it. Map toTypedValue(dynamic value) { if (value is int) { return {'type': 'int', 'value': value}; diff --git a/test/confidence_flutter_sdk_method_channel_test.dart b/test/confidence_flutter_sdk_method_channel_test.dart index ac16352..3f128a2 100644 --- a/test/confidence_flutter_sdk_method_channel_test.dart +++ b/test/confidence_flutter_sdk_method_channel_test.dart @@ -111,4 +111,47 @@ void main() { expect(methodCalls, hasLength(1)); expect(methodCalls.single.method, 'track'); }); + + // Values Confidence has no type for must reach native as a stringified + // 'unknown', never as a number and never dropped. The native halves of this + // contract (iOS convertValue, Android convert) cannot be exercised from + // Dart, so what is pinned here is the wire format they rely on. + group('unsupported tracking values keep their value', () { + Map trackedData() { + final args = methodCalls.single.arguments as Map; + return (args['data'] as Map) + .map((k, v) => MapEntry(k as String, v)); + } + + test('a DateTime is stringified, not coerced to a number', () { + final now = DateTime.utc(2026, 9, 7, 12, 34, 56); + platform.track('my_event', {'ts': now}); + + final ts = trackedData()['ts'] as Map; + expect(ts['type'], 'unknown'); + expect(ts['value'], now.toString()); + expect(ts['value'], isNot(0)); + }); + + test('a null keeps a value and the key is not dropped', () { + platform.track('my_event', {'maybe': null}); + + final data = trackedData(); + expect(data.containsKey('maybe'), isTrue, + reason: 'the key must survive so the caller can see what was sent'); + final maybe = data['maybe'] as Map; + expect(maybe['type'], 'unknown'); + expect(maybe['value'], isNot(0)); + }); + + test('supported types are unaffected', () { + platform.track('my_event', {'n': 7, 's': 'x', 'b': true, 'd': 1.5}); + + final data = trackedData(); + expect((data['n'] as Map)['type'], 'int'); + expect((data['s'] as Map)['type'], 'string'); + expect((data['b'] as Map)['type'], 'bool'); + expect((data['d'] as Map)['type'], 'double'); + }); + }); }