diff --git a/.changeset/proud-owls-bake.md b/.changeset/proud-owls-bake.md new file mode 100644 index 00000000..0c2ecf87 --- /dev/null +++ b/.changeset/proud-owls-bake.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +Add a `bootstrap` option to `PostHogConfig` for pre-seeding identity and feature flags before the first `/flags` response. Set `config.bootstrap = PostHogBootstrapConfig(...)` before `setup()` so early events carry a caller-controlled distinct ID and flag reads return your values during cold start. The values are forwarded to the native iOS and Android SDKs and mirror the `bootstrap` option in posthog-js. On Flutter web, configure `bootstrap` in your `posthog.init` call instead. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82930c0f..0c8677af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,45 +41,39 @@ flutter build macos ## Testing with local native SDKs +The native SDK version floors in `posthog_flutter/darwin/posthog_flutter.podspec`, +`posthog_flutter/darwin/posthog_flutter/Package.swift`, and +`posthog_flutter/android/build.gradle` track the next expected native release. To +develop against an unreleased native branch, point the example app at your local +checkout with a gitignored override file — the committed build stays release-clean, +and a source override ignores the version floor. + ### iOS -- Update `example/ios/Podfile` to override the `PostHog` pod with your local path: +- Create `example/ios/Podfile.local` (gitignored) pointing at your checkout: ```ruby -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - - # add this - pod 'PostHog', :path => File.expand_path('~/posthog-ios') -end +pod 'PostHog', :path => File.expand_path('~/posthog-ios') ``` -- Run `cd example/ios && pod install` to install the local pod -- Open iOS simulator -- Run the app with `flutter run` - -### Android - -In your local `posthog-android` repo: +- Run `cd example/ios && pod install`, then `flutter run`. -- Run `make dryRelease` to build and publish the package to Maven local +The example `Podfile` evaluates `Podfile.local` when present, so no committed file +changes. -In the `posthog-flutter` repo: +### Android -- Update `/android/build.gradle` to add `mavenLocal()` as a repository: +- Create `example/android/local.settings.gradle.kts` (gitignored) with a composite + build of your checkout: -```gradle -allprojects { - repositories { - mavenLocal() // add this - google() - mavenCentral() - } -} +```kotlin +// absolute path — Kotlin does not expand "~" +includeBuild("/absolute/path/to/posthog-android") ``` -- Open Android simulator -- Run the app with `flutter run` +- Run `flutter run`. + +Gradle substitutes `com.posthog:posthog(-android)` with the local source regardless +of the version floor, so no publishing or `mavenLocal()` is needed. As a fallback you +can still `make dryRelease` in `posthog-android` (add `-PandroidVersion=` so the +published version satisfies the floor) and add `mavenLocal()` to the repositories. diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index bfbda2e1..9b55fe23 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -2125,6 +2125,17 @@ "relativePath": "lib/src/posthog_config.dart", "typeName": "PostHogLogsConfig" }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "bootstrap", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "PostHogBootstrapConfig?" + }, { "entryPoints": [], "isDeprecated": false, @@ -2765,6 +2776,130 @@ ], "typeParameterNames": [] }, + { + "entryPoints": [ + "posthog_flutter.dart" + ], + "executableDeclarations": [ + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "toMap", + "parameters": [], + "relativePath": "lib/src/posthog_config.dart", + "returnTypeName": "Map", + "type": "method", + "typeParameterNames": [] + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "new", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "distinctId", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "String?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "isIdentifiedId", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "bool" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "featureFlags", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "featureFlagPayloads", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "Map?" + } + ], + "relativePath": "lib/src/posthog_config.dart", + "returnTypeName": "PostHogBootstrapConfig", + "type": "constructor", + "typeParameterNames": [] + } + ], + "fieldDeclarations": [ + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": false, + "name": "distinctId", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "String?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": false, + "name": "isIdentifiedId", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "bool" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": false, + "name": "featureFlags", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "Map?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": false, + "name": "featureFlagPayloads", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "Map?" + } + ], + "isDeprecated": false, + "isExperimental": false, + "isRequired": false, + "isSealed": false, + "name": "PostHogBootstrapConfig", + "relativePath": "lib/src/posthog_config.dart", + "superTypeNames": [ + "Object" + ], + "typeParameterNames": [] + }, { "entryPoints": [ "posthog_flutter.dart" diff --git a/example/android/.gitignore b/example/android/.gitignore index 6f568019..3fa29020 100644 --- a/example/android/.gitignore +++ b/example/android/.gitignore @@ -6,6 +6,9 @@ gradle-wrapper.jar /local.properties GeneratedPluginRegistrant.java +# Local development override for an unreleased posthog-android (see CONTRIBUTING.md). +local.settings.gradle.kts + # Remember to never publicly share your keystore. # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app key.properties diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts index f6198875..216f9238 100644 --- a/example/android/settings.gradle.kts +++ b/example/android/settings.gradle.kts @@ -24,3 +24,9 @@ plugins { } include(":app") + +// Local development against an unreleased posthog-android. Inert unless a +// gitignored local.settings.gradle.kts exists; see CONTRIBUTING.md. The name must +// end in .settings.gradle.kts so Kotlin DSL compiles it against Settings. +val localSettings = file("local.settings.gradle.kts") +if (localSettings.exists()) apply(from = localSettings) diff --git a/example/ios/.gitignore b/example/ios/.gitignore index 7a7f9873..d2a8090f 100644 --- a/example/ios/.gitignore +++ b/example/ios/.gitignore @@ -27,6 +27,9 @@ Flutter/flutter_export_environment.sh ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* +# Local development override for an unreleased PostHog iOS SDK (see CONTRIBUTING.md). +Podfile.local + # Exceptions to above rules. !default.mode1v3 !default.mode2v3 diff --git a/example/ios/Podfile b/example/ios/Podfile index 04c36cf4..266a1cd0 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -32,6 +32,12 @@ target 'Runner' do use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + # Local development against an unreleased PostHog iOS SDK. Inert unless a + # gitignored Podfile.local exists; see CONTRIBUTING.md. + podfile_local = File.expand_path('Podfile.local', __dir__) + instance_eval(File.read(podfile_local), podfile_local) if File.exist?(podfile_local) + target 'RunnerTests' do inherit! :search_paths end diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1ed32bf8..4c014b3b 100644 --- a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/PostHog/posthog-ios", "state" : { - "revision" : "9fcdb83e79b155c0dae104eadf5c269079dcbddb", - "version" : "3.62.0" + "revision" : "19288ee2f0575606b4669b2abadda2c9db97c529", + "version" : "3.66.1" } } ], diff --git a/posthog_flutter/android/build.gradle b/posthog_flutter/android/build.gradle index 0eaf3000..7bb04d06 100644 --- a/posthog_flutter/android/build.gradle +++ b/posthog_flutter/android/build.gradle @@ -64,8 +64,7 @@ android { dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation 'org.mockito:mockito-core:5.0.0' - // + Version 3.54.1 and the versions up to 4.0.0, not including 4.0.0 and higher - implementation 'com.posthog:posthog-android:[3.54.1,4.0.0]' + implementation 'com.posthog:posthog-android:[3.55.0,4.0.0)' } testOptions { diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index f3de27d7..d34a7e60 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -21,6 +21,7 @@ import android.view.ViewGroup import androidx.annotation.RequiresApi import com.posthog.PersonProfiles import com.posthog.PostHog +import com.posthog.PostHogBootstrapConfig import com.posthog.PostHogConfig import com.posthog.PostHogOnFeatureFlags import com.posthog.android.PostHogAndroid @@ -488,6 +489,11 @@ class PosthogFlutterPlugin : } } + // Bootstrap precedence and flag layering live in the native SDK; forward values only. + posthogConfig.getIfNotNull>("bootstrap") { + this.bootstrap = bootstrapConfigFromMap(it) + } + sdkName = "posthog-flutter" sdkVersion = postHogVersion @@ -810,7 +816,8 @@ class PosthogFlutterPlugin : // different platform view (e.g. a masked map) that merely overlaps, so // compositing it would leak masked content. Slack absorbs rounding. val tolerance = 8 - if (destX < -tolerance || destY < -tolerance || + if (destX < -tolerance || + destY < -tolerance || destX + svLogW > destBitmap.width + tolerance || destY + svLogH > destBitmap.height + tolerance ) { @@ -1423,3 +1430,12 @@ class PosthogFlutterPlugin : flutterSurveysDelegate?.handleSurveyAction(type, args, result) } } + +@Suppress("UNCHECKED_CAST") +internal fun bootstrapConfigFromMap(bootstrap: Map): PostHogBootstrapConfig = + PostHogBootstrapConfig( + distinctId = bootstrap["distinctId"] as? String, + isIdentifiedId = bootstrap["isIdentifiedId"] as? Boolean ?: false, + featureFlags = bootstrap["featureFlags"] as? Map, + featureFlagPayloads = bootstrap["featureFlagPayloads"] as? Map, + ) diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index 5822b829..1e4ef8bf 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -4,10 +4,12 @@ import android.app.Activity import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull import org.mockito.Mockito import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue /* * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. @@ -189,4 +191,49 @@ internal class PosthogFlutterPluginTest { Mockito.isNull(), ) } + + @Test + fun bootstrapConfigFromMap_fullMap_decodesAllFields() { + val config = + bootstrapConfigFromMap( + mapOf( + "distinctId" to "user-123", + "isIdentifiedId" to true, + "featureFlags" to mapOf("beta-ui" to "variant-a", "legacy" to true), + "featureFlagPayloads" to mapOf("beta-ui" to mapOf("color" to "blue")), + ), + ) + + assertEquals("user-123", config.distinctId) + assertTrue(config.isIdentifiedId) + assertEquals(mapOf("beta-ui" to "variant-a", "legacy" to true), config.featureFlags) + assertEquals(mapOf("beta-ui" to mapOf("color" to "blue")), config.featureFlagPayloads) + } + + @Test + fun bootstrapConfigFromMap_emptyMap_usesDefaults() { + val config = bootstrapConfigFromMap(emptyMap()) + + assertNull(config.distinctId) + assertFalse(config.isIdentifiedId) + assertNull(config.featureFlags) + assertNull(config.featureFlagPayloads) + } + + @Test + fun bootstrapConfigFromMap_wrongTypes_fallBackToDefaults() { + val config = + bootstrapConfigFromMap( + mapOf( + "distinctId" to 42, + "isIdentifiedId" to "yes", + "featureFlags" to listOf("beta-ui"), + ), + ) + + assertNull(config.distinctId) + assertFalse(config.isIdentifiedId) + assertNull(config.featureFlags) + assertNull(config.featureFlagPayloads) + } } diff --git a/posthog_flutter/darwin/posthog_flutter.podspec b/posthog_flutter/darwin/posthog_flutter.podspec index be2cc262..4458d19d 100644 --- a/posthog_flutter/darwin/posthog_flutter.podspec +++ b/posthog_flutter/darwin/posthog_flutter.podspec @@ -21,8 +21,8 @@ Postog flutter plugin s.ios.dependency 'Flutter' s.osx.dependency 'FlutterMacOS' - # ~> Version 3.61.0 up to, but not including, 4.0.0 - s.dependency 'PostHog', '>= 3.61.0', '< 4.0.0' + # ~> Version 3.66.0 up to, but not including, 4.0.0 + s.dependency 'PostHog', '>= 3.66.0', '< 4.0.0' s.ios.deployment_target = '13.0' # PH iOS SDK 3.0.0 requires >= 10.15 diff --git a/posthog_flutter/darwin/posthog_flutter/Package.swift b/posthog_flutter/darwin/posthog_flutter/Package.swift index c10b10dc..3998a97d 100644 --- a/posthog_flutter/darwin/posthog_flutter/Package.swift +++ b/posthog_flutter/darwin/posthog_flutter/Package.swift @@ -14,7 +14,7 @@ let package = Package( ], dependencies: [ .package(name: "FlutterFramework", path: "../FlutterFramework"), - .package(url: "https://github.com/PostHog/posthog-ios", "3.61.0" ..< "4.0.0"), + .package(url: "https://github.com/PostHog/posthog-ios", "3.66.0" ..< "4.0.0"), ], targets: [ .target( diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 8972bfc4..0fc1f9a6 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -253,6 +253,18 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { } } + // Bootstrap precedence and flag layering live in the native SDK; forward values only. + if let bootstrap = posthogConfig["bootstrap"] as? [String: Any] { + let bootstrapConfig = PostHogBootstrapConfig() + bootstrapConfig.distinctId = bootstrap["distinctId"] as? String + if let isIdentifiedId = bootstrap["isIdentifiedId"] as? Bool { + bootstrapConfig.isIdentifiedId = isIdentifiedId + } + bootstrapConfig.featureFlags = bootstrap["featureFlags"] as? [String: Any] + bootstrapConfig.featureFlagPayloads = bootstrap["featureFlagPayloads"] as? [String: Any] + config.bootstrap = bootstrapConfig + } + // Update SDK name and version postHogSdkName = "posthog-flutter" postHogVersion = postHogFlutterVersion @@ -613,10 +625,14 @@ extension PosthogFlutterPlugin { if let webView = view as? WKWebView { let frameInWindow = webView.convert(webView.bounds, to: nil) // 1pt slack absorbs rounding between Flutter's rect and the native frame. - if rect.insetBy(dx: -1, dy: -1).contains(frameInWindow) { return webView } + if rect.insetBy(dx: -1, dy: -1).contains(frameInWindow) { + return webView + } } for sub in view.subviews { - if let found = findWKWebView(in: sub, containedBy: rect) { return found } + if let found = findWKWebView(in: sub, containedBy: rect) { + return found + } } return nil } diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index ffa3f482..17ef682f 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'logs/posthog_log_record.dart'; import 'posthog_event.dart'; import 'posthog_flutter_platform_interface.dart'; +import 'util/logging.dart'; /// Callback to intercept and modify events before they are sent to PostHog. /// @@ -175,6 +176,23 @@ class PostHogConfig { /// `Posthog().logger` facade). final logsConfig = PostHogLogsConfig(); + /// Pre-seeded identity and feature-flag state applied on the very first SDK + /// launch, before any network request completes. + /// + /// Set this before calling `Posthog().setup(config)` so events captured during + /// cold start carry a caller-controlled `$distinct_id` and feature-flag reads + /// return caller-provided values before the first `/flags` response. Mirrors + /// the [`bootstrap` option in `posthog-js`](https://posthog.com/docs/feature-flags/bootstrapping). + /// + /// Forwarded to the native iOS/Android SDKs, which apply all precedence rules + /// (never overwrite persisted identity, overlay loaded flags over bootstrapped + /// ones, drop the bootstrap on `reset()`). Defaults to `null` (no bootstrap). + /// + /// **Flutter web:** not applied. The web SDK hooks onto an already-initialized + /// posthog-js instance, so configure `bootstrap` in your `posthog.init({...})` + /// call instead. + PostHogBootstrapConfig? bootstrap; + /// Callback to be invoked when feature flags are loaded. /// /// Use [Posthog.getFeatureFlag] or [Posthog.isFeatureEnabled] within this @@ -303,6 +321,91 @@ class PostHogConfig { 'sessionReplayConfig': sessionReplayConfig.toMap(), 'errorTrackingConfig': errorTrackingConfig.toMap(), 'logs': logsConfig.toMap(), + if (bootstrap != null) 'bootstrap': bootstrap!.toMap(), + }; + } +} + +/// Pre-seeded identity and feature-flag state applied on the very first SDK +/// launch, before any network request completes. +/// +/// Assign an instance to [PostHogConfig.bootstrap] before calling +/// `Posthog().setup(config)`. The values are forwarded to the native iOS/Android +/// SDKs, which own all bootstrap behavior: +/// +/// - Bootstrapped identity seeds the very first session only. It is applied only +/// when no identity is persisted for that scope, and never overwrites an +/// existing user. An anonymous bootstrap ([isIdentifiedId] `false`) seeds the +/// anonymous id; an identified bootstrap ([isIdentifiedId] `true`) seeds the +/// distinct id and marks the user identified (merging an existing anonymous +/// user via `identify()`, or preserving a different identified user with a +/// warning) — it never becomes the device id. +/// - Only enabled bootstrapped flags are served (a `true` or a non-empty +/// variant string; `false` or empty values are dropped). They are served +/// until the first `/flags` response, which then takes over, and they are +/// dropped on `reset()`. +/// +/// **Flutter web:** not applied. Configure `bootstrap` in your +/// `posthog.init({...})` call instead. +class PostHogBootstrapConfig { + /// Creates a bootstrap configuration. + /// + /// Pass only the dimensions you want to seed; leave the rest `null`. + const PostHogBootstrapConfig({ + this.distinctId, + this.isIdentifiedId = false, + this.featureFlags, + this.featureFlagPayloads, + }); + + /// The distinct id to seed on first launch. + /// + /// When [isIdentifiedId] is `false` (the default) this becomes the anonymous + /// id — the `$distinct_id` on pre-identify events. When `true` it is treated + /// as an already-identified user's distinct id. + final String? distinctId; + + /// Whether [distinctId] represents an already-identified user. + /// + /// Defaults to `false`. Set to `true` when the host application resolved the + /// user's identity outside the SDK (for example from a backend session token). + final bool isIdentifiedId; + + /// Feature flag values served until the first `/flags` response arrives, + /// keyed by flag key. Each value is a `bool` for boolean flags or a `String` + /// for multivariate flags. Only enabled values are served: `false` or an + /// empty string is dropped. + final Map? featureFlags; + + /// JSON payloads paired with [featureFlags], keyed by flag key. Each value is + /// the already-decoded payload (map, list, string, number, `null`, ...). + final Map? featureFlagPayloads; + + /// Converts this configuration to a platform-channel map. + /// + /// Only the dimensions that were set are included; [isIdentifiedId] is always + /// sent so the native SDK doesn't have to infer it. + Map toMap() { + final flags = featureFlags; + if (flags != null) { + for (final entry in flags.entries) { + // Only bool/String are served (see [featureFlags]); the native SDKs drop + // anything else silently, so warn instead of leaving no trace. + if (entry.value is! bool && entry.value is! String) { + printIfDebug( + '[PostHog] bootstrap featureFlags["${entry.key}"] is ' + '${entry.value.runtimeType}; only bool and String values are served, ' + 'so this entry will be ignored.', + ); + } + } + } + return { + if (distinctId != null) 'distinctId': distinctId, + 'isIdentifiedId': isIdentifiedId, + if (featureFlags != null) 'featureFlags': featureFlags, + if (featureFlagPayloads != null) + 'featureFlagPayloads': featureFlagPayloads, }; } } diff --git a/posthog_flutter/test/posthog_test.dart b/posthog_flutter/test/posthog_test.dart index 97475e3e..bb829a1e 100644 --- a/posthog_flutter/test/posthog_test.dart +++ b/posthog_flutter/test/posthog_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; @@ -155,6 +157,89 @@ void main() { config.toMap()['sessionReplayConfig'] as Map; expect(updatedReplayConfig['maskAllPlatformViews'], isFalse); }); + + test('omits bootstrap from toMap when not set', () { + final config = PostHogConfig('test_project_token'); + + expect(config.bootstrap, isNull); + expect(config.toMap().containsKey('bootstrap'), isFalse); + }); + + test('serializes an identified identity bootstrap', () { + final config = PostHogConfig('test_project_token') + ..bootstrap = const PostHogBootstrapConfig( + distinctId: 'user-123', + isIdentifiedId: true, + ); + + final bootstrap = config.toMap()['bootstrap'] as Map; + expect(bootstrap['distinctId'], equals('user-123')); + expect(bootstrap['isIdentifiedId'], isTrue); + expect(bootstrap.containsKey('featureFlags'), isFalse); + expect(bootstrap.containsKey('featureFlagPayloads'), isFalse); + }); + + test('defaults isIdentifiedId to false and serializes it', () { + final config = PostHogConfig('test_project_token') + ..bootstrap = const PostHogBootstrapConfig(distinctId: 'anon-abc'); + + final bootstrap = config.toMap()['bootstrap'] as Map; + expect(bootstrap['distinctId'], equals('anon-abc')); + expect(bootstrap['isIdentifiedId'], isFalse); + }); + + test('serializes feature flags and payloads without identity', () { + final config = PostHogConfig('test_project_token') + ..bootstrap = const PostHogBootstrapConfig( + featureFlags: {'beta-ui': 'variant-a', 'legacy': true}, + featureFlagPayloads: { + 'beta-ui': {'color': 'blue'}, + 'legacy': null, + }, + ); + + final bootstrap = config.toMap()['bootstrap'] as Map; + expect(bootstrap.containsKey('distinctId'), isFalse); + expect(bootstrap['isIdentifiedId'], isFalse); + expect( + bootstrap['featureFlags'], + equals({'beta-ui': 'variant-a', 'legacy': true}), + ); + expect( + bootstrap['featureFlagPayloads'], + equals({ + 'beta-ui': {'color': 'blue'}, + 'legacy': null, + }), + ); + }); + + test('warns on a non-bool/String featureFlags value but still forwards it', + () { + final config = PostHogConfig('test_project_token') + ..bootstrap = const PostHogBootstrapConfig( + featureFlags: {'discount-tier': 2, 'beta-ui': 'variant-a'}, + ); + + final logs = []; + final map = runZoned( + config.toMap, + zoneSpecification: ZoneSpecification( + print: (_, __, ___, line) => logs.add(line), + ), + ); + + // The mismatched value is still forwarded; the native SDK drops it. + final bootstrap = map['bootstrap'] as Map; + expect( + bootstrap['featureFlags'], + equals({'discount-tier': 2, 'beta-ui': 'variant-a'}), + ); + // ...but the caller gets a breadcrumb for the ignored entry, and no + // false warning for the valid String value. + expect(logs.where((l) => l.contains('discount-tier')), isNotEmpty); + expect(logs.where((l) => l.contains('beta-ui')), isEmpty); + }); }); group('PostHogPlatformView', () { diff --git a/sdk_compliance_adapter/lib/adapter_server.dart b/sdk_compliance_adapter/lib/adapter_server.dart index 550d7f88..d85dda03 100644 --- a/sdk_compliance_adapter/lib/adapter_server.dart +++ b/sdk_compliance_adapter/lib/adapter_server.dart @@ -97,6 +97,18 @@ class ComplianceAdapter { ..preloadFeatureFlags = false ..captureApplicationLifecycleEvents = false ..debug = true; + + final bootstrap = _readNullableObjectMap(body['bootstrap']); + if (bootstrap != null) { + config.bootstrap = PostHogBootstrapConfig( + distinctId: bootstrap['distinct_id'] as String?, + isIdentifiedId: bootstrap['is_identified_id'] == true, + featureFlags: _readObjectMap(bootstrap['feature_flags']), + featureFlagPayloads: + _readNullableObjectMap(bootstrap['feature_flag_payloads']), + ); + } + await Posthog().setup(config); await _sendJson(request, {'success': true});